mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-27 13:02:25 +03:00
feat: media bitrate control, HDR (#346)
Fixes: #335 Still a work-in-progress. --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
co-authored by
DatCaptainHorse
Claude Opus 5
Wanjohi
parent
1c721962f4
commit
0811f57f1a
@@ -11,7 +11,7 @@ name = "neshub"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iroh = "1.1.0"
|
||||
iroh = "1.2"
|
||||
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
//! Deciding what the encoder may spend.
|
||||
//!
|
||||
//! # Why this exists at all
|
||||
//!
|
||||
//! Nothing used to decide. The encoder took a bitrate from an environment
|
||||
//! variable nobody set, so every session offered the same 10 Mbps whatever the
|
||||
//! path between the two ends could carry. On a path sustaining under 3 Mbps that
|
||||
//! does not degrade the picture, it removes it: most fragments are dropped,
|
||||
//! every frame made of several fragments arrives incomplete, and the keyframe --
|
||||
//! the largest frame there is -- never completes either, so nothing ever
|
||||
//! recovers.
|
||||
//!
|
||||
//! # Why the receiver decides, and not this end
|
||||
//!
|
||||
//! The obvious inputs are here: the hub holds the QUIC connection and can read
|
||||
//! its round-trip time, its congestion window and its remaining datagram buffer.
|
||||
//! They were measured, over an entire multi-session run, saying the path was
|
||||
//! healthy while the client was receiving almost nothing -- 182 ms round trip,
|
||||
//! *zero* QUIC packet loss, and arrivals falling from 1776 to 239 datagrams a
|
||||
//! second.
|
||||
//!
|
||||
//! Part of that is structural rather than bad luck. `send_datagram` evicts the
|
||||
//! oldest queued datagrams and returns `Ok`, so a sender that is overrunning the
|
||||
//! path is told nothing at all; the "frames dropped" counter it feeds cannot be
|
||||
//! non-zero however badly things are going. So the primary evidence is the
|
||||
//! receiver's own report of what arrived, and this end's view is the fallback
|
||||
//! for when no report has come in.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Slow and dull on purpose. This is not a congestion controller in the QUIC
|
||||
//! sense and does not try to be: it finds the right order of magnitude for a
|
||||
//! video bitrate and stays there, at one decision a second. The transport
|
||||
//! underneath is still doing real congestion control on its own packets.
|
||||
|
||||
use nesprotocol::{ControlMode, ReceiverReport};
|
||||
|
||||
/// Loss above this means the path is being overrun and the target comes down.
|
||||
const LOSS_DECREASE: f32 = 0.10;
|
||||
/// Loss below this means there is room to climb.
|
||||
const LOSS_INCREASE: f32 = 0.02;
|
||||
|
||||
/// What to keep of the measured goodput when backing off.
|
||||
///
|
||||
/// Backing off to *below* what actually got through, rather than to a fraction
|
||||
/// of what we were asking for, is what makes recovery quick. At 10 Mbps into a
|
||||
/// 3 Mbps path the next target is about 2.5 Mbps rather than 8.5, so one step
|
||||
/// does what twenty multiplicative decreases would.
|
||||
const BACKOFF: f32 = 0.85;
|
||||
|
||||
/// How much of the ceiling to add per calm second. Twenty steps from floor to
|
||||
/// ceiling: slow enough that a brief quiet spell does not undo a back-off.
|
||||
const CLIMB_FRACTION: u32 = 20;
|
||||
|
||||
/// Don't actuate for less than this fraction of the current target.
|
||||
///
|
||||
/// Retuning is cheap now -- it no longer rebuilds the encoder or forces a
|
||||
/// keyframe -- but a line in the log every second saying the bitrate moved by
|
||||
/// 1% is noise that hides the changes that matter.
|
||||
const DEADBAND: f32 = 0.10;
|
||||
|
||||
/// Never go below this, whatever the ceiling is.
|
||||
///
|
||||
/// Under this a 1080p stream is not worth sending and the right answer is fewer
|
||||
/// pixels rather than fewer bits, which is a decision about the tier and not one
|
||||
/// this can make. It also has to leave room for audio, which shares the
|
||||
/// connection and is not counted against the video ceiling.
|
||||
const ABSOLUTE_FLOOR_KBPS: u32 = 800;
|
||||
|
||||
/// Seconds without a receiver report before this end's own view is used instead.
|
||||
const SILENT_TICKS_BEFORE_FALLBACK: u32 = 3;
|
||||
|
||||
/// Backlog, in milliseconds of video, that means the path is being overrun.
|
||||
///
|
||||
/// Not a tuned number: it is a latency budget. At sixty frames a second a frame
|
||||
/// is under 17 ms, so a quarter second of backlog is fifteen frames already
|
||||
/// handed over and not yet gone -- a player is looking at a picture from before
|
||||
/// their last four keypresses. There is no bitrate worth that, so past this the
|
||||
/// target comes down whatever the loss says.
|
||||
const QUEUE_DECREASE_MS: u32 = 250;
|
||||
|
||||
/// Backlog, in milliseconds, under which the path is considered clear.
|
||||
///
|
||||
/// Climbing needs a stronger warrant than holding does, because climbing is
|
||||
/// what digs the queue. Five frames or so of backlog is the most that can be
|
||||
/// outstanding and still be called live.
|
||||
const QUEUE_CLIMB_MS: u32 = 80;
|
||||
|
||||
/// The band a target must stay inside.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
pub ceiling_kbps: u32,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub fn new(ceiling_kbps: u32) -> Self {
|
||||
Self { ceiling_kbps }
|
||||
}
|
||||
|
||||
/// The lowest this may go.
|
||||
pub fn floor_kbps(&self) -> u32 {
|
||||
ABSOLUTE_FLOOR_KBPS
|
||||
.max(self.ceiling_kbps / 10)
|
||||
// A ceiling below the floor is a tier nobody should have configured,
|
||||
// but clamping the wrong way round would put the target *above* the
|
||||
// ceiling, which is the one thing a ceiling must never allow.
|
||||
.min(self.ceiling_kbps)
|
||||
}
|
||||
|
||||
fn clamp(&self, kbps: u32) -> u32 {
|
||||
kbps.clamp(self.floor_kbps(), self.ceiling_kbps)
|
||||
}
|
||||
}
|
||||
|
||||
/// What this end can see about the path, for when the far end is not talking.
|
||||
///
|
||||
/// Every field is optional because every one of them can be unavailable: a
|
||||
/// connection with no established path has no round-trip time, and a congestion
|
||||
/// window is only meaningful once something has been sent.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PathView {
|
||||
/// Congestion window in bytes, as the transport currently believes it.
|
||||
pub cwnd_bytes: Option<u64>,
|
||||
/// Round-trip time in milliseconds.
|
||||
pub rtt_ms: Option<u32>,
|
||||
/// Datagram bytes handed to the transport that have not yet left.
|
||||
///
|
||||
/// The backlog is the one thing this end can see that says the path is
|
||||
/// being overrun *while it is still only late*. Loss says so too, but only
|
||||
/// afterwards, and on a path that queues rather than drops, "afterwards"
|
||||
/// can be several seconds.
|
||||
pub backlog_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl PathView {
|
||||
/// How long the backlog takes to drain at `drain_kbps`, in milliseconds.
|
||||
///
|
||||
/// Bits divided by kilobits-per-second is milliseconds. The rate to divide
|
||||
/// by is the one the queue actually drains at -- what is getting through --
|
||||
/// and not what we are asking for, which is the number that is too high
|
||||
/// whenever this matters.
|
||||
pub fn backlog_ms(&self, drain_kbps: u32) -> Option<u32> {
|
||||
let backlog = self.backlog_bytes?;
|
||||
let drain = u64::from(drain_kbps.max(1));
|
||||
Some((backlog.saturating_mul(8) / drain).min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
|
||||
/// Delivery rate the window and round trip imply, in kbps.
|
||||
///
|
||||
/// A window is a quantity of bytes in flight for one round trip, so the two
|
||||
/// together are a rate. Treated as an estimate of last resort: it describes
|
||||
/// the transport's own opinion of the path, and that opinion is exactly what
|
||||
/// was observed to be wrong when this mattered most.
|
||||
pub fn estimate_kbps(&self) -> Option<u32> {
|
||||
let (cwnd, rtt) = (self.cwnd_bytes?, self.rtt_ms?.max(1));
|
||||
let bits_per_second = cwnd.saturating_mul(8) * 1000 / u64::from(rtt);
|
||||
Some((bits_per_second / 1000).min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the target is what it is, for the overlay and the log.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Reason {
|
||||
/// Loss was high; backed off to under what was actually getting through.
|
||||
Congested,
|
||||
/// Loss was in the middle band; left alone.
|
||||
Holding,
|
||||
/// Loss was low; climbing towards the ceiling.
|
||||
Climbing,
|
||||
/// No receiver report recently; using this end's view of the path.
|
||||
Fallback,
|
||||
/// No report and no usable view either; decaying towards the floor.
|
||||
Blind,
|
||||
/// Somebody set the bitrate by hand.
|
||||
Manual,
|
||||
/// The encoder is not under a bitrate at all, so there is nothing to decide.
|
||||
ConstantQuality,
|
||||
/// Nobody is connected, so there is no path to have an opinion about.
|
||||
NoClients,
|
||||
/// This second's numbers describe frames the hub chose not to send, so they
|
||||
/// say nothing about the path.
|
||||
SelfInflicted,
|
||||
/// Nothing was lost, but the send queue is standing deep enough that the
|
||||
/// picture is arriving late. Backed off on the queue rather than on loss.
|
||||
Backlogged,
|
||||
}
|
||||
|
||||
/// The controller's whole state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Controller {
|
||||
/// The ceiling the box was given, which a client may lower but never raise.
|
||||
///
|
||||
/// Kept apart from `limits`, which holds whatever is in force *now*. Folding
|
||||
/// the two together means a client that lowers the ceiling can never raise
|
||||
/// it again, because the only number left to compare against is the one it
|
||||
/// just lowered.
|
||||
box_ceiling_kbps: u32,
|
||||
/// What the controller currently believes the bitrate should be.
|
||||
///
|
||||
/// Kept apart from `sent_kbps` deliberately. Folding the two together looks
|
||||
/// simpler and stalls the climb: the step up is a fraction of the *ceiling*,
|
||||
/// so once the target passes half of it each step is under the deadband, and
|
||||
/// a target that only moved when it actuated could never accumulate past
|
||||
/// that point. Belief moves every second; only saying so is rationed.
|
||||
target_kbps: u32,
|
||||
/// What the encoder was last told, or `None` before anything was sent.
|
||||
sent_kbps: Option<u32>,
|
||||
limits: Limits,
|
||||
mode: ControlMode,
|
||||
/// Set when the encoder is in constant-QP; there is no bitrate to control.
|
||||
constant_quality: bool,
|
||||
silent_ticks: u32,
|
||||
reason: Reason,
|
||||
/// Last measured send-queue depth, kept for reporting rather than control.
|
||||
backlog_ms: u32,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
/// Start at the ceiling.
|
||||
///
|
||||
/// Optimistic on purpose: the common case is a path that can carry the tier
|
||||
/// that was sold, and a session that opened at its floor and climbed would
|
||||
/// take twenty seconds to look like what was paid for. The first bad report
|
||||
/// undoes it in one step.
|
||||
pub fn new(limits: Limits) -> Self {
|
||||
Self {
|
||||
box_ceiling_kbps: limits.ceiling_kbps,
|
||||
target_kbps: limits.ceiling_kbps,
|
||||
sent_kbps: None,
|
||||
limits,
|
||||
mode: ControlMode::Auto,
|
||||
constant_quality: false,
|
||||
silent_ticks: 0,
|
||||
reason: Reason::Holding,
|
||||
backlog_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_kbps(&self) -> u32 {
|
||||
self.target_kbps
|
||||
}
|
||||
pub fn limits(&self) -> Limits {
|
||||
self.limits
|
||||
}
|
||||
pub fn mode(&self) -> ControlMode {
|
||||
self.mode
|
||||
}
|
||||
pub fn reason(&self) -> Reason {
|
||||
self.reason
|
||||
}
|
||||
|
||||
/// How far behind the send queue was at the last decision, in milliseconds.
|
||||
pub fn backlog_ms(&self) -> u32 {
|
||||
self.backlog_ms
|
||||
}
|
||||
|
||||
/// The ceiling the box was given, whatever a client has since asked for.
|
||||
pub fn box_ceiling_kbps(&self) -> u32 {
|
||||
self.box_ceiling_kbps
|
||||
}
|
||||
|
||||
/// Adopt a ceiling a client asked for, never above the one the box was given.
|
||||
///
|
||||
/// A client may lower its own ceiling -- to test a path, or because it knows
|
||||
/// something about its link that this end does not -- but it may not raise
|
||||
/// the one the tier bought.
|
||||
pub fn set_ceiling(&mut self, ceiling_kbps: u32) {
|
||||
self.limits = Limits::new(ceiling_kbps.min(self.box_ceiling_kbps).max(1));
|
||||
self.target_kbps = self.limits.clamp(self.target_kbps);
|
||||
// A ceiling that moved is worth restating even when the target did not,
|
||||
// because the encoder is the thing that has to hear about it.
|
||||
self.sent_kbps = None;
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: ControlMode) {
|
||||
self.mode = mode;
|
||||
if mode == ControlMode::Manual {
|
||||
self.reason = Reason::Manual;
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that somebody set the bitrate by hand.
|
||||
pub fn note_manual_target(&mut self, kbps: u32) {
|
||||
self.target_kbps = kbps;
|
||||
self.sent_kbps = Some(kbps);
|
||||
self.mode = ControlMode::Manual;
|
||||
self.reason = Reason::Manual;
|
||||
}
|
||||
|
||||
/// Note that the encoder is, or is no longer, under constant quality.
|
||||
pub fn set_constant_quality(&mut self, constant_quality: bool) {
|
||||
self.constant_quality = constant_quality;
|
||||
}
|
||||
|
||||
/// One second's decision.
|
||||
///
|
||||
/// `report` is the worst report across the clients attached, or `None` when
|
||||
/// none of them said anything. Returns the new target when it is worth
|
||||
/// sending, and `None` when nothing should be sent -- which is most seconds.
|
||||
/// One second's decision.
|
||||
///
|
||||
/// `self_inflicted` says this second contained frames the hub deliberately
|
||||
/// withheld -- a client resynchronising, whose deltas were undecodable and
|
||||
/// were dropped rather than sent. Those seconds cannot be read as evidence
|
||||
/// about the path: fewer frames were sent, so fewer arrived, so the measured
|
||||
/// goodput is low and the loss is high, and a controller anchoring a backoff
|
||||
/// on that would cut the bitrate in response to its own decision. Every
|
||||
/// stutter would then also cost bandwidth, which is the opposite of what a
|
||||
/// resynchronisation needs.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
clients: usize,
|
||||
report: Option<ReceiverReport>,
|
||||
path: PathView,
|
||||
self_inflicted: bool,
|
||||
) -> Option<u32> {
|
||||
if clients == 0 {
|
||||
// Nothing is connected, so nothing is being carried and there is no
|
||||
// path to form an opinion about. Deciding here means deciding on the
|
||||
// absence of evidence: the controller used to read it as silence and
|
||||
// decay to the floor, so a box waiting for its first client spent
|
||||
// that time winding itself down and then jumped back up the moment
|
||||
// somebody arrived.
|
||||
self.reason = Reason::NoClients;
|
||||
self.silent_ticks = 0;
|
||||
return None;
|
||||
}
|
||||
if self.constant_quality {
|
||||
self.reason = Reason::ConstantQuality;
|
||||
return None;
|
||||
}
|
||||
if self.mode == ControlMode::Manual {
|
||||
self.reason = Reason::Manual;
|
||||
return None;
|
||||
}
|
||||
if self_inflicted {
|
||||
// Held, not decayed. The path may be fine; this second simply
|
||||
// cannot say, and silence about the path is not evidence against it.
|
||||
self.reason = Reason::SelfInflicted;
|
||||
self.silent_ticks = 0;
|
||||
return None;
|
||||
}
|
||||
|
||||
// What the queue drains at is what is getting through. With no report
|
||||
// to say, the target is the best guess available -- and a target that
|
||||
// is too high only makes the backlog look shorter than it is, so this
|
||||
// errs towards patience rather than towards cutting the rate.
|
||||
let drain_kbps = match report.as_ref().map(|r| (r.goodput_bps / 1000) as u32) {
|
||||
Some(measured) if measured > 0 => measured,
|
||||
_ => self.target_kbps,
|
||||
};
|
||||
self.backlog_ms = path.backlog_ms(drain_kbps).unwrap_or(0);
|
||||
|
||||
let next = match report.as_ref().and_then(|r| r.loss().map(|l| (r, l))) {
|
||||
Some((report, loss)) => {
|
||||
self.silent_ticks = 0;
|
||||
self.decide_from_report(report, loss, path)
|
||||
}
|
||||
None => {
|
||||
self.silent_ticks = self.silent_ticks.saturating_add(1);
|
||||
if self.silent_ticks < SILENT_TICKS_BEFORE_FALLBACK {
|
||||
self.reason = Reason::Holding;
|
||||
return None;
|
||||
}
|
||||
self.decide_from_path(path)
|
||||
}
|
||||
};
|
||||
|
||||
self.target_kbps = self.limits.clamp(next);
|
||||
// Every second moves the belief; only a change worth acting on is said.
|
||||
// The first decision is always said -- the encoder started at whatever
|
||||
// its environment gave it, and this end has no way to know it matches.
|
||||
match self.sent_kbps {
|
||||
Some(sent) if !worth_sending(sent, self.target_kbps, self.limits) => None,
|
||||
_ => {
|
||||
self.sent_kbps = Some(self.target_kbps);
|
||||
Some(self.target_kbps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decide_from_report(&mut self, report: &ReceiverReport, loss: f32, path: PathView) -> u32 {
|
||||
let measured = (report.goodput_bps / 1000) as u32;
|
||||
// `backlog_ms` is zero both when the queue is empty and when the
|
||||
// transport cannot say, which are the same thing for this purpose: a
|
||||
// queue nobody can see is not evidence of one.
|
||||
let known = path.backlog_bytes.is_some();
|
||||
let queued = known && self.backlog_ms > QUEUE_DECREASE_MS;
|
||||
|
||||
if loss > LOSS_DECREASE || queued {
|
||||
self.reason = if queued {
|
||||
Reason::Backlogged
|
||||
} else {
|
||||
Reason::Congested
|
||||
};
|
||||
// Anchored on what actually arrived, not on what we were asking for.
|
||||
// `goodput` of zero means nothing completed at all, in which case
|
||||
// there is no measurement to anchor to and the target is simply
|
||||
// halved -- the alternative, backing off to zero, would take the
|
||||
// stream below the floor on a single bad second.
|
||||
let anchor = if measured == 0 {
|
||||
self.target_kbps / 2
|
||||
} else {
|
||||
measured.min(self.target_kbps)
|
||||
};
|
||||
return (anchor as f32 * BACKOFF) as u32;
|
||||
}
|
||||
// Climbing is what digs the queue, so it needs the queue to be empty as
|
||||
// well as the loss to be low. Without this the controller climbs all
|
||||
// the way to the ceiling against a path it is already overrunning,
|
||||
// because a path that queues instead of dropping reports no loss at all
|
||||
// until the buffer finally overflows -- and by then the picture is
|
||||
// seconds behind.
|
||||
if loss < LOSS_INCREASE && (!known || self.backlog_ms < QUEUE_CLIMB_MS) {
|
||||
self.reason = Reason::Climbing;
|
||||
return self
|
||||
.target_kbps
|
||||
.saturating_add(self.limits.ceiling_kbps / CLIMB_FRACTION);
|
||||
}
|
||||
self.reason = Reason::Holding;
|
||||
self.target_kbps
|
||||
}
|
||||
|
||||
fn decide_from_path(&mut self, path: PathView) -> u32 {
|
||||
match path.estimate_kbps() {
|
||||
Some(estimate) => {
|
||||
self.reason = Reason::Fallback;
|
||||
estimate
|
||||
}
|
||||
None => {
|
||||
// Nothing said and nothing visible. Holding a high target on no
|
||||
// evidence is how the original failure sustained itself, so this
|
||||
// decays rather than holds.
|
||||
self.reason = Reason::Blind;
|
||||
(self.target_kbps as f32 * BACKOFF) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a change is big enough to be worth actuating.
|
||||
///
|
||||
/// Reaching either end of the band always is: a target pinned to the floor or
|
||||
/// the ceiling is a fact worth stating even when the step to it was small.
|
||||
pub fn worth_sending(current: u32, next: u32, limits: Limits) -> bool {
|
||||
if current == next {
|
||||
return false;
|
||||
}
|
||||
if next == limits.floor_kbps() || next == limits.ceiling_kbps {
|
||||
return true;
|
||||
}
|
||||
let change = current.abs_diff(next) as f32;
|
||||
change >= current as f32 * DEADBAND
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CEILING: u32 = 10_000;
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(CEILING)
|
||||
}
|
||||
|
||||
fn controller() -> Controller {
|
||||
Controller::new(limits())
|
||||
}
|
||||
|
||||
/// A second in which `released` of `total` frames made it, carrying
|
||||
/// `goodput_kbps` of completed video.
|
||||
fn report(released: u32, incomplete: u32, goodput_kbps: u64) -> ReceiverReport {
|
||||
ReceiverReport {
|
||||
goodput_bps: goodput_kbps * 1000,
|
||||
released,
|
||||
incomplete,
|
||||
never_arrived: 0,
|
||||
rtt_ms: 180,
|
||||
}
|
||||
}
|
||||
|
||||
fn healthy() -> ReceiverReport {
|
||||
report(60, 0, 9_800)
|
||||
}
|
||||
|
||||
/// A path with `backlog` bytes handed over and not yet gone.
|
||||
fn backlogged(backlog: u64) -> PathView {
|
||||
PathView {
|
||||
cwnd_bytes: Some(13_000),
|
||||
rtt_ms: Some(180),
|
||||
backlog_bytes: Some(backlog),
|
||||
}
|
||||
}
|
||||
|
||||
/// The second reported failure, as measured over a 1000-mile link.
|
||||
///
|
||||
/// Every frame arrived and every frame completed -- zero loss, zero QUIC
|
||||
/// congestion events, a flat 180 ms round trip for the whole session -- and
|
||||
/// the picture was still several seconds behind, because the offer was
|
||||
/// several times what the path carried and the difference was sitting in
|
||||
/// the hub's own send buffer. A controller reading loss alone sees a
|
||||
/// perfect path here and climbs to the ceiling against it.
|
||||
#[test]
|
||||
fn a_path_that_queues_instead_of_dropping_must_not_read_as_healthy() {
|
||||
let mut c = controller();
|
||||
// Two megabits get through; a megabyte is already queued behind them.
|
||||
let arriving = report(60, 0, 2_000);
|
||||
let path = backlogged(1024 * 1024);
|
||||
|
||||
for _ in 0..30 {
|
||||
c.tick(1, Some(arriving), path, false);
|
||||
}
|
||||
|
||||
assert_ne!(
|
||||
c.reason(),
|
||||
Reason::Climbing,
|
||||
"climbing against a path with four seconds of backlog"
|
||||
);
|
||||
assert!(
|
||||
c.target_kbps() < 2_000,
|
||||
"target {} kbps is at or above what is getting through, so the \
|
||||
queue can only grow",
|
||||
c.target_kbps()
|
||||
);
|
||||
}
|
||||
|
||||
/// The sawtooth: floor, climb to ceiling, collapse, repeat every 20 s.
|
||||
///
|
||||
/// Loss alone cannot break this cycle, because on a queueing path loss only
|
||||
/// appears once the buffer finally overflows -- long after the latency has
|
||||
/// made the session unplayable, and by then the queue is deep enough that
|
||||
/// backing off to the floor is the only way out.
|
||||
#[test]
|
||||
fn the_target_settles_instead_of_sawtoothing() {
|
||||
let mut c = controller();
|
||||
// A steady 2 Mbps path. The backlog is what the last second of
|
||||
// over-sending left behind, drained at what actually gets through.
|
||||
let mut backlog: i64 = 0;
|
||||
let mut seen = Vec::new();
|
||||
|
||||
for _ in 0..60 {
|
||||
let target = c.target_kbps();
|
||||
// Whatever was asked for above 2 Mbps piles up; the rest drains.
|
||||
backlog = (backlog + (i64::from(target) - 2_000) * 1000 / 8).clamp(0, 4 * 1024 * 1024);
|
||||
c.tick(
|
||||
1,
|
||||
Some(report(60, 0, 2_000)),
|
||||
backlogged(backlog as u64),
|
||||
false,
|
||||
);
|
||||
seen.push(c.target_kbps());
|
||||
}
|
||||
|
||||
let settled = &seen[30..];
|
||||
let (lo, hi) = (
|
||||
*settled.iter().min().unwrap(),
|
||||
*settled.iter().max().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
hi - lo <= 1_000,
|
||||
"target still swinging between {lo} and {hi} kbps after 30 seconds"
|
||||
);
|
||||
assert!(
|
||||
hi <= 2_400,
|
||||
"settled at {hi} kbps against a path carrying 2000"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reported depth is what the next session will be judged on, so it
|
||||
/// has to be right: bytes over the rate they leave at, in milliseconds.
|
||||
#[test]
|
||||
fn the_queue_depth_is_reported_as_measured() {
|
||||
let mut c = controller();
|
||||
// 250 kB queued behind a 2 Mbps drain is exactly one second.
|
||||
c.tick(1, Some(report(60, 0, 2_000)), backlogged(250_000), false);
|
||||
assert_eq!(c.backlog_ms(), 1_000);
|
||||
|
||||
// A transport that cannot say must report no queue, not a wrong one.
|
||||
c.tick(1, Some(report(60, 0, 2_000)), PathView::default(), false);
|
||||
assert_eq!(c.backlog_ms(), 0);
|
||||
}
|
||||
|
||||
/// The backlog must not become a reason never to climb again.
|
||||
#[test]
|
||||
fn a_clear_queue_still_climbs() {
|
||||
let mut c = controller();
|
||||
c.set_ceiling(4_000);
|
||||
let clear = backlogged(0);
|
||||
for _ in 0..5 {
|
||||
c.tick(1, Some(report(60, 0, 3_900)), clear, false);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::Climbing);
|
||||
}
|
||||
|
||||
/// The reported failure, as measured: a 10 Mbps offer into a path carrying
|
||||
/// under three, where *no* frame completed for minutes.
|
||||
#[test]
|
||||
fn the_reported_failure_is_corrected_in_seconds() {
|
||||
let mut c = controller();
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
|
||||
let mut ticks = 0;
|
||||
while c.target_kbps() > 2_800 && ticks < 10 {
|
||||
// 46 frames a second starting to arrive, none of them completing.
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), false);
|
||||
ticks += 1;
|
||||
}
|
||||
assert!(
|
||||
c.target_kbps() <= 2_800,
|
||||
"still at {} kbps after {ticks} seconds",
|
||||
c.target_kbps(),
|
||||
);
|
||||
assert!(
|
||||
ticks <= 5,
|
||||
"took {ticks} seconds to stop overrunning the path"
|
||||
);
|
||||
}
|
||||
|
||||
/// With a goodput measurement to anchor on it should take one step, not
|
||||
/// several -- that is the whole reason for backing off to what arrived
|
||||
/// rather than to a fraction of what was asked for.
|
||||
#[test]
|
||||
fn a_measured_goodput_is_corrected_in_one_step() {
|
||||
let mut c = controller();
|
||||
let sent = c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false);
|
||||
assert_eq!(sent, Some(c.target_kbps()));
|
||||
assert!(
|
||||
c.target_kbps() < 2_850,
|
||||
"backed off to {} kbps, which is above what actually arrived",
|
||||
c.target_kbps(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The negative that matters most: a path that is fine must be left alone.
|
||||
#[test]
|
||||
fn a_healthy_path_is_not_wandered_away_from() {
|
||||
let mut c = controller();
|
||||
// The first decision is always stated. The encoder started at whatever
|
||||
// its environment gave it and this end cannot know that matches, so the
|
||||
// target is asserted once rather than assumed.
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING)
|
||||
);
|
||||
for _ in 0..60 {
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
None,
|
||||
"a healthy path produced a bitrate change",
|
||||
);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_decision_is_always_stated() {
|
||||
// Otherwise a session whose path happens to match its ceiling never
|
||||
// tells the encoder anything, and the encoder keeps whatever its
|
||||
// environment gave it -- which is the failure this replaces.
|
||||
let mut c = controller();
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING),
|
||||
"the opening target was never stated",
|
||||
);
|
||||
// And not repeated, now that the encoder has been told.
|
||||
assert_eq!(c.tick(1, Some(healthy()), PathView::default(), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_change_is_not_worth_saying() {
|
||||
// Below the deadband nothing is sent, so the log and the encoder are not
|
||||
// touched sixty times a minute for changes nobody could see.
|
||||
let l = limits();
|
||||
assert!(!worth_sending(5_000, 5_200, l));
|
||||
assert!(worth_sending(5_000, 5_600, l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaching_either_end_of_the_band_is_always_worth_saying() {
|
||||
let l = limits();
|
||||
assert!(worth_sending(l.floor_kbps() + 1, l.floor_kbps(), l));
|
||||
assert!(worth_sending(CEILING - 1, CEILING, l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ceiling_is_never_exceeded() {
|
||||
let mut c = controller();
|
||||
for _ in 0..200 {
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
assert!(c.target_kbps() <= CEILING, "{} kbps", c.target_kbps());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_is_never_gone_below() {
|
||||
let mut c = controller();
|
||||
for _ in 0..200 {
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false);
|
||||
assert!(
|
||||
c.target_kbps() >= c.limits().floor_kbps(),
|
||||
"{} kbps is below the floor",
|
||||
c.target_kbps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_climbs_back_after_a_bad_patch() {
|
||||
let mut c = controller();
|
||||
for _ in 0..5 {
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), false);
|
||||
}
|
||||
let bottom = c.target_kbps();
|
||||
for _ in 0..40 {
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
}
|
||||
assert!(
|
||||
c.target_kbps() > bottom,
|
||||
"never recovered from {bottom} kbps"
|
||||
);
|
||||
assert_eq!(c.target_kbps(), CEILING, "did not climb all the way back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_the_hub_starved_is_not_read_as_a_bad_path() {
|
||||
// While a client resynchronises its deltas are withheld, so fewer frames
|
||||
// are sent, fewer arrive, goodput reads low and loss reads high. Backing
|
||||
// off on that would cut the bitrate in response to the hub's own
|
||||
// decision -- and make every stutter cost bandwidth as well.
|
||||
let mut c = controller();
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING)
|
||||
);
|
||||
|
||||
let starved = report(0, 46, 0);
|
||||
for _ in 0..10 {
|
||||
assert_eq!(c.tick(1, Some(starved), PathView::default(), true), None);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING, "the hub cut its own bitrate");
|
||||
assert_eq!(c.reason(), Reason::SelfInflicted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_genuinely_bad_second_still_acts_once_the_hub_stops_starving_it() {
|
||||
// The flag holds, it does not blind. The moment a second is the path's
|
||||
// own, the same evidence is acted on.
|
||||
let mut c = controller();
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), true);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
|
||||
c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false);
|
||||
assert!(c.target_kbps() < 2_850, "a real bad second was ignored too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_starved_second_does_not_count_towards_silence() {
|
||||
// It is not a missing report -- one arrived, it just cannot be read.
|
||||
// Counting it as silence would slide towards the path-based fallback and
|
||||
// then to decaying blind, which is a different wrong answer.
|
||||
let mut c = controller();
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
for _ in 0..10 {
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), true);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::SelfInflicted);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_bitrate_stands_the_controller_down() {
|
||||
// How this class of bug gets diagnosed. A controller that overrode a
|
||||
// person's setting a second later would take the tool away.
|
||||
let mut c = controller();
|
||||
c.note_manual_target(1_000);
|
||||
for _ in 0..30 {
|
||||
assert_eq!(
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), 1_000);
|
||||
assert_eq!(c.reason(), Reason::Manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_quality_has_no_bitrate_to_decide() {
|
||||
let mut c = controller();
|
||||
c.set_constant_quality(true);
|
||||
assert_eq!(
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(c.reason(), Reason::ConstantQuality);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_tolerated_briefly_and_then_acted_on() {
|
||||
let mut c = controller();
|
||||
// A missed report or two says nothing; the path was fine a second ago.
|
||||
for _ in 0..(SILENT_TICKS_BEFORE_FALLBACK - 1) {
|
||||
assert_eq!(c.tick(1, None, PathView::default(), false), None);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
// Past that, with nothing visible from this end either, it decays rather
|
||||
// than holding a high target on no evidence at all -- holding is how the
|
||||
// original failure sustained itself.
|
||||
c.tick(1, None, PathView::default(), false);
|
||||
assert_eq!(c.reason(), Reason::Blind);
|
||||
assert!(c.target_kbps() < CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_ends_view_is_used_only_when_the_far_end_is_silent() {
|
||||
let mut c = controller();
|
||||
let path = PathView {
|
||||
cwnd_bytes: Some(120_000),
|
||||
rtt_ms: Some(200),
|
||||
backlog_bytes: None,
|
||||
};
|
||||
// A report present means the path view is ignored, however tempting.
|
||||
c.tick(1, Some(healthy()), path, false);
|
||||
assert_eq!(c.reason(), Reason::Climbing);
|
||||
|
||||
for _ in 0..SILENT_TICKS_BEFORE_FALLBACK {
|
||||
c.tick(1, None, path, false);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::Fallback);
|
||||
// 120 KB in flight per 200 ms is 4.8 Mbps.
|
||||
assert_eq!(c.target_kbps(), 4_800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_decided_while_nobody_is_connected() {
|
||||
// A box waiting for its first client used to read the silence as a dead
|
||||
// path and wind itself down to the floor, then jump back up the moment
|
||||
// somebody arrived. There is no path to have an opinion about yet.
|
||||
let mut c = controller();
|
||||
for _ in 0..30 {
|
||||
assert_eq!(c.tick(0, None, PathView::default(), false), None);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
assert_eq!(c.reason(), Reason::NoClients);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lowered_ceiling_can_be_raised_again() {
|
||||
// The trap in folding the box's ceiling together with the one in force:
|
||||
// after lowering, the only number left to compare against is the lowered
|
||||
// one, so the client can never get back up.
|
||||
let mut c = controller();
|
||||
c.set_ceiling(2_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 2_000);
|
||||
c.set_ceiling(5_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 5_000);
|
||||
assert_eq!(c.box_ceiling_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ceiling_change_is_restated_to_the_encoder() {
|
||||
// The encoder is the thing that has to act on it, and a target that
|
||||
// happens to land on the same number is still a different instruction
|
||||
// when the band around it moved.
|
||||
let mut c = controller();
|
||||
assert!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false)
|
||||
.is_some()
|
||||
);
|
||||
c.set_ceiling(3_000);
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(3_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_may_lower_its_ceiling_but_not_raise_it() {
|
||||
let mut c = controller();
|
||||
c.set_ceiling(4_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 4_000);
|
||||
assert!(c.target_kbps() <= 4_000, "the target outlived its ceiling");
|
||||
|
||||
c.set_ceiling(50_000);
|
||||
assert_eq!(
|
||||
c.limits().ceiling_kbps,
|
||||
CEILING,
|
||||
"a client raised the ceiling the tier bought",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ceiling_under_the_floor_does_not_invert_the_band() {
|
||||
// A misconfigured tier must not produce a floor above its own ceiling,
|
||||
// which would put every target above the limit it exists to enforce.
|
||||
let l = Limits::new(500);
|
||||
assert!(l.floor_kbps() <= l.ceiling_kbps);
|
||||
assert_eq!(l.clamp(10_000), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_second_is_not_read_as_a_healthy_one() {
|
||||
// No frames at all accounted for: nothing sent, or nothing arrived.
|
||||
// Reading it as no loss would climb straight into a path that may be
|
||||
// carrying nothing.
|
||||
let mut c = controller();
|
||||
let empty = ReceiverReport::default();
|
||||
assert_eq!(empty.loss(), None);
|
||||
c.tick(1, Some(empty), PathView::default(), false);
|
||||
assert_eq!(c.target_kbps(), CEILING, "climbed on an empty report");
|
||||
}
|
||||
}
|
||||
+204
-31
@@ -143,6 +143,73 @@ impl DatagramSender {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long deltas may be withheld from a client waiting to resynchronise.
|
||||
///
|
||||
/// A bound rather than a belief. Withholding is correct only while the keyframe
|
||||
/// is actually coming, and if the encoder never produces one -- it refused, it
|
||||
/// died, the request never reached it -- then withholding forever turns a
|
||||
/// recoverable freeze into a permanent black screen. Past this the deltas go out
|
||||
/// again: useless to a desynchronised decoder, but "useless" beats "nothing at
|
||||
/// all, forever" when the assumption behind the suppression has been proven
|
||||
/// wrong.
|
||||
const MAX_RESYNC_WITHHOLD: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Whether to send delta frames to a client that cannot decode them yet.
|
||||
///
|
||||
/// A client that has lost synchronisation asks for a keyframe, and until one
|
||||
/// arrives every delta frame sent to it is undecodable -- it predicts from
|
||||
/// pictures that client does not have. Those frames are not merely wasted: quinn
|
||||
/// writes DATAGRAM frames into a packet before STREAM frames, so a steady stream
|
||||
/// of deltas takes the space the keyframe needs and starves the one frame that
|
||||
/// would end the freeze. That is the loop behind "26 keyframe fallbacks, 19 IDR
|
||||
/// requests": the recovery frame could not get out past the frames that needed
|
||||
/// it to arrive first.
|
||||
///
|
||||
/// So while a client is waiting, its deltas are dropped rather than sent.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResyncGate {
|
||||
/// When this client started waiting, or `None` if it is not.
|
||||
waiting_since: Option<std::time::Instant>,
|
||||
/// Deltas dropped during the current wait.
|
||||
withheld: u64,
|
||||
/// Set once the withhold bound is passed, so it is said once per wait.
|
||||
gave_up: bool,
|
||||
}
|
||||
|
||||
impl ResyncGate {
|
||||
/// Whether this delta frame should go out.
|
||||
///
|
||||
/// `awaiting` is whether the client has asked for a keyframe and not yet
|
||||
/// been sent one.
|
||||
pub fn admit_delta(&mut self, awaiting: bool, now: std::time::Instant) -> bool {
|
||||
if !awaiting {
|
||||
self.end_wait();
|
||||
return true;
|
||||
}
|
||||
let since = *self.waiting_since.get_or_insert(now);
|
||||
if now.duration_since(since) >= MAX_RESYNC_WITHHOLD {
|
||||
self.gave_up = true;
|
||||
return true;
|
||||
}
|
||||
self.withheld += 1;
|
||||
false
|
||||
}
|
||||
|
||||
/// A keyframe has gone out, so the wait is over.
|
||||
pub fn note_keyframe(&mut self) -> Option<(u64, bool)> {
|
||||
let withheld = self.withheld;
|
||||
let gave_up = self.gave_up;
|
||||
self.end_wait();
|
||||
(withheld > 0).then_some((withheld, gave_up))
|
||||
}
|
||||
|
||||
fn end_wait(&mut self) {
|
||||
self.waiting_since = None;
|
||||
self.withheld = 0;
|
||||
self.gave_up = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames arriving on `rx` are numbered and sent — deltas as datagrams,
|
||||
/// keyframes on a reliable stream each when `keyframes_reliable` is set.
|
||||
///
|
||||
@@ -161,6 +228,12 @@ pub async fn run_datagram_writer(
|
||||
mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
relay_ms: Option<Arc<AtomicU32>>,
|
||||
keyframes_reliable: bool,
|
||||
// Set while this client has asked for a keyframe and not yet been sent
|
||||
// one. Video only; audio has no such notion.
|
||||
awaiting_keyframe: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
// Counts frames withheld during a resynchronisation, so the bitrate
|
||||
// controller can tell a second it starved from a second the path did.
|
||||
withheld: Option<Arc<std::sync::atomic::AtomicU64>>,
|
||||
) {
|
||||
let sender = DatagramSender::new(conn.clone(), kind);
|
||||
let keyframes = keyframes_reliable.then(|| KeyframeSender::new(conn, sender.clone()));
|
||||
@@ -170,33 +243,54 @@ pub async fn run_datagram_writer(
|
||||
// debug. Losing datagram support entirely is not, and is worth a warning —
|
||||
// but only the first time, since it will then be true for every frame.
|
||||
let mut warned_unsupported = false;
|
||||
|
||||
// Where this writer's second went.
|
||||
//
|
||||
// The pair that matters is the first two: if frames arrive here already
|
||||
// 43 ms apart then the hole was made upstream, in the encoder or on the IPC
|
||||
// hop, and nothing in this file can be the cause. If they arrive evenly and
|
||||
// leave unevenly, it is made here. A client measured exactly that hole in
|
||||
// video datagram arrivals while audio — same connection, same congestion
|
||||
// window, its own writer — stayed at 8 ms.
|
||||
let mut last_in = std::time::Instant::now();
|
||||
let mut worst_in_gap = std::time::Duration::ZERO;
|
||||
let mut worst_send = std::time::Duration::ZERO;
|
||||
let mut frames: u32 = 0;
|
||||
let mut last_pace = std::time::Instant::now();
|
||||
let mut resync = ResyncGate::default();
|
||||
|
||||
while let Some(payload) = rx.recv().await {
|
||||
let t0 = std::time::Instant::now();
|
||||
worst_in_gap = worst_in_gap.max(t0.duration_since(last_in));
|
||||
last_in = t0;
|
||||
frames += 1;
|
||||
|
||||
body.clear();
|
||||
nesprotocol::encode_frame_body(&mut body, MSG_DATA, seq, &payload);
|
||||
|
||||
// A keyframe goes on a stream of its own when one will take it. The
|
||||
// relay timing below is not recorded for it: the write is asynchronous
|
||||
// by design, so the time this loop spent on it says nothing.
|
||||
// A client that cannot decode has asked for a keyframe; until it gets
|
||||
// one, everything else sent to it is undecodable and takes the space the
|
||||
// keyframe needs. See `ResyncGate`.
|
||||
if let Some(ref awaiting) = awaiting_keyframe {
|
||||
if nesprotocol::reliable::video_is_keyframe(&payload) {
|
||||
awaiting.store(false, Ordering::Relaxed);
|
||||
if let Some((withheld, gave_up)) = resync.note_keyframe() {
|
||||
debug!(
|
||||
"{label}: resynchronised after withholding {withheld} frame(s){}",
|
||||
if gave_up {
|
||||
", having given up waiting"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if !resync
|
||||
.admit_delta(awaiting.load(Ordering::Relaxed), std::time::Instant::now())
|
||||
{
|
||||
// The sequence number deliberately does *not* advance. A
|
||||
// withheld frame was never sent, so leaving a hole would make
|
||||
// the receiver count it as lost -- and that count is what the
|
||||
// bitrate controller reads. The hub would then lower the
|
||||
// bitrate because of frames it chose not to send, which is a
|
||||
// controller reacting to its own decision rather than to the
|
||||
// path. Reusing the number is safe precisely because nothing
|
||||
// went out under it.
|
||||
//
|
||||
// Saying so is the other half: the receiver still reports fewer
|
||||
// frames this second, and without this count the controller has
|
||||
// no way to tell that second from one the path ruined.
|
||||
if let Some(ref withheld) = withheld {
|
||||
withheld.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref keyframes) = keyframes
|
||||
&& video_wants_reliable(&payload)
|
||||
{
|
||||
@@ -229,19 +323,6 @@ pub async fn run_datagram_writer(
|
||||
}
|
||||
Err(e) => debug!("{label}: dropping frame {seq}: {e}"),
|
||||
}
|
||||
worst_send = worst_send.max(t0.elapsed());
|
||||
|
||||
if last_pace.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
last_pace = std::time::Instant::now();
|
||||
debug!(
|
||||
"{label}: {frames} frames, worst gap between frames in {:.1}ms, worst send {:.1}ms",
|
||||
worst_in_gap.as_secs_f64() * 1000.0,
|
||||
worst_send.as_secs_f64() * 1000.0,
|
||||
);
|
||||
worst_in_gap = std::time::Duration::ZERO;
|
||||
worst_send = std::time::Duration::ZERO;
|
||||
frames = 0;
|
||||
}
|
||||
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
@@ -252,3 +333,95 @@ pub async fn run_datagram_writer(
|
||||
}
|
||||
debug!("{label} datagram writer exiting (channel closed)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resync_gate_tests {
|
||||
use super::{MAX_RESYNC_WITHHOLD, ResyncGate};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn a_client_that_can_decode_gets_everything() {
|
||||
// The negative that matters: nothing is withheld from a healthy client.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..1000 {
|
||||
assert!(gate.admit_delta(false, now + Duration::from_millis(i)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_client_is_not_sent_frames_it_cannot_decode() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..60 {
|
||||
assert!(
|
||||
!gate.admit_delta(true, now + Duration::from_millis(i * 16)),
|
||||
"frame {i} went to a client with no reference to decode it against",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyframe_ends_the_wait_and_reports_the_cost() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..5 {
|
||||
gate.admit_delta(true, now + Duration::from_millis(i * 16));
|
||||
}
|
||||
assert_eq!(gate.note_keyframe(), Some((5, false)));
|
||||
// And the next delta goes out immediately.
|
||||
assert!(gate.admit_delta(false, now + Duration::from_millis(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyframe_with_nothing_withheld_says_nothing() {
|
||||
// So an ordinary periodic keyframe does not log a recovery that did not
|
||||
// happen.
|
||||
let mut gate = ResyncGate::default();
|
||||
assert_eq!(gate.note_keyframe(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withholding_gives_up_rather_than_going_dark_forever() {
|
||||
// The bound. Withholding is only correct while the keyframe is actually
|
||||
// coming; if the encoder never produces one, suppressing forever turns a
|
||||
// recoverable freeze into a permanent black screen. Undecodable frames
|
||||
// beat no frames once the assumption is disproven.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
assert!(!gate.admit_delta(true, now));
|
||||
assert!(!gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD - Duration::from_millis(1)));
|
||||
assert!(
|
||||
gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD),
|
||||
"still withholding after the keyframe plainly is not coming",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn giving_up_is_reported_when_the_keyframe_finally_lands() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
gate.admit_delta(true, now);
|
||||
gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD);
|
||||
let (withheld, gave_up) = gate.note_keyframe().expect("something was withheld");
|
||||
assert_eq!(withheld, 1);
|
||||
assert!(gave_up, "the wait timed out and nothing said so");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_wait_starts_fresh() {
|
||||
// Otherwise the first wait's elapsed time carries over and the second is
|
||||
// abandoned immediately, or its count reports the wrong recovery.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
gate.admit_delta(true, now);
|
||||
gate.note_keyframe();
|
||||
|
||||
let later = now + Duration::from_secs(60);
|
||||
assert!(
|
||||
!gate.admit_delta(true, later),
|
||||
"the new wait was not honoured"
|
||||
);
|
||||
assert_eq!(gate.note_keyframe(), Some((1, false)));
|
||||
}
|
||||
}
|
||||
|
||||
+155
-12
@@ -1,3 +1,4 @@
|
||||
mod control;
|
||||
mod dgram;
|
||||
mod ipc_listener;
|
||||
mod keyframe;
|
||||
@@ -14,7 +15,7 @@ use iroh::endpoint::presets;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::ticket::NestriTicket;
|
||||
use nesprotocol::ALPN;
|
||||
use nesprotocol::{ALPNS, Carrier};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "neshub")]
|
||||
@@ -73,6 +74,16 @@ struct Args {
|
||||
#[arg(long, env = "NESTRI_AUDIO_BITRATE", default_value_t = 64)]
|
||||
audio_bitrate_per_channel: u32,
|
||||
|
||||
/// Ceiling on the video bitrate, in kbps.
|
||||
///
|
||||
/// Set by `nesinit` from the boot descriptor's video limits, which come
|
||||
/// from the tier the box was sized for. Absent means nobody said -- which is
|
||||
/// not a licence to send whatever the encoder defaults to, since that is
|
||||
/// precisely how every session came to offer 10 Mbps regardless of what the
|
||||
/// path could carry. Unset is reported, and a conservative ceiling is used.
|
||||
#[arg(long, env = "NESTRI_MAX_BITRATE")]
|
||||
max_bitrate_kbps: Option<u32>,
|
||||
|
||||
/// Socket nescope sends screenshots on. neshub listens; nescope dials out.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -82,6 +93,14 @@ struct Args {
|
||||
screenshot_ipc: PathBuf,
|
||||
}
|
||||
|
||||
/// What to assume when nobody said.
|
||||
///
|
||||
/// Deliberately modest. A ceiling that was never set should not behave like an
|
||||
/// unlimited one: the whole failure this exists to fix was a session offering
|
||||
/// 10 Mbps into a path carrying under three, because no number had ever been
|
||||
/// chosen and the encoder's own default stood in for one.
|
||||
const DEFAULT_MAX_BITRATE_KBPS: u32 = 4_000;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -93,7 +112,7 @@ async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut builder = iroh::Endpoint::builder(presets::N0)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.alpns(ALPNS.iter().map(|a| a.to_vec()).collect::<Vec<_>>())
|
||||
.transport_config(crate::dgram::media_transport_config());
|
||||
|
||||
match args.relay.as_str() {
|
||||
@@ -117,6 +136,15 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
match args.max_bitrate_kbps {
|
||||
Some(kbps) => tracing::info!("video ceiling: {kbps} kbps, from the boot descriptor"),
|
||||
None => tracing::warn!(
|
||||
"no video ceiling on the boot descriptor; using {DEFAULT_MAX_BITRATE_KBPS} kbps. \
|
||||
A box sized by a tier is told its ceiling -- if this is one, the descriptor did \
|
||||
not carry it."
|
||||
),
|
||||
}
|
||||
|
||||
let endpoint = builder.bind().await?;
|
||||
let endpoint_addr = endpoint.addr();
|
||||
let ep_id = endpoint_addr.id;
|
||||
@@ -134,6 +162,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
|
||||
// One controller for the box, not one per client: there is one encoder, so
|
||||
// there is one bitrate, and the client having the worst time is the one it
|
||||
// has to answer.
|
||||
let box_ceiling_kbps = args.max_bitrate_kbps.unwrap_or(DEFAULT_MAX_BITRATE_KBPS);
|
||||
let controller = Arc::new(tokio::sync::Mutex::new(control::Controller::new(
|
||||
control::Limits::new(box_ceiling_kbps),
|
||||
)));
|
||||
|
||||
// IDR / encode settings command channel: input reader → nescapture
|
||||
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
||||
{
|
||||
@@ -181,23 +217,105 @@ async fn main() -> Result<()> {
|
||||
args.audio_channels,
|
||||
args.audio_bitrate_per_channel
|
||||
);
|
||||
let controller = controller.clone();
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Ten times a second, and only when asked for. Pairs with
|
||||
// nescapture's rate probe: that measures how fast the encoder
|
||||
// follows a new bitrate, this shows how fast the queue downstream
|
||||
// of it responds, and the slower of the two is the fastest a
|
||||
// control loop can usefully run. At one sample a second neither is
|
||||
// visible -- a queue that fills and drains inside a second looks
|
||||
// like a queue that was never there.
|
||||
if std::env::var("NESHUB_BACKLOG_TRACE").is_ok_and(|v| v != "0" && !v.is_empty()) {
|
||||
let mgr = mgr.clone();
|
||||
let controller = controller.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut fast = tokio::time::interval(std::time::Duration::from_millis(100));
|
||||
tracing::info!("backlog trace on, 10 Hz");
|
||||
loop {
|
||||
fast.tick().await;
|
||||
let backlogs = mgr.backlog_bytes().await;
|
||||
if backlogs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target = controller.lock().await.target_kbps();
|
||||
for bytes in backlogs {
|
||||
// Against the target rather than a measured drain:
|
||||
// this is a trace to read afterwards, and a number
|
||||
// divided by a second measurement is two things
|
||||
// moving at once.
|
||||
let ms = bytes.saturating_mul(8) / u64::from(target.max(1));
|
||||
tracing::info!("backlog {ms} ms ({bytes} bytes) at {target} kbps");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// One decision a second, on the same tick as the stats, because
|
||||
// a report describes the second that just passed and there is
|
||||
// nothing to gain from deciding more often than they arrive.
|
||||
{
|
||||
let clients = mgr.client_count().await;
|
||||
let (report, path, self_inflicted) = mgr.worst_report().await;
|
||||
let mut controller = controller.lock().await;
|
||||
if let Some(kbps) = controller.tick(clients, report, path, self_inflicted) {
|
||||
let mut cmd = vec![nesprotocol::MSG_ENCODE_SETTINGS];
|
||||
nesprotocol::encode_bitrate_only(&mut cmd, kbps);
|
||||
if cmd_tx.send(cmd).is_err() {
|
||||
tracing::warn!("encoder command channel closed");
|
||||
} else {
|
||||
// Trace, not info: under a path that keeps moving
|
||||
// this fires every second, and a per-second line at
|
||||
// info buries everything worth reading.
|
||||
tracing::trace!(
|
||||
"video ceiling {}/{} kbps: {:?}",
|
||||
kbps,
|
||||
controller.limits().ceiling_kbps,
|
||||
controller.reason(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let clients = mgr.client_count().await as u8;
|
||||
let bitrate = mgr.video_bitrate_bps();
|
||||
let (key_bps, delta_bps, keyframes) = mgr.video_breakdown();
|
||||
let (pipeline_p50_ms, pipeline_p95_ms, pipeline_max_ms) = mgr.pipeline_delays();
|
||||
let audio_kbps = mgr.audio_bitrate_kbps();
|
||||
let relay_ms = mgr.relay_ms();
|
||||
let mut buf = Vec::with_capacity(15);
|
||||
let mut buf = Vec::with_capacity(34);
|
||||
nesprotocol::stats::encode_hub_stats(
|
||||
&mut buf,
|
||||
clients,
|
||||
bitrate,
|
||||
key_bps.saturating_add(delta_bps),
|
||||
relay_ms,
|
||||
audio_kbps,
|
||||
audio_channels,
|
||||
);
|
||||
{
|
||||
let controller = controller.lock().await;
|
||||
nesprotocol::stats::encode_video_breakdown(
|
||||
&mut buf,
|
||||
&nesprotocol::stats::VideoBreakdown {
|
||||
key_bps,
|
||||
delta_bps,
|
||||
keyframes,
|
||||
target_kbps: controller.target_kbps(),
|
||||
ceiling_kbps: controller.limits().ceiling_kbps,
|
||||
reason: controller.reason() as u8,
|
||||
manual: u8::from(controller.mode() == nesprotocol::ControlMode::Manual),
|
||||
box_ceiling_kbps: controller.box_ceiling_kbps(),
|
||||
pipeline_p50_ms,
|
||||
pipeline_p95_ms,
|
||||
pipeline_max_ms,
|
||||
backlog_ms: controller.backlog_ms().min(u32::from(u16::MAX)) as u16,
|
||||
},
|
||||
);
|
||||
}
|
||||
mgr.broadcast_stats(buf).await;
|
||||
}
|
||||
});
|
||||
@@ -262,18 +380,43 @@ async fn main() -> Result<()> {
|
||||
match incoming.await {
|
||||
Ok(conn) => {
|
||||
let remote_id = conn.remote_id();
|
||||
tracing::info!(remote = %remote_id.fmt_short(), "client connected");
|
||||
let session = session::ClientSession::new(
|
||||
// A client opens one connection per kind of traffic, so the
|
||||
// ALPN says which this is and the endpoint id says whose.
|
||||
let Some(carrier) = Carrier::from_alpn(conn.alpn()) else {
|
||||
tracing::warn!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
"connection with an unknown ALPN; closing"
|
||||
);
|
||||
conn.close(0u32.into(), b"unknown alpn");
|
||||
continue;
|
||||
};
|
||||
tracing::info!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
carrier = carrier.label(),
|
||||
"client connected"
|
||||
);
|
||||
mgr.attach(
|
||||
remote_id,
|
||||
carrier,
|
||||
conn.clone(),
|
||||
input_broadcast_tx.clone(),
|
||||
session_manager.relay_ms_atomic(),
|
||||
cmd_tx.clone(),
|
||||
);
|
||||
mgr.add_session(remote_id, session).await;
|
||||
controller.clone(),
|
||||
)
|
||||
.await;
|
||||
let mgr_clone = mgr.clone();
|
||||
let conn_clone = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
conn_clone.closed().await;
|
||||
conn.closed().await;
|
||||
// Any one of them going means the session goes. A client
|
||||
// left holding audio and input but no video is not a
|
||||
// degraded session, it is a stuck one, and a clean
|
||||
// reconnect is both simpler to reason about and quicker
|
||||
// than whatever partial recovery would be built here.
|
||||
tracing::debug!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
carrier = carrier.label(),
|
||||
"carrier closed, ending session"
|
||||
);
|
||||
mgr_clone.remove_session(&remote_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
+773
-171
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user