Finding
ClientSession::run decodes frames directly out of a fixed 65536-byte read buffer with no state carried between recv_stream.read() calls. QUIC gives no guarantee that a single stream read delivers a whole frame, so any frame that spans a read boundary — routine for FLAC frames, which regularly exceed 65536 bytes — fails to decode: decode_frame consumes the 4-byte length prefix, finds the declared payload length exceeds the bytes available in the current read, and returns an error. The loop logs at trace! and breaks. The data: Bytes holding the already-consumed prefix and partial body is dropped at the end of the match arm, so those bytes are gone from the stream. The next read() starts mid-frame; every subsequent decode on the misaligned bytes also fails. The stream is permanently desynchronised with no resync mechanism, and the only signal of the loss is a trace! line that is invisible in production.
Evidence
crates/syndesis/src/client/session.rs:125 — fixed read buffer, no carry state:
let mut read_buf = vec![0u8; 65536];
crates/syndesis/src/client/session.rs:152-171 — the read loop builds an ephemeral Bytes per read (Bytes::copy_from_slice) and decodes from it with no carry-forward of leftover bytes. data is declared inside the Ok(Some(n)) arm at crates/syndesis/src/client/session.rs:155 and dropped at the end of that arm, discarding any partially-consumed frame.
crates/syndesis/src/client/session.rs:167 — the only signal that bytes were lost:
trace!(error = %e, "partial frame in read buffer");
crates/syndesis/src/protocol/codec.rs:63-72 — decode_frame consumes the 4-byte length prefix (buf.get_u32()) before checking that the remaining buffer holds the full declared body, so on a short read those prefix bytes are already gone from the caller's Bytes.
Why this matters
This is a guaranteed data-loss path in normal operation, not an edge case: any frame larger than a single read or merely straddling a read boundary triggers it, and FLAC frames hit it on nearly every session. Once it fires the stream never recovers, the jitter buffer receives a gap (gap_count increments) but nothing above trace! surfaces, so audio degrades to clicks, dropouts, or silence with no operator-visible error. Under the threat model this is also an availability weakness: an adversary positioned on the network path can shape QUIC delivery to fragment frames at read boundaries, deterministically and silently denying or degrading the audio channel while leaving no production log evidence that anything failed.
Desired correction
Maintain a persistent BytesMut reassembly buffer outside the select!/read loop. After each recv_stream.read(), append the received bytes to the reassembly buffer, then drain only complete frames — attempt a decode only once the buffer holds at least LENGTH_PREFIX_SIZE + declared_body_size bytes, leaving any partial trailing frame in the buffer for the next read. (Equivalently, switch to recv_stream.read_exact() for the length prefix then the body, each under a tokio::time::timeout.) Partial frames must accumulate across reads instead of being dropped, and a true framing/decode error must be reported above trace!.
Done when: a test feeds the client one frame whose encoded length exceeds 65536 bytes split across two sequential reads (including the case split exactly at the 65536 boundary), and exactly one frame is decoded and delivered with no gap_count increment.
Finding
ClientSession::rundecodes frames directly out of a fixed 65536-byte read buffer with no state carried betweenrecv_stream.read()calls. QUIC gives no guarantee that a single stream read delivers a whole frame, so any frame that spans a read boundary — routine for FLAC frames, which regularly exceed 65536 bytes — fails to decode:decode_frameconsumes the 4-byte length prefix, finds the declared payload length exceeds the bytes available in the current read, and returns an error. The loop logs attrace!andbreaks. Thedata: Bytesholding the already-consumed prefix and partial body is dropped at the end of the match arm, so those bytes are gone from the stream. The nextread()starts mid-frame; every subsequent decode on the misaligned bytes also fails. The stream is permanently desynchronised with no resync mechanism, and the only signal of the loss is atrace!line that is invisible in production.Evidence
crates/syndesis/src/client/session.rs:125— fixed read buffer, no carry state:crates/syndesis/src/client/session.rs:152-171— the read loop builds an ephemeralBytesper read (Bytes::copy_from_slice) and decodes from it with no carry-forward of leftover bytes.datais declared inside theOk(Some(n))arm atcrates/syndesis/src/client/session.rs:155and dropped at the end of that arm, discarding any partially-consumed frame.crates/syndesis/src/client/session.rs:167— the only signal that bytes were lost:crates/syndesis/src/protocol/codec.rs:63-72—decode_frameconsumes the 4-byte length prefix (buf.get_u32()) before checking that the remaining buffer holds the full declared body, so on a short read those prefix bytes are already gone from the caller'sBytes.Why this matters
This is a guaranteed data-loss path in normal operation, not an edge case: any frame larger than a single read or merely straddling a read boundary triggers it, and FLAC frames hit it on nearly every session. Once it fires the stream never recovers, the jitter buffer receives a gap (
gap_countincrements) but nothing abovetrace!surfaces, so audio degrades to clicks, dropouts, or silence with no operator-visible error. Under the threat model this is also an availability weakness: an adversary positioned on the network path can shape QUIC delivery to fragment frames at read boundaries, deterministically and silently denying or degrading the audio channel while leaving no production log evidence that anything failed.Desired correction
Maintain a persistent
BytesMutreassembly buffer outside theselect!/read loop. After eachrecv_stream.read(), append the received bytes to the reassembly buffer, then drain only complete frames — attempt a decode only once the buffer holds at leastLENGTH_PREFIX_SIZE + declared_body_sizebytes, leaving any partial trailing frame in the buffer for the next read. (Equivalently, switch torecv_stream.read_exact()for the length prefix then the body, each under atokio::time::timeout.) Partial frames must accumulate across reads instead of being dropped, and a true framing/decode error must be reported abovetrace!.Done when: a test feeds the client one frame whose encoded length exceeds 65536 bytes split across two sequential reads (including the case split exactly at the 65536 boundary), and exactly one frame is decoded and delivered with no
gap_countincrement.