Files
rustdesk/src/server/video_qos.rs
21pages 080211ff36 improve qos (#16082)
* first improve

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(qos): avoid reducing FPS for transient network jitter

  Require consecutive bad samples to confirm congestion before normal
  FPS reductions. Prefer bitrate reduction when ABR is available and
  allow an outstanding probe to complete before evaluating its effect.

  Recover FPS faster on fresh good samples while preserving severe-delay
  and timeout protection.

  Add regression coverage for jitter, bandwidth changes, and multiple viewers.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* test(qos): virtual clock and a closed-loop link simulation

Tests drove time by moving `Instant`s into the past, which panics on a host
that booted less than two minutes ago, and the ABR smoke had to detect ratio
changes to keep its fake clock consistent.  `VideoQoS` now reads the clock
through `now()`; tests set a virtual instant and advance it.

`tests/sim.rs` drives the controller the way `Connection` does, over a link
with variable frame sizes, wobbling capacity, heavy-tailed jitter,
retransmission stalls and link stalls, with both a bitrate-targeted
(VP8/VP9/AV1) and a fixed-rate (hardware) encoder model.  It prints one
table row per scenario; the assertions arrive with the controller changes.
The short-stall smoke sweeps the stall phase instead of three fixed values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): keep jittery but healthy links fast, drain congestion with bitrate

A weak home Wi-Fi with plenty of capacity but frequent jitter and the odd
stall ended up at about 10 fps: single bad replies, the two second probe
timeout and a slow climb back each took their share.  The controller now
treats a transient stall and a saturated link differently.

- The probe timeout no longer slams the frame rate to 2.  Every second the
  probe stays out beyond the first halves it instead, and the late reply
  that finally closes the probe does not reduce again.
- After a reduction, good replies return halfway, then fully, to the level
  held before it.  A restored level that congests within five replies
  becomes a lower ceiling, so a real capacity drop converges instead of
  oscillating.
- VP8, VP9 and AV1 run CBR against timestamps: fewer frames only means
  bigger frames.  While the bitrate can still be reduced the frame rate
  keeps its floor, and three bad replies in a row confirm congestion and
  halve the bitrate instead of stepping it down by a fifth every three
  seconds.
- `bitrate_first` now means the bitrate can actually still drop; at the
  floor, congestion during the adjustment cooldown reduces the frame rate
  (Greptile).
- `avg_delay()` subtracts the baseline with `saturating_sub`; at exactly
  the baseline it returned the whole delay, which kept the bitrate of a
  stable high-RTT link from ever recovering.
- `HISTORY_DELAY_LEN` kept three samples, not two.
- Every ratio adjustment resets the dynamic screen counters, so a long
  congestion episode cannot make a static screen look dynamic
  (CodeRabbit).
- One `qos_trace` debug line per probe reply and per timeout, for replay
  through `tests/sim.rs`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): adapt each viewer from its own target, not the shared minimum

`user_network_delay` started every viewer's adaptation from `self.fps`,
the minimum over all viewers.  One congested viewer therefore pulled the
others' targets down with it, and when it recovered the stream stayed low
until the others had climbed back on their own.  The per-viewer memory
added for restores made the coupling worse: a viewer recorded another
viewer's low rate as its own pre-congestion level (Greptile).

Each viewer now adapts from its own `delay.fps`, falling back to the shared
value only for its first reply; `adjust_fps` keeps aggregating the minimum.
The replay test now fails on a malformed trace value instead of dropping
it (CodeRabbit).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* test(qos): paired network trace, bitrate-conserving encoder, twenty seeds

The simulator drew every random quantity from one stream, so two controllers
with the same seed saw different links as soon as they produced a different
number of frames; the A/B was not paired.  The link trace (capacity wobble,
stalls, loss events) is now generated before the run from a network stream of
its own, and encoder noise and probe jitter have separate streams.

The CBR model let a scene change add three frames' worth of data every five
seconds without clawing it back, which raised the offered load of any
controller that lowered the frame rate (up to +27% at 2 fps).  The encoder now
repays the surplus over the following frames and the size spread has mean one,
so the long-term load no longer depends on the frame rate.

Every scenario runs over twenty seeds and the assertions bound the
distribution: median of the mean target, worst p10, p90 of the time below half
the limit and of the queue p95.  The bounds state what the product needs, not
what one seed produced.  New columns: produced and delivered frames per
second, delivered frame age, sustained recovery (target at the limit and queue
under 200 ms held for five seconds), cold-start minimum and time to 90% of the
limit.  The replay advances by recorded `t=` deltas when present and is
labelled as the open-loop, FPS-only diagnostic it is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): baseline from the first reply, bitrate cuts on confirmation only

Two findings from the design re-check.

The baseline needed ten replies before it was used, so a stable 180 or
300 ms link spent its first ten seconds read as congested: the frame rate
fell to 5 and the bitrate was cut before either recovered.  The running
minimum is the baseline from the first reply on; the smoothed estimate
takes over once the window is full.

A single reply a second above the baseline, or a single timer tick with the
probe out for two seconds, cut the bitrate by a fifth.  A static screen never
earns an increase back, so repeated stalls ratcheted its ratio down and the
next dynamic episode started from there.  Bitrate cuts now need two bad
replies in a row, or a probe still outstanding at the second tick past two
seconds; the frame rate brake and the severe-reply rule are unchanged.  An
elevated but unconfirmed delay no longer restarts the ratio cooldown, so a
confirmation on the next reply is acted on at once.

`qos_trace` lines carry a millisecond timestamp for the replay test.  The
simulator asserts the intercontinental cold start: minimum target at
INIT_FPS and 90% of the limit within ten seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* test(qos): held-out seeds and parameter sensitivity as guards against tuning

