fix(client/abr): a granted climb disproves the learned cap

Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the
client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so
even a host that had fully recovered still granted the session its real ceiling
one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps
link. The crawl was never the point; re-learning was.

A request granted IN FULL at or above the cap is the host's own word that the
limit is gone. Drop the cap outright at that point instead of nudging it. A
standing limit is unaffected — it answers the same re-probe with another short
ack, which re-latches it and doubles its clock, exactly as before.

Adds the end-to-end regression the sweep was really about: a session pinned at
20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling,
now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes.
This commit is contained in:
2026-08-03 18:53:02 +02:00
parent 48565c4e9e
commit 33ecd8e1a5
+71
View File
@@ -416,6 +416,21 @@ impl BitrateController {
}
} 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;
}
}
}
self.current_kbps = kbps;
@@ -1596,6 +1611,62 @@ mod tests {
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_standing_cap_backs_its_reprobe_clock_off() {
// The other half of the re-probe: an encoder's real codec ceiling (794 Mbps, L6.2)