use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crossbeam_channel::{Sender, TrySendError, bounded};
use crate::error::Error;
use crate::halt::Halt;
pub const JOIN_TIMEOUT_SECS: u64 = 600;
use crate::halt::POLL_INTERVAL;
const SEND_HALT_CHECK_INTERVAL: Duration = POLL_INTERVAL;
pub fn debug_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("FREEMKV_DEBUG")
.ok()
.map(|v| v == "1" || v == "true" || v == "yes")
.unwrap_or(false)
})
}
fn consumer_panicked(payload: Box<dyn std::any::Any + Send>) -> Error {
let msg = payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("(no message)");
tracing::error!(
target: "freemkv::pipeline",
phase = "consumer_panicked",
panic_message = msg,
"pipeline consumer thread panicked"
);
Error::PipelineConsumerPanicked
}
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
pub const READ_PIPELINE_DEPTH: usize = 32;
pub const WRITE_PIPELINE_DEPTH: usize = 16;
pub const WRITE_THROUGH_DEPTH: usize = 1;
pub enum Flow {
Continue,
#[allow(dead_code)]
Stop,
}
pub trait Sink<I>: Send + 'static {
type Output: Send + 'static;
fn apply(&mut self, item: I) -> Result<Flow, Error>;
fn close(self) -> Result<Self::Output, Error>;
}
pub struct Pipeline<I: Send + 'static, R: Send + 'static> {
tx: Sender<I>,
handle: JoinHandle<Result<R, Error>>,
}
impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
}
pub fn spawn_named<S: Sink<I, Output = R>>(
name: &str,
depth: usize,
sink: S,
) -> Result<Self, Error> {
let (tx, rx) = bounded::<I>(depth);
let handle = thread::Builder::new()
.name(name.into())
.spawn(move || -> Result<R, Error> {
let mut sink = sink;
let mut first_err: Option<Error> = None;
let mut stopped = false;
while let Ok(item) = rx.recv() {
let debug = debug_enabled();
if debug {
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
}
if first_err.is_some() || stopped {
continue;
}
let apply_start = debug.then(Instant::now);
match sink.apply(item) {
Ok(Flow::Continue) => {}
Ok(Flow::Stop) => {
stopped = true;
if debug {
tracing::debug!("Pipeline: consumer returned Flow::Stop");
}
}
Err(e) => {
if debug {
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
}
first_err = Some(e);
}
}
if let Some(start) = apply_start {
let apply_elapsed = start.elapsed();
if apply_elapsed > Duration::from_millis(100) {
tracing::debug!(
"Pipeline apply: took {:.2}s, item={}",
apply_elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else {
tracing::debug!(
"Pipeline apply: OK in {:.3}ms, item={}",
apply_elapsed.as_micros(),
std::any::type_name::<I>()
);
}
}
}
match first_err {
Some(e) => Err(e),
None => sink.close(),
}
})
.map_err(|e| Error::IoError { source: e })?;
Ok(Pipeline { tx, handle })
}
pub fn send(&self, item: I) -> Result<(), I> {
let start = debug_enabled().then(Instant::now);
match self.tx.send(item) {
Ok(()) => {
if let Some(start) = start {
let elapsed = start.elapsed();
if elapsed > Duration::from_millis(10) {
tracing::debug!(
"Pipeline send: blocked {:.2}s, item={}",
elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else {
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
}
}
Ok(())
}
Err(e) => {
if let Some(start) = start {
let elapsed = start.elapsed();
if elapsed > Duration::from_millis(10) {
tracing::debug!(
"Pipeline send: blocked {:.2}s before channel closed, item={}",
elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else {
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
}
}
Err(e.0)
}
}
}
pub fn try_send(&self, item: I) -> Result<(), TrySendError<I>> {
self.tx.try_send(item)
}
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
use crossbeam_channel::SendTimeoutError;
let end = Instant::now() + deadline;
let mut pending = item;
loop {
if halt.is_cancelled() {
if debug_enabled() {
tracing::debug!(
"Pipeline send_with_halt: halt observed, returning item={}",
std::any::type_name::<I>()
);
}
return Err(pending);
}
let now = Instant::now();
if now >= end {
if debug_enabled() {
tracing::debug!(
"Pipeline send_with_halt: deadline elapsed, returning item={}",
std::any::type_name::<I>()
);
}
return Err(pending);
}
let slice = SEND_HALT_CHECK_INTERVAL.min(end.saturating_duration_since(now));
match self.tx.send_timeout(pending, slice) {
Ok(()) => return Ok(()),
Err(SendTimeoutError::Timeout(returned)) => {
pending = returned;
}
Err(SendTimeoutError::Disconnected(returned)) => {
if debug_enabled() {
tracing::debug!(
"Pipeline send_with_halt: consumer disconnected, item={}",
std::any::type_name::<I>()
);
}
return Err(returned);
}
}
}
}
pub fn finish(self) -> Result<R, Error> {
let Pipeline { tx, handle } = self;
drop(tx);
match handle.join() {
Ok(result) => result,
Err(payload) => Err(consumer_panicked(payload)),
}
}
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
let Pipeline { tx, handle } = self;
drop(tx);
let deadline = Instant::now() + Duration::from_secs(JOIN_TIMEOUT_SECS);
loop {
if handle.is_finished() {
return match handle.join() {
Ok(result) => result,
Err(payload) => Err(consumer_panicked(payload)),
};
}
if let Some(h) = halt {
if h.is_cancelled() {
return Err(Error::Halted);
}
}
if Instant::now() >= deadline {
return Err(Error::PipelineJoinTimeout);
}
thread::sleep(POLL_INTERVAL);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
struct SumSink {
total: u64,
}
impl Sink<u64> for SumSink {
type Output = u64;
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
self.total += item;
Ok(Flow::Continue)
}
fn close(self) -> Result<u64, Error> {
Ok(self.total)
}
}
#[test]
fn happy_path_sums_items() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 })
.expect("spawn should succeed");
let mut expected = 0u64;
for i in 0..100u64 {
expected += i;
pipe.send(i).expect("send should succeed");
}
let total = pipe.finish().expect("finish should succeed");
assert_eq!(total, expected);
assert_eq!(total, (0..100u64).sum::<u64>());
}
struct SlowSink {
delay: Duration,
count: Arc<AtomicUsize>,
}
impl Sink<()> for SlowSink {
type Output = usize;
fn apply(&mut self, _item: ()) -> Result<Flow, Error> {
std::thread::sleep(self.delay);
self.count.fetch_add(1, Ordering::SeqCst);
Ok(Flow::Continue)
}
fn close(self) -> Result<usize, Error> {
Ok(self.count.load(Ordering::SeqCst))
}
}
#[test]
fn back_pressure_blocks_sender() {
let count = Arc::new(AtomicUsize::new(0));
let sink = SlowSink {
delay: Duration::from_millis(50),
count: count.clone(),
};
let pipe = Pipeline::spawn(2, sink).expect("spawn should succeed");
let start = Instant::now();
for _ in 0..5 {
pipe.send(()).expect("send should succeed");
}
let elapsed_send = start.elapsed();
let total = pipe.finish().expect("finish should succeed");
assert_eq!(total, 5);
assert!(
elapsed_send >= Duration::from_millis(80),
"back-pressure not observed: 5 sends with depth=2 and 50ms/apply \
took {elapsed_send:?}, expected ≥ ~100ms (one or more sends \
should have blocked behind the consumer)"
);
}
struct FailOnNthSink {
n: usize,
seen: Arc<AtomicUsize>,
close_called: Arc<AtomicUsize>,
}
impl Sink<u64> for FailOnNthSink {
type Output = ();
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
let i = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
if i == self.n {
Err(Error::DecryptFailed)
} else {
Ok(Flow::Continue)
}
}
fn close(self) -> Result<(), Error> {
self.close_called.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[test]
fn apply_error_drains_then_propagates() {
let seen = Arc::new(AtomicUsize::new(0));
let close_called = Arc::new(AtomicUsize::new(0));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
FailOnNthSink {
n: 3,
seen: seen.clone(),
close_called: close_called.clone(),
},
)
.expect("spawn should succeed");
for i in 0..10u64 {
pipe.send(i).expect("send should succeed even after error");
}
let res = pipe.finish();
assert!(matches!(res, Err(Error::DecryptFailed)));
assert_eq!(
close_called.load(Ordering::SeqCst),
0,
"close() must not be called when apply returned Err"
);
assert_eq!(seen.load(Ordering::SeqCst), 3);
}
struct StopOnNthSink {
n: usize,
seen: Arc<AtomicUsize>,
close_called: Arc<AtomicUsize>,
}
impl Sink<u64> for StopOnNthSink {
type Output = usize;
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
let i = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
if i >= self.n {
Ok(Flow::Stop)
} else {
Ok(Flow::Continue)
}
}
fn close(self) -> Result<usize, Error> {
self.close_called.fetch_add(1, Ordering::SeqCst);
Ok(self.seen.load(Ordering::SeqCst))
}
}
#[test]
fn apply_stop_calls_close_and_returns_output() {
let seen = Arc::new(AtomicUsize::new(0));
let close_called = Arc::new(AtomicUsize::new(0));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
StopOnNthSink {
n: 3,
seen: seen.clone(),
close_called: close_called.clone(),
},
)
.expect("spawn should succeed");
for i in 0..10u64 {
let _ = pipe.send(i);
}
let out = pipe.finish().expect("finish should succeed after Stop");
assert_eq!(close_called.load(Ordering::SeqCst), 1);
assert!(
out >= 3,
"expected ≥ 3 applies before Stop took effect, got {out}"
);
}
struct PanickingSink;
impl Sink<u64> for PanickingSink {
type Output = ();
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
panic!("synthetic test panic");
}
fn close(self) -> Result<(), Error> {
Ok(())
}
}
#[test]
fn consumer_panic_becomes_io_error() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe =
Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn should succeed");
let _ = pipe.send(1);
for i in 0..5u64 {
let _ = pipe.send(i);
}
let res = pipe.finish();
std::panic::set_hook(prev);
assert!(
matches!(res, Err(Error::PipelineConsumerPanicked)),
"expected Err(PipelineConsumerPanicked), got {res:?}"
);
}
struct NeverDrainsSink {
cancel: Arc<std::sync::atomic::AtomicBool>,
started: Arc<std::sync::atomic::AtomicBool>,
}
impl Sink<u64> for NeverDrainsSink {
type Output = ();
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
self.started.store(true, Ordering::SeqCst);
while !self.cancel.load(Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(20));
}
Ok(Flow::Continue)
}
fn close(self) -> Result<(), Error> {
Ok(())
}
}
fn wait_for_started(started: &Arc<std::sync::atomic::AtomicBool>, bail: Duration) {
let end = Instant::now() + bail;
while !started.load(Ordering::SeqCst) {
assert!(Instant::now() < end, "consumer never started apply()");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn send_with_halt_returns_item_on_deadline() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
1,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn should succeed");
pipe.send(0u64).expect("first send hands off to consumer");
wait_for_started(&started, Duration::from_secs(2));
pipe.send(1u64).expect("second send fills the buffer");
let halt = crate::halt::Halt::new();
let start = Instant::now();
let res = pipe.send_with_halt(99u64, &halt, Duration::from_millis(200));
let elapsed = start.elapsed();
cancel.store(true, Ordering::SeqCst);
let _ = pipe.finish();
assert!(matches!(res, Err(99)), "expected item returned on deadline");
assert!(
elapsed >= Duration::from_millis(150),
"deadline returned too early: {elapsed:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"deadline blew past tolerance: {elapsed:?}"
);
}
#[test]
fn send_with_halt_returns_item_on_halt() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
1,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn should succeed");
pipe.send(0u64).expect("first send hands off to consumer");
wait_for_started(&started, Duration::from_secs(2));
pipe.send(1u64).expect("second send fills the buffer");
let halt = crate::halt::Halt::new();
let halt2 = halt.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
halt2.cancel();
});
let start = Instant::now();
let res = pipe.send_with_halt(7u64, &halt, Duration::from_secs(10));
let elapsed = start.elapsed();
cancel.store(true, Ordering::SeqCst);
let _ = pipe.finish();
assert!(matches!(res, Err(7)), "expected item returned on halt");
assert!(
elapsed < Duration::from_secs(2),
"halt observation took too long: {elapsed:?}"
);
}
#[test]
fn finish_with_halt_returns_halted_when_consumer_wedged() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn should succeed");
pipe.send(0u64).expect("seed item the consumer wedges on");
wait_for_started(&started, Duration::from_secs(2));
let halt = crate::halt::Halt::new();
let halt2 = halt.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(400));
halt2.cancel();
});
let start = Instant::now();
let res = pipe.finish_with_halt(Some(&halt));
let elapsed = start.elapsed();
cancel.store(true, Ordering::SeqCst);
assert!(
matches!(res, Err(Error::Halted)),
"expected Err(Halted), got {res:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"halt observation took too long: {elapsed:?}"
);
}
#[test]
fn finish_with_halt_happy_path_returns_output() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 })
.expect("spawn should succeed");
for i in 0..10u64 {
pipe.send(i).expect("send should succeed");
}
let total = pipe
.finish_with_halt(None)
.expect("happy-path finish_with_halt should succeed");
assert_eq!(total, (0..10u64).sum::<u64>());
}
struct OrderSink {
seen: Vec<u64>,
}
impl Sink<u64> for OrderSink {
type Output = Vec<u64>;
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
self.seen.push(item);
Ok(Flow::Continue)
}
fn close(self) -> Result<Vec<u64>, Error> {
Ok(self.seen)
}
}
#[test]
fn items_delivered_in_fifo_order() {
let pipe =
Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, OrderSink { seen: Vec::new() }).expect("spawn");
let input: Vec<u64> = (0..50).map(|i| i * 7 + 1).collect();
for &i in &input {
pipe.send(i).expect("send");
}
let seen = pipe.finish().expect("finish");
assert_eq!(seen, input, "pipeline reordered or dropped items");
}
#[test]
fn empty_pipeline_still_calls_close() {
let close_called = Arc::new(AtomicUsize::new(0));
struct CountClose(Arc<AtomicUsize>);
impl Sink<u64> for CountClose {
type Output = ();
fn apply(&mut self, _: u64) -> Result<Flow, Error> {
Ok(Flow::Continue)
}
fn close(self) -> Result<(), Error> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, CountClose(close_called.clone()))
.expect("spawn");
pipe.finish().expect("finish on empty pipeline");
assert_eq!(close_called.load(Ordering::SeqCst), 1);
}
#[test]
fn close_error_propagates_from_finish() {
struct CloseFails;
impl Sink<u64> for CloseFails {
type Output = ();
fn apply(&mut self, _: u64) -> Result<Flow, Error> {
Ok(Flow::Continue)
}
fn close(self) -> Result<(), Error> {
Err(Error::DecryptFailed)
}
}
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, CloseFails).expect("spawn");
pipe.send(1).expect("send");
let res = pipe.finish();
assert!(matches!(res, Err(Error::DecryptFailed)));
}
#[test]
fn try_send_reports_full_when_saturated() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
1,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn");
pipe.send(0u64).expect("first send hands off to consumer");
wait_for_started(&started, Duration::from_secs(2));
pipe.send(1u64)
.expect("second send fills the depth-1 buffer");
let r = pipe.try_send(2u64);
assert!(
matches!(r, Err(TrySendError::Full(2))),
"expected Full(2), got {r:?}"
);
cancel.store(true, Ordering::SeqCst);
let _ = pipe.finish();
}
#[test]
fn try_send_reports_disconnected_after_consumer_gone() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let end = Instant::now() + Duration::from_secs(2);
let mut saw_disconnect = false;
let mut last = None;
while Instant::now() < end {
match pipe.try_send(1u64) {
Err(TrySendError::Disconnected(_)) => {
saw_disconnect = true;
break;
}
other => last = Some(format!("{other:?}")),
}
std::thread::sleep(Duration::from_millis(10));
}
std::panic::set_hook(prev);
let _ = pipe.finish();
assert!(
saw_disconnect,
"try_send never reported Disconnected; last was {last:?}"
);
}
#[test]
fn send_returns_item_after_consumer_panicked() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let end = Instant::now() + Duration::from_secs(2);
let mut returned = None;
while Instant::now() < end {
if let Err(item) = pipe.send(0xDEAD_BEEF_u64) {
returned = Some(item);
break;
}
std::thread::sleep(Duration::from_millis(10));
}
std::panic::set_hook(prev);
let _ = pipe.finish();
assert_eq!(
returned,
Some(0xDEAD_BEEF_u64),
"send did not hand back the exact item after consumer death"
);
}
#[test]
fn send_with_halt_returns_item_on_disconnect() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let end = Instant::now() + Duration::from_secs(2);
while Instant::now() < end {
if pipe.send(1u64).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
let halt = crate::halt::Halt::new(); let res = pipe.send_with_halt(0xABCD_u64, &halt, Duration::from_secs(5));
std::panic::set_hook(prev);
let _ = pipe.finish();
assert!(
matches!(res, Err(0xABCD)),
"expected disconnected item returned, got {res:?}"
);
assert!(!halt.is_cancelled(), "halt must not have been the cause");
}
#[test]
fn send_with_halt_delivers_when_room_available() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 }).expect("spawn");
let halt = crate::halt::Halt::new();
for i in 1..=5u64 {
pipe.send_with_halt(i, &halt, Duration::from_secs(5))
.expect("send_with_halt should deliver when room is available");
}
let total = pipe.finish().expect("finish");
assert_eq!(total, 15, "1+2+3+4+5");
}
#[test]
fn send_with_halt_precancelled_returns_item_without_send() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 }).expect("spawn");
let halt = crate::halt::Halt::new();
halt.cancel();
let res = pipe.send_with_halt(77u64, &halt, Duration::from_secs(5));
assert!(
matches!(res, Err(77)),
"pre-cancelled halt must return item"
);
let total = pipe.finish().expect("finish");
assert_eq!(total, 0, "item was enqueued despite pre-cancelled halt");
}
#[test]
fn finish_with_halt_propagates_consumer_panic() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let _ = pipe.send(1);
for i in 0..5u64 {
let _ = pipe.send(i);
}
let res = pipe.finish_with_halt(None);
std::panic::set_hook(prev);
assert!(
matches!(res, Err(Error::PipelineConsumerPanicked)),
"expected PipelineConsumerPanicked, got {res:?}"
);
}
#[test]
fn finish_with_halt_none_does_not_spuriously_halt() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn");
pipe.send(0u64).expect("seed");
wait_for_started(&started, Duration::from_secs(2));
let cancel2 = cancel.clone();
let (tx, rx) = bounded::<Result<(), Error>>(1);
std::thread::spawn(move || {
let r = pipe.finish_with_halt(None);
let _ = tx.send(r);
});
assert!(
rx.recv_timeout(Duration::from_millis(600)).is_err(),
"finish_with_halt(None) returned while consumer was wedged"
);
cancel2.store(true, Ordering::SeqCst);
let final_res = rx
.recv_timeout(Duration::from_secs(5))
.expect("finish_with_halt should return after consumer unwedges");
assert!(
final_res.is_ok(),
"expected Ok after release, got {final_res:?}"
);
}
#[test]
fn stop_halts_further_apply_calls() {
let seen = Arc::new(AtomicUsize::new(0));
let close_called = Arc::new(AtomicUsize::new(0));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
StopOnNthSink {
n: 2,
seen: seen.clone(),
close_called: close_called.clone(),
},
)
.expect("spawn");
for i in 0..100u64 {
let _ = pipe.send(i);
}
let out = pipe.finish().expect("finish after stop");
assert_eq!(
close_called.load(Ordering::SeqCst),
1,
"close must run exactly once"
);
assert_eq!(out, 2, "apply was called after Stop");
}
}