a784682d4c
ci / docs-site (push) Successful in 56s
apple / swift (push) Successful in 1m14s
ci / web (push) Successful in 2m30s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m57s
release / apple (push) Successful in 9m8s
deb / build-publish (push) Successful in 9m33s
arch / build-publish (push) Successful in 13m14s
docker / deploy-docs (push) Successful in 23s
flatpak / build-publish (push) Failing after 8m1s
deb / build-publish-host (push) Successful in 10m24s
android / android (push) Successful in 14m56s
apple / screenshots (push) Successful in 6m43s
ci / rust (push) Successful in 19m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m29s
windows-host / package (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 7m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 8m51s
The two-pair investigation (wired Mac clients stuck at a rock-steady ~18-19 ms "network" that survived the load ending and cleared only on reconnect) exposed two structural gaps, one of measurement and one of recovery: - Receipt was stamped at the hand-off PULL (Swift nextAU, pf-client-core, Android decode loops), not at reassembly completion — so any client-side standing state between the reassembler and the pull read as NETWORK latency, undiagnosable from the HUD. ABI v9: `PunktfunkFrame`/`Frame` grow `received_ns`, stamped by `Session::poll_frame` as the AU crosses the session boundary. Every embedder now uses the core stamp; the Apple client keeps the pull instant as `AccessUnit.pulledNs` and shows the receipt→pull wait as its own "client queue" term (detailed HUD tier from 2 ms + a `queue_p50` stats-log field). Decode stages keep their pull anchor on all platforms, so no historical stage shifts meaning. - The jump-to-live detectors deliberately ignore anything under 6 queued frames / 400 ms behind — so a small, constant, loss-free elevation (a sub-frame standing backlog, or a stale clock offset after a wall-clock step/slew) is carried for the rest of the session. New third detector (`StandingLatency`, unit-tested ladder): window-MIN one-way delay ≥ 10 ms above the session floor with zero loss for ~4.5 s escalates gently — a free clock re-sync first (an applied re-sync re-bases the floor), then at most 3 flush+keyframe bleeds sharing the jump-to-live cooldown, then a loud disarm naming what it means. Loss windows reset the run: congestion belongs to FEC/ABR, not this detector. Also: mid-stream re-sync apply/discard logs debug→info — they are the forensic trail for the stale-offset case and were invisible in the field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
538 lines
23 KiB
Rust
538 lines
23 KiB
Rust
//! The pre-decode FIFO video hand-off (`FrameChannel`) + jump-to-live tuning consts + `DecodeLatAcc`.
|
|
|
|
use crate::session::Frame;
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Condvar, Mutex};
|
|
use std::time::Duration;
|
|
|
|
/// Depth at/above which the pre-decode hand-off queue counts as "not draining" for the clock-free
|
|
/// standing-queue detector. A consumer that keeps up (or drains newest-per-vsync, like the Apple
|
|
/// client) holds this near 0; a transient Wi-Fi clump or a small jitter buffer spikes it briefly then
|
|
/// drains. Sits above a reasonable jitter buffer (~100 ms @ 60 fps) so only a genuine backlog trips it.
|
|
pub(crate) 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.
|
|
pub(crate) const QUEUE_LOW: usize = 2;
|
|
|
|
/// How long the hand-off queue must sit ≥ [`QUEUE_HIGH`] (never dropping to [`QUEUE_LOW`], and
|
|
/// still ≥ high at the trip) before the pump declares a standing backlog and jumps to live.
|
|
/// WALL-CLOCK (latency plan T1.4) — the old 30-FRAME count made the accepted backlog scale with
|
|
/// fps (500 ms @60 but 125 ms @240) and stretched further under the frame-driven host's slower
|
|
/// static-scene repeat cadence. 250 ms sits comfortably above a Wi-Fi clump's drain time (a
|
|
/// clump empties in a few frames, ≤ ~100 ms) at any rate.
|
|
pub(crate) const STANDING_TIME: Duration = Duration::from_millis(250);
|
|
|
|
/// Memory backstop on the pre-decode hand-off queue. The standing-queue detector jumps to live long
|
|
/// before this (typically ≤ QUEUE_HIGH + a [`STANDING_TIME`] run 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
|
|
/// capture clock (skew-corrected), the pump jumps to live (discards the receive backlog + the queued
|
|
/// AUs and requests a keyframe) instead of playing that far behind forever. Deliberately generous — an
|
|
/// interactive stream is unusable well before 400 ms, but the bound must sit safely above the skew
|
|
/// handshake's own error (≈ RTT/2) plus normal delivery jitter so a healthy stream can never trip it.
|
|
/// This is the CLOCK-BASED detector; the clock-free [`QUEUE_HIGH`]/[`STANDING_TIME`] detector covers
|
|
/// same-clock and no-handshake sessions (where `clock_offset_ns == 0` disarms this one).
|
|
pub(crate) const FLUSH_LATENCY: Duration = Duration::from_millis(400);
|
|
|
|
/// How long frames must run CONTINUOUSLY over the [`FLUSH_LATENCY`] bound before the clock-based
|
|
/// jump fires. WALL-CLOCK (latency plan T1.4, was a 30-frame count — same fps-scaling problem as
|
|
/// the standing-queue detector). A genuine standing queue puts EVERY frame over the bound; a
|
|
/// one-off burst (an IDR, a Wi-Fi scan blip) clears within a frame or two and resets the run.
|
|
pub(crate) const FLUSH_AFTER: Duration = Duration::from_millis(250);
|
|
|
|
/// Minimum spacing between jump-to-live events, so a bottleneck that instantly rebuilds the queue (a
|
|
/// link/consumer that can't sustain the bitrate at all) degrades into a periodic skip + a logged
|
|
/// warning instead of a continuous flush/keyframe storm.
|
|
pub(crate) const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
|
|
|
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
|
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
|
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
|
/// shifted the skew-corrected latency by a constant — every future frame reads over-bound and the
|
|
/// detector would fire forever, one flush + recovery IDR per cooldown, dragging the bitrate
|
|
/// controller to its floor; or the delay is standing in an **upstream queue** (router bufferbloat),
|
|
/// which a local flush can't drain — the OWD signal already feeds the bitrate controller, the
|
|
/// actual remedy. Even at the 5 Mbps bitrate floor a genuine 400 ms backlog is ~170 datagrams, so
|
|
/// 64 cleanly separates "empty" from "real". See `NOOP_CLOCK_FLUSHES_TO_DISARM`.
|
|
pub(crate) const NOOP_FLUSH_DATAGRAMS: u64 = 64;
|
|
|
|
/// Consecutive no-op clock-triggered flushes (see [`NOOP_FLUSH_DATAGRAMS`]) before the clock-based
|
|
/// detector is disarmed. The clock-free standing-queue detector stays armed — it measures the
|
|
/// local queue directly and can't be fooled by a clock step. No longer for the rest of the
|
|
/// session: an applied mid-stream clock re-sync re-arms the detector (the disarm stays as the
|
|
/// final backstop between re-syncs).
|
|
pub(crate) const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
|
|
|
|
/// Cadence of the control task's periodic mid-stream clock re-sync (see [`ClockResync`]): often
|
|
/// enough to bound slow drift and pick up an NTP step within a minute, rare enough to be free
|
|
/// (8 tiny control messages per batch). The pump additionally fires one immediately after the
|
|
/// FIRST no-op clock flush — the moment a step is actually suspected.
|
|
pub(crate) const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
|
|
|
|
/// Standing-latency bleed (the 2026-07 two-pair investigation): how far above the session's own
|
|
/// one-way-delay floor a report window's MINIMUM must sit to count as a standing elevation. The
|
|
/// jump-to-live detectors above deliberately ignore anything below ~6 frames / 400 ms, so a
|
|
/// small standing state — a sub-frame kernel/reassembly backlog, or a stale clock offset after a
|
|
/// wall-clock step — is carried forever and reads as permanent extra "network" latency. 10 ms
|
|
/// sits above skew-handshake error + normal LAN jitter, and below a single 60 fps frame period,
|
|
/// so the observed one-frame plateau (~17 ms) trips it while a healthy stream cannot.
|
|
pub(crate) const STANDING_LAT_THRESH_NS: i128 = 10_000_000;
|
|
|
|
/// Consecutive elevated report windows (~750 ms each) before the bleed escalates — ~4.5 s of a
|
|
/// continuously standing, loss-free elevation. Windows with any loss reset the run: loss means
|
|
/// genuine congestion, which the FEC/ABR machinery owns, not this detector.
|
|
pub(crate) const STANDING_LAT_WINDOWS: u32 = 6;
|
|
|
|
/// Per-session cap on flush+keyframe bleeds. A standing state that survives a clock re-sync AND
|
|
/// this many local flushes is not local and not clock — the path latency itself changed; the
|
|
/// detector disarms with a warning instead of paying a recovery keyframe every few seconds.
|
|
pub(crate) const STANDING_LAT_MAX_BLEEDS: u32 = 3;
|
|
|
|
/// What the standing-latency detector asks the pump to do this window (see [`StandingLatency`]).
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) enum StandingLatAction {
|
|
None,
|
|
/// First escalation: ask for a mid-stream clock re-sync — free, and a stale offset from a
|
|
/// stepped/slewed wall clock produces exactly this signature (an applied re-sync re-bases
|
|
/// the floor via the pump's `clock_gen` watch, clearing the elevation if that was the cause).
|
|
Resync {
|
|
above_ms: i64,
|
|
},
|
|
/// The elevation survived a re-sync attempt: flush the local receive backlog + request a
|
|
/// keyframe (the jump-to-live action), draining a real sub-threshold standing queue. The
|
|
/// pump reports execution back via [`StandingLatency::bled`]; an unexecuted action simply
|
|
/// re-arms next window.
|
|
Bleed {
|
|
above_ms: i64,
|
|
},
|
|
/// Bleed cap reached and the elevation is back: give up and say so.
|
|
Disarm {
|
|
above_ms: i64,
|
|
},
|
|
}
|
|
|
|
/// Detector for a small, constant, loss-free one-way-delay elevation — the standing state the
|
|
/// jump-to-live thresholds deliberately tolerate. Tracks the session's OWD floor (minimum of
|
|
/// report-window minimums since start / last re-base) and escalates when windows sit
|
|
/// persistently above it: re-sync first, then a bounded number of flush+keyframe bleeds, then
|
|
/// disarm. Pure state machine (no clocks, no I/O) so the escalation ladder is unit-testable.
|
|
pub(crate) struct StandingLatency {
|
|
/// Lowest window-minimum OWD seen since session start / last [`rebase`](Self::rebase).
|
|
floor_ns: Option<i128>,
|
|
/// Minimum per-frame OWD this report window; `None` = no frames yet.
|
|
window_min_ns: Option<i128>,
|
|
/// Consecutive elevated windows.
|
|
run: u32,
|
|
/// The current elevation already got its re-sync request — next escalation is a bleed.
|
|
resync_tried: bool,
|
|
bleeds: u32,
|
|
disarmed: bool,
|
|
}
|
|
|
|
impl StandingLatency {
|
|
pub(crate) fn new() -> Self {
|
|
StandingLatency {
|
|
floor_ns: None,
|
|
window_min_ns: None,
|
|
run: 0,
|
|
resync_tried: false,
|
|
bleeds: 0,
|
|
disarmed: false,
|
|
}
|
|
}
|
|
|
|
/// Feed one frame's skew-corrected OWD (capture→reassembly-complete, ns). Caller gates on a
|
|
/// live clock offset and plausibility (0 < owd < 10 s), like the ABR OWD signal.
|
|
pub(crate) fn note_frame(&mut self, owd_ns: i128) {
|
|
self.window_min_ns = Some(match self.window_min_ns {
|
|
Some(m) => m.min(owd_ns),
|
|
None => owd_ns,
|
|
});
|
|
}
|
|
|
|
/// Close a report window. `loss_free` = the window carried zero loss (loss resets the run —
|
|
/// congestion is the FEC/ABR machinery's problem, and queues under loss are not "standing").
|
|
pub(crate) fn on_window(&mut self, loss_free: bool) -> StandingLatAction {
|
|
let Some(wmin) = self.window_min_ns.take() else {
|
|
return StandingLatAction::None; // no frames this window — no evidence either way
|
|
};
|
|
let floor = *self.floor_ns.get_or_insert(wmin);
|
|
self.floor_ns = Some(floor.min(wmin));
|
|
let above_ns = wmin - floor;
|
|
if self.disarmed {
|
|
return StandingLatAction::None;
|
|
}
|
|
if !loss_free || above_ns < STANDING_LAT_THRESH_NS {
|
|
self.run = 0;
|
|
if above_ns < STANDING_LAT_THRESH_NS {
|
|
self.resync_tried = false; // elevation cleared — a future one re-syncs first again
|
|
}
|
|
return StandingLatAction::None;
|
|
}
|
|
self.run += 1;
|
|
if self.run < STANDING_LAT_WINDOWS {
|
|
return StandingLatAction::None;
|
|
}
|
|
self.run = 0; // each escalation gets a fresh observation run
|
|
let above_ms = (above_ns / 1_000_000) as i64;
|
|
if !self.resync_tried {
|
|
self.resync_tried = true;
|
|
StandingLatAction::Resync { above_ms }
|
|
} else if self.bleeds < STANDING_LAT_MAX_BLEEDS {
|
|
StandingLatAction::Bleed { above_ms }
|
|
} else {
|
|
self.disarmed = true;
|
|
StandingLatAction::Disarm { above_ms }
|
|
}
|
|
}
|
|
|
|
/// The pump executed a [`StandingLatAction::Bleed`] (flush + keyframe). The floor is KEPT: a
|
|
/// successful bleed brings OWD back down to it (elevation clears naturally); an unsuccessful
|
|
/// one leaves the elevation visible so the ladder continues toward the cap.
|
|
pub(crate) fn bled(&mut self) {
|
|
self.bleeds += 1;
|
|
self.window_min_ns = None;
|
|
}
|
|
|
|
/// A mid-stream clock re-sync was APPLIED (the pump's `clock_gen` watch): every OWD reading
|
|
/// shifted, so the floor and any elevation measured under the old offset are meaningless —
|
|
/// re-learn from scratch. The bleed budget survives (it caps keyframes per session).
|
|
pub(crate) fn rebase(&mut self) {
|
|
self.floor_ns = None;
|
|
self.window_min_ns = None;
|
|
self.run = 0;
|
|
self.resync_tried = false;
|
|
}
|
|
}
|
|
|
|
/// Client decode-stage latency accumulator for the adaptive-bitrate controller's decode signal.
|
|
/// The embedder adds one sample per decoded frame ([`NativeClient::report_decode_us`], µs from the
|
|
/// AU leaving [`NativeClient::next_frame`] to its decoded output) and the data-plane pump drains a
|
|
/// window mean once per report window to feed [`crate::abr::BitrateController::on_window`]. This is
|
|
/// the only signal that sees the CLIENT'S decoder: on a fast LAN a mobile HW decoder saturates long
|
|
/// before the link, backlogging frames inside the decoder where loss/OWD never register. Sum+count
|
|
/// (not a running mean) so the pump takes an unweighted window mean and resets. Always accumulated —
|
|
/// the controller ignores it when Automatic is off, and the pump drains it every window regardless,
|
|
/// so it stays bounded (a full window at 240 fps is ~180 samples).
|
|
#[derive(Default)]
|
|
pub(crate) struct DecodeLatAcc {
|
|
pub(crate) sum_us: u64,
|
|
pub(crate) count: u32,
|
|
}
|
|
|
|
/// 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
|
|
pub(crate) 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.
|
|
pub(crate) enum FramePop {
|
|
Frame(Frame),
|
|
Timeout,
|
|
Closed,
|
|
}
|
|
|
|
impl FrameChannel {
|
|
pub(crate) 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).
|
|
pub(crate) 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.
|
|
pub(crate) 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.
|
|
pub(crate) 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.
|
|
pub(crate) 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.
|
|
pub(crate) 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
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
complete: true,
|
|
received_ns: 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));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod standing_latency_tests {
|
|
use super::{
|
|
StandingLatAction, StandingLatency, STANDING_LAT_MAX_BLEEDS, STANDING_LAT_THRESH_NS,
|
|
STANDING_LAT_WINDOWS,
|
|
};
|
|
|
|
const FLOOR: i128 = 2_000_000; // a healthy 2 ms LAN OWD
|
|
const ELEVATED: i128 = FLOOR + STANDING_LAT_THRESH_NS + 7_000_000; // ~one 60fps frame above
|
|
|
|
/// Run `n` windows at `owd`, asserting every window but the last returns None; returns the
|
|
/// last window's action.
|
|
fn run_windows(d: &mut StandingLatency, owd: i128, n: u32) -> StandingLatAction {
|
|
for i in 0..n {
|
|
d.note_frame(owd);
|
|
let a = d.on_window(true);
|
|
if i + 1 < n {
|
|
assert_eq!(a, StandingLatAction::None, "window {i} escalated early");
|
|
} else {
|
|
return a;
|
|
}
|
|
}
|
|
unreachable!("n > 0 by construction");
|
|
}
|
|
|
|
/// Learn a clean floor: one window at the healthy OWD.
|
|
fn learned(d: &mut StandingLatency) {
|
|
d.note_frame(FLOOR);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn healthy_stream_never_escalates() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
// Jitter riding above the floor but under the threshold: never a run.
|
|
for _ in 0..(STANDING_LAT_WINDOWS * 4) {
|
|
d.note_frame(FLOOR + STANDING_LAT_THRESH_NS - 1);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn escalation_ladder_resync_then_bleeds_then_disarm() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
// First full elevated run asks for the free fix: a clock re-sync.
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
// Re-sync didn't help (no rebase came) — each further run is a bleed, up to the cap...
|
|
for _ in 0..STANDING_LAT_MAX_BLEEDS {
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Bleed { .. }
|
|
));
|
|
d.bled();
|
|
}
|
|
// ...then the detector gives up loudly, once, and stays quiet.
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Disarm { .. }
|
|
));
|
|
d.note_frame(ELEVATED);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn loss_windows_reset_the_run() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
|
|
d.note_frame(ELEVATED);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
// A lossy window means congestion, not a standing state: run resets...
|
|
d.note_frame(ELEVATED);
|
|
assert_eq!(d.on_window(false), StandingLatAction::None);
|
|
// ...so the ladder needs the full run again before acting.
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_resets_the_ladder_to_resync_first() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
// The elevation clears on its own (e.g. the successful bleed case, or transient): the
|
|
// next episode starts back at the free escalation, not at a bleed.
|
|
d.note_frame(FLOOR);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn applied_resync_rebases_and_clears_a_stale_offset_elevation() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
assert!(matches!(
|
|
run_windows(&mut d, ELEVATED, STANDING_LAT_WINDOWS),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
// The re-sync APPLIES (pump sees clock_gen move) → rebase. The corrected offset brings
|
|
// OWD readings back to truth; the floor re-learns and nothing ever escalates to a bleed.
|
|
d.rebase();
|
|
for _ in 0..(STANDING_LAT_WINDOWS * 2) {
|
|
d.note_frame(FLOOR);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_windows_are_no_evidence() {
|
|
let mut d = StandingLatency::new();
|
|
learned(&mut d);
|
|
for _ in 0..(STANDING_LAT_WINDOWS - 1) {
|
|
d.note_frame(ELEVATED);
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
}
|
|
// A frameless window (paused stream) neither advances nor resets the run...
|
|
assert_eq!(d.on_window(true), StandingLatAction::None);
|
|
// ...so one more elevated window completes it.
|
|
d.note_frame(ELEVATED);
|
|
assert!(matches!(
|
|
d.on_window(true),
|
|
StandingLatAction::Resync { .. }
|
|
));
|
|
}
|
|
}
|