apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 6m2s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 2m57s
ci / rust-arm64 (pull_request) Successful in 1m24s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 5m30s
ci / rust (pull_request) Successful in 9m50s
Stall program T2 (amplification kill), the phantom-latch half. A deciding window that delivered under a quarter of the target rate (a host-side capture stall, an outage, a mid-window pause) carries starvation-shaped distress — a jump-to-live flush, a keyframe-ask burst — that the decode-cap latch read as decoder evidence: under a periodic capture stall (the RDNA4 standby-sink field cases, one stall every ~5 s) every edge offers another 'backoff' at the SAME rate, and one pair latches a phantom decoder knee at whatever rate the display driver happened to interrupt. The session then fights the cap's re-probe ladder (+12.5% per 16-128 clean windows) for minutes on a decoder that was never the problem. Starved windows still back off (real damage deserves the safe response) but take the same 'not a knee sample either way' arm as a draining backoff: they neither latch a decode cap nor erase the reference a genuine choke set, so a real knee's pair still finds itself around the interruption. The ¼ bar sits deliberately far under the ×¾ utilization bar climbs require. Gates: 44 abr tests green (2 new: the stall-cycle no-latch scenario and the reference-preservation scenario), full core lib suite 346 green (--features quic), fmt + clippy clean.
2689 lines
114 KiB
Rust
2689 lines
114 KiB
Rust
//! Adaptive bitrate: the client-side AIMD controller behind the "Automatic" bitrate setting.
|
||
//!
|
||
//! Runs inside [`crate::client`]'s data-plane pump on the same 750 ms cadence as the adaptive-FEC
|
||
//! [`crate::quic::LossReport`], deciding when to ask the host for a different encoder bitrate via
|
||
//! [`crate::quic::SetBitrate`]. Division of labour with adaptive FEC: **FEC answers fast, random
|
||
//! loss** (Wi-Fi bursts, RF noise — recoverable redundancy is the right tool); **bitrate answers
|
||
//! persistent congestion** (the link simply can't carry the rate — more FEC only adds load). The
|
||
//! controller therefore reacts to *sustained* signals only:
|
||
//!
|
||
//! - **unrecoverable frames** — loss exceeded the FEC budget (the stream visibly froze/recovered);
|
||
//! - **heavy loss** — a window whose shard loss is beyond what FEC should be left to absorb alone;
|
||
//! - **one-way-delay rise** — capture→received latency (host-clock skew corrected) climbing above
|
||
//! its rolling baseline: standing queue growth, the *pre-loss* signature of a saturated link
|
||
//! (bufferbloat) — this is the early-warning signal loss-based control lacks;
|
||
//! - **a jump-to-live flush** — the pump discarded its backlog, the strongest "we were behind"
|
||
//! evidence there is;
|
||
//! - **host-encode-latency rise** — the host's per-AU 0xCF `encode_us` climbing above its rolling
|
||
//! baseline: the ENCODER falling behind its frame budget (the compute knee), the one failure a
|
||
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
|
||
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
|
||
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
|
||
//! encoder off a cliff the network could carry.
|
||
//!
|
||
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
|
||
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
|
||
//! (heavy-but-recoverable loss, an OWD rise, a decode rise) needs two consecutive bad windows.
|
||
//! Recovery is two-mode: **slow start** — until the first congestion signal each clean window
|
||
//! asks for double the current rate, bounded (like every climb) by the proven-throughput
|
||
//! headroom below, so the step a loaded session actually takes is ×1.5 over what it last
|
||
//! delivered; either way it climbs from the conservative start to the
|
||
//! [`set_ceiling`](BitrateController::set_ceiling) measured by the startup link-capacity probe
|
||
//! in seconds rather than minutes — then classic additive recovery (+~6 % after ~4.5 s clean,
|
||
//! ceilinged). Changes are rate-limited (each one costs the IDR the host's
|
||
//! rebuilt encoder opens with) and the whole controller disables itself against a host that never
|
||
//! answers [`crate::quic::BitrateChanged`] (an older build that ignores unknown control messages).
|
||
//! Standing limits are LEARNED rather than re-poked: two identical short host acks latch the
|
||
//! encoder's ceiling (`host_cap_kbps`), two consecutive decode-severe backoffs at a similar rate
|
||
//! latch the client decoder's knee (`decode_cap_kbps`) — and both re-probe slowly
|
||
//! ([`CAP_REPROBE_WINDOWS_MIN`]) so neither latch outlives the condition that taught it.
|
||
//!
|
||
//! Climbs are additionally **evidence-gated**. The target is only a *promise* to the encoder —
|
||
//! how many bits it actually emits depends on the content — so on calm content (a menu, an idle
|
||
//! desktop) every window looks clean while proving nothing: the decoder was never exposed to the
|
||
//! target rate. Ungated, the climb drifts the target into territory the pipeline has never
|
||
//! carried, and the first motion spike becomes the first real test — which it fails, overloading
|
||
//! the decoder for the two-window backoff latency. So (a) a clean window only counts toward a
|
||
//! climb when its actual delivered throughput came close to the current target, and (b) no climb
|
||
//! steps past a modest headroom over the session's *proven* throughput — the highest windowed
|
||
//! rate the decoder demonstrably digested with flat decode latency, kept as a high-water mark
|
||
//! (never decayed: calm periods neither raise nor lower a validated target, so the encoder keeps
|
||
//! its headroom and answers returning motion instantly). The cost is a one-time paced ramp during
|
||
//! the session's first loaded stretch; capacity that later *shrinks* (thermal throttling) is the
|
||
//! reactive decode signal's job, as before.
|
||
|
||
use std::collections::VecDeque;
|
||
use std::time::{Duration, Instant};
|
||
|
||
/// Never ask for less than this — below it the stream is unusable anyway and the floor keeps a
|
||
/// mis-measured window from cratering the session.
|
||
const FLOOR_KBPS: u32 = 5_000;
|
||
/// Consecutive bad windows before an ORDINARY decrease — one window can be a scheduler blip or a
|
||
/// single Wi-Fi scan; two in a row (1.5 s) is a condition. A SEVERE window skips the wait.
|
||
const BAD_WINDOWS_TO_DECREASE: u32 = 2;
|
||
/// Window shard loss at/above which ONE window is enough to back off — 6 % is past any
|
||
/// blip/retry tail, and every 750 ms spent there is visible damage. Unrecoverable frames and
|
||
/// jump-to-live flushes are severe for the same reason.
|
||
const SEVERE_LOSS_PPM: u32 = 60_000;
|
||
/// Consecutive clean windows before probing back up in congestion-avoidance mode (~4.5 s at the
|
||
/// 750 ms cadence): recovery stays slower than backoff, classic AIMD. (Slow start ignores this —
|
||
/// it doubles on every cooled clean window until the first congestion signal.)
|
||
const CLEAN_WINDOWS_TO_INCREASE: u32 = 6;
|
||
/// Minimum gap between requested changes — every accepted change costs an encoder rebuild + IDR
|
||
/// on the host today (in-place reconfigure is planned), and back-to-back steps would outrun the
|
||
/// ack/effect round trip.
|
||
const CHANGE_COOLDOWN: Duration = Duration::from_millis(1500);
|
||
/// Window shard loss beyond which the window counts bad even without an unrecoverable frame:
|
||
/// 2 % sustained is congestion territory, not the random tail FEC exists for.
|
||
const HEAVY_LOSS_PPM: u32 = 20_000;
|
||
/// Decode-recovery KEYFRAME asks in one window at/above which the window is bad: the decoder
|
||
/// asked for a fresh picture twice inside 750 ms — it is being overdriven (or repeatedly
|
||
/// wedged), whatever loss_ppm says. This is the signal the RX-9070 field trace exposed: 14
|
||
/// requests in 2 s at ~300 Mbps with ZERO loss, and the controller kept the rate because no
|
||
/// loss/OWD/latency signal moved. RFI asks are deliberately NOT counted — they are the routine
|
||
/// loss-recovery mechanism and loss_ppm already prices them in.
|
||
const RECOVERY_KF_BAD: u32 = 2;
|
||
/// One window at/above this many keyframe asks is SEVERE (skips the two-window confirmation):
|
||
/// the emitters throttle at 100 ms, so 4+ inside a window means the decoder spent most of it
|
||
/// unable to produce pictures — the user is already watching the damage.
|
||
const RECOVERY_KF_SEVERE: u32 = 4;
|
||
/// How far the window's mean one-way delay may sit above the rolling baseline before it counts
|
||
/// as queue growth. 25 ms is far beyond jitter at any streamable frame rate.
|
||
const OWD_RISE_US: i64 = 25_000;
|
||
/// How far the window's mean *decode-stage* latency (client hand-off → decoder output, reported by
|
||
/// the embedder) may sit above its rolling baseline before it counts as the decoder falling behind.
|
||
/// This is the signal the network-side ones can't see: on a fast LAN a mobile HW decoder saturates
|
||
/// long before the link does, backlogging frames INSIDE the decoder where loss/OWD never register —
|
||
/// so without this the controller slow-starts straight to the link ceiling and parks there, choking
|
||
/// the decoder. A rising decode latency ends the climb and (sustained) backs the rate off to the
|
||
/// real decode limit. Local, low-noise signal (no network jitter), so a tighter threshold than OWD:
|
||
/// 15 ms of standing decode queue is unambiguous backlog at any streamable frame rate.
|
||
const DECODE_RISE_US: i64 = 15_000;
|
||
/// Decode-stage latency this far above baseline is SEVERE — back off after ONE window instead of
|
||
/// two. 45 ms of standing decode queue is several frames of backlog at any streamable rate; the
|
||
/// user is already watching the spike-overload damage, and every extra window spent confirming it
|
||
/// is 750 ms more of it.
|
||
const DECODE_SEVERE_US: i64 = 45_000;
|
||
/// A clean window counts toward a CLIMB only when its actual delivered throughput reached
|
||
/// `actual × UTILIZATION_DEN ≥ target × UTILIZATION_NUM` (¾ of the current target). Below that
|
||
/// the encoder wasn't constrained by the target, so the window is evidence of nothing — climbing
|
||
/// on it just parks the target deeper into unvalidated territory (the settled-calm-then-spike
|
||
/// failure). At/above it the pipeline genuinely carried ~the target rate and survived.
|
||
const UTILIZATION_NUM: u64 = 3;
|
||
const UTILIZATION_DEN: u64 = 4;
|
||
/// A climb may step at most this far (×1.5) past the proven-throughput high-water mark: the next
|
||
/// target stays within a bounded experiment over what the decoder has demonstrably digested,
|
||
/// rather than doubling blind. Utilization-gated climbs guarantee `proven ≥ ¾ × current`, so the
|
||
/// cap always leaves ≥ ~12 % of climbing room — the two gates can't deadlock.
|
||
const PROVEN_HEADROOM_NUM: u32 = 3;
|
||
const PROVEN_HEADROOM_DEN: u32 = 2;
|
||
/// How far the window's mean HOST-ENCODE latency (the 0xCF `HostStages::encode_us` the host
|
||
/// already ships per AU) may rise above its rolling baseline before the window is bad. This is
|
||
/// the down-driver for the ENCODER's compute knee — the failure loss/OWD/decode are all blind
|
||
/// to: on a fat LAN the controller can climb to a rate the link carries fine but the ASIC
|
||
/// can't encode inside the frame budget (4K120 HEVC at ~800 Mbps ≈ 9.3 ms against 8.33), and
|
||
/// the only symptom is encode time. Baseline-RELATIVE on purpose: an escalated host reports
|
||
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
|
||
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
|
||
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
|
||
const ENCODE_RISE_US: i64 = 4_000;
|
||
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
|
||
/// queue is growing past the knee; skip the two-window confirmation.
|
||
const ENCODE_SEVERE_US: i64 = 12_000;
|
||
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
|
||
/// interval backs off to.
|
||
///
|
||
/// A learned cap is EVIDENCE, not a spec limit: the host's short ack means "not right now",
|
||
/// which covers both its encoder's codec-level ceiling (durable) and a climb refused while
|
||
/// encode is behind cadence (transient, and routinely latched during slow start at the
|
||
/// conservative 20 Mbps default). The client cannot tell those apart from the ack alone, so the
|
||
/// re-probe is what keeps a transient from becoming the session's ceiling — and a flat ~60 s
|
||
/// clock at +12.5 % made that escape take upwards of twenty minutes to cross the gap to a
|
||
/// probe-measured link ceiling, which is indistinguishable from never.
|
||
///
|
||
/// So: probe again after 12 s, and DOUBLE the interval each time the lift is immediately
|
||
/// re-learned at the same value (see [`on_ack`](BitrateController::on_ack)). A transient is out
|
||
/// in one interval; a standing limit settles into a slow poll instead of a permanent one. The
|
||
/// re-probe itself is nearly free either way — a still-standing limit re-teaches itself in two
|
||
/// short acks, which the host pre-clamps without touching the encoder: no rebuild, no IDR.
|
||
/// The [`decode cap`](BitrateController::decode_cap_kbps) re-probes on the same schedule for
|
||
/// the same reason: the decoder's knee moves with content and thermals.
|
||
const CAP_REPROBE_WINDOWS_MIN: u32 = 16;
|
||
const CAP_REPROBE_WINDOWS_MAX: u32 = 128;
|
||
/// Two decode-driven backoffs latch the
|
||
/// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree
|
||
/// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its
|
||
/// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share
|
||
/// no knee and must not teach one. Each sample must come from a rate the controller CLIMBED
|
||
/// back to (`climb_since_backoff`) — the knee's real signature is choke, recover, re-climb,
|
||
/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the
|
||
/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction).
|
||
const DECODE_CAP_SIMILAR_DIV: u32 = 8;
|
||
/// A deciding window that DELIVERED under `current / STARVED_DELIVERY_DIV` is STARVED: the
|
||
/// stream barely flowed (a host-side capture stall, an outage, a mid-window pause), so whatever
|
||
/// distress the window carries — a flush, a keyframe-ask burst — is starvation-shaped, not
|
||
/// rate-shaped, and the decoder decoded almost nothing at the nominal rate. Such a window may
|
||
/// still back off (real damage deserves the safe response) but must never be a decode-knee
|
||
/// sample: latching `current_kbps` off a starved window teaches a phantom decoder cap at
|
||
/// whatever rate the stall interrupted (the periodic-capture-stall field case: every 5 s cycle
|
||
/// offers another pair of "backoffs" at the same rate — a bogus latch that then fights the
|
||
/// re-probe ladder for minutes). Deliberately far below the ×¾ utilization bar climbs require:
|
||
/// the band between them is ambiguous and keeps today's behavior.
|
||
const STARVED_DELIVERY_DIV: u32 = 4;
|
||
/// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline.
|
||
/// Long enough to remember the uncongested floor, short enough to follow genuine path changes.
|
||
const BASELINE_WINDOWS: usize = 40;
|
||
/// Windows a rolling baseline must hold before the signal it feeds may fire. A baseline is a
|
||
/// rolling MINIMUM, so a single sample IS the baseline — and if that one window landed on calm
|
||
/// content, ordinary content variance clears the rise threshold by itself. That hole is not
|
||
/// theoretical: [`on_ack`](BitrateController::on_ack) deliberately CLEARS the encode baseline
|
||
/// after every decrease we ourselves asked for, so the encode down-driver re-armed on a
|
||
/// one-sample floor each time — a calm re-seed window followed by a motion scene reads as
|
||
/// `ENCODE_RISE_US` of "congestion", backs off, clears again, and ratchets to the floor on a
|
||
/// link that was never the problem. Four windows (3 s) of evidence before any of the three
|
||
/// latency signals may fire costs a little reaction latency at session start and buys a floor
|
||
/// that means something.
|
||
const BASELINE_MIN_WINDOWS: usize = 4;
|
||
/// Requests sent without a single [`crate::quic::BitrateChanged`] ack before concluding the host
|
||
/// predates bitrate renegotiation and going quiet for the rest of the session.
|
||
const MAX_UNACKED: u32 = 3;
|
||
|
||
/// Operator escape hatch: `PUNKTFUNK_ABR_MAX_MBPS` (megabits/second, the
|
||
/// `PUNKTFUNK_PYROWAVE_MAX_MBPS` convention) caps the climb ceiling however it is learned.
|
||
/// The startup link-capacity probe MEASURES the ceiling, and
|
||
/// [`set_ceiling`](BitrateController::set_ceiling)'s deliberate monotonicity makes an inflated
|
||
/// measurement permanent for the session — a link that mis-measures (a bursty middlebox, a
|
||
/// queue-flattered interval) needs a knob that binds regardless of what any probe claims.
|
||
/// `PUNKTFUNK_ABR_PROBE_KBPS` is NOT that knob: it only shrinks the burst target, not what the
|
||
/// measurement may conclude. Unset/0/garbage → no cap. Read once per controller, at
|
||
/// construction.
|
||
fn ceiling_cap_from_env() -> Option<u32> {
|
||
std::env::var("PUNKTFUNK_ABR_MAX_MBPS")
|
||
.ok()
|
||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||
.filter(|&m| m > 0)
|
||
.map(|m| m.saturating_mul(1_000))
|
||
}
|
||
|
||
/// Score one window's latency sample against its rolling-min baseline, then record it.
|
||
///
|
||
/// Shared by all three latency signals (OWD, client decode, host encode) — same shape, different
|
||
/// thresholds. `mean` is `None` when nobody reports the signal (no clock handshake, an embedder
|
||
/// that doesn't measure decode, a host that ships no stage timings); the signal is then simply
|
||
/// absent rather than clean, so it can neither mark a window bad nor teach a baseline.
|
||
///
|
||
/// The baseline is the minimum of the PRIOR windows — this window is compared before it is
|
||
/// recorded, so a rising window can't drag its own floor up with it — and only counts once
|
||
/// [`BASELINE_MIN_WINDOWS`] of them exist. Returns `(rise, severe)`; pass `i64::MAX` for
|
||
/// `severe_us` on a signal with no severe tier.
|
||
fn score_baseline(
|
||
means: &mut VecDeque<i64>,
|
||
mean: Option<i64>,
|
||
rise_us: i64,
|
||
severe_us: i64,
|
||
) -> (bool, bool) {
|
||
let Some(mean) = mean else {
|
||
return (false, false);
|
||
};
|
||
let base = (means.len() >= BASELINE_MIN_WINDOWS)
|
||
.then(|| means.iter().min().copied())
|
||
.flatten();
|
||
let over = |t: i64| base.is_some_and(|b| mean > b.saturating_add(t));
|
||
if means.len() == BASELINE_WINDOWS {
|
||
means.pop_front();
|
||
}
|
||
means.push_back(mean);
|
||
(over(rise_us), over(severe_us))
|
||
}
|
||
|
||
/// One decision per report window; `Some(kbps)` = send a [`crate::quic::SetBitrate`].
|
||
pub(crate) struct BitrateController {
|
||
/// `false` = permanently off (explicit user bitrate, an old host, or ack silence).
|
||
enabled: bool,
|
||
/// The rate we believe the host encodes at (updated by acks; requests are not assumed).
|
||
current_kbps: u32,
|
||
/// The climb ceiling: the negotiated start rate until the startup link-capacity probe
|
||
/// raises it via [`set_ceiling`](Self::set_ceiling) — that measurement is what lets an
|
||
/// Automatic session scale past its conservative start.
|
||
ceiling_kbps: u32,
|
||
/// The `PUNKTFUNK_ABR_MAX_MBPS` cap in kbps (see [`ceiling_cap_from_env`]), injected at
|
||
/// construction so tests exercise the clamp without touching the process environment.
|
||
/// `None` = no cap.
|
||
ceiling_cap_kbps: Option<u32>,
|
||
floor_kbps: u32,
|
||
/// Slow start: true until the first congestion signal — clean windows DOUBLE the rate
|
||
/// (cooldown-paced) instead of the +6 % additive step.
|
||
probing: bool,
|
||
/// Recent window mean OWDs (µs); the rolling min is the uncongested baseline.
|
||
owd_means: VecDeque<i64>,
|
||
/// Recent window mean decode-stage latencies (µs); the rolling min is the decoder's
|
||
/// keeping-up baseline. Empty on embedders that don't report decode latency (the decode
|
||
/// signal is then simply absent — identical to the pre-decode-signal behavior).
|
||
decode_means: VecDeque<i64>,
|
||
/// Recent window mean host-encode latencies (µs, from the 0xCF datagrams); rolling-min
|
||
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
|
||
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
|
||
encode_means: VecDeque<i64>,
|
||
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
|
||
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
|
||
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
|
||
/// the probe-measured link authority survives a mode switch's reset. Slowly re-probed
|
||
/// ([`CAP_REPROBE_WINDOWS_MIN`]) so scene-dependent evidence can't cap the session forever.
|
||
host_cap_kbps: Option<u32>,
|
||
/// The rate the last [`request`](Self::request) asked for — the reference an ack is judged
|
||
/// short against. Taken (not kept) by the ack, so one request is judged at most once.
|
||
last_requested_kbps: Option<u32>,
|
||
/// Consecutive short-ack streak: the value and how many times in a row it was acked. Two
|
||
/// identical short acks latch [`host_cap_kbps`](Self::host_cap_kbps) — one can be a
|
||
/// transient (a failed host rebuild keeping the old rate); the host's resolves are
|
||
/// deterministic min()s, so a persistent limit reproduces exactly.
|
||
short_ack_kbps: u32,
|
||
short_acks: u32,
|
||
/// Clean windows spent parked at the learned cap (the re-probe clock) and the interval it is
|
||
/// counting toward — [`CAP_REPROBE_WINDOWS_MIN`], doubled toward
|
||
/// [`CAP_REPROBE_WINDOWS_MAX`] each time a lift is immediately re-learned.
|
||
cap_probe_windows: u32,
|
||
cap_reprobe_after: u32,
|
||
/// The client-decoder rate cap, mirroring [`host_cap_kbps`](Self::host_cap_kbps) for the
|
||
/// OTHER end of the pipe: latched when two CONSECUTIVE backoffs carried decode-severe
|
||
/// evidence (a deep decode-latency excursion, or a jump-to-live flush — in the
|
||
/// decoder-saturation regime the flushed backlog formed BEHIND a decoder that stopped
|
||
/// keeping up) at a similar pre-backoff rate. Without it a decoder knee below the link
|
||
/// ceiling is a permanent 30–60 s sawtooth: every ×0.7 backoff re-climbs toward a ceiling
|
||
/// the decoder can't hold, and each cycle costs a flush plus a dropped-frame burst (the
|
||
/// 1440p120 HEVC field case: knee ~490 Mbps under a ~658 Mbps ceiling). Slowly re-probed
|
||
/// on the [`CAP_REPROBE_WINDOWS_MIN`] clock, exactly like the host cap, so a decoder that
|
||
/// recovers (lighter content, thermal headroom) climbs again — the latch is never
|
||
/// permanent.
|
||
decode_cap_kbps: Option<u32>,
|
||
/// The previous decode-driven backoff's pre-backoff rate (0 = the last backoff wasn't
|
||
/// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`])
|
||
/// to latch the cap — one spurious flush teaches nothing.
|
||
decode_backoff_kbps: u32,
|
||
/// Decode-flagged windows in the CURRENT bad-window streak. The ordinary two-window backoff
|
||
/// path is the decoder knee's most common presentation (a standing 15–45 ms decode rise —
|
||
/// deep enough to hurt, not deep enough for the severe tier), and judging decode evidence
|
||
/// from the FINAL window alone threw that attribution away: the backoff the decode signal
|
||
/// itself caused then RESET the knee streak. Counted per bad window, cleared with the streak.
|
||
streak_decode_windows: u32,
|
||
/// Whether `current_kbps` has RISEN (via an ack — ours or a host-initiated re-target) since
|
||
/// the last backoff. A knee sample is only meaningful for a rate the controller climbed to
|
||
/// or held; a backoff that fires while the previous backoff's damage is still draining
|
||
/// samples a rate the decoder never choked at (the host acks a ×0.7 request in ~100 ms, so
|
||
/// a cascade's second backoff ALWAYS sits at the already-reduced rate — dissimilar to the
|
||
/// knee by construction, 0.7 < 7/8). Such a backoff neither samples nor erases the
|
||
/// reference.
|
||
climb_since_backoff: bool,
|
||
/// Clean windows spent parked at the learned decode cap (its re-probe clock), and that
|
||
/// clock's own backoff interval — same schedule as the host cap's.
|
||
decode_cap_probe_windows: u32,
|
||
decode_cap_reprobe_after: u32,
|
||
/// Proven throughput: the session's highest windowed ACTUAL delivered rate seen with flat
|
||
/// decode latency — the known-good high-water mark climbs are bounded against. Never decays;
|
||
/// shrinking capacity (thermals, a heavier scene) is the reactive decode signal's job. On
|
||
/// embedders without a decode signal this is just the delivered high-water mark — weaker
|
||
/// evidence, but the same bound.
|
||
proven_kbps: u32,
|
||
bad_windows: u32,
|
||
clean_windows: u32,
|
||
last_change: Option<Instant>,
|
||
/// Requests since the last ack — reaching [`MAX_UNACKED`] disables the controller.
|
||
unacked: u32,
|
||
/// The last ceiling-clamp target asked for (0 = none). A session running ABOVE its effective
|
||
/// ceiling is asked down to it exactly once per distinct target — a host that answers higher
|
||
/// has said it cannot go there, and re-asking every cooldown only costs reconfigures.
|
||
ceiling_ask_kbps: u32,
|
||
}
|
||
|
||
impl BitrateController {
|
||
/// `start_kbps` is the Welcome-resolved session bitrate when the user chose Automatic, or `0`
|
||
/// to build a permanently-disabled controller (explicit bitrate / an old host that didn't
|
||
/// echo one — no known ceiling to work against).
|
||
pub(crate) fn new(start_kbps: u32) -> Self {
|
||
Self::with_ceiling_cap(start_kbps, ceiling_cap_from_env())
|
||
}
|
||
|
||
/// [`new`](Self::new) with the `PUNKTFUNK_ABR_MAX_MBPS` cap injected — the seam the unit
|
||
/// tests use so the clamp's behavior never depends on the test process's environment.
|
||
fn with_ceiling_cap(start_kbps: u32, ceiling_cap_kbps: Option<u32>) -> Self {
|
||
BitrateController {
|
||
enabled: start_kbps > 0,
|
||
current_kbps: start_kbps,
|
||
// The env cap binds the NEGOTIATED ceiling too, not just probe-learned ones. It is
|
||
// the only lever an Automatic session gives the operator (Automatic is precisely
|
||
// "no explicit bitrate"), so a start rate above it has to come down rather than
|
||
// stand as a ceiling the user asked not to reach — see the clamp-down step in
|
||
// [`on_window`](Self::on_window).
|
||
ceiling_kbps: start_kbps.min(ceiling_cap_kbps.unwrap_or(u32::MAX)),
|
||
ceiling_cap_kbps,
|
||
floor_kbps: FLOOR_KBPS.min(start_kbps.max(1)),
|
||
probing: true,
|
||
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
|
||
host_cap_kbps: None,
|
||
last_requested_kbps: None,
|
||
short_ack_kbps: 0,
|
||
short_acks: 0,
|
||
cap_probe_windows: 0,
|
||
cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||
decode_cap_kbps: None,
|
||
decode_backoff_kbps: 0,
|
||
streak_decode_windows: 0,
|
||
// The negotiated start rate was held, not drained to — the first backoff ever is a
|
||
// legitimate knee sample.
|
||
climb_since_backoff: true,
|
||
decode_cap_probe_windows: 0,
|
||
decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
|
||
proven_kbps: 0,
|
||
bad_windows: 0,
|
||
clean_windows: 0,
|
||
last_change: None,
|
||
unacked: 0,
|
||
ceiling_ask_kbps: 0,
|
||
}
|
||
}
|
||
|
||
/// Raise the climb ceiling to a measured link capacity (the startup speed-test probe's
|
||
/// delivered throughput with headroom already subtracted by the caller). Without this call
|
||
/// the ceiling stays the negotiated start rate — exactly the old behavior. Never lowers:
|
||
/// a congested-moment measurement must not shrink authority below what was negotiated
|
||
/// (descent is the congestion signals' job). The `PUNKTFUNK_ABR_MAX_MBPS` cap clamps HERE
|
||
/// — the one funnel every learned ceiling passes through — so it binds no matter how the
|
||
/// ceiling was learned; monotonicity is precisely why the user needs it (one inflated
|
||
/// measurement is otherwise permanent for the session).
|
||
pub(crate) fn set_ceiling(&mut self, kbps: u32) {
|
||
let kbps = kbps.min(self.ceiling_cap_kbps.unwrap_or(u32::MAX));
|
||
if self.enabled && kbps > self.ceiling_kbps {
|
||
self.ceiling_kbps = kbps;
|
||
}
|
||
}
|
||
|
||
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
|
||
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
|
||
///
|
||
/// A SHORT ack (below what we asked) is the host telling us about a limit the network
|
||
/// signals can't see — its encoder's codec-level ceiling, or a climb refusal while encode
|
||
/// can't hold cadence. Two consecutive short acks at the SAME value latch it as
|
||
/// [`host_cap_kbps`](Self::host_cap_kbps), stopping the AIMD sawtooth from re-poking a
|
||
/// limit the host already refused; ONE is not enough — a failed host rebuild also acks
|
||
/// short once, and latching a transient would cap the session on a hiccup.
|
||
pub(crate) fn on_ack(&mut self, kbps: u32) {
|
||
if kbps > 0 {
|
||
if kbps < self.current_kbps {
|
||
// Our own decrease changes the encode-time regime (less work per frame; on an
|
||
// escalated host the queue offset shifts too) — judging the new regime against
|
||
// the old baseline would train-fire the encode down-driver. Re-seed it.
|
||
self.encode_means.clear();
|
||
}
|
||
if let Some(req) = self.last_requested_kbps.take() {
|
||
if kbps < req {
|
||
if self.short_ack_kbps == kbps {
|
||
self.short_acks += 1;
|
||
} else {
|
||
self.short_ack_kbps = kbps;
|
||
self.short_acks = 1;
|
||
}
|
||
if self.short_acks >= 2 && self.host_cap_kbps.is_none_or(|c| kbps < c) {
|
||
// Re-learning a cap we had already lifted means the limit is STANDING,
|
||
// not the transient the re-probe exists to escape — back its clock off
|
||
// (see [`CAP_REPROBE_WINDOWS_MIN`]) so a hard encoder ceiling settles
|
||
// into a slow poll instead of two pointless acks every 12 s. A first
|
||
// latch starts the clock fast, because that is the case that matters.
|
||
self.cap_reprobe_after = if self.host_cap_kbps.is_some() {
|
||
self.cap_reprobe_after
|
||
.saturating_mul(2)
|
||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||
} else {
|
||
CAP_REPROBE_WINDOWS_MIN
|
||
};
|
||
tracing::info!(
|
||
cap_kbps = kbps,
|
||
reprobe_after_windows = self.cap_reprobe_after,
|
||
"adaptive bitrate: host cap learned (encoder ceiling or cadence \
|
||
refusal) — climbs stop here until it lifts"
|
||
);
|
||
self.host_cap_kbps = Some(kbps.max(self.floor_kbps));
|
||
self.cap_probe_windows = 0;
|
||
}
|
||
} else {
|
||
self.short_acks = 0;
|
||
// GRANTED in full at or above the learned cap: the limit that taught it is
|
||
// gone, and we have the host's own word for it. Drop the cap outright rather
|
||
// than keep crawling up in +12.5 % re-probe steps — for a cap latched from a
|
||
// transient (a host briefly behind cadence) that crawl is the entire
|
||
// remaining cost of the transient, and it is measured in minutes.
|
||
if self.host_cap_kbps.is_some_and(|c| kbps >= c) {
|
||
tracing::info!(
|
||
granted_kbps = kbps,
|
||
"adaptive bitrate: host granted a climb at the learned cap — the \
|
||
limit has lifted, dropping it"
|
||
);
|
||
self.host_cap_kbps = None;
|
||
self.cap_probe_windows = 0;
|
||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||
}
|
||
}
|
||
}
|
||
if kbps > self.current_kbps {
|
||
// The rate ROSE — whatever the pipeline chokes on next, it will choke at a rate
|
||
// it was driven up to: a fresh knee sample (see `climb_since_backoff`). An ack'd
|
||
// decrease deliberately does not arm this — the drain after a backoff is not a
|
||
// knee encounter.
|
||
self.climb_since_backoff = true;
|
||
}
|
||
self.current_kbps = kbps;
|
||
// The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited
|
||
// `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually
|
||
// encodes (a 1080p session mirroring a 4K panel resolves ~3× higher), and that is
|
||
// the host's own Automatic answer, not a climb we asked for. Let the ceiling follow
|
||
// — `set_ceiling` only ever raises, and still clamps to the operator's
|
||
// `PUNKTFUNK_ABR_MAX_MBPS`, which is what must bind here if anything does. Without
|
||
// this the ceiling stays at the stale negotiated rate and the step-down below
|
||
// immediately drags the host back off the rate it just chose. A no-op for ordinary
|
||
// acks: we never request above the effective ceiling in the first place.
|
||
self.set_ceiling(kbps);
|
||
}
|
||
self.unacked = 0;
|
||
}
|
||
|
||
/// An accepted mode switch: the encoder's ceiling and compute knee are properties of the
|
||
/// MODE (4K120 caps where 1080p60 never would) — drop the mode-scoped learned state. The
|
||
/// decoder's knee is just as mode-scoped (pixel rate drives both ends of the codec), so
|
||
/// the decode cap goes with it. The probe-measured `ceiling_kbps` (a LINK property)
|
||
/// survives.
|
||
///
|
||
/// Every rolling BASELINE is mode-scoped too, and for the same reason the encode one always
|
||
/// was: a mode switch changes what "normal" costs at both ends of the pipe. 4K120 decodes
|
||
/// and encodes far slower than 1080p60 and puts bigger frames on the wire, so a baseline
|
||
/// learned under the old mode is a floor the new one clears on its very first window —
|
||
/// [`DECODE_RISE_US`] is 15 µs-thousands, well inside the gap between those two modes. Left
|
||
/// standing (only `encode_means` used to be cleared here), the ~30 s it takes
|
||
/// [`BASELINE_WINDOWS`] to age out is ~30 s of every window scoring bad, which is a ×0.7
|
||
/// backoff every other window: a switch UP in mode cratered the rate instead of raising it.
|
||
/// `proven_kbps` goes with them — it is the mark climbs are bounded against, and throughput
|
||
/// the OLD mode's decoder digested is not evidence about this one. It re-earns itself from
|
||
/// the next window.
|
||
pub(crate) fn on_mode_switch(&mut self) {
|
||
self.host_cap_kbps = None;
|
||
self.short_acks = 0;
|
||
self.cap_probe_windows = 0;
|
||
self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
|
||
self.decode_cap_kbps = None;
|
||
self.decode_backoff_kbps = 0;
|
||
self.streak_decode_windows = 0;
|
||
self.climb_since_backoff = true;
|
||
self.decode_cap_probe_windows = 0;
|
||
self.owd_means.clear();
|
||
self.decode_means.clear();
|
||
self.encode_means.clear();
|
||
self.proven_kbps = 0;
|
||
}
|
||
|
||
/// Feed one report window; returns the rate to request now, if any. `dropped` = frames that
|
||
/// went FEC-unrecoverable in the window, `loss_ppm` the window's [`crate::quic::LossReport`]
|
||
/// figure, `owd_mean_us` the window's mean skew-corrected capture→received latency (`None`
|
||
/// without a clock handshake), `decode_mean_us` the window's mean client decode-stage latency
|
||
/// (`None` on an embedder that doesn't report it — the signal is then absent),
|
||
/// `encode_mean_us` the window's mean HOST encode-stage latency (from the per-AU 0xCF
|
||
/// datagrams; `None` on an old host that doesn't send them), `actual_kbps` the window's
|
||
/// ACTUAL delivered throughput (wire bytes received ÷ window — what the pipeline really
|
||
/// carried, as opposed to the target it was allowed; feeds the utilization climb gate and
|
||
/// the proven-throughput high-water mark), `flushed` = the pump's jump-to-live fired in the
|
||
/// window, `recovery_kf` = decode-recovery keyframe asks the client sent in the window (see
|
||
/// [`RECOVERY_KF_BAD`]).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn on_window(
|
||
&mut self,
|
||
now: Instant,
|
||
dropped: u64,
|
||
loss_ppm: u32,
|
||
owd_mean_us: Option<i64>,
|
||
decode_mean_us: Option<i64>,
|
||
encode_mean_us: Option<i64>,
|
||
actual_kbps: u32,
|
||
flushed: bool,
|
||
recovery_kf: u32,
|
||
) -> Option<u32> {
|
||
if !self.enabled {
|
||
return None;
|
||
}
|
||
if self.unacked >= MAX_UNACKED {
|
||
// The host never answered: an older build. Go quiet instead of spamming a message it
|
||
// logs as unknown every few seconds.
|
||
self.enabled = false;
|
||
tracing::info!("adaptive bitrate off — host never acked a SetBitrate (older host)");
|
||
return None;
|
||
}
|
||
// OWD: compare against the rolling-min baseline of PRIOR windows (so a rising window
|
||
// doesn't drag its own baseline up), then record it. No severe tier — a standing queue is
|
||
// congestion evidence, not visible damage, so it always takes the two-window path.
|
||
let (owd_bad, _) = score_baseline(&mut self.owd_means, owd_mean_us, OWD_RISE_US, i64::MAX);
|
||
// Decode-stage latency: same rolling-min-baseline treatment as OWD, but measuring the
|
||
// CLIENT'S decoder rather than the link. A rise means the decoder is backlogging frames —
|
||
// the bottleneck the network signals are blind to. Marking the window bad both ends slow
|
||
// start (so the climb stops the moment decode latency lifts, instead of doubling on into
|
||
// the link ceiling) and, sustained, drives the ×0.7 backoff down to the real decode limit.
|
||
// An excursion far past baseline is SEVERE: the decoder is deep in spike-overload and the
|
||
// user is watching it — skip the two-window confirmation.
|
||
let (decode_bad, decode_severe) = score_baseline(
|
||
&mut self.decode_means,
|
||
decode_mean_us,
|
||
DECODE_RISE_US,
|
||
DECODE_SEVERE_US,
|
||
);
|
||
// Host-encode latency: the same rolling-min-baseline treatment, measuring the HOST'S
|
||
// encoder — the compute-knee down-driver (see [`ENCODE_RISE_US`]). This is the only
|
||
// signal that can push an already-too-high rate back under the knee: the host refuses
|
||
// further climbs while behind cadence, but nothing else ever DESCENDS on a clean LAN.
|
||
let (encode_bad, encode_severe) = score_baseline(
|
||
&mut self.encode_means,
|
||
encode_mean_us,
|
||
ENCODE_RISE_US,
|
||
ENCODE_SEVERE_US,
|
||
);
|
||
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
|
||
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
|
||
// any blip — one window is enough. Ordinary congestion (heavy-but-recoverable loss, an
|
||
// OWD rise, a decode-latency rise, repeated keyframe asks) still needs two consecutive
|
||
// windows.
|
||
let severe = dropped > 0
|
||
|| flushed
|
||
|| loss_ppm >= SEVERE_LOSS_PPM
|
||
|| decode_severe
|
||
|| encode_severe
|
||
|| recovery_kf >= RECOVERY_KF_SEVERE;
|
||
let bad = severe
|
||
|| loss_ppm >= HEAVY_LOSS_PPM
|
||
|| owd_bad
|
||
|| decode_bad
|
||
|| encode_bad
|
||
|| recovery_kf >= RECOVERY_KF_BAD;
|
||
// The proven-throughput high-water mark: this window's delivered rate is now demonstrably
|
||
// digestible — the pipeline carried it and NOTHING went wrong while it did. Scored after
|
||
// the verdict and gated on the whole of it, not on decode alone: the mark never decays, so
|
||
// one window is permanent authority over how far every later climb may step, and the
|
||
// windows that overstate delivered throughput are exactly the damaged ones (a stall's
|
||
// backlog draining in a single window, a flush's queue, the FEC surge that answers a loss
|
||
// burst). "Loss doesn't disqualify, the bytes still arrived" was true about the bytes and
|
||
// wrong about the conclusion drawn from them.
|
||
if !bad && actual_kbps > self.proven_kbps {
|
||
self.proven_kbps = actual_kbps;
|
||
}
|
||
if bad {
|
||
self.bad_windows += 1;
|
||
if decode_bad {
|
||
// Per-window decode attribution for the streak (see `streak_decode_windows`) —
|
||
// scored HERE because at backoff time only the final window's signals are in
|
||
// scope, and on the two-window path the first bad window never even reaches a
|
||
// decision (the cooldown eats it).
|
||
self.streak_decode_windows += 1;
|
||
}
|
||
self.clean_windows = 0;
|
||
// Any congestion signal ends slow start for good — from here on, climbs are additive.
|
||
self.probing = false;
|
||
} else {
|
||
self.clean_windows += 1;
|
||
self.bad_windows = 0;
|
||
self.streak_decode_windows = 0;
|
||
}
|
||
// The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run
|
||
// parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent
|
||
// refusal can't quietly cap the whole session — a still-standing limit just re-latches
|
||
// from the next pair of short acks, at zero encoder cost, and backs the clock off.
|
||
if let Some(cap) = self.host_cap_kbps {
|
||
if bad {
|
||
self.cap_probe_windows = 0;
|
||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||
self.cap_probe_windows += 1;
|
||
if self.cap_probe_windows >= self.cap_reprobe_after {
|
||
self.cap_probe_windows = 0;
|
||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||
if lifted > cap {
|
||
tracing::debug!(
|
||
from_kbps = cap,
|
||
to_kbps = lifted,
|
||
"adaptive bitrate: re-probing above the learned host cap"
|
||
);
|
||
self.host_cap_kbps = Some(lifted);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// The decode cap re-probes on the same clock and for the same reason: the knee is
|
||
// content- and thermals-dependent evidence, not a spec limit — a decoder that recovers
|
||
// must get its headroom back, so the latch clears UPWARD through here rather than ever
|
||
// being permanent. A still-standing knee re-latches from the next pair of
|
||
// decode-driven backoffs.
|
||
if let Some(cap) = self.decode_cap_kbps {
|
||
if bad {
|
||
self.decode_cap_probe_windows = 0;
|
||
} else if self.current_kbps >= cap.saturating_sub(cap / 16) {
|
||
self.decode_cap_probe_windows += 1;
|
||
if self.decode_cap_probe_windows >= self.decode_cap_reprobe_after {
|
||
self.decode_cap_probe_windows = 0;
|
||
let lifted = cap.saturating_add(cap / 8).min(self.ceiling_kbps);
|
||
if lifted > cap {
|
||
tracing::debug!(
|
||
from_kbps = cap,
|
||
to_kbps = lifted,
|
||
"adaptive bitrate: re-probing above the learned decode cap"
|
||
);
|
||
self.decode_cap_kbps = Some(lifted);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let cooled = self
|
||
.last_change
|
||
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
|
||
if !cooled {
|
||
return None;
|
||
}
|
||
if (self.bad_windows >= BAD_WINDOWS_TO_DECREASE || (severe && self.bad_windows >= 1))
|
||
&& self.current_kbps > self.floor_kbps
|
||
{
|
||
// Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff
|
||
// with decode evidence remembers its pre-backoff rate; the next one at a similar
|
||
// rate latches that rate as the decoder's knee. One event never latches (a spurious
|
||
// flush must stay a one-off), and a decode-free backoff in between breaks the
|
||
// streak — whatever it saw, it wasn't the same knee.
|
||
//
|
||
// Decode evidence, in order:
|
||
// - a decode-SEVERE excursion in the deciding window;
|
||
// - the ordinary two-window path where EVERY bad window was decode-flagged
|
||
// (`streak_decode_windows`) — the knee's most common presentation is a standing
|
||
// 15–45 ms rise, below the severe tier, and the deciding window alone can't see
|
||
// that the streak it ends was decode's doing;
|
||
// - a keyframe-ask storm without meaningful loss: a decoder begging for fresh
|
||
// pictures on a clean link is being overdriven, whatever its latency figure says
|
||
// (some decoders wedge rather than queue — the Steam Deck presentation). With
|
||
// real loss present the asks are network-attributed and teach nothing here;
|
||
// - a flush, where the decode signal can't speak against it: on an embedder that
|
||
// reports decode latency, a flush with FLAT decode is a network event (a stall, a
|
||
// clock step) that drained a queue the decoder was keeping up with — teaching a
|
||
// "decoder knee" from it caps the session on the wrong end of the pipe. Where the
|
||
// signal is absent the flush is the only decoder-saturation evidence there is.
|
||
let decode_evidence = decode_severe
|
||
|| self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE
|
||
|| (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM)
|
||
|| (flushed && (decode_bad || decode_mean_us.is_none()));
|
||
// Starved deciding window (see [`STARVED_DELIVERY_DIV`]): the stream barely flowed,
|
||
// so the window says nothing about what the decoder can hold at this rate.
|
||
let starved =
|
||
(actual_kbps as u64) * (STARVED_DELIVERY_DIV as u64) < self.current_kbps as u64;
|
||
if !self.climb_since_backoff {
|
||
// Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms,
|
||
// so this window's rate is one the decoder never choked at while keeping up —
|
||
// its distress is residue of the choke above. Not a knee sample either way:
|
||
// neither latch against it nor let it erase the reference the real knee set.
|
||
tracing::debug!(
|
||
at_kbps = self.current_kbps,
|
||
reference_kbps = self.decode_backoff_kbps,
|
||
"adaptive bitrate: backoff without an intervening climb — draining the \
|
||
previous choke, not a knee sample"
|
||
);
|
||
} else if starved {
|
||
// Same "not a knee sample either way" treatment as the draining arm: neither
|
||
// latch against a starved window nor let it erase the reference a real knee
|
||
// set — the next genuine choke at that rate must still find its pair.
|
||
tracing::debug!(
|
||
at_kbps = self.current_kbps,
|
||
actual_kbps,
|
||
reference_kbps = self.decode_backoff_kbps,
|
||
"adaptive bitrate: backoff in a starved window (delivery a fraction of \
|
||
the target) — starvation-shaped distress, not a knee sample"
|
||
);
|
||
} else if decode_evidence {
|
||
let rate = self.current_kbps;
|
||
let similar = self.decode_backoff_kbps > 0
|
||
&& rate.abs_diff(self.decode_backoff_kbps)
|
||
<= self.decode_backoff_kbps / DECODE_CAP_SIMILAR_DIV;
|
||
// Latch just UNDER the rate that choked, not at it: the knee is the rate the
|
||
// decoder could not hold, so a cap sitting exactly on it authorizes climbing
|
||
// straight back into the failure — the sawtooth the cap exists to end, merely
|
||
// slower. One sixteenth is inside the ±1/8 band the pair had to agree within,
|
||
// so it costs nothing the evidence actually established.
|
||
let knee = rate.saturating_sub(rate / 16).max(self.floor_kbps);
|
||
if similar && self.decode_cap_kbps.is_none_or(|c| knee < c) {
|
||
// Same standing-vs-transient backoff as the host cap.
|
||
self.decode_cap_reprobe_after = if self.decode_cap_kbps.is_some() {
|
||
self.decode_cap_reprobe_after
|
||
.saturating_mul(2)
|
||
.min(CAP_REPROBE_WINDOWS_MAX)
|
||
} else {
|
||
CAP_REPROBE_WINDOWS_MIN
|
||
};
|
||
tracing::info!(
|
||
cap_kbps = knee,
|
||
choked_at_kbps = rate,
|
||
reprobe_after_windows = self.decode_cap_reprobe_after,
|
||
"adaptive bitrate: decode cap learned (decoder knee) — climbs stop \
|
||
here until it lifts"
|
||
);
|
||
self.decode_cap_kbps = Some(knee);
|
||
self.decode_cap_probe_windows = 0;
|
||
}
|
||
self.decode_backoff_kbps = rate;
|
||
} else {
|
||
self.decode_backoff_kbps = 0;
|
||
}
|
||
self.climb_since_backoff = false;
|
||
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
|
||
self.bad_windows = 0;
|
||
self.streak_decode_windows = 0;
|
||
return self.request(next, now);
|
||
}
|
||
// Climbs only fire off a UTILIZED clean window (actual delivered ≥ ¾ of the target — the
|
||
// target was genuinely tested, not idling under calm content) and step at most ×1.5 past
|
||
// the proven high-water mark. Calm windows still count as clean (clean_windows keeps
|
||
// accumulating — the network is healthy), they just can't authorize a climb; the first
|
||
// utilized window after a long-enough clean run climbs immediately.
|
||
let utilized =
|
||
actual_kbps as u64 * UTILIZATION_DEN >= self.current_kbps as u64 * UTILIZATION_NUM;
|
||
// The effective ceiling folds in both learned caps: the probe measured the LINK, the
|
||
// host's short acks measured the ENCODER, and the decode cap measured the CLIENT
|
||
// DECODER — whichever binds first is the limit.
|
||
let eff_ceiling = self
|
||
.ceiling_kbps
|
||
.min(self.host_cap_kbps.unwrap_or(u32::MAX))
|
||
.min(self.decode_cap_kbps.unwrap_or(u32::MAX));
|
||
// Above the ceiling with nothing wrong: the session negotiated a rate the operator's
|
||
// `PUNKTFUNK_ABR_MAX_MBPS` forbids (no congestion signal will ever find this — the link
|
||
// is fine, the cap is a policy). Step straight to it rather than sitting above a limit
|
||
// the user set, and never below the floor. Asked ONCE per distinct target: if the host
|
||
// answers with something higher it has told us it cannot go there (its own floor, an
|
||
// encoder minimum), and repeating the ask every cooldown would buy nothing but a
|
||
// reconfigure each time.
|
||
let ceiling_target = eff_ceiling.max(self.floor_kbps);
|
||
if self.current_kbps > ceiling_target && self.ceiling_ask_kbps != ceiling_target {
|
||
tracing::info!(
|
||
from_kbps = self.current_kbps,
|
||
to_kbps = ceiling_target,
|
||
"adaptive bitrate: session rate is above the configured ceiling — stepping down"
|
||
);
|
||
self.ceiling_ask_kbps = ceiling_target;
|
||
return self.request(ceiling_target, now);
|
||
}
|
||
let cap = eff_ceiling
|
||
.min(self.proven_kbps.saturating_mul(PROVEN_HEADROOM_NUM) / PROVEN_HEADROOM_DEN);
|
||
if self.current_kbps < eff_ceiling && utilized && cap > self.current_kbps {
|
||
// Slow start: double on every cooled clean window until the first congestion signal
|
||
// (this is how an Automatic session reaches a probe-measured ceiling in seconds).
|
||
// Congestion avoidance: +~6 % after a sustained clean run.
|
||
if self.probing && self.clean_windows >= 1 {
|
||
let next = self.current_kbps.saturating_mul(2).min(cap);
|
||
self.clean_windows = 0;
|
||
return self.request(next, now);
|
||
}
|
||
if self.clean_windows >= CLEAN_WINDOWS_TO_INCREASE {
|
||
let next = (self.current_kbps + self.current_kbps / 16 + 1).min(cap);
|
||
self.clean_windows = 0;
|
||
return self.request(next, now);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn request(&mut self, kbps: u32, now: Instant) -> Option<u32> {
|
||
self.last_change = Some(now);
|
||
self.unacked += 1;
|
||
self.last_requested_kbps = Some(kbps);
|
||
// `current_kbps` is NOT updated here — the host's ack is authoritative. A lost/ignored
|
||
// request just recomputes from the same base next time (and counts toward MAX_UNACKED).
|
||
Some(kbps)
|
||
}
|
||
|
||
/// The decision [`on_window`](Self::on_window) returned never reached the wire (the control
|
||
/// queue was full). Undo the request's bookkeeping: [`MAX_UNACKED`] exists to detect a HOST
|
||
/// that doesn't answer, and counting a message we never sent toward it retires the
|
||
/// controller for the session — with a log line blaming an "older host" that is not what
|
||
/// happened. Clearing the pending request also keeps a later unsolicited ack from being
|
||
/// judged short against a rate we never asked for.
|
||
pub(crate) fn on_request_dropped(&mut self) {
|
||
self.unacked = self.unacked.saturating_sub(1);
|
||
self.last_requested_kbps = None;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A window cadence matching the pump's 750 ms tick, safely past the change cooldown when
|
||
/// stepped 5× between decisions.
|
||
const TICK: Duration = Duration::from_millis(750);
|
||
|
||
fn ticks(start: Instant, n: u32) -> Instant {
|
||
start + TICK * n
|
||
}
|
||
|
||
/// Drive `n` clean windows, asserting no decision fires before the clean threshold. Windows
|
||
/// are fully loaded (1 Gb/s actual) so neither the utilization gate nor the proven cap binds.
|
||
fn run_clean(c: &mut BitrateController, start: Instant, from: u32, n: u32) -> Option<u32> {
|
||
let mut out = None;
|
||
for i in from..from + n {
|
||
out = c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
if out.is_some() {
|
||
return out;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn disabled_when_not_automatic_or_old_host() {
|
||
// start 0 = explicit user bitrate or a host that didn't echo one → permanently off.
|
||
let mut c = BitrateController::new(0);
|
||
let now = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(
|
||
now,
|
||
5,
|
||
900_000,
|
||
Some(500_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
true,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_ordinary_bad_windows_step_down_multiplicatively() {
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
// Heavy-but-recoverable loss (2–6 %) is ORDINARY: one window is a blip — no reaction.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
25_000,
|
||
None,
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
// The second consecutive bad window backs off ×0.7.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 1),
|
||
0,
|
||
25_000,
|
||
None,
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
c.on_ack(14_000);
|
||
// Still bad after the cooldown → another ×0.7 step from the ACKED rate.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 6),
|
||
0,
|
||
25_000,
|
||
None,
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
); // bad #1 again
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 7),
|
||
0,
|
||
25_000,
|
||
None,
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(9_800)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn severe_window_backs_off_immediately() {
|
||
// An unrecoverable frame (the user SAW a freeze) skips the two-window wait…
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
Some(14_000)
|
||
);
|
||
// …and so does a jump-to-live flush.
|
||
let mut c = BitrateController::new(20_000);
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 0), 0, 0, None, None, None, 1_000_000, true, 0),
|
||
Some(14_000)
|
||
);
|
||
// …and ≥6 % window loss.
|
||
let mut c = BitrateController::new(20_000);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
80_000,
|
||
None,
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cooldown_blocks_back_to_back_steps() {
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
Some(14_000)
|
||
);
|
||
c.on_ack(14_000);
|
||
// A severe window INSIDE the 1.5 s cooldown (tick 1 = 750 ms) → held; at the cooldown
|
||
// boundary (tick 2 = 1.5 s) it fires.
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 1), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 2), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
Some(9_800)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn floor_is_never_crossed() {
|
||
let mut c = BitrateController::new(6_000);
|
||
let start = Instant::now();
|
||
// ×0.7 of 6000 = 4200 < floor → clamped to 5000.
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
Some(5_000)
|
||
);
|
||
c.on_ack(5_000);
|
||
// At the floor, further bad windows request nothing.
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 6), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 7), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sustained_clean_recovers_toward_ceiling_only() {
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(ticks(start, 0), 1, 0, None, None, None, 1_000_000, false, 0),
|
||
Some(14_000)
|
||
);
|
||
c.on_ack(14_000);
|
||
// The backoff ended slow start → additive recovery: 6 clean windows → one +~6 % step
|
||
// (14000 + 14000/16 + 1 = 14876).
|
||
let up = run_clean(&mut c, start, 2, 7);
|
||
assert_eq!(up, Some(14_876));
|
||
c.on_ack(14_876);
|
||
// Fully recovered → clean windows at the ceiling stay quiet (never probe past it).
|
||
c.on_ack(20_000);
|
||
assert_eq!(run_clean(&mut c, start, 40, 20), None);
|
||
}
|
||
|
||
#[test]
|
||
fn slow_start_doubles_to_a_probed_ceiling_then_stops() {
|
||
let mut c = BitrateController::new(20_000);
|
||
// The startup link-capacity probe measured ~430 Mbps delivered → ×0.7 ceiling.
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
// Every cooled clean window doubles until the ceiling caps the climb, then quiet.
|
||
let mut got = Vec::new();
|
||
for i in 0..14 {
|
||
if let Some(k) = c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
) {
|
||
c.on_ack(k);
|
||
got.push(k);
|
||
}
|
||
}
|
||
assert_eq!(got, vec![40_000, 80_000, 160_000, 300_000]);
|
||
}
|
||
|
||
#[test]
|
||
fn first_congestion_ends_slow_start_for_good() {
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(40_000)
|
||
);
|
||
c.on_ack(40_000);
|
||
// Severe window → immediate ×0.7, and slow start is over.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 2),
|
||
1,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(28_000)
|
||
);
|
||
c.on_ack(28_000);
|
||
// Clean again — but the next climb is additive, after the 6-window clean run.
|
||
let mut next = None;
|
||
for i in 3..12 {
|
||
next = c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
if next.is_some() {
|
||
assert!(i >= 8, "additive climb must wait for the clean run");
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(next, Some(29_751)); // 28000 + 28000/16 + 1
|
||
}
|
||
|
||
#[test]
|
||
fn set_ceiling_is_ignored_when_disabled_and_never_lowers() {
|
||
let mut c = BitrateController::new(0);
|
||
c.set_ceiling(1_000_000);
|
||
assert_eq!(
|
||
c.on_window(Instant::now(), 0, 0, None, None, None, 1_000_000, false, 0),
|
||
None
|
||
);
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(10_000); // below the negotiated start → ignored
|
||
assert_eq!(c.ceiling_kbps, 20_000);
|
||
}
|
||
|
||
#[test]
|
||
fn owd_rise_alone_is_a_congestion_signal() {
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
// Establish a ~10 ms baseline over a few clean windows.
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
// Delay climbs 40 ms above baseline with ZERO loss — bufferbloat. Two windows → back off.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(50_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 5),
|
||
0,
|
||
0,
|
||
Some(52_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_latency_rise_alone_is_a_congestion_signal() {
|
||
// The link is pristine (zero loss, flat OWD) but the client's decoder is falling behind —
|
||
// the LAN-vs-mobile-decoder case. Only the decode signal can catch it.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
// A ~8 ms decode baseline over a few clean windows.
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
// Decode latency climbs 30 ms above baseline with ZERO loss and flat OWD: the decoder is
|
||
// backlogging. Two windows → back off ×0.7, exactly like an OWD rise.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(38_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 5),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(40_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_ask_storm_alone_is_a_congestion_signal() {
|
||
// The RX-9070 field shape: pristine link (zero loss, flat OWD), no latency signal — but
|
||
// the decoder keeps begging for keyframes. Two asks per window is ordinary-bad: two
|
||
// consecutive windows back off ×0.7.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
2
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 1),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
2
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_ask_saturation_is_severe() {
|
||
// The emitters throttle at 100 ms, so 4+ asks in one 750 ms window means the decoder
|
||
// spent most of it unable to produce pictures — one window is enough.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
4
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_keyframe_ask_is_not_congestion() {
|
||
// A lone hiccup's recovery ask must not read as congestion — windows carrying one ask
|
||
// stay clean (no backoff however many in a row).
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
1
|
||
),
|
||
None
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn decode_latency_caps_the_slow_start_climb() {
|
||
// A fat link (probe measured ~300 Mbps) but a decoder that saturates below it.
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
// Slow start doubles while the decoder keeps up, and the first BASELINE_MIN_WINDOWS of
|
||
// those windows are what teach the decode baseline (one sample is not a floor).
|
||
let mut last = 0;
|
||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||
if let Some(k) = c.on_window(
|
||
ticks(start, i * 2),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
) {
|
||
last = k;
|
||
c.on_ack(k);
|
||
}
|
||
}
|
||
assert_eq!(last, 300_000, "slow start should reach the probed ceiling");
|
||
// Now the decoder starts backing up (30 ms over the learned baseline): the window is bad,
|
||
// so the climb stops instead of parking at the link ceiling…
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 20),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(38_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
// …and a second backed-up window backs the rate off toward the real decode limit rather
|
||
// than choking the decoder at the link ceiling (the reported bug).
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 22),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(40_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(210_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn one_calm_window_is_not_a_baseline() {
|
||
// The ratchet this guard exists to stop: our own decrease CLEARS the encode baseline, so
|
||
// it re-seeds from whatever the next window happens to be. If that window is calm, the
|
||
// ordinary content variance that follows reads as a rise, backs off, clears again — all
|
||
// the way to the floor on a link that was never the problem. A single sample must not
|
||
// arm the signal.
|
||
let mut c = BitrateController::new(100_000);
|
||
let start = Instant::now();
|
||
// One calm 3 ms encode window, then windows 9 ms above it: far past ENCODE_RISE_US, and
|
||
// sustained — yet no baseline exists to judge them against yet.
|
||
for i in 0..BASELINE_MIN_WINDOWS as u32 {
|
||
let mean = if i == 0 { 3_000 } else { 12_000 };
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(mean),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None,
|
||
"window {i} fired off a baseline of fewer than {BASELINE_MIN_WINDOWS} samples"
|
||
);
|
||
}
|
||
// With a real baseline (min 3 ms over 4 windows) the signal works exactly as before: a
|
||
// sustained rise past it still backs the rate off.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 8),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(20_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(70_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unloaded_clean_windows_never_authorize_a_climb() {
|
||
// Calm content: the network is pristine but the encoder emits a fraction of the target —
|
||
// those windows prove nothing, so the target must NOT drift up (the settle-calm-then-
|
||
// spike-overload bug this gate exists for).
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
for i in 0..12 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
2_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
// Motion arrives: the first utilized window climbs immediately (clean credit is already
|
||
// banked), but only to ×1.5 over the proven high-water (18 000 delivered → 27 000), not a
|
||
// blind doubling to 40 000.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 12),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
18_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(27_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn slow_start_steps_stay_within_proven_headroom() {
|
||
// Under real load the climb proceeds, but each step is a bounded experiment: ×1.5 over
|
||
// what was actually delivered and digested, never a blind 2× toward the link ceiling.
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
// The window delivered the full target (the encoder is constrained by it): proven 20 000
|
||
// → the doubling is capped at 30 000.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
20_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(30_000)
|
||
);
|
||
c.on_ack(30_000);
|
||
// The next loaded window delivers 30 000 → the next step is 45 000, not 60 000.
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 2),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
30_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(45_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn calm_period_keeps_the_validated_target() {
|
||
// A target validated under load is NOT surrendered when the scene goes calm: no
|
||
// down-steps, no ceiling decay — the encoder keeps the proven headroom so returning
|
||
// motion gets the full rate instantly instead of re-ramping every calm→action edge.
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000);
|
||
let start = Instant::now();
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 0),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
20_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(30_000)
|
||
);
|
||
c.on_ack(30_000);
|
||
// A long calm stretch (2 % utilization, decoder idle): the controller stays silent.
|
||
for i in 2..30 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(4_000),
|
||
None,
|
||
600,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn deep_decode_excursion_is_severe() {
|
||
// A motion spike that shoots decode latency far past baseline (>45 ms) is the overload
|
||
// already happening — it must not wait out the two-window confirmation.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
// 52 ms over the 8 ms baseline in ONE window → immediate ×0.7. (A 30 ms rise — see
|
||
// decode_latency_rise_alone_is_a_congestion_signal — still takes the ordinary two.)
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(60_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_identical_short_acks_latch_the_host_cap() {
|
||
// The 4K120 field failure: the encoder ceilings at ~794 Mbps while the link carries
|
||
// more — the host acks short. TWO identical short acks teach the cap; climbs then stop
|
||
// poking a limit the host already refused (the rebuild-storm driver).
|
||
let mut c = BitrateController::new(400_000);
|
||
c.set_ceiling(1_400_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||
// First short ack: current follows (authoritative), but one short ack is not a cap.
|
||
c.on_ack(794_000);
|
||
assert!(c.host_cap_kbps.is_none());
|
||
// The next climb overshoots again and is short-acked at the SAME value: latch.
|
||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||
// Parked AT the learned cap, nothing left to climb to — no more requests.
|
||
assert_eq!(run_clean(&mut c, start, 20, 12), None);
|
||
}
|
||
|
||
#[test]
|
||
fn one_short_ack_is_a_transient_not_a_cap() {
|
||
// A failed host rebuild acks short once (it kept the old rate) — latching THAT would
|
||
// cap the session on a driver hiccup. The streak must survive only identical repeats.
|
||
let mut c = BitrateController::new(400_000);
|
||
c.set_ceiling(1_400_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||
c.on_ack(400_000); // rebuild failed, host kept the old rate
|
||
assert!(c.host_cap_kbps.is_none());
|
||
// The retry applies fully: streak broken, still no cap, full authority kept.
|
||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(800_000));
|
||
c.on_ack(800_000);
|
||
assert!(c.host_cap_kbps.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mode_switch_clears_the_learned_cap() {
|
||
let mut c = BitrateController::new(400_000);
|
||
c.set_ceiling(1_400_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||
// 4K120's ceiling means nothing at the new mode — the cap must not survive the switch
|
||
// (the probe-measured link ceiling does).
|
||
c.on_mode_switch();
|
||
assert!(c.host_cap_kbps.is_none());
|
||
assert_eq!(c.ceiling_kbps, 1_400_000);
|
||
}
|
||
|
||
#[test]
|
||
fn learned_cap_reprobes_after_a_sustained_clean_run() {
|
||
// A cadence-refusal cap is scene evidence, not a spec limit: after a clean run parked at
|
||
// the cap, lift one step so a one-time heavy scene can't cap the session forever. A
|
||
// still-standing limit just re-latches from the next short-ack pair, at zero cost.
|
||
let mut c = BitrateController::new(400_000);
|
||
c.set_ceiling(1_400_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||
// The FIRST re-probe is the fast one — a transient refusal must not cost the session
|
||
// minutes to escape.
|
||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||
for i in 0..CAP_REPROBE_WINDOWS_MIN {
|
||
let _ = c.on_window(
|
||
ticks(start, 20 + i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
}
|
||
assert_eq!(c.host_cap_kbps, Some(794_000 + 794_000 / 8));
|
||
}
|
||
|
||
#[test]
|
||
fn a_transient_refusal_does_not_pin_the_session() {
|
||
// The field failure this whole cap-escape change exists for. A host that escalates its
|
||
// capture/encode pipeline once — a startup hitch is enough — used to refuse every climb
|
||
// for the rest of the session; the client latched that refusal as a cap, at whatever
|
||
// rate slow start had reached, which is routinely the 20 Mbps default. Escaping cost
|
||
// +12.5 % per ~60 s: north of twenty minutes to reach a 300 Mbps link ceiling, which the
|
||
// user experiences as "Automatic is broken".
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(300_000); // the startup probe measured a fat link
|
||
let start = Instant::now();
|
||
let mut tick = 0u32;
|
||
let mut windows_pinned = 0u32;
|
||
// Two refused climbs at the same rate → the cap latches at 20 Mbps.
|
||
for _ in 0..2 {
|
||
let k = run_clean(&mut c, start, tick, 4).expect("slow start should ask to climb");
|
||
tick += 4;
|
||
assert!(k > 20_000);
|
||
c.on_ack(20_000); // "behind cadence — held at the current rate"
|
||
}
|
||
assert_eq!(c.host_cap_kbps, Some(20_000));
|
||
// The host recovers immediately (its bucket drains; the escalation bought the headroom
|
||
// it was for), but the client has no way to know that except by asking again. Drive
|
||
// clean windows and grant whatever it asks for.
|
||
while c.current_kbps < 150_000 && windows_pinned < 400 {
|
||
if let Some(k) = c.on_window(
|
||
ticks(start, tick),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
) {
|
||
c.on_ack(k);
|
||
}
|
||
tick += 1;
|
||
windows_pinned += 1;
|
||
}
|
||
assert!(
|
||
c.current_kbps >= 150_000,
|
||
"still pinned at {} after {windows_pinned} windows",
|
||
c.current_kbps
|
||
);
|
||
// ~750 ms a window: this must be tens of seconds, not the old tens of minutes.
|
||
assert!(
|
||
windows_pinned <= 40,
|
||
"took {windows_pinned} windows (~{} s) to escape a transient refusal",
|
||
windows_pinned * 3 / 4
|
||
);
|
||
// And the disproven cap is gone, not merely nudged upward.
|
||
assert!(c.host_cap_kbps.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_host_retarget_above_the_ceiling_raises_it() {
|
||
// The host sends an unsolicited `BitrateChanged` when a rebuild re-resolves an Automatic
|
||
// rate for what it ACTUALLY encodes — a 1080p session mirroring a 4K panel resolves far
|
||
// above the negotiated rate. That is the host's own Automatic answer, so the climb
|
||
// ceiling has to follow it; otherwise the ceiling stays stale and the step-down drags
|
||
// the host straight back off the rate it just chose.
|
||
let mut c = BitrateController::new(20_000);
|
||
assert_eq!(c.ceiling_kbps, 20_000);
|
||
c.on_ack(60_000); // unsolicited: no request was outstanding
|
||
assert_eq!(c.current_kbps, 60_000);
|
||
assert_eq!(c.ceiling_kbps, 60_000);
|
||
let start = Instant::now();
|
||
// No step-down, and no spurious re-target of any kind.
|
||
assert_eq!(run_clean(&mut c, start, 0, 4), None);
|
||
// The operator's cap still outranks it — that is the one thing that must bind here.
|
||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||
c.on_ack(60_000);
|
||
assert_eq!(c.ceiling_kbps, 50_000);
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||
}
|
||
|
||
#[test]
|
||
fn a_standing_cap_backs_its_reprobe_clock_off() {
|
||
// The other half of the re-probe: an encoder's real codec ceiling (794 Mbps, L6.2)
|
||
// re-teaches itself every time the lift is tried. Escaping fast is right for a
|
||
// transient and pointless here, so each re-learn doubles the interval — a hard limit
|
||
// settles into a slow poll instead of two acks every 12 s for the whole session.
|
||
let mut c = BitrateController::new(400_000);
|
||
c.set_ceiling(1_400_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(800_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(run_clean(&mut c, start, 10, 1), Some(1_400_000));
|
||
c.on_ack(794_000);
|
||
assert_eq!(c.cap_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
|
||
// Each round: park clean at the cap until it re-probes upward, then have the host refuse
|
||
// the lift at the same value again. That is a STANDING limit, so the clock doubles.
|
||
let mut tick = 20;
|
||
for round in 0..3 {
|
||
let before = c.cap_reprobe_after;
|
||
for _ in 0..before {
|
||
let _ = c.on_window(
|
||
ticks(start, tick),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
tick += 1;
|
||
}
|
||
let lifted = c.host_cap_kbps.expect("cap should still be latched");
|
||
assert!(lifted > 794_000, "round {round}: the re-probe never lifted");
|
||
// The host clamps the lift straight back to its real ceiling.
|
||
c.last_requested_kbps = Some(lifted);
|
||
c.on_ack(794_000);
|
||
assert_eq!(c.host_cap_kbps, Some(794_000));
|
||
assert_eq!(
|
||
c.cap_reprobe_after,
|
||
(before * 2).min(CAP_REPROBE_WINDOWS_MAX)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn host_encode_latency_rise_backs_off() {
|
||
// The compute knee: link pristine, client decoder fine — only HOST encode time moves
|
||
// (the 4K120 case: ~9.3 ms against an 8.33 ms budget shows up nowhere else). Two risen
|
||
// windows → ×0.7, exactly like an OWD/decode rise.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(7_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(11_500),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 6),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(12_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn deep_encode_excursion_is_severe() {
|
||
// Encode time shooting ≈1.5 frame budgets over baseline = the queue is growing past
|
||
// the knee right now — no two-window confirmation.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
for i in 0..4 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(7_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(20_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rate_decrease_rebases_the_encode_baseline() {
|
||
// After OUR OWN decrease the encode regime legitimately changes (less work per frame;
|
||
// an escalated host's reported encode_us also carries a queue offset) — the old
|
||
// baseline must not train-fire repeated backoffs down to the floor.
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
for i in 0..4 {
|
||
let _ = c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(7_000),
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
}
|
||
let _ = c.on_window(
|
||
ticks(start, 4),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(12_000),
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
);
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, 6),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(12_500),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(14_000)
|
||
);
|
||
// The decrease applies → rebase. The new regime's ~15 ms means (an escalated host's
|
||
// queue offset) would be far over the OLD 7 ms baseline, but must now read clean.
|
||
c.on_ack(14_000);
|
||
for i in 8..11 {
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, i),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
Some(15_000),
|
||
1_000_000,
|
||
false,
|
||
0
|
||
),
|
||
None
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn env_max_mbps_caps_every_learned_ceiling() {
|
||
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
|
||
// construction): a probe "measuring" 886 Mbps (the divisor bug's field figure) must
|
||
// not out-rank the user's cap…
|
||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||
c.set_ceiling(886_312);
|
||
assert_eq!(c.ceiling_kbps, 50_000);
|
||
// …while a measurement under the cap stands untouched.
|
||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||
c.set_ceiling(40_000);
|
||
assert_eq!(c.ceiling_kbps, 40_000);
|
||
// And the climb honors it: slow start doubles 20→40, the capped ceiling truncates the
|
||
// next step to 50, then quiet — never a request past the user's limit.
|
||
let mut c = BitrateController::with_ceiling_cap(20_000, Some(50_000));
|
||
c.set_ceiling(886_312);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(40_000));
|
||
c.on_ack(40_000);
|
||
assert_eq!(run_clean(&mut c, start, 2, 1), Some(50_000));
|
||
c.on_ack(50_000);
|
||
assert_eq!(run_clean(&mut c, start, 4, 20), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_session_above_the_env_cap_steps_down_to_it_once() {
|
||
// PUNKTFUNK_ABR_MAX_MBPS is the only lever an Automatic session gives the operator, and
|
||
// it used to bind only ceilings the PROBE taught — so a session that negotiated a rate
|
||
// above the cap simply ran above it forever. No congestion signal will ever find that:
|
||
// the link is fine, the cap is policy.
|
||
let mut c = BitrateController::with_ceiling_cap(100_000, Some(50_000));
|
||
assert_eq!(c.ceiling_kbps, 50_000);
|
||
let start = Instant::now();
|
||
assert_eq!(run_clean(&mut c, start, 0, 1), Some(50_000));
|
||
// Suppose the host answers HIGHER than asked (its own floor, an encoder minimum): that
|
||
// is the host saying it cannot go there. Don't re-ask every cooldown forever.
|
||
c.on_ack(80_000);
|
||
assert_eq!(run_clean(&mut c, start, 2, 20), None);
|
||
// A ceiling that MOVES is a new question, and gets asked once more.
|
||
c.set_ceiling(90_000); // clamped to the 50 Mbps cap → still 50 000, no new ask
|
||
assert_eq!(run_clean(&mut c, start, 24, 20), None);
|
||
}
|
||
|
||
fn calm_window(c: &mut BitrateController, at: Instant) {
|
||
// One calm, unutilized window (2 Mb/s actual): seeds the latency baselines without
|
||
// authorizing climbs, and must decide nothing.
|
||
assert_eq!(
|
||
c.on_window(at, 0, 0, Some(10_000), Some(8_000), None, 2_000, false, 0),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// Drive clean, fully-utilized windows (1 Gb/s actual), acking every climb the controller
|
||
/// asks for — a live host answers in ~100 ms — until `current_kbps` reaches `target`.
|
||
/// Bounded so a climb-path regression fails loudly instead of spinning.
|
||
fn climb_to(c: &mut BitrateController, start: Instant, tick: &mut u32, target: u32) {
|
||
for _ in 0..600 {
|
||
if c.current_kbps >= target {
|
||
return;
|
||
}
|
||
if let Some(k) = c.on_window(
|
||
ticks(start, *tick),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
) {
|
||
c.on_ack(k);
|
||
}
|
||
*tick += 1;
|
||
}
|
||
panic!(
|
||
"no climb to {target} within 600 windows (stuck at {})",
|
||
c.current_kbps
|
||
);
|
||
}
|
||
|
||
/// One decode-SEVERE window (60 ms against the ~8 ms baseline) at the current rate — a
|
||
/// knee choke. Steps past the change cooldown first so the decision can fire.
|
||
fn choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||
*tick += 2;
|
||
let r = c.on_window(
|
||
ticks(start, *tick),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(60_000),
|
||
None,
|
||
c.current_kbps,
|
||
false,
|
||
0,
|
||
);
|
||
*tick += 1;
|
||
r
|
||
}
|
||
|
||
/// The latch's only production-reachable shape: choke at the knee, the host ACKS the ×0.7
|
||
/// (a live host answers in ~100 ms, so a cascade's second backoff always sits at the
|
||
/// already-reduced rate — dissimilar by construction), the controller climbs back, and the
|
||
/// re-climb chokes inside the ±1/8 band. Latches, acks the backoff, returns the cap.
|
||
fn latch_knee(c: &mut BitrateController, start: Instant, tick: &mut u32) -> u32 {
|
||
for _ in 0..4 {
|
||
calm_window(c, ticks(start, *tick));
|
||
*tick += 1;
|
||
}
|
||
let knee = c.current_kbps;
|
||
let r1 = choke(c, start, tick).expect("first choke must back off");
|
||
assert!(c.decode_cap_kbps.is_none(), "one event must not latch");
|
||
c.on_ack(r1);
|
||
climb_to(c, start, tick, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||
let rate = c.current_kbps;
|
||
let r2 = choke(c, start, tick).expect("re-climb choke must back off");
|
||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||
c.on_ack(r2);
|
||
rate - rate / 16
|
||
}
|
||
|
||
/// One capture-stall-shaped window at the current rate: almost nothing delivered
|
||
/// (current/10), nothing decoded, no loss — but a jump-to-live flush and a keyframe-ask
|
||
/// storm (the stall edge's damage signature). SEVERE, so it backs off; STARVED, so it must
|
||
/// never be a knee sample.
|
||
fn stall_choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option<u32> {
|
||
*tick += 2;
|
||
let r = c.on_window(
|
||
ticks(start, *tick),
|
||
0,
|
||
0,
|
||
None,
|
||
None,
|
||
None,
|
||
c.current_kbps / 10,
|
||
true,
|
||
RECOVERY_KF_SEVERE,
|
||
);
|
||
*tick += 1;
|
||
r
|
||
}
|
||
|
||
#[test]
|
||
fn capture_stall_windows_never_latch_a_decode_cap() {
|
||
// The periodic-capture-stall field case (RDNA4 standby-sink, 5 s cycle): every stall
|
||
// edge offers another flush + kf-storm "backoff" at the SAME rate — without the starved
|
||
// guard that pair latches a phantom decoder knee at whatever rate the display driver
|
||
// happened to interrupt, and the session then fights the re-probe ladder for minutes.
|
||
let mut c = BitrateController::new(240_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
climb_to(&mut c, start, &mut t, 400_000);
|
||
let at = c.current_kbps;
|
||
let r1 = stall_choke(&mut c, start, &mut t).expect("stall damage still backs off");
|
||
assert!(
|
||
c.decode_cap_kbps.is_none(),
|
||
"one starved window must not latch"
|
||
);
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, 0,
|
||
"a starved window is not a knee sample — no reference recorded"
|
||
);
|
||
c.on_ack(r1);
|
||
climb_to(&mut c, start, &mut t, at - at / DECODE_CAP_SIMILAR_DIV);
|
||
let r2 = stall_choke(&mut c, start, &mut t).expect("second stall edge backs off too");
|
||
c.on_ack(r2);
|
||
assert!(
|
||
c.decode_cap_kbps.is_none(),
|
||
"a starved pair at the same rate must not latch a phantom knee"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn starved_window_preserves_the_knee_reference() {
|
||
// A REAL knee sample, then a stall edge, then the genuine re-climb choke: the starved
|
||
// window in the middle must neither latch nor ERASE the reference the real choke set —
|
||
// the genuine pair must still find each other around it.
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
let knee = c.current_kbps;
|
||
let r1 = choke(&mut c, start, &mut t).expect("real choke backs off");
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, knee,
|
||
"real choke records the reference"
|
||
);
|
||
c.on_ack(r1);
|
||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||
let r2 = stall_choke(&mut c, start, &mut t).expect("stall edge backs off");
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, knee,
|
||
"the starved window must not erase the real reference"
|
||
);
|
||
assert!(c.decode_cap_kbps.is_none(), "and must not latch against it");
|
||
c.on_ack(r2);
|
||
climb_to(&mut c, start, &mut t, knee - knee / DECODE_CAP_SIMILAR_DIV);
|
||
let rate = c.current_kbps;
|
||
choke(&mut c, start, &mut t).expect("genuine re-climb choke backs off");
|
||
assert_eq!(
|
||
c.decode_cap_kbps,
|
||
Some(rate - rate / 16),
|
||
"the genuine pair still latches around the starved interruption"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() {
|
||
// The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated)
|
||
// link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush +
|
||
// dropped-frame burst. Choke, recover, climb back, choke again inside the band: latch.
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
latch_knee(&mut c, start, &mut t);
|
||
// The latch applies; from here every climb must stop AT the knee — not the 900 Mbps
|
||
// link ceiling the old sawtooth kept re-poking.
|
||
let mut max_req = 0;
|
||
for _ in 0..62 {
|
||
if let Some(k) = c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
1_000_000,
|
||
false,
|
||
0,
|
||
) {
|
||
// Never past the cap in force when the decision was made. (A long clean run
|
||
// legitimately re-probes that cap upward — `decode_cap_reprobes_after_a_
|
||
// sustained_clean_run` owns that; here the point is that nothing climbs toward
|
||
// the 900 Mbps LINK ceiling the old sawtooth kept re-poking.)
|
||
assert!(
|
||
k <= c.decode_cap_kbps.unwrap(),
|
||
"climb past the decode cap: {k}"
|
||
);
|
||
max_req = max_req.max(k);
|
||
c.on_ack(k);
|
||
}
|
||
t += 1;
|
||
}
|
||
assert!(
|
||
max_req < 600_000,
|
||
"the decode knee stopped binding: climbed to {max_req}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() {
|
||
// The latch's false-positive guards, every event at a rate the controller climbed to
|
||
// or held (drain-time backoffs are no sample at all —
|
||
// `cascade_backoffs_neither_sample_nor_erase_the_knee_reference` owns those). A lone
|
||
// jump-to-live flush (a Wi-Fi clump can flush once at ANY rate) backs off but teaches
|
||
// nothing…
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
let r1 = c
|
||
.on_window(ticks(start, t), 0, 0, None, None, None, 490_000, true, 0)
|
||
.expect("flush must back off");
|
||
assert_eq!(r1, 350_000);
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
c.on_ack(r1);
|
||
// …a LOSS-driven backoff at the re-climbed rate breaks the streak (whatever choked
|
||
// there, it wasn't the decoder — even inside the similarity band)…
|
||
climb_to(&mut c, start, &mut t, 460_000);
|
||
t += 2;
|
||
let r2 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
1,
|
||
0,
|
||
None,
|
||
None,
|
||
None,
|
||
c.current_kbps,
|
||
false,
|
||
0,
|
||
)
|
||
.expect("loss must back off");
|
||
t += 1;
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, 0,
|
||
"a climbed-to non-decode backoff must reset the knee reference"
|
||
);
|
||
c.on_ack(r2);
|
||
// …so the next flush counts as a FIRST decode event again — still no latch…
|
||
climb_to(&mut c, start, &mut t, 460_000);
|
||
t += 2;
|
||
let r3 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
None,
|
||
None,
|
||
None,
|
||
c.current_kbps,
|
||
true,
|
||
0,
|
||
)
|
||
.expect("flush must back off");
|
||
t += 1;
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
c.on_ack(r3);
|
||
// …and two decode events at DISSIMILAR climbed-to rates (~460 vs ~350 Mbps — no
|
||
// common knee) must not latch either.
|
||
let dissimilar_target = c.current_kbps + 20_000;
|
||
climb_to(&mut c, start, &mut t, dissimilar_target);
|
||
t += 2;
|
||
let _ = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
None,
|
||
None,
|
||
None,
|
||
c.current_kbps,
|
||
true,
|
||
0,
|
||
)
|
||
.expect("flush must back off");
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn decode_cap_reprobes_after_a_sustained_clean_run() {
|
||
// The knee is content/thermals evidence, not a spec limit: after ~60 s parked clean at
|
||
// the latched cap, it lifts one step (+12.5 %, ceiling-bounded) — the re-probe path is
|
||
// how the latch clears (never permanent), and a still-standing knee just re-latches
|
||
// from the next pair of decode-driven backoffs.
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
let knee = latch_knee(&mut c, start, &mut t);
|
||
// The host parks the session at the knee (an unsolicited re-target up to it — its
|
||
// clamp is authoritative).
|
||
c.on_ack(knee);
|
||
for _ in 0..CAP_REPROBE_WINDOWS_MIN {
|
||
let _ = c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
490_000,
|
||
false,
|
||
0,
|
||
);
|
||
t += 1;
|
||
}
|
||
assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8));
|
||
}
|
||
|
||
#[test]
|
||
fn mode_switch_clears_the_decode_cap() {
|
||
// A 1440p120 knee means nothing at the new mode's pixel rate — the decode cap must
|
||
// not survive the switch (the probe-measured link ceiling does).
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
let _ = latch_knee(&mut c, start, &mut t);
|
||
c.on_mode_switch();
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
assert_eq!(c.ceiling_kbps, 900_000);
|
||
}
|
||
|
||
#[test]
|
||
fn ordinary_decode_bad_window_pairs_latch_the_knee_field_trace() {
|
||
// The 2026-08-03 780M field trace, numbers from the log. The knee's most common
|
||
// presentation is a standing ~26 ms decode rise — deep enough for the ordinary
|
||
// two-window backoff, below the 45 ms severe tier. Judging evidence from the deciding
|
||
// window alone read those backoffs as decode-free and RESET the knee streak each
|
||
// time; the session sawtoothed 220↔450 Mbps for its remaining minutes.
|
||
let mut c = BitrateController::new(20_000);
|
||
c.set_ceiling(657_788); // the log's probe ceiling
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
// A single heavy-loss window ends slow start (as the field session's startup hitch
|
||
// did) so the climb below is the additive one the trace shows.
|
||
let _ = c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
HEAVY_LOSS_PPM,
|
||
Some(10_000),
|
||
Some(8_000),
|
||
None,
|
||
15_000,
|
||
false,
|
||
0,
|
||
);
|
||
t += 1;
|
||
// Choke #1 (00:35:56Z): flush + 40 ms decode at ~417 Mbps — evidence, first sample.
|
||
climb_to(&mut c, start, &mut t, 417_277);
|
||
let first = c.current_kbps;
|
||
t += 2;
|
||
let r1 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(8_313),
|
||
Some(40_087),
|
||
None,
|
||
first,
|
||
true,
|
||
1,
|
||
)
|
||
.expect("flush choke must back off");
|
||
t += 1;
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
assert_eq!(c.decode_backoff_kbps, first);
|
||
c.on_ack(r1);
|
||
// Choke #2 (00:36:32Z): TWO consecutive ~26 ms decode-bad windows at ~446 Mbps — the
|
||
// ordinary two-window path, no flush, nothing severe. This is the backoff the old
|
||
// evidence gate threw away.
|
||
climb_to(&mut c, start, &mut t, 440_000);
|
||
let second = c.current_kbps;
|
||
t += 2;
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(6_877),
|
||
Some(26_474),
|
||
None,
|
||
second,
|
||
false,
|
||
0
|
||
),
|
||
None,
|
||
"the first bad window must not decide"
|
||
);
|
||
t += 1;
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(6_877),
|
||
Some(26_474),
|
||
None,
|
||
second,
|
||
false,
|
||
0
|
||
),
|
||
Some(((second as u64 * 7 / 10) as u32).max(FLOOR_KBPS))
|
||
);
|
||
assert_eq!(
|
||
c.decode_cap_kbps,
|
||
Some(second - second / 16),
|
||
"two decode-bad windows are knee evidence"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cascade_backoffs_neither_sample_nor_erase_the_knee_reference() {
|
||
// Choke at the knee (reference set), the host acks the ×0.7 within ~100 ms, and the
|
||
// drain flushes → a second backoff fires at the REDUCED rate. That rate is one the
|
||
// decoder never choked at while keeping up — the old code overwrote the reference
|
||
// with it (and could never latch from a cascade at all: ×0.7 sits outside the ±1/8
|
||
// band by construction). A drain backoff must neither latch nor erase; the eventual
|
||
// re-climb's choke latches against the ORIGINAL sample.
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
let r1 = choke(&mut c, start, &mut t).expect("knee choke must back off");
|
||
assert_eq!(c.decode_backoff_kbps, 500_000);
|
||
c.on_ack(r1);
|
||
t += 2;
|
||
let r2 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(43_305),
|
||
None,
|
||
r1,
|
||
true,
|
||
1,
|
||
)
|
||
.expect("drain flush must back off");
|
||
t += 1;
|
||
assert!(
|
||
c.decode_cap_kbps.is_none(),
|
||
"a drain backoff must not latch"
|
||
);
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, 500_000,
|
||
"…nor erase the knee reference"
|
||
);
|
||
c.on_ack(r2);
|
||
climb_to(&mut c, start, &mut t, 460_000);
|
||
let rate = c.current_kbps;
|
||
choke(&mut c, start, &mut t).expect("re-climb choke must back off");
|
||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_storms_on_a_clean_link_latch_the_knee() {
|
||
// The Steam Deck presentation of the knee: an overdriven decoder that WEDGES instead
|
||
// of queueing — decode latency reads absent-to-flat while the client begs for
|
||
// keyframes with zero loss (the field traces: 14–19 asks at ~300 Mbps, loss_ppm=0).
|
||
// The asks are the decode evidence.
|
||
let mut c = BitrateController::new(300_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
t += 2;
|
||
let r1 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
300_000,
|
||
false,
|
||
RECOVERY_KF_SEVERE,
|
||
)
|
||
.expect("keyframe storm must back off");
|
||
t += 1;
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
c.on_ack(r1);
|
||
climb_to(&mut c, start, &mut t, 280_000);
|
||
let rate = c.current_kbps;
|
||
t += 2;
|
||
let _ = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
rate,
|
||
false,
|
||
RECOVERY_KF_SEVERE,
|
||
)
|
||
.expect("second storm must back off");
|
||
assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16));
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_storms_with_real_loss_teach_no_knee() {
|
||
// The same storm WITH heavy loss is network-attributed (a lost reference forces
|
||
// recovery asks; loss_ppm already prices that path): it must not latch, and it must
|
||
// break the streak like any other non-decode backoff.
|
||
let mut c = BitrateController::new(300_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
t += 2;
|
||
let r1 = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
300_000,
|
||
false,
|
||
RECOVERY_KF_SEVERE,
|
||
)
|
||
.expect("clean storm must back off");
|
||
t += 1;
|
||
assert_eq!(c.decode_backoff_kbps, 300_000);
|
||
c.on_ack(r1);
|
||
climb_to(&mut c, start, &mut t, 280_000);
|
||
t += 2;
|
||
let _ = c
|
||
.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
SEVERE_LOSS_PPM,
|
||
Some(10_000),
|
||
None,
|
||
None,
|
||
c.current_kbps,
|
||
false,
|
||
RECOVERY_KF_SEVERE,
|
||
)
|
||
.expect("lossy storm must back off");
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, 0,
|
||
"a loss-attributed storm must reset the knee reference"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_mixed_streak_without_decode_attribution_is_no_knee_evidence() {
|
||
// Two bad windows, only ONE decode-flagged (OWD carried the other): the backoff is
|
||
// not decode-attributed — the reference must reset, not sample.
|
||
let mut c = BitrateController::new(500_000);
|
||
c.set_ceiling(900_000);
|
||
let start = Instant::now();
|
||
let mut t = 0;
|
||
for _ in 0..4 {
|
||
calm_window(&mut c, ticks(start, t));
|
||
t += 1;
|
||
}
|
||
t += 2;
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(40_000),
|
||
Some(8_000),
|
||
None,
|
||
490_000,
|
||
false,
|
||
0
|
||
),
|
||
None,
|
||
"one OWD-bad window must not decide"
|
||
);
|
||
t += 1;
|
||
assert_eq!(
|
||
c.on_window(
|
||
ticks(start, t),
|
||
0,
|
||
0,
|
||
Some(10_000),
|
||
Some(26_000),
|
||
None,
|
||
490_000,
|
||
false,
|
||
0
|
||
),
|
||
Some(350_000)
|
||
);
|
||
assert!(c.decode_cap_kbps.is_none());
|
||
assert_eq!(
|
||
c.decode_backoff_kbps, 0,
|
||
"a mixed-attribution backoff must reset the knee reference"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ack_silence_disables_the_controller() {
|
||
let mut c = BitrateController::new(20_000);
|
||
let start = Instant::now();
|
||
let mut sent = 0;
|
||
let mut i = 0;
|
||
// Keep every window bad and never ack: exactly MAX_UNACKED requests, then silence.
|
||
while i < 60 {
|
||
if c.on_window(ticks(start, i), 1, 0, None, None, None, 1_000_000, false, 0)
|
||
.is_some()
|
||
{
|
||
sent += 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
assert_eq!(sent, MAX_UNACKED);
|
||
}
|
||
}
|