The scenario bounds now live in one function shared by the CI run over seeds
1 to 20 and by `robustness.rs`, whose two ignored tests apply the same bounds
to seeds 21 to 120 in blocks of twenty and halve or double each scenario
parameter in turn.  Anyone changing a controller constant or a bound runs
them; a bound that fails on unseen seeds was fitted to the CI seeds.

At this head every held-out block passes, with medians within a few percent
of the CI seeds, while master fails five of five blocks in every home Wi-Fi
row.  The sweep keeps the lead over master in all fourteen variants for the
frame-rate metrics and shows two limits worth knowing: at 6 Mbps of capacity
both controllers hold about 2.4 s of queue p95, and at a drop to 1.5 Mbps
both are poor because the 1 Mbps bitrate floor leaves little to drain with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* test(qos): frame age bounds, wall-clock scene changes, time-indexed probe jitter

The queue metric divides the queued bytes by the nominal capacity, so during a
link stall it reports how long the backlog takes to drain afterwards, not how
long the frame at the head has already waited.  Frame age, the time a
delivered frame spent in the shared path, was computed but not bounded.  It
is now bounded per scenario, as a regression bound set from the scenario
rather than from a run: 1.5 s on the home Wi-Fi rows (isolated stalls of up
to 2.5 s are tolerated, a sustained multi-second backlog is not), 100 ms on a
clean link, 150 ms on a stable high-RTT link (frame age excludes the round
trip, so RTT earns no allowance), and the same bound as the queue on the
bandwidth-drop and mobile rows.

Two residual couplings between controller decisions and the exogenous inputs
are removed: scene changes follow the wall clock instead of the frame count,
and probe jitter is a per-second table drawn before the run, so two
controllers with the same seed meet the same content timeline and the same
jitter.  The moderate-congestion smoke profile now asserts that the frame
rate actually drops, and the held-out test builds its combined summary from
the block reports instead of simulating every seed twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* test(qos): remove the placeholder viewer from trace replay

  Clear the synthetic viewer created by smoke::session so it cannot
  cap replayed FPS at 15 when the recorded connection ID differs from 1.

  Add a regression test verifying identical FPS sequences for connection
  IDs 1 and 1652, both recovering to 30 FPS.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(qos): cap each viewer by its own limit, judge bitrate steps per viewer

Two places still let one viewer's state leak into another's.

The per-viewer target was clamped by `highest_fps()`, the minimum of every
viewer's limit, before being stored, and a new viewer started from the shared
stream rate.  A viewer that lowered its limit dragged the others' targets down
with it, and when it left the stream stayed there until the others had climbed
back; a viewer joining a congested session started at the congested rate.
Each viewer now starts at INIT_FPS, is capped by its own limit only, and
`adjust_fps` keeps applying the shared limit to the aggregate.

`adjust_ratio` paired the maximum delay over viewers with any viewer's
confirmation, so one viewer's unconfirmed 1200 ms spike and another viewer's
two 200 ms replies produced a 20% cut, and a third mild reply a halving, when
each viewer on its own called for five percent.  Each viewer's own delay and
confirmation now decide the step it calls for, and the stream takes the most
conservative one; increases still need every viewer below the threshold.
Single-viewer behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* test(qos): replay advances by the wall clock across connections

The replay kept a last timestamp per connection id, so a log with several
viewers each writing once a second advanced the virtual clock once per line
and replayed several times slower than it was recorded.  It now advances by
the delta between consecutive lines whatever their connection.  The replay is
a plain function over the log text, with the environment-driven test as its
entry point, so the time axis can be tested directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): first reply keeps the ratio cooldown, closing a viewer re-aggregates

A viewer's first TestDelay reply called `adjust_ratio` with no cooldown
check.  With the per-viewer steps, that scan finds the other viewers' still
confirmed evidence, so a viewer joining right after a cut halved the bitrate a
second time inside the three seconds the cut is meant to be observed for.  The
first reply is now one more trigger of the periodic adjustment and keeps its
cooldown; a fresh session still adjusts on its first reply, since its
controller was created long before.  Linux was never on this path.

`on_connection_close` removed the viewer without re-aggregating, so the
stream stayed at the departed viewer's rate until the next tick; the
remaining viewers are aggregated at once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): the newcomer guard belongs to the viewer that joined

Re-aggregating on close applied the one-second INIT_FPS guard that the
departing viewer had set when it joined, so a viewer that connected and
dropped within a second throttled the others to 15 fps for the rest of that
second.  The guard now records which viewer set it and is cleared when that
viewer leaves; a genuinely new viewer is still capped for its first second.

The first-reply ratio adjustment's platform switch is a field instead of a
`cfg!` inside the condition, so the cooldown regression test exercises the
path on Linux CI as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): every newcomer carries its own start-up guard

The start-up guard had one slot, so a second viewer joining within a second
overwrote the first one's join time, and when the second viewer left the
first one's window was released early.  The join time now lives in the
viewer's own entry, `adjust_fps` caps the stream while any viewer is inside
its first second, and a departed viewer takes its guard with its entry; no
clearing logic is needed (Greptile).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): adapt delay baselines and speed up FPS recovery

  - Relearn stable baseline increases from recent fresh replies.
  - Require fresh congestion confirmation between ordinary FPS reductions.
  - Keep automatic FPS reductions above a 5 FPS floor, respecting lower caps.
  - Restore FPS after two good replies, with rollback on renewed congestion.
  - Add regression tests for baseline changes, jitter, and FPS recovery.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* test(qos): align FPS floor tests with the 5 FPS minimum

  - Remove the unregistered sustained tests with outdated expectations.
  - Test severe delays and timeouts independently, including lower user
    caps and recovery.
  - Move the 700 kbps scenario into active adaptation tests, checking
    the FPS floor and recovery after bandwidth returns.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(qos): a timeout never lifts a target, and the invariants as property tests

The timeout brake floored its output at MIN_FPS + 1, so a viewer whose target
had already reached 1 fps was lifted to 2 by the next tick past two seconds:
bad evidence raising the frame rate.  Inherited from master, where the timeout
set the whole stream to MIN_FPS + 1 outright.  The brake now never exceeds the
target it found, whatever the elapsed time it is told.

