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:
@@ -38,7 +38,7 @@ pub struct ClockSkew {
|
||||
/// with, so the offset aligns a client receive instant to the host's capture clock.
|
||||
pub async fn clock_sync(
|
||||
send: &mut quinn::SendStream,
|
||||
recv: &mut quinn::RecvStream,
|
||||
recv: &mut io::MsgReader,
|
||||
) -> Option<ClockSkew> {
|
||||
use std::time::Duration;
|
||||
const ROUNDS: usize = 8;
|
||||
@@ -50,7 +50,7 @@ pub async fn clock_sync(
|
||||
if io::write_msg(send, &probe).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
|
||||
let read = tokio::time::timeout(read_timeout, recv.read_msg()).await;
|
||||
let echo = match read {
|
||||
Ok(Ok(b)) => match ClockEcho::decode(&b) {
|
||||
Ok(e) => e,
|
||||
|
||||
Reference in New Issue
Block a user