From 98e040fd0107d1609c3c75947c9c27db0c14254d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 10:05:09 +0200 Subject: [PATCH] fix(host/stream): the wire holds the session rate when the display outruns it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUNKTFUNK_VDISPLAY_HZ_MULT promises extra display refreshes without one extra frame on the wire, but the frame-driven trigger enforced its pace only as a per-gap floor: sleep to 0.9×interval, then wake on arrival. A source that always has a frame pending — the overdriven display under uncapped content — settled at 0.9-interval spacing, 1.11× the negotiated rate. That is the field report's 132 fps on a 120 fps session: ten percent more bitrate, encode and decode for frames a 120 Hz panel can only drop. A credit bucket (PaceBudget) now pins the long-run average at the pacing rate: credit accrues at one frame per interval of real elapsed time, capped at 1.25 frames of post-stall burst, and every submitted frame spends one. A grab may run early only against banked credit, so the 0.9 floor keeps its per-gap jitter headroom while the average cannot exceed the rate — and a source at or below it banks faster than it spends and is never delayed. Anchoring to real elapsed time also keeps the synchronous-encode overlap the arrival-anchored floor bought (the owed fraction absorbs a constant encode tail instead of stacking on top of it), and it cannot fight the phase lock's submit grid: both agree the period is the interval. The charge lives under the same guard as the gate — the legacy fixed tick paces by its own grid, and charging it without ever accruing would bank unbounded debt that stalls the loop if a rebuild later flips the capturer to arrival-wait. Verified on .25: native::stream tests 15/15 (three new PaceBudget tests), punktfunk-host 369/369, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/native/stream.rs | 180 ++++++++++++++++++++- 1 file changed, 173 insertions(+), 7 deletions(-) diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 39feb264..0c263638 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1815,6 +1815,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option session fps); - // the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape, + // wake on the capture's actual arrival. The 0.9×interval floor leaves per-gap jitter + // headroom; the `pace` budget pins the long-run AVERAGE at the pacing rate — the + // floor alone let a source that always has a frame pending (an HZ_MULT-overdriven + // display under uncapped content) settle at 0.9-interval spacing, 1.11× the + // negotiated rate on the wire, frames the client's panel can only drop. The + // +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape, // client liveness) at 1.5×interval cadence and bounds control-servicing latency. // // Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is @@ -3489,9 +3500,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option u32 { achieved_hz.min(session_hz).max(1) } +/// Long-run rate limiter for the frame-driven trigger (T1.1): pins the AVERAGE encode rate at the +/// pacing rate while the 0.9×interval arrival floor keeps its jitter headroom. +/// +/// The floor alone bounds only each gap, so a source that always has a frame pending — a display +/// overdriven by `PUNKTFUNK_VDISPLAY_HZ_MULT`, uncapped content — settles at 0.9-interval spacing: +/// 1.11× the negotiated rate on the wire (field report: 132 fps on a 120 fps session, frames the +/// client's 120 Hz panel can only drop). Credit accrues at one frame per interval of real elapsed +/// time (capped at [`Self::CAP`]) and every submitted frame spends one; a grab may run early only +/// against banked credit, so per-gap jitter still passes while the average cannot exceed the +/// pacing rate. A source at or below the pacing rate banks credit faster than it spends and is +/// never delayed. +struct PaceBudget { + /// Banked frames, in `[-1.0, CAP]`. Transiently dips below 0 when a grab spent credit it had + /// only partly banked; the owed fraction is repaid before the next grab. + credit: f32, + /// When credit last accrued (the previous [`Self::earliest`] call). + last: std::time::Instant, +} + +impl PaceBudget { + /// Burst allowance: at most this many frames may follow a stall back-to-back before the + /// bucket re-gates. One frame of instant catch-up plus the floor's own headroom. + const CAP: f32 = 1.25; + + fn new(now: std::time::Instant) -> PaceBudget { + PaceBudget { + credit: Self::CAP, + last: now, + } + } + + /// Accrue the elapsed credit and return the earliest instant the next grab may run: `now` + /// once a full frame is banked, else the missing fraction of an interval out. + fn earliest( + &mut self, + now: std::time::Instant, + interval: std::time::Duration, + ) -> std::time::Instant { + let secs = interval.as_secs_f32(); + if secs > 0.0 { + let accrued = now.duration_since(self.last).as_secs_f32() / secs; + self.credit = (self.credit + accrued).min(Self::CAP); + } + self.last = now; + now + interval.mul_f32((1.0 - self.credit).max(0.0)) + } + + /// One frame submitted — spend its credit. + fn charge(&mut self) { + self.credit -= 1.0; + } +} + /// Adopt the rate a freshly built pipeline's encoder was actually opened at. /// /// The session's own `bitrate_kbps` is the number every later decision reads — the ABR controller's @@ -4280,6 +4347,105 @@ mod tests { assert_eq!(pacing_hz(60, 0), 1); } + /// Drive [`PaceBudget`] against a source that ALWAYS has a frame pending (the overdriven + /// display + uncapped content case): each cycle grabs the instant the gate opens, the next + /// `earliest` call runs right after (encode folded into the wait, like the loop). Returns the + /// grab instants. + fn grab_saturated( + b: &mut PaceBudget, + start: std::time::Instant, + interval: std::time::Duration, + n: usize, + ) -> Vec { + let mut now = start; + let mut grabs = Vec::with_capacity(n); + for _ in 0..n { + let gate = b.earliest(now, interval); + let grab = gate.max(now); + b.charge(); + grabs.push(grab); + now = grab; + } + grabs + } + + #[test] + fn pace_budget_pins_a_saturated_source_at_the_interval() { + let interval = std::time::Duration::from_millis(10); + let t0 = std::time::Instant::now(); + let mut b = PaceBudget::new(t0); + let grabs = grab_saturated(&mut b, t0, interval, 120); + // Whatever the initial credit bought, the total may exceed the on-rate schedule by at + // most the burst cap — 120 grabs span no less than (120 - 1 - CAP) intervals. + let span = grabs[119].duration_since(grabs[0]); + assert!( + span >= interval.mul_f32(120.0 - 1.0 - PaceBudget::CAP), + "span {span:?} admits more than CAP frames of overshoot" + ); + // And the steady state is EXACTLY the interval: past the warmup, consecutive grabs are + // one interval apart (not 0.9 — the 132-fps bug). + for w in grabs[20..].windows(2) { + let gap = w[1].duration_since(w[0]); + assert!( + gap >= interval.mul_f32(0.999) && gap <= interval.mul_f32(1.001), + "steady-state gap {gap:?} != interval {interval:?}" + ); + } + } + + #[test] + fn pace_budget_never_delays_an_on_rate_or_slow_source() { + let interval = std::time::Duration::from_millis(10); + let t0 = std::time::Instant::now(); + let mut b = PaceBudget::new(t0); + // A source at half the pacing rate (a 60 fps game on a 120 fps session): every arrival + // banks two frames of credit and spends one — the gate is always already open. + let mut now = t0; + for _ in 0..50 { + now += interval * 2; + assert_eq!( + b.earliest(now, interval), + now, + "slow source must not be gated" + ); + b.charge(); + } + // Exactly on-rate: still never gated (credit hovers at the cap, never below 1). + let mut b = PaceBudget::new(t0); + let mut now = t0; + for _ in 0..50 { + now += interval; + assert_eq!( + b.earliest(now, interval), + now, + "on-rate source must not be gated" + ); + b.charge(); + } + } + + #[test] + fn pace_budget_burst_after_a_stall_is_capped() { + let interval = std::time::Duration::from_millis(10); + let t0 = std::time::Instant::now(); + let mut b = PaceBudget::new(t0); + // Settle into the gated steady state, then stall the source for 10 intervals. + let grabs = grab_saturated(&mut b, t0, interval, 20); + let stall_end = grabs[19] + interval * 10; + // However long the stall, the recovery may run ahead of the on-rate schedule by at most + // CAP frames: the second post-stall grab is already re-gated. + let after = grab_saturated(&mut b, stall_end, interval, 3); + assert_eq!(after[0], stall_end, "first post-stall grab is immediate"); + assert!( + after[1].duration_since(after[0]) >= interval.mul_f32(2.0 - PaceBudget::CAP), + "second post-stall grab spent more than the burst cap" + ); + assert!( + after[2].duration_since(after[1]) >= interval.mul_f32(0.999), + "third post-stall grab must be back on the interval grid" + ); + } + #[test] fn display_mode_multiplier_scales_only_the_refresh() { // Default (no env set in the test process) is 1× — the identity, which is what every