fix(core/quic): make the control-stream read cancel-safe

`io::read_msg` frames a message with two `quinn::RecvStream::read_exact` calls,
and quinn documents `read_exact` as explicitly NOT cancel-safe: the bytes it has
already taken out of the stream live only in the future's own buffer and nothing
puts them back on drop. Both long-lived control loops drive that read from a
`tokio::select!` arm — the client pump alongside `ctrl_rx.recv()` and the resync
tick, the host alongside probe/reconfig/clip-offer channels — and neither uses
`biased;`, so any sibling that becomes ready ends the iteration and drops a
partially-progressed read. `clock_sync` has the same shape via
`tokio::time::timeout`, which can fire mid-frame before the session even starts.

A control frame only has to straddle two wakeups for this to bite: a ClipOffer
carries up to 16 kinds x 128 bytes of MIME, ~2 KB, which exceeds one QUIC packet
and is subject to the pacer; any frame whose second half is lost or reordered
does it too. Losing the consumed length prefix misaligns the stream permanently
— the next read takes two payload bytes as a length, so Reconfigured,
ProbeResult, BitrateChanged, ClockEcho and ClipState all decode as garbage and
are silently dropped, and a bogus length up to 64 KiB parks the read forever.
Mode switches, adaptive bitrate, mid-stream clock resync and clipboard are dead
for the rest of the session; only a reconnect recovers, and the log shows at
most one `warn!`.

Add `io::MsgReader`, which keeps the frame in progress in the reader rather than
the future and reads via quinn's cancel-safe `read`, and switch the three
cancelling sites to it (client control loop, host control loop, clock_sync).
The sequential handshake/pairing callers keep the plain `read_msg`, whose doc
comment now states the constraint. No wire bytes and no ABI change — only how
the same length-prefixed frames are assembled.

Tests: a frame split across two wakeups with the read cancelled in between must
resume and leave the following frame correctly framed (confirmed to fail — it
hangs on the desynced stream — against the old behavior), plus a zero-length
frame round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 01:13:11 +02:00
parent 7b2cdf5a7a
commit 810d918d36
5 changed files with 173 additions and 8 deletions
+76
View File
@@ -1,6 +1,14 @@
//! Length-prefixed framing for QUIC control-stream messages: a `u16` length header followed by the
//! payload, bounded at 64 KiB (control messages are tiny).
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
///
/// **Not cancel-safe**: it frames with two `quinn::RecvStream::read_exact` calls, and quinn
/// documents `read_exact` as not cancel-safe (the bytes it has already taken out of the stream
/// live only in the future's own buffer, and nothing puts them back on drop). Dropping a
/// partially-progressed future therefore destroys the bytes it consumed and misaligns every
/// subsequent read on that stream. Use it only where the read runs to completion — the sequential
/// handshake/pairing exchanges. Anything driving a read from a `select!` arm or a
/// `tokio::time::timeout` must use [`MsgReader`] instead.
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
let mut len = [0u8; 2];
recv.read_exact(&mut len)
@@ -14,6 +22,74 @@ pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>>
Ok(buf)
}
/// Cancel-safe framed reader for a long-lived control stream.
///
/// Keeps the frame in progress in `buf` rather than inside the read future, so dropping the future
/// — which both control loops do on every iteration where a sibling `select!` arm wins, and which
/// [`clock_sync`](super::clock_sync) does on a read timeout — resumes instead of losing bytes.
/// With the plain [`read_msg`] a control frame that straddles two wakeups (a ~2 KB `ClipOffer`
/// exceeds one QUIC packet; so does any frame whose second half is lost or reordered) left the
/// stream permanently misaligned: the next read took two payload bytes as a length, every later
/// message decoded as garbage and was silently ignored, and a bogus 64 KiB length parked the read
/// forever — killing mode switches, adaptive bitrate, clock re-sync and clipboard for the rest of
/// the session with nothing but a `warn!` in the log.
pub struct MsgReader {
recv: quinn::RecvStream,
/// The frame in progress, length prefix included.
buf: Vec<u8>,
/// Bytes `buf` must reach: 2 while reading the prefix, then `2 + payload length`.
need: usize,
}
impl MsgReader {
pub fn new(recv: quinn::RecvStream) -> Self {
MsgReader {
recv,
buf: Vec::new(),
need: 2,
}
}
/// Read one framed message. Cancel-safe: dropping the future keeps the partial frame, so the
/// next call resumes where this one stopped.
pub async fn read_msg(&mut self) -> std::io::Result<Vec<u8>> {
loop {
while self.buf.len() < self.need {
let mut chunk = [0u8; 2048];
let want = (self.need - self.buf.len()).min(chunk.len());
// `read` IS cancel-safe: it only reports bytes it hands back, and they are
// committed to `self.buf` before the next await point.
match self
.recv
.read(&mut chunk[..want])
.await
.map_err(std::io::Error::other)?
{
Some(n) => self.buf.extend_from_slice(&chunk[..n]),
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"control stream finished mid-frame",
))
}
}
}
if self.need == 2 {
self.need = 2 + u16::from_le_bytes([self.buf[0], self.buf[1]]) as usize;
if self.need == 2 {
self.buf.clear();
return Ok(Vec::new()); // zero-length frame
}
} else {
let msg = self.buf.split_off(2);
self.buf.clear();
self.need = 2;
return Ok(msg);
}
}
}
}
/// Write one framed message.
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
send.write_all(&super::frame(payload))