fix(net): split chunk stream reader and writer to avoid frame desync

This commit is contained in:
Serkyo 2026-07-22 14:45:41 +02:00
parent 20d36624c7
commit bb4e591a09

View file

@ -1,10 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries. //! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries.
//!
//! After the handshake, each connection carries a dedicated bidirectional QUIC stream for chunk sync (the canonical `StreamLayout::chunk_lod0` id). The client writes [`ChunkSubscribe`] requests on it and the server writes [`ChunkMessage`] deliveries back on the same stream. This module owns the server-side pump: a single [`tokio::select`] loop that reads subscriptions off the stream and forwards them to the synchronous simulation loop, while draining outbound [`ChunkMessage`]s handed to it by that loop.
//!
//! The two channels crossing the async/sync boundary run in opposite directions and therefore use different primitives. Inbound (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the crossbeam [`ServerEvent`] channel, whose sender is non-blocking. Outbound (a `ChunkMessage` produced by the sync loop, consumed async) uses a `tokio` unbounded MPSC: its `send` is synchronous, so the non-async simulation thread can push without a runtime, while the receiver's `recv().await` composes into the pump's `select!`. A blocking `crossbeam` receiver would instead freeze the current-thread runtime and cannot appear in a `select!` arm.
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
@ -56,11 +52,10 @@ pub(crate) async fn chunk_stream_task(
} }
}; };
// Inbound subscriptions are read on their own future so `read_frame` is never cancelled mid-frame by an outbound write becoming ready.
let reader = async {
loop { loop {
tokio::select! { match read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
// A subscription frame arrived from the client.
frame = read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN) => {
match frame {
Ok(request) => { Ok(request) => {
// A closed events receiver means the simulation loop is gone; nothing more to do. // A closed events receiver means the simulation loop is gone; nothing more to do.
if events if events
@ -77,20 +72,22 @@ pub(crate) async fn chunk_stream_task(
} }
} }
} }
// The simulation loop handed back a chunk to deliver. };
msg = outbound.recv() => {
match msg { // Outbound chunks handed back by the simulation loop are written on their own future. The loop ends when the sink is dropped (the connection is being torn down).
Some(message) => { let writer = async {
while let Some(message) = outbound.recv().await {
if let Err(error) = write_frame(&mut send, &message).await { if let Err(error) = write_frame(&mut send, &message).await {
warn!(%error, id, "failed to write chunk frame; ending chunk stream"); warn!(%error, id, "failed to write chunk frame; ending chunk stream");
break; break;
} }
} }
// The sink was dropped: the connection is being torn down. };
None => break,
} // The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down.
} tokio::select! {
} () = reader => {}
() = writer => {}
} }
} }
@ -136,24 +133,10 @@ pub(crate) async fn client_chunk_task(
} }
}; };
// Inbound chunks are read on their own future so `read_frame` is never cancelled mid-frame by an outbound subscribe becoming ready.
let reader = async {
loop { loop {
tokio::select! { match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
// The UI thread pushed a subscription update to forward to the server.
request = subscribe.recv() => {
match request {
Some(request) => {
if let Err(error) = write_frame(&mut send, &request).await {
warn!(%error, "failed to write chunk subscribe; ending chunk stream");
break;
}
}
// The subscriber was dropped: the UI is shutting down.
None => break,
}
}
// A chunk arrived from the server.
frame = read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN) => {
match frame {
Ok(message) => { Ok(message) => {
// `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends.
if deliveries.send(message).await.is_err() { if deliveries.send(message).await.is_err() {
@ -167,8 +150,23 @@ pub(crate) async fn client_chunk_task(
} }
} }
} }
};
// Outbound subscription updates from the UI thread are written on their own future. The loop ends when the subscriber is dropped (the UI is shutting down).
let writer = async {
while let Some(request) = subscribe.recv().await {
if let Err(error) = write_frame(&mut send, &request).await {
warn!(%error, "failed to write chunk subscribe; ending chunk stream");
break;
} }
} }
};
// The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down.
tokio::select! {
() = reader => {}
() = writer => {}
}
} }
#[cfg(test)] #[cfg(test)]