use super::*;
#[test]
fn watchdog_detects_stall_and_flags_recovered() {
struct WholeChunkBurst;
impl CaptureBackend for WholeChunkBurst {
fn native_format(&self) -> (u32, u16) {
(48_000, 2)
}
fn start(&mut self, mut sink: RawSink) -> Result<()> {
sink.push(&[0.25; 15 * 960 * 2], 0);
Ok(())
}
fn stop(&mut self) {}
}
let backend = Box::new(WholeChunkBurst);
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.enable_capture_tap().expect("canonical tap");
stream.start().expect("start");
let mut chunks: Vec<AudioChunk> = Vec::new();
let mut saw_stalled = false;
let mut saw_recovered = false;
let deadline = Instant::now() + Duration::from_secs(8);
let mut recovered_chunk_seen = false;
while Instant::now() < deadline && !recovered_chunk_seen {
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::RECOVERED) {
recovered_chunk_seen = true;
}
chunks.push(c);
}
while let Some(ev) = stream.poll_event() {
match ev {
Event::StreamStalled => saw_stalled = true,
Event::StreamRecovered => saw_recovered = true,
_ => {}
}
}
thread::sleep(Duration::from_millis(20));
}
stream.stop();
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::RECOVERED) {
recovered_chunk_seen = true;
}
chunks.push(c);
}
let capture: Vec<_> = std::iter::from_fn(|| stream.poll_capture()).collect();
assert!(!capture.is_empty());
assert!(capture
.iter()
.any(|chunk| chunk.flags.contains(ChunkFlags::DISCONTINUITY)));
for pair in capture.windows(2) {
assert_eq!(
pair[1].frame_index - pair[0].frame_index,
(pair[1].seq - pair[0].seq) * 960
);
}
for pair in chunks.windows(2) {
assert_eq!(
pair[1].frame_index - pair[0].frame_index,
(pair[1].seq - pair[0].seq) * 960,
"recovery must preserve the producer timeline, including queue drops"
);
}
assert!(saw_stalled, "expected Event::StreamStalled to fire");
assert!(saw_recovered, "expected Event::StreamRecovered to fire");
assert!(
recovered_chunk_seen,
"expected RECOVERED on the first post-recovery chunk"
);
let recovered: Vec<&AudioChunk> = chunks
.iter()
.filter(|c| c.flags.contains(ChunkFlags::RECOVERED))
.collect();
assert!(!recovered.is_empty());
for c in &recovered {
assert!(
c.flags.contains(ChunkFlags::DISCONTINUITY),
"expected DISCONTINUITY with RECOVERED: flags={:?}",
c.flags
);
}
for w in chunks.windows(2) {
assert!(
w[1].seq > w[0].seq,
"seq should increase monotonically across recovery: {} -> {}",
w[0].seq,
w[1].seq
);
}
}
#[test]
fn no_recovered_flag_under_steady_feed() {
let backend = Box::new(MockBackend::new(48_000, 2, 440.0));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(500));
let mut saw_stalled = false;
while let Some(ev) = stream.poll_event() {
if matches!(ev, Event::StreamStalled) {
saw_stalled = true;
}
}
stream.stop();
assert!(!chunks.is_empty(), "expected chunks with steady input");
assert!(
!saw_stalled,
"steady input should not be reported as stalled"
);
for c in &chunks {
assert!(
!c.flags.contains(ChunkFlags::RECOVERED),
"RECOVERED should not be set with steady input: flags={:?}",
c.flags
);
}
}
#[test]
fn backend_panic_in_start_returns_err_not_silent_death() {
let backend = Box::new(PanickingMockBackend::new(
48_000,
2,
440.0,
PanicMode::Start,
));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
let result = stream.start();
match result {
Ok(()) => panic!("backend panicked in start, but start() returned Ok"),
Err(Error::Backend(msg)) => {
assert!(
msg.contains("panicked"),
"expected Error::Backend with a message identifying the panic: {msg}"
);
}
Err(other) => panic!("expected Error::Backend, got a different error: {other:?}"),
}
stream.stop();
}
#[test]
fn backend_panic_in_stop_does_not_kill_process() {
let backend = Box::new(PanickingMockBackend::new(48_000, 2, 440.0, PanicMode::Stop));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let chunks = collect_for(&mut stream, Duration::from_millis(300));
assert!(
!chunks.is_empty(),
"chunks should flow normally before stop (happy path unchanged)"
);
stream.stop();
let _ = stream.poll_chunk();
let _ = stream.poll_event();
}
#[test]
fn backend_panic_on_watchdog_reopen_surfaces_event_error() {
let backend = Box::new(StallThenPanicOnReopenBackend::new(
48_000,
2,
440.0,
Duration::from_millis(300),
));
let mut stream = Stream::open(StreamConfig::default(), backend).expect("open");
stream.start().expect("start");
let mut saw_stalled = false;
let mut saw_reopen_error = false;
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline && !saw_reopen_error {
while stream.poll_chunk().is_some() {}
while let Some(ev) = stream.poll_event() {
match ev {
Event::StreamStalled => saw_stalled = true,
Event::RecoverableError {
error: Error::Context { context, .. },
} if context.operation() == Operation::Reopen => {
saw_reopen_error = true;
}
_ => {}
}
}
thread::sleep(Duration::from_millis(20));
}
stream.stop();
assert!(
saw_stalled,
"expected stall detection (Event::StreamStalled)"
);
assert!(
saw_reopen_error,
"backend panic during reopen should surface as RecoverableError with Reopen context \
(no silent death)"
);
}
#[test]
fn ring_overflow_marks_discontinuity() {
let backend = Box::new(FloodingMockBackend::new());
let config = StreamConfig {
ring_capacity_chunks: 200,
..Default::default()
};
let mut stream = Stream::open(config, backend).expect("open");
stream.start().expect("start");
let mut saw_discontinuity = false;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && !saw_discontinuity {
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::DISCONTINUITY) {
saw_discontinuity = true;
}
}
thread::sleep(Duration::from_millis(10));
}
stream.stop();
while let Some(c) = stream.poll_chunk() {
if c.flags.contains(ChunkFlags::DISCONTINUITY) {
saw_discontinuity = true;
}
}
assert!(
saw_discontinuity,
"RawRing overflow should set DISCONTINUITY"
);
}