fix(core): jump to live on a standing receive backlog instead of ratcheting latency

The embedder-facing frame queue was a 16-deep sync_channel whose try_send
dropped the NEWEST access unit on overflow — backwards for a live stream (keeps
stale, discards fresh), a ~266 ms floor that could not self-drain (producer and
consumer both run at frame rate, so any depth a burst injects is conserved
forever — the latency ratchet), and a silent reference-chain break the loss
counters never saw. The clock-based flush meant to catch it was gated on the
skew handshake and never even drained that queue.

Replace it with a purpose-built FrameChannel (VecDeque + Condvar) exposing
depth() and clear(). Pre-decode AUs are reference-chained under the host's
infinite GOP, so they are never dropped mid-stream; instead, when the embedder
falls persistently behind, the pump JUMPS TO LIVE — flush_backlog() + clear the
queued AUs + request a keyframe — so decode re-anchors cleanly at an IDR.

Two cooldown-gated detectors, both suspended during a speed test:
- clock-based (existing): > FLUSH_LATENCY behind the skew-corrected clock for
  FLUSH_AFTER_FRAMES straight; also catches kernel/reassembler backlog.
- clock-free (new): the hand-off queue sat >= QUEUE_HIGH without draining to
  QUEUE_LOW for STANDING_FRAMES straight. Works on same-clock / no-handshake
  sessions where the clock path is disarmed — the direct "the embedder can't
  keep up" signal. A transient Wi-Fi clump drains in a few frames and never
  trips it.

