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
+7 -3
View File
@@ -94,10 +94,14 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// as `PunktfunkError::Rejected` instead of the generic transport error the failed
// read produces — the difference between "not accepted" and the actual cause.
let handshake = async {
let (mut send, mut recv) = conn
let (mut send, recv) = conn
.open_bi()
.await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
// Frame every read on this stream through the resumable reader: the control loop
// below drives it from a `select!` arm and `clock_sync` wraps it in a timeout, and a
// partial frame lost to either would misalign the stream for the whole session.
let mut recv = io::MsgReader::new(recv);
io::write_msg(
&mut send,
@@ -136,7 +140,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
.encode(),
)
.await?;
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
let welcome = Welcome::decode(&recv.read_msg().await?)?;
if welcome.compositor != CompositorPref::Auto {
tracing::info!(
compositor = welcome.compositor.as_str(),
@@ -462,7 +466,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
break;
}
}
msg = io::read_msg(&mut ctrl_recv) => {
msg = ctrl_recv.read_msg() => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) {
if ack.accepted {