The controller's six invariants become property tests over random sessions
(150 seeds, 300 steps, one to three viewers, ABR on and off): a viewer's
target is independent of other viewers; bad evidence never raises a target or
the ratio; joins and leaves only change the aggregation; a bitrate cut is
owned by a viewer's own evidence and not spent again by a newcomer; targets
stay within their caps and the stream is their aggregation; a braked probe's
late reply does not brake again.  The timeout case is also pinned exhaustively
over every target and a range of elapsed times.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* feat(qos): log the encode and send pipeline behind RUSTDESK_QOS_VERBOSE

The controller's target frame rate is neither the rate the encoder produced
nor the rate the send path accepted, and two facts kept that gap invisible.
libvpx drops frames on its own when the bitrate cannot carry them, so a
capture round is not a delivered frame.  The video send is inline in the
connection's message loop, so a slow write stalls capture and the delay
probe alike, and the recorded delay cannot tell the two apart.

`qos_video` reports, per second and per display, the capture rounds, the
frames that actually reached a connection, and the longest wait for the
previous frame to be picked up.  `qos_send` reports, per second and per
connection, how long `stream.send().await` blocked and how deep the video
queue is.  Both carry `t=`, so they join with `qos_trace` offline; `replay`
filters on `qos_trace` and ignores them.

The default log level is `debug`, so an unconditional line would land in
every user's log file once a second forever.  Neither line is emitted
unless `RUSTDESK_QOS_VERBOSE` is set, nothing enables it implicitly, and
without it the timing calls are skipped as well.  TestDelay, the controller
and every threshold are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PARvswNPeZ88LVT7Ew5hkp

* fix(qos): speed up FPS ramp-up on clean connections

  Double startup FPS after every two fresh low-excess-delay replies,
  up to the viewer's cap. End acceleration on queue growth, timeout,
  or FPS reduction.

  Add regression tests for startup speed, viewer caps, congestion,
  timeouts, and multiple viewers.

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:48:32 +08:00

1024 lines
36 KiB
Rust