Bounded (90-frame hard cap, drop-oldest memory backstop) and diagnosable (each
jump logs queue_depth / flushed_datagrams / dropped_frames). next_frame's
external Timeout/Closed contract is unchanged, so every native client inherits
the fix. Adds 5 FrameChannel unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:18:18 +00:00
parent 1dc8dc7f0d
commit bdcbb2d3a7
+269 -45
View File
@@ -21,9 +21,10 @@ use crate::quic::{
}; };
use crate::session::{Frame, Session}; use crate::session::{Frame, Session};
use crate::transport::UdpTransport; use crate::transport::UdpTransport;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// A control-stream request the embedder makes on the open handshake stream: a mode switch or a /// A control-stream request the embedder makes on the open handshake stream: a mode switch or a
@@ -118,29 +119,136 @@ pub struct ProbeOutcome {
pub send_dropped: u32, pub send_dropped: u32,
} }
/// Frames buffered between the data-plane pump and the embedder. Small: the embedder /// Depth at/above which the pre-decode hand-off queue counts as "not draining" for the clock-free
/// (decoder) should drain at frame rate; when it falls behind, the newest frame is dropped /// standing-queue detector. A consumer that keeps up (or drains newest-per-vsync, like the Apple
/// (display freshness over completeness — FEC/keyframes recover). /// client) holds this near 0; a transient Wi-Fi clump or a small jitter buffer spikes it briefly then
const FRAME_QUEUE: usize = 16; /// drains. Sits above a reasonable jitter buffer (~100 ms @ 60 fps) so only a genuine backlog trips it.
const QUEUE_HIGH: usize = 6;
/// Depth at/below which the hand-off queue is considered drained — resets the standing-queue counter.
/// A true standing queue never falls back to this; a clump does within a few frames.
const QUEUE_LOW: usize = 2;
/// Consecutive frames the hand-off queue must sit ≥ [`QUEUE_HIGH`] (never dropping to [`QUEUE_LOW`])
/// before the pump declares a standing backlog and jumps to live. ~0.5 s at 60 fps — long enough that
/// a burst/clump (which drains in a few frames) never reaches it.
const STANDING_FRAMES: u32 = 30;
/// Memory backstop on the pre-decode hand-off queue. The standing-queue detector jumps to live long
/// before this (typically ≤ QUEUE_HIGH + STANDING_FRAMES deep), and a jump already requested a
/// keyframe, so on the rare path that outruns it (a wedged consumer during the flush cooldown) dropping
/// the OLDEST queued AU is safe — the pending IDR re-anchors decode regardless. Purely bounds memory.
const FRAME_QUEUE_HARD_CAP: usize = 90;
/// Backlog latency bound: when completed frames keep arriving further than this behind the host's /// Backlog latency bound: when completed frames keep arriving further than this behind the host's
/// capture clock (skew-corrected), the pump flushes the receive backlog /// capture clock (skew-corrected), the pump jumps to live (discards the receive backlog + the queued
/// ([`Session::flush_backlog`]) and requests a keyframe instead of playing that far behind /// AUs and requests a keyframe) instead of playing that far behind forever. Deliberately generous — an
/// forever. Deliberately generous — an interactive stream is unusable well before 400 ms, but the /// interactive stream is unusable well before 400 ms, but the bound must sit safely above the skew
/// bound must sit safely above the skew handshake's own error (≈ RTT/2) plus normal delivery /// handshake's own error (≈ RTT/2) plus normal delivery jitter so a healthy stream can never trip it.
/// jitter so a healthy stream can never trip it. /// This is the CLOCK-BASED detector; the clock-free [`QUEUE_HIGH`]/[`STANDING_FRAMES`] detector covers
/// same-clock and no-handshake sessions (where `clock_offset_ns == 0` disarms this one).
const FLUSH_LATENCY: Duration = Duration::from_millis(400); const FLUSH_LATENCY: Duration = Duration::from_millis(400);
/// How many CONSECUTIVE over-bound frames arm a flush (~0.5 s at 60 fps). A genuine standing queue /// How many CONSECUTIVE over-bound frames arm the clock-based jump (~0.5 s at 60 fps). A genuine
/// puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears within a /// standing queue puts EVERY frame over the bound; a one-off burst (an IDR, a Wi-Fi scan blip) clears
/// frame or two and never reaches the count. /// within a frame or two and never reaches the count.
const FLUSH_AFTER_FRAMES: u32 = 30; const FLUSH_AFTER_FRAMES: u32 = 30;
/// Minimum spacing between backlog flushes, so a bottleneck that instantly rebuilds the queue (a /// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
/// link that can't sustain the bitrate at all) degrades into a periodic skip + a logged warning /// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
/// instead of a continuous flush/keyframe storm. /// warning instead of a continuous flush/keyframe storm.
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2); const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
/// IDR. So this queue is strictly FIFO and never drops a frame from the middle. When the embedder falls
/// PERSISTENTLY behind — the queue stops draining — the pump JUMPS TO LIVE instead ([`clear`] + a
/// keyframe request), so decode resumes cleanly at an IDR rather than ratcheting latency forever (the
/// old bounded channel silently dropped the NEWEST AU on overflow — backwards for a live stream, and a
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
/// its own, so a clump never costs a keyframe.
///
/// [`clear`]: FrameChannel::clear
struct FrameChannel {
inner: Mutex<FrameQueue>,
ready: Condvar,
}
struct FrameQueue {
q: VecDeque<Frame>,
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
closed: bool,
}
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
/// Timeout/Closed mapping is unchanged.
enum FramePop {
Frame(Frame),
Timeout,
Closed,
}
impl FrameChannel {
fn new() -> Self {
Self {
inner: Mutex::new(FrameQueue {
q: VecDeque::new(),
closed: false,
}),
ready: Condvar::new(),
}
}
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
/// already in flight by the time this can bite).
fn push(&self, frame: Frame) {
let mut st = self.inner.lock().unwrap();
st.q.push_back(frame);
while st.q.len() > FRAME_QUEUE_HARD_CAP {
st.q.pop_front();
}
drop(st);
self.ready.notify_one();
}
/// Pump side: current queued depth — the clock-free standing-queue signal.
fn depth(&self) -> usize {
self.inner.lock().unwrap().q.len()
}
/// Pump side: discard the whole backlog (the jump-to-live path); returns how many were dropped.
fn clear(&self) -> usize {
let mut st = self.inner.lock().unwrap();
let n = st.q.len();
st.q.clear();
n
}
/// Pump side: mark the stream ended and wake every blocked consumer.
fn close(&self) {
self.inner.lock().unwrap().closed = true;
self.ready.notify_all();
}
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed {
st = self.ready.wait_timeout(st, timeout).unwrap().0;
}
if let Some(f) = st.q.pop_front() {
FramePop::Frame(f)
} else if st.closed {
FramePop::Closed
} else {
FramePop::Timeout
}
}
}
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging /// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
/// embedder drops the newest packet (the audio renderer conceals the gap). /// embedder drops the newest packet (the audio renderer conceals the gap).
const AUDIO_QUEUE: usize = 64; const AUDIO_QUEUE: usize = 64;
@@ -177,7 +285,7 @@ pub struct NativeClient {
// embedders can share one `Arc<NativeClient>` across their plane threads (the same // embedders can share one `Arc<NativeClient>` across their plane threads (the same
// one-thread-per-plane contract the C ABI documents — the lock is uncontended there, // one-thread-per-plane contract the C ABI documents — the lock is uncontended there,
// and two threads racing one plane now serialize instead of being undefined). // and two threads racing one plane now serialize instead of being undefined).
frames: Mutex<Receiver<Frame>>, frames: Arc<FrameChannel>,
audio: Mutex<Receiver<AudioPacket>>, audio: Mutex<Receiver<AudioPacket>>,
rumble: Mutex<Receiver<(u16, u16, u16)>>, rumble: Mutex<Receiver<(u16, u16, u16)>>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
@@ -365,7 +473,7 @@ impl NativeClient {
identity: Option<(String, String)>, identity: Option<(String, String)>,
timeout: Duration, timeout: Duration,
) -> Result<NativeClient> { ) -> Result<NativeClient> {
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE); let frame_chan = Arc::new(FrameChannel::new());
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE); let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE); let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
@@ -385,6 +493,7 @@ impl NativeClient {
let hot_tids = Arc::new(Mutex::new(Vec::new())); let hot_tids = Arc::new(Mutex::new(Vec::new()));
let host = host.to_string(); let host = host.to_string();
let frame_chan_w = frame_chan.clone();
let shutdown_w = shutdown.clone(); let shutdown_w = shutdown.clone();
let quit_w = quit.clone(); let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone(); let mode_slot_w = mode_slot.clone();
@@ -424,7 +533,7 @@ impl NativeClient {
launch, launch,
pin, pin,
identity, identity,
frame_tx, frames: frame_chan_w,
audio_tx, audio_tx,
rumble_tx, rumble_tx,
hidout_tx, hidout_tx,
@@ -468,7 +577,7 @@ impl NativeClient {
}; };
*mode_slot.lock().unwrap() = negotiated; *mode_slot.lock().unwrap() = negotiated;
Ok(NativeClient { Ok(NativeClient {
frames: Mutex::new(frame_rx), frames: frame_chan,
audio: Mutex::new(audio_rx), audio: Mutex::new(audio_rx),
rumble: Mutex::new(rumble_rx), rumble: Mutex::new(rumble_rx),
hidout: Mutex::new(hidout_rx), hidout: Mutex::new(hidout_rx),
@@ -735,10 +844,10 @@ impl NativeClient {
/// (`&self` here supports the cross-plane sharing; a plane's queue is still /// (`&self` here supports the cross-plane sharing; a plane's queue is still
/// single-consumer by contract). /// single-consumer by contract).
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> { pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
match self.frames.lock().unwrap().recv_timeout(timeout) { match self.frames.pop(timeout) {
Ok(f) => Ok(f), FramePop::Frame(f) => Ok(f),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), FramePop::Timeout => Err(PunktfunkError::NoFrame),
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed), FramePop::Closed => Err(PunktfunkError::Closed),
} }
} }
@@ -860,7 +969,7 @@ struct WorkerArgs {
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
frame_tx: SyncSender<Frame>, frames: Arc<FrameChannel>,
audio_tx: SyncSender<AudioPacket>, audio_tx: SyncSender<AudioPacket>,
rumble_tx: SyncSender<(u16, u16, u16)>, rumble_tx: SyncSender<(u16, u16, u16)>,
hidout_tx: SyncSender<HidOutput>, hidout_tx: SyncSender<HidOutput>,
@@ -898,7 +1007,7 @@ async fn worker_main(args: WorkerArgs) {
launch, launch,
pin, pin,
identity, identity,
frame_tx, frames,
audio_tx, audio_tx,
rumble_tx, rumble_tx,
hidout_tx, hidout_tx,
@@ -1231,7 +1340,7 @@ async fn worker_main(args: WorkerArgs) {
let pump_probe = probe.clone(); let pump_probe = probe.clone();
let pump_hot_tids = hot_tids.clone(); let pump_hot_tids = hot_tids.clone();
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
pin_thread_user_interactive(); // feeds frame_tx → the client's user-interactive video pump pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
// Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the // Adaptive-FEC loss reporting: every ADAPT_REPORT_INTERVAL, report the loss observed over the
// window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can // window (shards FEC recovered, plus a bump if any frame went unrecoverable) so the host can
@@ -1239,10 +1348,12 @@ async fn worker_main(args: WorkerArgs) {
const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750); const ADAPT_REPORT_INTERVAL: Duration = Duration::from_millis(750);
let mut last_report = Instant::now(); let mut last_report = Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64); let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
// Backlog latency bound (see FLUSH_LATENCY): consecutive over-bound frames + the last // Jump-to-live state (see the guard in the loop below): the clock-based over-bound run
// flush, for the cooldown. Armed only when the skew handshake succeeded (offset ≠ 0) — // (`stale_frames`, armed only when the skew handshake succeeded so the clocks are comparable),
// without it the host and client clocks aren't comparable and the bound would misfire. // the clock-free non-draining-queue run (`standing_frames`), and the last-jump instant for the
// shared cooldown.
let mut stale_frames: u32 = 0; let mut stale_frames: u32 = 0;
let mut standing_frames: u32 = 0;
let mut last_flush: Option<Instant> = None; let mut last_flush: Option<Instant> = None;
while !pump_shutdown.load(Ordering::SeqCst) { while !pump_shutdown.load(Ordering::SeqCst) {
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery // Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
@@ -1278,37 +1389,66 @@ async fn worker_main(args: WorkerArgs) {
if frame.flags & FLAG_PROBE as u32 != 0 { if frame.flags & FLAG_PROBE as u32 != 0 {
continue; // speed-test filler, not video — measured via the counters above continue; // speed-test filler, not video — measured via the counters above
} }
// Latency bound: a standing receive queue (pump transiently outpaced, a Wi-Fi // Jump-to-live guard. A standing receive/hand-off queue never drains by itself —
// stall, power-save clumping) never drains by itself — the pump consumes at // the pump consumes strictly in order at the arrival rate, so once behind, the
// exactly the arrival rate, so once behind, the stream stays behind for good // stream stays behind for good (observed live: stuck 67 s). Pre-decode AUs are
// (observed live: stuck 67 s). When frames keep completing over the bound, // reference-chained (infinite GOP), so we can NOT drop a frame mid-stream to catch
// discard the whole backlog and ask for a keyframe: one visible skip instead of // up; the only safe recovery is to discard the whole backlog and re-anchor decode
// a permanently unusable stream. Suspended during a speed test (the probe // on a fresh keyframe. Two independent "we're behind" signals arm it, both gated by
// MEASURES a saturated queue; flushing would corrupt its receive counters). // FLUSH_COOLDOWN, both suspended during a speed test (the probe MEASURES a saturated
if clock_offset_ns != 0 && !probe_active { // queue; flushing would corrupt its counters):
let lat_ns = // * clock-based — completed frames sit > FLUSH_LATENCY behind the skew-corrected
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128; // capture clock for FLUSH_AFTER_FRAMES straight. Needs the skew handshake, and
if lat_ns > FLUSH_LATENCY.as_nanos() as i128 { // also catches kernel/reassembler backlog the hand-off queue hasn't reached yet.
// * clock-free — the pre-decode hand-off queue stopped draining: its depth stayed
// ≥ QUEUE_HIGH (never falling to QUEUE_LOW) for STANDING_FRAMES straight. Works
// with no handshake / a same-clock session (where the clock path is disarmed),
// and is the direct signal that the embedder can't keep up. A transient Wi-Fi
// clump drains in a few frames and never reaches the count.
if probe_active {
// Keep both detectors disarmed across a speed test so its (deliberately)
// saturated queue doesn't leave a primed count that fires the moment it ends.
stale_frames = 0;
standing_frames = 0;
} else {
let lat_ns = if clock_offset_ns != 0 {
now_realtime_ns() + clock_offset_ns as i128 - frame.pts_ns as i128
} else {
0
};
if clock_offset_ns != 0 && lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
stale_frames += 1; stale_frames += 1;
} else { } else {
stale_frames = 0; stale_frames = 0;
} }
if stale_frames >= FLUSH_AFTER_FRAMES let depth = frames.depth();
if depth >= QUEUE_HIGH {
standing_frames += 1;
} else if depth <= QUEUE_LOW {
standing_frames = 0;
}
let clock_behind = stale_frames >= FLUSH_AFTER_FRAMES;
let queue_behind = standing_frames >= STANDING_FRAMES;
if (clock_behind || queue_behind)
&& last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN) && last_flush.is_none_or(|t| t.elapsed() >= FLUSH_COOLDOWN)
{ {
stale_frames = 0; stale_frames = 0;
standing_frames = 0;
last_flush = Some(Instant::now()); last_flush = Some(Instant::now());
let flushed = session.flush_backlog().unwrap_or(0); let flushed = session.flush_backlog().unwrap_or(0);
let dropped = frames.clear();
let _ = ctrl_tx.send(CtrlRequest::Keyframe); let _ = ctrl_tx.send(CtrlRequest::Keyframe);
tracing::warn!( tracing::warn!(
behind_ms = lat_ns / 1_000_000, behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
queue_depth = depth,
flushed_datagrams = flushed, flushed_datagrams = flushed,
"receive backlog exceeded the latency bound — flushed to live" dropped_frames = dropped,
"receive backlog stopped draining — jumped to live (flush + keyframe)"
); );
continue; // this frame is part of the stale past — don't render it continue; // this frame is part of the stale past — don't render it
} }
} }
let _ = frame_tx.try_send(frame); frames.push(frame);
} }
Err(PunktfunkError::NoFrame) => { Err(PunktfunkError::NoFrame) => {
std::thread::sleep(Duration::from_micros(300)); std::thread::sleep(Duration::from_micros(300));
@@ -1316,6 +1456,10 @@ async fn worker_main(args: WorkerArgs) {
Err(_) => break, Err(_) => break,
} }
} }
// The pump exited (shutdown / fatal session error) — wake any consumer blocked in
// `next_frame` with a Closed signal instead of a spurious timeout (the old mpsc did this
// implicitly when the sender dropped).
frames.close();
}) })
.await; .await;
@@ -1328,3 +1472,83 @@ async fn worker_main(args: WorkerArgs) {
}; };
conn.close(close_code.into(), b"client closed"); conn.close(close_code.into(), b"client closed");
} }
#[cfg(test)]
mod frame_channel_tests {
use super::{FrameChannel, FramePop, FRAME_QUEUE_HARD_CAP};
use crate::session::Frame;
use std::time::Duration;
fn frame(i: u32) -> Frame {
Frame {
data: vec![i as u8],
frame_index: i,
pts_ns: i as u64,
flags: 0,
}
}
fn popped(ch: &FrameChannel) -> Option<u32> {
match ch.pop(Duration::from_millis(0)) {
FramePop::Frame(f) => Some(f.frame_index),
_ => None,
}
}
#[test]
fn fifo_order_and_depth() {
let ch = FrameChannel::new();
assert_eq!(ch.depth(), 0);
ch.push(frame(1));
ch.push(frame(2));
assert_eq!(ch.depth(), 2);
assert_eq!(popped(&ch), Some(1)); // oldest first (never newest-wins pre-decode)
assert_eq!(popped(&ch), Some(2));
assert_eq!(ch.depth(), 0);
}
#[test]
fn empty_pop_times_out_not_closed() {
let ch = FrameChannel::new();
assert!(matches!(
ch.pop(Duration::from_millis(1)),
FramePop::Timeout
));
}
#[test]
fn clear_drops_backlog_and_reports_count() {
let ch = FrameChannel::new();
for i in 0..5 {
ch.push(frame(i));
}
assert_eq!(ch.clear(), 5); // the jump-to-live discard returns what it dropped
assert_eq!(ch.depth(), 0);
assert!(matches!(
ch.pop(Duration::from_millis(1)),
FramePop::Timeout
));
}
#[test]
fn close_after_drain_reports_closed() {
let ch = FrameChannel::new();
ch.push(frame(7));
ch.close();
// Queued frames still drain BEFORE the Closed signal.
assert_eq!(popped(&ch), Some(7));
assert!(matches!(ch.pop(Duration::from_millis(1)), FramePop::Closed));
}
#[test]
fn hard_cap_drops_oldest() {
let ch = FrameChannel::new();
let total = FRAME_QUEUE_HARD_CAP as u32 + 10;
for i in 0..total {
ch.push(frame(i));
}
// Capped at the backstop; the OLDEST were dropped, so the newest survive in order.
assert_eq!(ch.depth(), FRAME_QUEUE_HARD_CAP);
assert_eq!(popped(&ch), Some(total - FRAME_QUEUE_HARD_CAP as u32));
}
}