Skip to content

cluster: a timed-out send leaves a partial frame and desynchronizes the QUIC stream #143

Description

@fabracht

A send that times out leaves a partial frame on the wire and permanently desynchronizes the stream, which is then reused for every subsequent send to that peer.

send_to_peer writes a 4-byte big-endian length prefix followed by the payload, wrapping each write in a 5 second timeout (cluster/quic_transport.rs:296-316, SEND_TIMEOUT_MS = 5000 at :18):

tokio::time::timeout(timeout, stream.write_all(&len_prefix)).await ...
tokio::time::timeout(timeout, stream.write_all(&payload)).await ...

quinn::SendStream::write_all is a loop, and every completed iteration has already committed its bytes to the connection's send buffer (quinn 0.11.11, src/send_stream.rs:72-78):

pub async fn write_all(&mut self, mut buf: &[u8]) -> Result<(), WriteError> {
    while !buf.is_empty() {
        let written = self.write(buf).await?;
        buf = &buf[written..];
    }
    Ok(())
}

tokio::time::timeout cancels that future between iterations, so a timed-out send transmits a prefix of the frame and abandons the rest. The stream is not closed and the entry is not removed, so the next send writes a fresh frame directly after the truncated one.

The reader is strictly length-prefixed (quic_transport.rs:524-542): it does read_exact for 4 bytes, treats them as a length, and read_exacts that many bytes. After a truncation it reads payload bytes as a length. Either the bogus length exceeds MAX_MESSAGE_SIZE (10 MiB, :26) and the receiver logs "message too large" and breaks — killing the peer link — or the length is plausible and the connection silently mis-frames from then on, handing parse_message garbage.

Two things make this worse than it first looks:

  • The timeout fires exactly under congestion, which is when the cluster is least able to absorb a broken link.
  • Callers discard the error (broadcast logs a warning at :350-352 and continues; send_tick_output does let _ = ...), so nothing reacts to the failed send and nothing repairs or closes the stream.

The underlying problem is that write_all is not cancellation-safe for framed output. A timeout can only be applied safely to a whole frame if failure also tears down the stream. Options:

  1. Give each peer a writer task fed by a bounded channel. Backpressure then shows up as a failed try_send on an intact stream instead of a cancelled write on a corrupt one, and the task's exit is an unambiguous per-peer death signal (useful for cluster: nodes never dial peers they discover, never retry failed dials, and never drop dead peers #140).
  2. Keep the timeout but treat expiry as fatal for that connection: close the stream and drop the entry so the next send re-establishes rather than appending to a truncated frame.
  3. Write the prefix and payload as a single buffer, which narrows but does not close the window, since write_all can still be cancelled mid-buffer.

Option 1 is preferred; it also gives #140 the death signal and generation ownership it needs.

Found while modelling the peer map for #140. Not covered by any test: crates/mqdb-cluster/tests/ has no QUIC transport test, and the unit tests in quic_transport.rs cover only serialization.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions