Files
rustfs/crates/rio/src/tee_reader.rs
T
Zhengchao An 1dd81cf276 feat(rio): add bounded tee reader for one-read two-consumer paths (#7066)
Add `tee_reader` / `tee_reader_with_options` in `rustfs-rio`: a
`TeePrimary` that drives the source and a `TeeSecondary` that observes
an identical copy of every chunk through a byte-bounded queue. The
primary returns `Pending` when the queue is full, so both sides advance
at the pace of the slowest consumer; it is meant for small objects only.

Termination: source EOF and errors propagate to the secondary with the
same `io::ErrorKind`; dropping the secondary turns the primary into a
pass-through; dropping the primary early fails the secondary with
`BrokenPipe` by default, or hands the remaining source to a background
drain task bounded by `max_drain_bytes` when
`TeeOptions::drain_on_primary_drop` is set. `TeeSecondary::into_stream`
exposes the queued `Bytes` chunks without an extra copy.

Includes a proptest equivalence test, backpressure, error, drop,
drain-limit and cancel-safety tests, and a criterion bench comparing
tee throughput against a direct read (64 MiB in 1 MiB chunks).
2026-09-02 21:59:46 +08:00

1059 lines
38 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Bounded tee for async readers: one source read, two ordered consumers.
//!
//! [`tee_reader`] splits a single [`AsyncRead`] source into a [`TeePrimary`]
//! that drives the source and a [`TeeSecondary`] that observes an identical
//! copy of every byte through a byte-bounded queue. Chunks are shared as
//! [`Bytes`] between the two sides, so the only extra work per chunk is one
//! `memcpy` into the queue.
//!
//! **Intended for small objects only.** The queue holds at most
//! `buffer_bytes` plus one chunk; when it is full the primary returns
//! `Pending` until the secondary catches up, so both sides advance at the
//! pace of the slowest consumer. Do not put this in front of a large body
//! whose secondary consumer may stall (for example a slow disk write behind
//! a fast client): the primary would stall with it.
//!
//! Termination semantics:
//!
//! - Source EOF: the secondary sees EOF after draining the queue.
//! - Source error: the primary gets the original error, the secondary gets an
//! `io::Error` with the same [`io::ErrorKind`] so a partial stream is never
//! mistaken for a complete object.
//! - Secondary dropped: the primary keeps serving its own caller; the tee
//! becomes a pass-through.
//! - Primary dropped before EOF: by default the secondary gets
//! [`io::ErrorKind::BrokenPipe`]. With
//! [`TeeOptions::drain_on_primary_drop`] the remaining source bytes are
//! moved to a background task that keeps feeding the secondary until EOF
//! or [`TeeOptions::max_drain_bytes`], at which point the secondary gets a
//! [`TeeDrainLimitExceeded`] error rather than a silent EOF.
use crate::compress_index::TryGetIndex;
use crate::{EtagResolvable, HashReaderDetector};
use bytes::{Buf, Bytes};
use futures::Stream;
use std::collections::VecDeque;
use std::fmt;
use std::future::poll_fn;
use std::io;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::task::{Context, Poll, Waker};
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
/// Default cap on bytes a background drain task may pull from the source
/// after the primary is dropped.
pub const DEFAULT_TEE_MAX_DRAIN_BYTES: usize = 64 * 1024 * 1024;
/// Chunk size used by the background drain task.
const DRAIN_CHUNK_BYTES: usize = 256 * 1024;
/// Behaviour knobs for [`tee_reader_with_options`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TeeOptions {
/// When the primary is dropped before the source reached EOF, keep
/// reading the source in a background task and feed the secondary
/// instead of failing it with `BrokenPipe`.
pub drain_on_primary_drop: bool,
/// Upper bound on bytes the drain task may read after the primary drop.
/// Exceeding it fails the secondary with [`TeeDrainLimitExceeded`].
pub max_drain_bytes: usize,
}
impl Default for TeeOptions {
fn default() -> Self {
Self {
drain_on_primary_drop: false,
max_drain_bytes: DEFAULT_TEE_MAX_DRAIN_BYTES,
}
}
}
/// The background drain read more than `max_drain_bytes` after the primary
/// was dropped; the secondary stream is incomplete.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
#[error("tee drain exceeded max_drain_bytes ({max_drain_bytes})")]
pub struct TeeDrainLimitExceeded {
pub max_drain_bytes: usize,
}
type BoxedSource = Box<dyn AsyncRead + Unpin + Send>;
/// Why no more chunks will be queued.
#[derive(Debug)]
enum Terminal {
Eof,
SourceError { kind: io::ErrorKind, message: String },
PrimaryDropped,
DrainLimitExceeded { max_drain_bytes: usize },
}
impl Terminal {
fn secondary_error(&self) -> Option<io::Error> {
match self {
Terminal::Eof => None,
Terminal::SourceError { kind, message } => Some(io::Error::new(*kind, message.clone())),
Terminal::PrimaryDropped => Some(io::Error::new(
io::ErrorKind::BrokenPipe,
"tee primary dropped before the source reached EOF",
)),
Terminal::DrainLimitExceeded { max_drain_bytes } => Some(io::Error::other(TeeDrainLimitExceeded {
max_drain_bytes: *max_drain_bytes,
})),
}
}
}
#[derive(Debug)]
struct State {
queue: VecDeque<Bytes>,
queued_bytes: usize,
terminal: Option<Terminal>,
secondary_alive: bool,
/// Waker of whoever pushes chunks: the primary, or the drain task.
producer_waker: Option<Waker>,
secondary_waker: Option<Waker>,
}
#[derive(Debug)]
struct Shared {
capacity: usize,
state: Mutex<State>,
drain_tasks: AtomicUsize,
}
enum Capacity {
Available,
SecondaryGone,
}
impl Shared {
fn lock(&self) -> MutexGuard<'_, State> {
// The critical sections only touch plain data, so a poisoned lock
// cannot leave the queue in a state that is unsafe to keep using.
self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Wait until the queue is below capacity, or the secondary is gone.
fn poll_capacity(&self, cx: &mut Context<'_>) -> Poll<Capacity> {
let mut state = self.lock();
if !state.secondary_alive {
return Poll::Ready(Capacity::SecondaryGone);
}
if state.queued_bytes >= self.capacity {
state.producer_waker = Some(cx.waker().clone());
return Poll::Pending;
}
Poll::Ready(Capacity::Available)
}
fn push(&self, chunk: Bytes) {
let mut state = self.lock();
if !state.secondary_alive || state.terminal.is_some() {
return;
}
state.queued_bytes += chunk.len();
state.queue.push_back(chunk);
let waker = state.secondary_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
}
fn finish(&self, terminal: Terminal) {
let mut state = self.lock();
if state.terminal.is_none() {
state.terminal = Some(terminal);
}
let waker = state.secondary_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
}
/// Abort the secondary: discard queued chunks and fail it on next poll.
fn abort_secondary(&self) {
let mut state = self.lock();
if state.terminal.is_none() {
state.queue.clear();
state.queued_bytes = 0;
state.terminal = Some(Terminal::PrimaryDropped);
}
let waker = state.secondary_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
}
/// Copy queued bytes into `buf`, or report the terminal outcome once the
/// queue is empty. Both checks run under one lock so a chunk pushed
/// between them cannot leave the secondary parked without a wake-up.
fn poll_read_into(&self, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
let mut state = self.lock();
if let Some(front) = state.queue.front_mut() {
let n = front.len().min(buf.remaining());
buf.put_slice(&front[..n]);
if n == front.len() {
state.queue.pop_front();
} else {
front.advance(n);
}
state.queued_bytes -= n;
let waker = state.producer_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
return Poll::Ready(Ok(()));
}
Self::poll_terminal_locked(&mut state, cx)
}
/// Pop a whole chunk, or report the terminal outcome once the queue is
/// empty (same single-lock rule as [`Shared::poll_read_into`]).
fn poll_next_chunk(&self, cx: &mut Context<'_>) -> Poll<io::Result<Option<Bytes>>> {
let mut state = self.lock();
if let Some(chunk) = state.queue.pop_front() {
state.queued_bytes -= chunk.len();
let waker = state.producer_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
return Poll::Ready(Ok(Some(chunk)));
}
Self::poll_terminal_locked(&mut state, cx).map_ok(|()| None)
}
fn poll_terminal_locked(state: &mut State, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &state.terminal {
None => {
state.secondary_waker = Some(cx.waker().clone());
Poll::Pending
}
Some(terminal) => Poll::Ready(terminal.secondary_error().map_or(Ok(()), Err)),
}
}
fn secondary_dropped(&self) {
let mut state = self.lock();
state.secondary_alive = false;
state.queue.clear();
state.queued_bytes = 0;
let waker = state.producer_waker.take();
drop(state);
if let Some(waker) = waker {
waker.wake();
}
}
}
/// Split `source` into a primary and a secondary reader that both observe the
/// full byte stream.
///
/// Intended for small objects only: `buffer_bytes` bounds the bytes queued
/// for the secondary (a value of `0` is treated as `1`), the primary may
/// overshoot by at most one chunk, and once the queue is full the primary
/// returns `Pending` until the secondary consumes, so both sides advance at
/// the pace of the slowest consumer. Termination and drop semantics are
/// described on [`TeePrimary`], [`TeeSecondary`] and [`TeeOptions`].
pub fn tee_reader<R>(source: R, buffer_bytes: usize) -> (TeePrimary, TeeSecondary)
where
R: AsyncRead + Unpin + Send + 'static,
{
tee_reader_with_options(source, buffer_bytes, TeeOptions::default())
}
/// [`tee_reader`] with explicit [`TeeOptions`].
pub fn tee_reader_with_options<R>(source: R, buffer_bytes: usize, options: TeeOptions) -> (TeePrimary, TeeSecondary)
where
R: AsyncRead + Unpin + Send + 'static,
{
let shared = Arc::new(Shared {
capacity: buffer_bytes.max(1),
state: Mutex::new(State {
queue: VecDeque::new(),
queued_bytes: 0,
terminal: None,
secondary_alive: true,
producer_waker: None,
secondary_waker: None,
}),
drain_tasks: AtomicUsize::new(0),
});
let primary = TeePrimary {
source: Some(Box::new(source)),
shared: Arc::clone(&shared),
options,
};
let secondary = TeeSecondary { shared };
(primary, secondary)
}
/// The side of the tee that drives the source.
///
/// Every chunk read from the source is queued for the [`TeeSecondary`] before
/// being returned to the caller; when the queue is full `poll_read` returns
/// `Pending` without touching the source, which keeps cancellation of the
/// caller's read future safe (a chunk is either fully delivered to both sides
/// in one poll or not read at all).
pub struct TeePrimary {
/// `None` only after `Drop` moved the source into the drain task.
source: Option<BoxedSource>,
shared: Arc<Shared>,
options: TeeOptions,
}
impl fmt::Debug for TeePrimary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TeePrimary")
.field("options", &self.options)
.field("capacity", &self.shared.capacity)
.finish_non_exhaustive()
}
}
impl AsyncRead for TeePrimary {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
let this = &mut *self;
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let Some(source) = this.source.as_mut() else {
return Poll::Ready(Ok(()));
};
let tee_active = {
let mut state = this.shared.lock();
if !state.secondary_alive || state.terminal.is_some() {
false
} else if state.queued_bytes >= this.shared.capacity {
state.producer_waker = Some(cx.waker().clone());
return Poll::Pending;
} else {
true
}
};
let before = buf.filled().len();
match Pin::new(source).poll_read(cx, buf) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(err)) => {
if tee_active {
this.shared.finish(Terminal::SourceError {
kind: err.kind(),
message: err.to_string(),
});
}
Poll::Ready(Err(err))
}
Poll::Ready(Ok(())) => {
if tee_active {
let filled = &buf.filled()[before..];
if filled.is_empty() {
this.shared.finish(Terminal::Eof);
} else {
this.shared.push(Bytes::copy_from_slice(filled));
}
}
Poll::Ready(Ok(()))
}
}
}
}
impl Drop for TeePrimary {
fn drop(&mut self) {
let Some(source) = self.source.take() else {
return;
};
{
let state = self.shared.lock();
if state.terminal.is_some() || !state.secondary_alive {
return;
}
}
if self.options.drain_on_primary_drop
&& let Ok(handle) = tokio::runtime::Handle::try_current()
{
let guard = DrainGuard::new(Arc::clone(&self.shared));
handle.spawn(drain_source(source, guard, self.options.max_drain_bytes));
return;
}
self.shared.abort_secondary();
}
}
impl EtagResolvable for TeePrimary {}
impl HashReaderDetector for TeePrimary {}
impl TryGetIndex for TeePrimary {}
/// Tracks one live drain task. Dropping it (normal exit, cancellation, or a
/// runtime that never ran the task) guarantees the secondary is unblocked.
struct DrainGuard {
shared: Arc<Shared>,
}
impl DrainGuard {
fn new(shared: Arc<Shared>) -> Self {
shared.drain_tasks.fetch_add(1, Ordering::AcqRel);
Self { shared }
}
}
impl Drop for DrainGuard {
fn drop(&mut self) {
self.shared.abort_secondary();
self.shared.drain_tasks.fetch_sub(1, Ordering::AcqRel);
}
}
async fn drain_source(mut source: BoxedSource, guard: DrainGuard, max_drain_bytes: usize) {
let shared = Arc::clone(&guard.shared);
let mut scratch = vec![0u8; DRAIN_CHUNK_BYTES.min(max_drain_bytes.max(1))];
let mut drained = 0usize;
loop {
if let Capacity::SecondaryGone = poll_fn(|cx| shared.poll_capacity(cx)).await {
return;
}
// Once the budget is spent, a one-byte probe distinguishes a clean
// EOF from a source that still has data.
let want = if drained >= max_drain_bytes {
1
} else {
(max_drain_bytes - drained).min(scratch.len())
};
match source.read(&mut scratch[..want]).await {
Err(err) => {
shared.finish(Terminal::SourceError {
kind: err.kind(),
message: err.to_string(),
});
return;
}
Ok(0) => {
shared.finish(Terminal::Eof);
return;
}
Ok(n) => {
if drained >= max_drain_bytes {
shared.finish(Terminal::DrainLimitExceeded { max_drain_bytes });
return;
}
drained += n;
shared.push(Bytes::copy_from_slice(&scratch[..n]));
}
}
}
}
/// The side of the tee that observes a copy of the primary's stream.
///
/// After the source reaches EOF the secondary drains the queue and reports
/// EOF. A source error surfaces here as an `io::Error` of the same kind. If
/// the primary is dropped early the secondary fails with
/// [`io::ErrorKind::BrokenPipe`], unless the tee was created with
/// [`TeeOptions::drain_on_primary_drop`]. Dropping the secondary turns the
/// primary into a plain pass-through.
#[derive(Debug)]
pub struct TeeSecondary {
shared: Arc<Shared>,
}
impl TeeSecondary {
/// Consume the secondary as a stream of whole chunks (no extra copy).
pub fn into_stream(self) -> TeeStream {
TeeStream {
secondary: self,
done: false,
}
}
/// Number of background drain tasks still alive for this tee (0 or 1).
/// Diagnostics only.
pub fn active_drain_tasks(&self) -> usize {
self.shared.drain_tasks.load(Ordering::Acquire)
}
}
impl AsyncRead for TeeSecondary {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
self.shared.poll_read_into(cx, buf)
}
}
impl Drop for TeeSecondary {
fn drop(&mut self) {
self.shared.secondary_dropped();
}
}
impl EtagResolvable for TeeSecondary {}
impl HashReaderDetector for TeeSecondary {}
impl TryGetIndex for TeeSecondary {}
/// Chunk stream over a [`TeeSecondary`], see [`TeeSecondary::into_stream`].
///
/// Yields each queued [`Bytes`] chunk as-is. After an error or EOF the
/// stream is fused and keeps returning `None`.
#[derive(Debug)]
pub struct TeeStream {
secondary: TeeSecondary,
done: bool,
}
impl Stream for TeeStream {
type Item = io::Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.done {
return Poll::Ready(None);
}
match self.secondary.shared.poll_next_chunk(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Ok(Some(chunk))) => Poll::Ready(Some(Ok(chunk))),
Poll::Ready(Ok(None)) => {
self.done = true;
Poll::Ready(None)
}
Poll::Ready(Err(err)) => {
self.done = true;
Poll::Ready(Some(Err(err)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::StreamExt;
use proptest::prelude::*;
use tokio::io::AsyncReadExt;
use tokio::task::yield_now;
/// In-memory source that hands out data in a caller-defined sequence of
/// chunk sizes, optionally returning `Pending` before every chunk and
/// failing at a given offset.
struct ChunkedSource {
data: Bytes,
pos: usize,
chunk_sizes: Vec<usize>,
chunk_idx: usize,
yield_before_chunk: bool,
yield_pending: bool,
fail_at: Option<(usize, io::ErrorKind)>,
}
impl ChunkedSource {
fn new(data: impl Into<Bytes>, chunk_sizes: Vec<usize>) -> Self {
assert!(!chunk_sizes.is_empty());
Self {
data: data.into(),
pos: 0,
chunk_sizes,
chunk_idx: 0,
yield_before_chunk: false,
yield_pending: false,
fail_at: None,
}
}
fn yielding(mut self) -> Self {
self.yield_before_chunk = true;
self.yield_pending = true;
self
}
fn failing_at(mut self, offset: usize, kind: io::ErrorKind) -> Self {
self.fail_at = Some((offset, kind));
self
}
}
impl AsyncRead for ChunkedSource {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if self.yield_before_chunk && self.yield_pending {
self.yield_pending = false;
cx.waker().wake_by_ref();
return Poll::Pending;
}
self.yield_pending = true;
if let Some((offset, kind)) = self.fail_at
&& self.pos >= offset
{
return Poll::Ready(Err(io::Error::new(kind, "injected source failure")));
}
let size = self.chunk_sizes[self.chunk_idx % self.chunk_sizes.len()];
self.chunk_idx += 1;
let remaining = self.data.len() - self.pos;
let n = size.min(remaining).min(buf.remaining());
let n = self.fail_at.map_or(n, |(offset, _)| n.min(offset - self.pos));
buf.put_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
Poll::Ready(Ok(()))
}
}
fn pattern_bytes(len: usize, seed: u64) -> Vec<u8> {
let mut state = seed | 1;
(0..len)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 24) as u8
})
.collect()
}
async fn read_all<R: AsyncRead + Unpin>(reader: &mut R, read_size: usize) -> io::Result<Vec<u8>> {
let mut out = Vec::new();
let mut buf = vec![0u8; read_size];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
return Ok(out);
}
out.extend_from_slice(&buf[..n]);
}
}
/// Upper bound on scheduler turns a drain task may take to exit; the
/// clock is paused in these tests, so a busy yield loop must be bounded
/// by iterations rather than wall time.
const MAX_DRAIN_EXIT_YIELDS: usize = 10_000;
async fn wait_for_drain_tasks(drain_tasks: &AtomicUsize) {
for _ in 0..MAX_DRAIN_EXIT_YIELDS {
if drain_tasks.load(Ordering::Acquire) == 0 {
return;
}
yield_now().await;
}
panic!("drain task did not exit within {MAX_DRAIN_EXIT_YIELDS} scheduler turns");
}
async fn wait_for_drain_exit(secondary: &TeeSecondary) {
wait_for_drain_tasks(&secondary.shared.drain_tasks).await;
}
fn drain_error(err: &io::Error) -> Option<&TeeDrainLimitExceeded> {
err.get_ref().and_then(|inner| inner.downcast_ref::<TeeDrainLimitExceeded>())
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, ..ProptestConfig::default() })]
#[test]
fn tee_reader_property_both_sides_match_source(
len in prop_oneof![3 => 0usize..=64 * 1024, 1 => 0usize..=8 * 1024 * 1024],
chunk_sizes in prop::collection::vec(1usize..=256 * 1024, 1..8),
buffer_bytes in prop_oneof![1 => 1usize..=64, 1 => 1usize..=1024 * 1024],
primary_read in 1usize..=192 * 1024,
secondary_read in 1usize..=192 * 1024,
seed in any::<u64>(),
) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let data = pattern_bytes(len, seed);
let (primary_out, secondary_out) = runtime.block_on(async {
let (mut primary, mut secondary) =
tee_reader(ChunkedSource::new(data.clone(), chunk_sizes).yielding(), buffer_bytes);
let primary_task = tokio::spawn(async move { read_all(&mut primary, primary_read).await });
let secondary_out = read_all(&mut secondary, secondary_read).await;
(primary_task.await.expect("primary task"), secondary_out)
});
prop_assert_eq!(&primary_out.expect("primary read"), &data);
prop_assert_eq!(&secondary_out.expect("secondary read"), &data);
}
}
#[tokio::test]
async fn tee_reader_backpressure_primary_pending_until_secondary_consumes() {
let data = pattern_bytes(16 * 1024, 7);
let (mut primary, mut secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![512]), 1024);
let mut buf = vec![0u8; 4096];
let mut advanced = 0usize;
loop {
let mut read = tokio_test::task::spawn(primary.read(&mut buf));
match read.poll() {
Poll::Ready(Ok(n)) => {
assert!(n > 0, "source must not hit EOF during the backpressure phase");
advanced += n;
}
Poll::Ready(Err(err)) => panic!("unexpected error: {err}"),
Poll::Pending => break,
}
}
assert!(advanced <= 1024 + 512, "primary advanced {advanced} bytes past buffer + one chunk");
assert!(advanced >= 1024, "primary must fill the buffer before blocking, got {advanced}");
// A blocked primary stays blocked until the secondary consumes.
let mut blocked = tokio_test::task::spawn(primary.read(&mut buf));
assert!(blocked.poll().is_pending());
drop(blocked);
let mut secondary_buf = vec![0u8; 256];
let n = secondary.read(&mut secondary_buf).await.expect("secondary read");
assert_eq!(n, 256);
assert_eq!(&secondary_buf[..n], &data[..256]);
let mut resumed = tokio_test::task::spawn(primary.read(&mut buf));
match resumed.poll() {
Poll::Ready(Ok(n)) => assert!(n > 0),
other => panic!("primary must resume after secondary consumed, got {other:?}"),
}
}
#[tokio::test]
async fn tee_reader_source_error_propagates_kind_to_both() {
let data = pattern_bytes(8 * 1024, 11);
let source = ChunkedSource::new(data.clone(), vec![1024]).failing_at(3072, io::ErrorKind::ConnectionReset);
let (mut primary, mut secondary) = tee_reader(source, 64 * 1024);
let mut primary_out = Vec::new();
let primary_err = primary
.read_to_end(&mut primary_out)
.await
.expect_err("primary must observe the source error");
assert_eq!(primary_err.kind(), io::ErrorKind::ConnectionReset);
assert_eq!(primary_out, &data[..3072]);
let mut secondary_out = Vec::new();
let secondary_err = secondary
.read_to_end(&mut secondary_out)
.await
.expect_err("secondary must not see EOF after a source error");
assert_eq!(secondary_err.kind(), io::ErrorKind::ConnectionReset);
assert_eq!(secondary_out, &data[..3072]);
}
#[tokio::test]
async fn tee_reader_source_error_reaches_stream() {
let data = pattern_bytes(2048, 5);
let source = ChunkedSource::new(data.clone(), vec![1024]).failing_at(1024, io::ErrorKind::TimedOut);
let (mut primary, secondary) = tee_reader(source, 64 * 1024);
let mut stream = secondary.into_stream();
let mut sink = Vec::new();
let _ = primary.read_to_end(&mut sink).await.expect_err("source fails");
let mut out = Vec::new();
let err = loop {
match stream.next().await.expect("stream ends with an error item, not None") {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(err) => break err,
}
};
assert_eq!(out, &data[..1024]);
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
assert!(stream.next().await.is_none(), "stream is fused after an error");
}
#[tokio::test]
async fn tee_reader_secondary_drop_primary_reads_full() {
let data = pattern_bytes(64 * 1024, 3);
// Buffer far smaller than the data: without the drop the primary would block.
let (mut primary, secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![4096]), 1024);
let mut first = vec![0u8; 1024];
let n = primary.read(&mut first).await.expect("first read");
assert_eq!(n, 1024);
drop(secondary);
let mut rest = Vec::new();
primary
.read_to_end(&mut rest)
.await
.expect("primary continues after secondary drop");
let mut all = first;
all.extend_from_slice(&rest);
assert_eq!(all, data);
}
#[tokio::test]
async fn tee_reader_primary_drop_default_secondary_broken_pipe() {
let data = pattern_bytes(16 * 1024, 9);
let (mut primary, mut secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![1024]), 64 * 1024);
let mut head = vec![0u8; 2048];
primary.read_exact(&mut head).await.expect("read head");
drop(primary);
let mut buf = vec![0u8; 4096];
let err = secondary.read(&mut buf).await.expect_err("secondary must fail immediately");
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
let again = secondary.read(&mut buf).await.expect_err("error is sticky");
assert_eq!(again.kind(), io::ErrorKind::BrokenPipe);
}
#[tokio::test]
async fn tee_reader_primary_drop_after_eof_keeps_secondary_complete() {
let data = pattern_bytes(8 * 1024, 13);
let (mut primary, mut secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![1000]), 64 * 1024);
let mut sink = Vec::new();
primary.read_to_end(&mut sink).await.expect("primary to EOF");
drop(primary);
let out = read_all(&mut secondary, 777).await.expect("secondary reads everything");
assert_eq!(out, data);
}
#[tokio::test(start_paused = true)]
async fn tee_reader_primary_drop_drain_secondary_reads_full_and_task_exits() {
let data = pattern_bytes(256 * 1024, 17);
let options = TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: DEFAULT_TEE_MAX_DRAIN_BYTES,
};
// Small buffer so the drain task has to wait for the secondary repeatedly.
let (mut primary, mut secondary) =
tee_reader_with_options(ChunkedSource::new(data.clone(), vec![3000]).yielding(), 4096, options);
let mut head = vec![0u8; 5000];
primary.read_exact(&mut head).await.expect("read head");
assert_eq!(secondary.active_drain_tasks(), 0);
drop(primary);
assert_eq!(secondary.active_drain_tasks(), 1);
let out = read_all(&mut secondary, 1500)
.await
.expect("secondary reads everything via drain");
assert_eq!(out, data);
wait_for_drain_exit(&secondary).await;
assert_eq!(secondary.active_drain_tasks(), 0);
let mut buf = [0u8; 8];
assert_eq!(secondary.read(&mut buf).await.expect("EOF stays EOF"), 0);
}
#[tokio::test(start_paused = true)]
async fn tee_reader_drain_limit_exceeded_secondary_error() {
let data = pattern_bytes(64 * 1024, 19);
let options = TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: 10 * 1024,
};
let (mut primary, mut secondary) =
tee_reader_with_options(ChunkedSource::new(data.clone(), vec![1024]), 64 * 1024, options);
let mut head = vec![0u8; 4096];
primary.read_exact(&mut head).await.expect("read head");
drop(primary);
let mut out = Vec::new();
let err = secondary
.read_to_end(&mut out)
.await
.expect_err("hitting max_drain_bytes must not look like EOF");
assert_eq!(err.kind(), io::ErrorKind::Other);
assert_eq!(
drain_error(&err),
Some(&TeeDrainLimitExceeded {
max_drain_bytes: 10 * 1024
})
);
assert_eq!(out, &data[..4096 + 10 * 1024], "bytes within the budget are still delivered");
wait_for_drain_exit(&secondary).await;
}
#[tokio::test(start_paused = true)]
async fn tee_reader_drain_limit_exact_eof_is_clean() {
let data = pattern_bytes(12 * 1024, 23);
let options = TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: 8 * 1024,
};
let (mut primary, mut secondary) =
tee_reader_with_options(ChunkedSource::new(data.clone(), vec![1024]), 64 * 1024, options);
let mut head = vec![0u8; 4096];
primary.read_exact(&mut head).await.expect("read head");
drop(primary);
let out = read_all(&mut secondary, 4096)
.await
.expect("source ends exactly at the budget");
assert_eq!(out, data);
wait_for_drain_exit(&secondary).await;
}
#[tokio::test(start_paused = true)]
async fn tee_reader_drain_stops_when_secondary_dropped() {
let data = pattern_bytes(1024 * 1024, 29);
let options = TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: DEFAULT_TEE_MAX_DRAIN_BYTES,
};
let (mut primary, mut secondary) =
tee_reader_with_options(ChunkedSource::new(data, vec![4096]).yielding(), 1024, options);
let mut head = vec![0u8; 1024];
primary.read_exact(&mut head).await.expect("read head");
drop(primary);
assert_eq!(secondary.active_drain_tasks(), 1);
let mut buf = vec![0u8; 512];
secondary.read_exact(&mut buf).await.expect("one read while draining");
let shared = Arc::clone(&secondary.shared);
drop(secondary);
wait_for_drain_tasks(&shared.drain_tasks).await;
assert!(shared.lock().queue.is_empty());
}
#[test]
fn tee_reader_drain_without_runtime_falls_back_to_broken_pipe() {
let data = pattern_bytes(4096, 31);
let options = TeeOptions {
drain_on_primary_drop: true,
max_drain_bytes: DEFAULT_TEE_MAX_DRAIN_BYTES,
};
let (mut primary, mut secondary) = tee_reader_with_options(ChunkedSource::new(data, vec![1024]), 64 * 1024, options);
let mut head = vec![0u8; 1024];
tokio_test::block_on(primary.read_exact(&mut head)).expect("read head");
drop(primary);
assert_eq!(secondary.active_drain_tasks(), 0);
let mut buf = vec![0u8; 64];
let err = tokio_test::block_on(secondary.read(&mut buf)).expect_err("no runtime to drain on");
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[tokio::test]
async fn tee_reader_cancel_safety_no_lost_or_duplicate_chunks() {
let data = pattern_bytes(96 * 1024, 37);
let source = ChunkedSource::new(data.clone(), vec![700, 1300, 1, 4096, 333]).yielding();
let (mut primary, mut secondary) = tee_reader(source, 2048);
let mut primary_out = Vec::new();
let mut secondary_out = Vec::new();
let mut primary_done = false;
let mut secondary_done = false;
let mut primary_buf = vec![0u8; 2500];
let mut secondary_buf = vec![0u8; 900];
let mut cancelled = 0usize;
while !(primary_done && secondary_done) {
if !primary_done {
let mut read = tokio_test::task::spawn(primary.read(&mut primary_buf));
match read.poll() {
Poll::Ready(Ok(0)) => primary_done = true,
Poll::Ready(Ok(n)) => {
drop(read);
primary_out.extend_from_slice(&primary_buf[..n]);
}
Poll::Ready(Err(err)) => panic!("primary error: {err}"),
// Cancel the read future at its Pending point.
Poll::Pending => cancelled += 1,
}
}
if !secondary_done {
let mut read = tokio_test::task::spawn(secondary.read(&mut secondary_buf));
match read.poll() {
Poll::Ready(Ok(0)) => secondary_done = true,
Poll::Ready(Ok(n)) => {
drop(read);
secondary_out.extend_from_slice(&secondary_buf[..n]);
}
Poll::Ready(Err(err)) => panic!("secondary error: {err}"),
Poll::Pending => cancelled += 1,
}
}
}
assert!(cancelled > 0, "the test must actually exercise cancellation");
assert_eq!(primary_out, data);
assert_eq!(secondary_out, data);
}
#[tokio::test]
async fn tee_reader_into_stream_yields_whole_chunks() {
let data = pattern_bytes(10 * 1024, 41);
let (mut primary, secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![1024, 2048]), 64 * 1024);
let mut stream = secondary.into_stream();
let mut sink = Vec::new();
primary.read_to_end(&mut sink).await.expect("primary reads all");
assert_eq!(sink, data);
let mut chunks = Vec::new();
let mut out = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.expect("chunk ok");
chunks.push(chunk.len());
out.extend_from_slice(&chunk);
}
assert_eq!(out, data);
assert!(chunks.len() >= 2, "chunks should map to source reads, got {chunks:?}");
assert!(stream.next().await.is_none(), "stream is fused after EOF");
}
#[tokio::test]
async fn tee_reader_empty_source_gives_both_sides_eof() {
let (mut primary, mut secondary) = tee_reader(ChunkedSource::new(Vec::new(), vec![1024]), 16);
let mut buf = [0u8; 16];
assert_eq!(primary.read(&mut buf).await.expect("primary"), 0);
assert_eq!(secondary.read(&mut buf).await.expect("secondary"), 0);
}
#[tokio::test]
async fn tee_reader_zero_capacity_read_does_not_touch_source() {
let data = pattern_bytes(2048, 43);
// Buffer larger than the data: the sequential primary-then-secondary reads below must not block.
let (mut primary, mut secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![1024]), 64 * 1024);
let mut empty = [];
assert_eq!(primary.read(&mut empty).await.expect("empty primary read"), 0);
assert_eq!(secondary.read(&mut empty).await.expect("empty secondary read"), 0);
let mut sink = Vec::new();
primary.read_to_end(&mut sink).await.expect("primary");
assert_eq!(sink, data);
assert_eq!(read_all(&mut secondary, 100).await.expect("secondary"), data);
}
#[tokio::test]
async fn tee_reader_secondary_feeds_hash_reader() {
let data = pattern_bytes(3 * 1024, 47);
let (mut primary, secondary) = tee_reader(ChunkedSource::new(data.clone(), vec![512]), 64 * 1024);
let size = data.len() as i64;
let mut hash_reader = crate::HashReader::from_stream(secondary, size, size, None, None, false).expect("hash reader");
let mut sink = Vec::new();
primary.read_to_end(&mut sink).await.expect("primary");
let mut out = Vec::new();
hash_reader
.read_to_end(&mut out)
.await
.expect("hash reader consumes the secondary");
assert_eq!(out, data);
}
}