use super::*;
use scrap::codec::{Quality, BR_BALANCED, BR_BEST, BR_SPEED};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
/*
FPS adjust:
a. new user connected => set to INIT_FPS
b. TestDelay reply => update the user's fps from the excess delay, the reply's delay
above the baseline this connection has shown so far:
startup: two consecutive replies with excess < 50 ms permit doubling toward
the viewer's cap; a higher excess or a brake ends this acceleration;
excess < DELAY_THRESHOLD_150MS: a good reply; grows the fps, and after a
reduction returns to the level held before it after two good replies;
excess >= DELAY_THRESHOLD_150MS: a bad reply; nothing happens until three in a
row confirm congestion, including after each reduction. FPS drops by a
fifth at most; a second of excess cannot wait and halves it immediately.
A recent fast restore also permits halving at 600 ms of excess.
While the bitrate can still be reduced (ABR) it is reduced first and the fps keeps
a floor: bitrate-targeted encoders do not send fewer bytes at fewer frames.
c. probe outstanding for more than two seconds => halve the fps for every further
second, down to MIN_AUTO_FPS and never above the target it found; the late
reply does not reduce again. Automatic reductions respect this floor unless
the viewer requested a lower FPS cap.
d. second timeout / TestDelay reply => real fps is the minimum over all users;
every user starts at INIT_FPS, adapts from its own target and is capped by its
own limit, never by that minimum or by another user's limit
ratio adjust:
a. user set image quality => update to the maximum ratio of the latest quality
b. 3 seconds timeout => update ratio according to network delay
When network delay < DELAY_THRESHOLD_150MS, increase ratio, max 150kbps;
When a user calls for a reduction (two bad replies in a row, or a probe still
out at the second tick past two seconds), decrease ratio by the step that user's
own delay and confirmation call for, the most conservative step over all users;
one slow reply or one short stall does not, and one user's spike is never paired
with another user's confirmation.
c. confirmed congestion => decrease ratio at once, when the 3 seconds cooldown allows
delay:
TestDelay shares the video stream, so it measures the queue in front of it rather
than the path RTT. The baseline starts at the first reply and follows lower
delays immediately. Old minima expire after 20 fresh replies; a higher window
minimum is learned gradually only when the recent floor is no longer rising.
Outstanding-probe checks and their late replies do not age this window.
*/
// Constants
pub const FPS: u32 = 30;
pub const MIN_FPS: u32 = 1;
pub const MAX_FPS: u32 = 120;
pub const INIT_FPS: u32 = 15;
const MIN_AUTO_FPS: u32 = 5;
// Bitrate ratio constants for different quality levels
const BR_MAX: f32 = 40.0; // 2000 * 2 / 100
const BR_MIN: f32 = 0.2;
const BR_MIN_HIGH_RESOLUTION: f32 = 0.1; // For high resolution, BR_MIN is still too high, so we set a lower limit
const MAX_BR_MULTIPLE: f32 = 1.0;
const HISTORY_DELAY_LEN: usize = 2;
const ADJUST_RATIO_INTERVAL: usize = 3; // Adjust quality ratio every 3 seconds
const DYNAMIC_SCREEN_THRESHOLD: usize = 2; // Allow increase quality ratio if encode more than 2 times in one second
const DELAY_THRESHOLD_150MS: u32 = 150; // 150ms is the threshold for good network condition
const RESTORE_GUARD_SAMPLES: u8 = 5; // A restored level that congests this soon is lowered
#[derive(Default, Debug, Clone)]
struct UserDelay {
stall_ticks: u8, // timer ticks the outstanding probe has been out beyond two seconds
delay_history: VecDeque<u32>,
fps: Option<u32>,
rtt_calculator: RttCalculator,
quick_increase_fps_count: usize,
increase_fps_count: usize,
consecutive_bad_samples: usize,
fps_bad_samples: u8, // fresh bad replies since the last FPS reduction
good_samples: usize, // since the last reduction, capped at 3
replies_after_bitrate_reduction: Option<u8>,
fps_before_congestion: Option<u32>, // level to return to once replies are good again
samples_since_restore: Option<u8>, // set by a restore, cleared once it proved stable
stall_reference_fps: Option<u32>, // fps when the outstanding probe passed two seconds
startup_good_samples: u8, // u8::MAX permanently ends startup acceleration
}
impl UserDelay {
fn add_delay(&mut self, delay: u32) {
if self.delay_history.len() >= HISTORY_DELAY_LEN {
self.delay_history.pop_front();
}
self.delay_history.push_back(delay);
}
fn limit_fps_change(
&mut self,
current_fps: u32,
fps: u32,
delay: u32,
bitrate_first: bool,
braked: bool,
) -> u32 {
// A spike stays in the average for several samples; confirm congestion with fresh samples.
let delay = delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default());
if let Some(samples) = self.samples_since_restore.as_mut() {
*samples = samples.saturating_add(1);
}
if delay < DELAY_THRESHOLD_150MS {
self.consecutive_bad_samples = 0;
self.fps_bad_samples = 0;
self.replies_after_bitrate_reduction = None;
self.good_samples = (self.good_samples + 1).min(3);
return self.recover(current_fps, fps);
}
self.consecutive_bad_samples = (self.consecutive_bad_samples + 1).min(3);
self.fps_bad_samples = (self.fps_bad_samples + 1).min(3);
if let Some(replies) = self.replies_after_bitrate_reduction.as_mut() {
*replies = (*replies + 1).min(2);
}
let failed_restore = delay >= 600
&& self
.samples_since_restore
.is_some_and(|samples| samples <= RESTORE_GUARD_SAMPLES);
// A level that congests right after being restored is not the level to return to.
if self
.samples_since_restore
.is_some_and(|samples| samples <= RESTORE_GUARD_SAMPLES)
&& (failed_restore || self.consecutive_bad_samples >= 3)
{
self.fps_before_congestion = Some(current_fps - current_fps / 4);
self.samples_since_restore = None;
}
// The timeout brake already reduced for the probe this reply answers.
if fps >= current_fps || braked {
return current_fps;
}
// An extra second of delay cannot wait for another confirmation.
if !failed_restore
&& delay < 1000
&& (self.fps_bad_samples < 3
|| (bitrate_first && self.replies_after_bitrate_reduction.unwrap_or_default() < 2))
{
return current_fps;
}
// A fast restore probes capacity. Roll it back promptly if the queue grows
// again, rather than waiting through another ordinary confirmation window.
let divisor = if delay >= 1000 || failed_restore {
2
} else {
5
};
self.on_reduction(current_fps);
fps.max(current_fps.saturating_sub((current_fps / divisor).max(1)))
}
// Fresh low-delay replies permit recovery even while the average contains a spike:
// a little at first, then back to the level held before congestion.
fn recover(&mut self, current_fps: u32, fps: u32) -> u32 {
let gradual = current_fps + (current_fps / 10).max(1);
let level = self
.fps_before_congestion
.filter(|level| *level > current_fps);
match (self.good_samples, level) {
(2 | 3, Some(level)) => {
self.fps_before_congestion = None;
self.samples_since_restore = Some(0);
fps.max(level)
}
(3, None) => {
self.fps_before_congestion = None;
fps.max(current_fps + (current_fps / 5).max(2))
}
_ => gradual,
}
}
fn accelerate_startup(
&mut self,
current_fps: u32,
fps: u32,
cap: u32,
delay: u32,
braked: bool,
) -> u32 {
if self.startup_good_samples == u8::MAX {
return fps;
}
let excess = delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default());
// A low-load sample does not establish capacity: require two clean replies
// per step and abandon startup probing on the first sign of queue growth.
if braked || excess >= 50 || current_fps >= cap {
self.startup_good_samples = u8::MAX;
return fps;
}
self.startup_good_samples += 1;
if self.startup_good_samples < 2 {
return fps;
}
self.startup_good_samples = 0;
let accelerated = fps.max(current_fps.saturating_mul(2)).min(cap);
if accelerated >= cap {
self.startup_good_samples = u8::MAX;
}
accelerated
}
// The first reduction of an episode remembers the level to return to.
fn on_reduction(&mut self, current_fps: u32) {
self.startup_good_samples = u8::MAX;
self.good_samples = 0;
self.fps_bad_samples = 0;
if self.fps_before_congestion.is_none() {
self.fps_before_congestion = Some(current_fps);
}
}
// Bitrate is cut on confirmation only: two bad replies in a row, or a probe still
// outstanding at the second tick past two seconds. One slow reply or one short
// stall is jitter, and a static screen would never earn the cut back.
fn needs_bitrate_reduction(&self) -> bool {
self.consecutive_bad_samples >= 2 || self.stall_ticks >= 2
}
// The bitrate step this viewer's own evidence calls for, None when it calls for
// none. Severity and confirmation come from the same viewer; the controller
// never pairs one viewer's spike with another viewer's confirmation.
fn ratio_reduction(&self) -> Option<f32> {
if !self.needs_bitrate_reduction() {
return None;
}
let excess = self.avg_delay();
let confirmed = self.consecutive_bad_samples >= 3;
Some(if excess < 200 {
0.95
} else if excess < 300 {
0.9
} else if excess < 500 {
if confirmed {
0.7
} else {
0.85
}
} else if confirmed {
0.5
} else {
0.8
})
}
// Average delay above the baseline: what the queue adds on top of the path itself.
fn avg_delay(&self) -> u32 {
if self.delay_history.is_empty() {
return DELAY_THRESHOLD_150MS;
}
let avg_delay = self.delay_history.iter().sum::<u32>() / self.delay_history.len() as u32;
avg_delay.saturating_sub(self.rtt_calculator.get_rtt().unwrap_or_default())
}
}
// User session data structure
#[derive(Default, Debug, Clone)]
struct UserData {
auto_adjust_fps: Option<u32>, // reserve for compatibility
custom_fps: Option<u32>,
quality: Option<(i64, Quality)>, // (time, quality)
delay: UserDelay,
record: bool,
joined_at: Option<Instant>, // set by on_connection_open; the start-up guard's clock
}
impl UserData {
// The frame rate this viewer asked for, from its custom or auto-adjust limit.
fn fps_cap(&self) -> u32 {
let mut fps = self.custom_fps.unwrap_or(FPS);
if let Some(auto_adjust_fps) = self.auto_adjust_fps {
if fps == 0 || auto_adjust_fps < fps {
fps = auto_adjust_fps;
}
}
fps.clamp(MIN_FPS, MAX_FPS)
}
}
#[derive(Default, Debug, Clone)]
struct DisplayData {
send_counter: usize, // Number of times encode during period
support_changing_quality: bool,
}
// Main QoS controller structure
pub struct VideoQoS {
fps: u32,
ratio: f32,
users: HashMap<i32, UserData>,
displays: HashMap<String, DisplayData>,
bitrate_store: u32,
adjust_ratio_instant: Instant,
abr_config: bool,
first_reply_adjusts_ratio: bool, // false on Linux, where it can create vaapi twice
#[cfg(test)]
test_now: Option<Instant>,
}
impl Default for VideoQoS {
fn default() -> Self {
VideoQoS {
fps: FPS,
ratio: BR_BALANCED,
users: Default::default(),
displays: Default::default(),
bitrate_store: 0,
adjust_ratio_instant: Instant::now(),
abr_config: true,
first_reply_adjusts_ratio: !cfg!(target_os = "linux"),
#[cfg(test)]
test_now: None,
}
}
}
// Clock; tests drive a virtual clock so timing is deterministic.
impl VideoQoS {
fn now(&self) -> Instant {
#[cfg(test)]
if let Some(now) = self.test_now {
return now;
}
Instant::now()
}
fn since(&self, instant: Instant) -> Duration {
self.now().saturating_duration_since(instant)
}
#[cfg(test)]
fn advance_ms(&mut self, ms: u64) {
self.test_now = Some(self.now() + Duration::from_millis(ms));
}
}
// Basic functionality
impl VideoQoS {
// Calculate seconds per frame based on current FPS
pub fn spf(&self) -> Duration {
Duration::from_secs_f32(1. / (self.fps() as f32))
}
// Get current FPS within valid range
pub fn fps(&self) -> u32 {
let fps = self.fps;
if fps >= MIN_FPS && fps <= MAX_FPS {
fps
} else {
FPS
}
}
// Store bitrate for later use
pub fn store_bitrate(&mut self, bitrate: u32) {
self.bitrate_store = bitrate;
}
// Get stored bitrate
pub fn bitrate(&self) -> u32 {
self.bitrate_store
}
// Get current bitrate ratio with bounds checking
pub fn ratio(&mut self) -> f32 {
if self.ratio < BR_MIN_HIGH_RESOLUTION || self.ratio > BR_MAX {
self.ratio = BR_BALANCED;
}
self.ratio
}
// Check if any user is in recording mode
pub fn record(&self) -> bool {
self.users.iter().any(|u| u.1.record)
}
pub fn set_support_changing_quality(&mut self, video_service_name: &str, support: bool) {
if let Some(display) = self.displays.get_mut(video_service_name) {
display.support_changing_quality = support;
}
}
// Check if variable bitrate encoding is supported and enabled
pub fn in_vbr_state(&self) -> bool {
self.abr_config && self.displays.iter().all(|e| e.1.support_changing_quality)
}
}
// User session management
impl VideoQoS {
// Initialize new user session
pub fn on_connection_open(&mut self, id: i32) {
let user = UserData {
joined_at: Some(self.now()),
..Default::default()
};
self.users.insert(id, user);
self.abr_config = Config::get_option("enable-abr") != "N";
}
// Clean up user session
pub fn on_connection_close(&mut self, id: i32) {
self.users.remove(&id);
if self.users.is_empty() {
*self = Default::default();
return;
}
// The stream follows the remaining viewers at once; a departed viewer's
// start-up guard left with its entry.
self.adjust_fps();
}
pub fn user_custom_fps(&mut self, id: i32, fps: u32) {
if fps < MIN_FPS || fps > MAX_FPS {
return;
}
if let Some(user) = self.users.get_mut(&id) {
user.custom_fps = Some(fps);
}
}
pub fn user_auto_adjust_fps(&mut self, id: i32, fps: u32) {
if fps < MIN_FPS || fps > MAX_FPS {
return;
}
if let Some(user) = self.users.get_mut(&id) {
user.auto_adjust_fps = Some(fps);
}
}
pub fn user_image_quality(&mut self, id: i32, image_quality: i32) {
let convert_quality = |q: i32| -> Quality {
if q == ImageQuality::Balanced.value() {
Quality::Balanced
} else if q == ImageQuality::Low.value() {
Quality::Low
} else if q == ImageQuality::Best.value() {
Quality::Best
} else {
let b = ((q >> 8 & 0xFFF) * 2) as f32 / 100.0;
Quality::Custom(b.clamp(BR_MIN, BR_MAX))
}
};
let quality = Some((hbb_common::get_time(), convert_quality(image_quality)));
if let Some(user) = self.users.get_mut(&id) {
user.quality = quality;
// update ratio directly
self.ratio = self.latest_quality().ratio();
}
}
pub fn user_record(&mut self, id: i32, v: bool) {
if let Some(user) = self.users.get_mut(&id) {
user.record = v;
}
}
pub fn user_network_delay(&mut self, id: i32, delay: u32) {
let target_ratio = self.latest_quality().ratio();
// Fewer frames only save bytes with encoders that size frames for a fixed rate;
// bitrate-targeted encoders keep the bitrate, so the bitrate has to come down first.
let bitrate_first = self.can_reduce_bitrate();
// For bad network, small fps means quick reaction and high quality
let (min_fps, normal_fps) = if target_ratio >= BR_BEST {
(8, 16)
} else if target_ratio >= BR_BALANCED {
(10, 20)
} else {
(12, 24)
};
// Calculate minimum acceptable delay-fps product
let dividend_ms = DELAY_THRESHOLD_150MS * min_fps;
let mut adjust_ratio = false;
let mut reduce_bitrate = false;
if let Some(user) = self.users.get_mut(&id) {
let delay = delay.max(10);
// The reply closes the outstanding probe, braked or not.
user.delay.stall_ticks = 0;
let braked = user.delay.stall_reference_fps.take().is_some();
let old_avg_delay = user.delay.avg_delay();
if !braked {
user.delay.rtt_calculator.update(delay);
}
user.delay.add_delay(delay);
let mut avg_delay = user.delay.avg_delay();
avg_delay = avg_delay.max(10);
// Each viewer adapts from its own target, starts at INIT_FPS and is capped
// by its own limit. The stream follows the slowest viewer in adjust_fps;
// neither that minimum nor another viewer's limit feeds back into it.
let user_cap = user.fps_cap();
let current_fps = user.delay.fps.unwrap_or(INIT_FPS.min(user_cap));
let mut fps = current_fps;
// Adaptive FPS adjustment based on network delay:
if avg_delay < 50 {
user.delay.quick_increase_fps_count += 1;
let mut step = if fps < normal_fps { 1 } else { 0 };
if user.delay.quick_increase_fps_count >= 3 {
// After 3 consecutive good samples, increase more aggressively
user.delay.quick_increase_fps_count = 0;
step = 5;
}
fps = min_fps.max(fps + step);
} else if avg_delay < 100 {
let step = if avg_delay < old_avg_delay {
if fps < normal_fps {
1
} else {
0
}
} else {
0
};
fps = min_fps.max(fps + step);
} else if avg_delay < DELAY_THRESHOLD_150MS {
fps = min_fps.max(fps);
} else {
let devide_fps = ((fps as f32) / (avg_delay as f32 / DELAY_THRESHOLD_150MS as f32))
.ceil() as u32;
if avg_delay < 200 {
fps = min_fps.max(devide_fps);
} else if avg_delay < 300 {
fps = min_fps.min(devide_fps);
} else if avg_delay < 600 {
fps = dividend_ms / avg_delay;
} else {
fps = (dividend_ms / avg_delay).min(devide_fps);
}
}
if avg_delay < DELAY_THRESHOLD_150MS {
user.delay.increase_fps_count += 1;
} else {
user.delay.increase_fps_count = 0;
}
if user.delay.increase_fps_count >= 3 {
// After 3 stable samples, try increasing FPS
user.delay.increase_fps_count = 0;
fps += 1;
}
// Reset quick increase counter if network condition worsens
if avg_delay > 50 {
user.delay.quick_increase_fps_count = 0;
}
if bitrate_first {
// While the bitrate can still come down, the frame rate keeps its floor.
fps = fps.max(min_fps);
}
fps = fps.max(MIN_AUTO_FPS.min(user_cap));
fps = user
.delay
.limit_fps_change(current_fps, fps, delay, bitrate_first, braked);
fps = user
.delay
.accelerate_startup(current_fps, fps, user_cap, delay, braked);
reduce_bitrate = bitrate_first
&& user.delay.needs_bitrate_reduction()
&& user.delay.replies_after_bitrate_reduction.is_none();
fps = fps.clamp(MIN_FPS, user_cap);
// first network delay message
adjust_ratio = user.delay.fps.is_none();
user.delay.fps = Some(fps);
let base = user.delay.rtt_calculator.get_rtt().unwrap_or_default();
log::debug!(
"qos_trace t={} id={id} delay={delay} base={base} excess={} avg={avg_delay} bad={} good={} braked={braked} fps={fps} ratio={:.3} reduce_bitrate={reduce_bitrate}",
hbb_common::get_time(),
delay.saturating_sub(base),
user.delay.consecutive_bad_samples,
user.delay.good_samples,
self.ratio,
);
}
self.adjust_fps();
// A viewer's first reply is one more trigger of the periodic adjustment and
// keeps its cooldown: a viewer joining right after a cut must not spend the
// other viewers' evidence a second time.
if adjust_ratio
&& self.first_reply_adjusts_ratio
&& self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64
{
self.adjust_ratio(false);
}
if reduce_bitrate
&& self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64
{
self.adjust_ratio(false);
}
}
pub fn user_delay_response_elapsed(&mut self, id: i32, elapsed: u128) {
let Some(user) = self.users.get_mut(&id) else {
return;
};
if elapsed <= 2000 {
return;
}
user.delay.stall_ticks = user.delay.stall_ticks.saturating_add(1);
user.delay.add_delay(elapsed as u32);
// Halve for every second the probe stays out beyond the first: two seconds
// halve, three quarter, and so on down to the floor.
let reference = match user.delay.stall_reference_fps {
Some(reference) => reference,
None => {
let reference = user.delay.fps.unwrap_or(INIT_FPS.min(user.fps_cap()));
user.delay.stall_reference_fps = Some(reference);
user.delay.on_reduction(reference);
reference
}
};
let divisor = 1u32 << ((elapsed / 1000) as u32).saturating_sub(1).min(5);
let user_cap = user.fps_cap();
// The floor is a floor, not a lift: a target already below it stays.
let current = user.delay.fps.unwrap_or(reference);
let fps = (reference / divisor)
.clamp(MIN_AUTO_FPS.min(user_cap), user_cap)
.min(current);
user.delay.fps = Some(fps);
log::debug!(
"qos_trace t={} id={id} timeout={elapsed} fps={fps}",
hbb_common::get_time()
);
self.adjust_fps();
}
}
// Common adjust functions
impl VideoQoS {
pub fn new_display(&mut self, video_service_name: String) {
self.displays
.insert(video_service_name, DisplayData::default());
}
pub fn remove_display(&mut self, video_service_name: &str) {
self.displays.remove(video_service_name);
}
pub fn update_display_data(&mut self, video_service_name: &str, send_counter: usize) {
if let Some(display) = self.displays.get_mut(video_service_name) {
display.send_counter += send_counter;
}
self.adjust_fps();
let abr_enabled = self.in_vbr_state();
if abr_enabled {
if self.since(self.adjust_ratio_instant).as_secs() >= ADJUST_RATIO_INTERVAL as u64 {
let dynamic_screen = self
.displays
.iter()
.any(|d| d.1.send_counter >= ADJUST_RATIO_INTERVAL * DYNAMIC_SCREEN_THRESHOLD);
self.adjust_ratio(dynamic_screen);
}
} else {
self.ratio = self.latest_quality().ratio();
}
}
#[inline]
fn highest_fps(&self) -> u32 {
self.users
.values()
.map(|u| u.fps_cap())
.min()
.unwrap_or(FPS)
.clamp(MIN_FPS, MAX_FPS)
}
// Get latest quality settings from all users
pub fn latest_quality(&self) -> Quality {
self.users
.iter()
.map(|(_, u)| u.quality)
.filter(|q| *q != None)
.max_by(|a, b| a.unwrap_or_default().0.cmp(&b.unwrap_or_default().0))
.flatten()
.unwrap_or((0, Quality::Balanced))
.1
}
// Lowest ratio the latest quality allows: keeps about 1Mbps at high resolutions.
fn min_ratio(&self) -> f32 {
let current_bitrate = self.bitrate();
let ratio_1mbps = if current_bitrate > 0 {
Some((self.ratio * 1000.0 / current_bitrate as f32).max(BR_MIN_HIGH_RESOLUTION))
} else {
None
};
match self.latest_quality() {
Quality::Best => {
let mut min = BR_BEST / 2.5;
if let Some(ratio_1mbps) = ratio_1mbps {
if min > ratio_1mbps {
min = ratio_1mbps;
}
}
min.max(BR_MIN)
}
Quality::Balanced => {
let mut min = (BR_BALANCED / 2.0).min(0.4);
if let Some(ratio_1mbps) = ratio_1mbps {
if min > ratio_1mbps {
min = ratio_1mbps;
}
}
min.max(BR_MIN_HIGH_RESOLUTION)
}
Quality::Low | Quality::Custom(_) => BR_MIN_HIGH_RESOLUTION,
}
}
// Whether congestion can still be answered with a lower bitrate. Within two
// percent of the floor another step is not worth waiting a cooldown for.
fn can_reduce_bitrate(&self) -> bool {
self.in_vbr_state() && !self.displays.is_empty() && self.ratio > self.min_ratio() * 1.02
}
// Every ratio adjustment starts a new window for the dynamic screen counters.
fn reset_send_counters(&mut self) {
self.displays.values_mut().for_each(|d| d.send_counter = 0);
}
// Adjust quality ratio based on network delay and screen changes
fn adjust_ratio(&mut self, dynamic_screen: bool) {
if !self.in_vbr_state() {
return;
}
// Get maximum delay from all users
let max_delay = self.users.iter().map(|u| u.1.delay.avg_delay()).max();
let Some(max_delay) = max_delay else {
return;
};
// Each viewer judges its own delay; the stream takes the most conservative
// step any viewer asks for.
let reduction = self
.users
.values()
.filter_map(|u| u.delay.ratio_reduction())
.reduce(f32::min);
if reduction.is_none() && max_delay >= DELAY_THRESHOLD_150MS {
// Elevated but unconfirmed: no change, and no cooldown either, so a
// confirmation on the next reply is acted on at once.
self.reset_send_counters();
return;
}
let target_ratio = self.latest_quality().ratio();
let current_ratio = self.ratio;
let current_bitrate = self.bitrate();
// Calculate ratio for adding 150kbps bandwidth
let ratio_add_150kbps = if current_bitrate > 0 {
Some((current_bitrate + 150) as f32 * current_ratio / current_bitrate as f32)
} else {
None
};
let min = self.min_ratio();
let max = target_ratio * MAX_BR_MULTIPLE;
let mut v = current_ratio;
// Three bad replies in a row confirm congestion; with a bitrate-targeted
// encoder the bitrate is then the only thing that drains the queue, so it
// comes down hard. Increases need every viewer below the threshold.
if let Some(factor) = reduction {
v = current_ratio * factor;
} else if max_delay < 50 {
if dynamic_screen {
v = current_ratio * 1.15;
}
} else if max_delay < 100 {
if dynamic_screen {
v = current_ratio * 1.1;
}
} else if dynamic_screen {
v = current_ratio * 1.05;
}
// Limit quality increase rate for better stability
if let Some(ratio_add_150kbps) = ratio_add_150kbps {
if v > ratio_add_150kbps
&& ratio_add_150kbps > current_ratio
&& current_ratio >= BR_SPEED
{
v = ratio_add_150kbps;
}
}
if reduction.is_some() {
for user in self.users.values_mut() {
if user.delay.needs_bitrate_reduction()
&& user.delay.replies_after_bitrate_reduction.is_none()
{
// One outstanding probe may have started before the bitrate change.
user.delay.replies_after_bitrate_reduction =
Some(if v.clamp(min, max) < current_ratio {
0
} else {
2
});
}
}
}
self.ratio = v.clamp(min, max);
self.reset_send_counters();
self.adjust_ratio_instant = self.now();
}
// Adjust fps based on network delay and user response time
fn adjust_fps(&mut self) {
let highest_fps = self.highest_fps();
// Get minimum fps from all users
let mut fps = self
.users
.iter()
.map(|u| u.1.delay.fps.unwrap_or(INIT_FPS))
.min()
.unwrap_or(INIT_FPS);
// Every viewer inside its first second keeps the stream at INIT_FPS to
// ensure stability; each viewer carries its own start-up clock.
if self.users.values().any(|u| {
u.joined_at
.is_some_and(|joined| self.since(joined).as_secs() < 1)
}) {
fps = fps.min(INIT_FPS);
}
// Ensure fps stays within valid range
self.fps = fps.clamp(MIN_FPS, highest_fps);
}
}
#[derive(Default, Debug, Clone)]
struct RttCalculator {
baseline: Option<u32>,
samples: VecDeque<u32>,
}
impl RttCalculator {
const WINDOW_SAMPLES: usize = 20;
const MAX_INCREASE_MS: u32 = 50;
pub fn update(&mut self, delay: u32) {
if self.samples.len() >= Self::WINDOW_SAMPLES {
self.samples.pop_front();
}
self.samples.push_back(delay);
let baseline = self.baseline.unwrap_or(delay).min(delay);
self.baseline = Some(baseline);
if self.samples.len() < Self::WINDOW_SAMPLES {
return;
}
let half = Self::WINDOW_SAMPLES / 2;
let older_min = self.samples.iter().take(half).min().copied();
let recent_min = self.samples.iter().skip(half).min().copied();
let (Some(older_min), Some(recent_min)) = (older_min, recent_min) else {
return;
};
// A rising floor can be a growing queue. Allow 10 ms of probe granularity,
// but wait for it to settle before forgetting the old baseline.
if recent_min > older_min.saturating_add(10) {
return;
}
let rise = older_min.min(recent_min).saturating_sub(baseline);
if rise > 0 {
self.baseline = Some(baseline + (rise / 2).clamp(1, Self::MAX_INCREASE_MS));
}
}
pub fn get_rtt(&self) -> Option<u32> {
self.baseline
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stable_qos() -> VideoQoS {
let mut qos = VideoQoS::default();
qos.advance_ms(2000);
qos.users.insert(1, UserData::default());
for _ in 0..12 {
qos.user_network_delay(1, 10);
}
assert_eq!(qos.fps(), FPS);
qos
}
#[test]
fn isolated_delay_spike_does_not_lower_fps() {
let mut qos = stable_qos();
for delay in [800, 10, 10, 10] {
qos.user_network_delay(1, delay);
assert_eq!(qos.fps(), FPS);
}
}
#[test]
fn occasional_spikes_do_not_accumulate_congestion() {
let mut qos = stable_qos();
for delay in [800, 10, 10].repeat(20) {
qos.user_network_delay(1, delay);
assert_eq!(qos.fps(), FPS);
}
}
#[test]
fn sustained_delay_reduces_fps_gradually() {
let mut qos = stable_qos();
for expected_fps in [30, 30, 24, 24, 24, 20] {
qos.user_network_delay(1, 800);
assert_eq!(qos.fps(), expected_fps);
}
}
#[test]
fn delay_history_keeps_two_samples() {
let mut delay = UserDelay::default();
for sample in [1, 2, 3] {
delay.add_delay(sample);
}
assert_eq!(delay.delay_history.len(), HISTORY_DELAY_LEN);
}
#[test]
fn response_timeout_halves_fps_for_each_second_outstanding() {
let mut qos = stable_qos();
for (elapsed, expected) in [(2001, 15), (3001, 7), (4001, 5), (5001, 5), (6001, 5)] {
qos.user_delay_response_elapsed(1, elapsed);
assert_eq!(qos.fps(), expected, "{elapsed} ms outstanding");
}
}
#[test]
fn severe_delay_does_not_wait_for_another_reply() {
let mut qos = stable_qos();
qos.user_network_delay(1, 1200);
assert_eq!(qos.fps(), 15);
}
#[test]
fn response_timeout_recovers_in_two_good_replies() {
let mut qos = stable_qos();
qos.user_delay_response_elapsed(1, 3000);
assert_eq!(qos.fps(), 7);
qos.user_network_delay(1, 3200);
assert_eq!(
qos.fps(),
7,
"the late reply belongs to the stall that was braked"
);
qos.user_delay_response_elapsed(1, 0);
qos.user_network_delay(1, 10);
assert_eq!(
qos.fps(),
8,
"one good reply must not restore the full frame rate"
);
qos.user_network_delay(1, 10);
assert_eq!(
qos.fps(),
FPS,
"the second good reply restores the frame rate"
);
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), FPS, "the third keeps the restored frame rate");
}
#[test]
fn restore_aims_lower_after_a_restore_that_congested() {
let mut qos = stable_qos();
for _ in 0..2 {
qos.user_network_delay(1, 1200);
}
assert_eq!(qos.fps(), 8);
qos.user_network_delay(1, 10);
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), FPS);
// The restored level congests at once, so the next restore aims lower.
for _ in 0..3 {
qos.user_network_delay(1, 400);
}
assert!(qos.fps() < FPS);
qos.user_network_delay(1, 10);
qos.user_network_delay(1, 10);
assert!(
qos.fps() < FPS,
"no return to the level that failed: {}",
qos.fps()
);
qos.user_network_delay(1, 10);
assert_eq!(qos.fps(), FPS, "ordinary recovery can still reach the cap");
}
#[test]
fn custom_fps_limit_applies_during_delay_spike() {
let mut qos = stable_qos();
qos.user_custom_fps(1, 12);
qos.user_network_delay(1, 800);
assert_eq!(qos.fps(), 12);
}
mod adaptation;
mod baseline;
mod invariants;
mod jitter;
mod recovery;
mod robustness;
mod sim;
mod smoke;
mod startup;
}