Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,974 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const DEFAULT_AUDIO_BUFFER_TARGET_MS: u32 = 200;
|
||||
pub const DEFAULT_AUDIO_BUFFER_MAX_MS: u32 = 750;
|
||||
pub const DEFAULT_MIN_VIDEO_FPS: f64 = 15.0;
|
||||
|
||||
const PRESSURE_WINDOW_MS: u64 = 5_000;
|
||||
const RECOVERY_WINDOW_COUNT: u32 = 12;
|
||||
const AUDIO_REBUFFER_GAP_MS: u64 = 120;
|
||||
const AUDIO_STABLE_GAP_MS: u64 = 60;
|
||||
const AUDIO_BUFFER_STEP_MS: u32 = 100;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SendHealthSnapshot {
|
||||
pub outgoing_video_queue_depth: u64,
|
||||
pub outgoing_video_queue_capacity: u64,
|
||||
pub outgoing_video_max_queue_depth: u64,
|
||||
pub outgoing_video_frames_produced: u64,
|
||||
pub outgoing_video_frames_accepted: u64,
|
||||
pub outgoing_video_frames_dropped: u64,
|
||||
pub outgoing_video_frames_coalesced: u64,
|
||||
pub outgoing_video_frames_captured: u64,
|
||||
pub outgoing_video_capture_failures: u64,
|
||||
pub outgoing_video_effective_fps: f64,
|
||||
pub outgoing_video_target_fps: f64,
|
||||
pub outgoing_video_pacing_target_fps: f64,
|
||||
pub outgoing_video_max_queue_age_ms: u64,
|
||||
pub outgoing_video_max_push_latency_ms: u64,
|
||||
pub outgoing_video_pacing_mode: String,
|
||||
pub outgoing_video_bus_active: bool,
|
||||
pub outgoing_audio_buffer_target_ms: u32,
|
||||
pub outgoing_audio_buffer_max_ms: u32,
|
||||
pub outgoing_audio_underruns: u64,
|
||||
pub outgoing_audio_rebuffers: u64,
|
||||
pub outgoing_audio_max_frame_gap_ms: u64,
|
||||
pub adaptive_send_tier: String,
|
||||
pub adaptive_send_reason: String,
|
||||
}
|
||||
|
||||
impl SendHealthSnapshot {
|
||||
pub fn idle(audio: &AdaptiveAudioStats) -> Self {
|
||||
Self {
|
||||
outgoing_video_queue_depth: 0,
|
||||
outgoing_video_queue_capacity: 0,
|
||||
outgoing_video_max_queue_depth: 0,
|
||||
outgoing_video_frames_produced: 0,
|
||||
outgoing_video_frames_accepted: 0,
|
||||
outgoing_video_frames_dropped: 0,
|
||||
outgoing_video_frames_coalesced: 0,
|
||||
outgoing_video_frames_captured: 0,
|
||||
outgoing_video_capture_failures: 0,
|
||||
outgoing_video_effective_fps: 0.0,
|
||||
outgoing_video_target_fps: 0.0,
|
||||
outgoing_video_pacing_target_fps: 0.0,
|
||||
outgoing_video_max_queue_age_ms: 0,
|
||||
outgoing_video_max_push_latency_ms: 0,
|
||||
outgoing_video_pacing_mode: "idle".to_string(),
|
||||
outgoing_video_bus_active: false,
|
||||
outgoing_audio_buffer_target_ms: audio.target_buffer_ms(),
|
||||
outgoing_audio_buffer_max_ms: audio.max_buffer_ms(),
|
||||
outgoing_audio_underruns: audio.underruns.load(Ordering::Relaxed),
|
||||
outgoing_audio_rebuffers: audio.rebuffers.load(Ordering::Relaxed),
|
||||
outgoing_audio_max_frame_gap_ms: audio.max_frame_gap_ms.load(Ordering::Relaxed),
|
||||
adaptive_send_tier: "idle".to_string(),
|
||||
adaptive_send_reason: "notPublishing".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveVideoController {
|
||||
requested_fps: f64,
|
||||
min_fps: f64,
|
||||
adaptive: bool,
|
||||
state: Mutex<AdaptiveVideoState>,
|
||||
}
|
||||
|
||||
struct AdaptiveVideoState {
|
||||
current_fps: f64,
|
||||
tier: String,
|
||||
reason: String,
|
||||
window_started_ms: u64,
|
||||
window_produced: u64,
|
||||
window_coalesced: u64,
|
||||
window_dropped: u64,
|
||||
window_max_queue_age_ms: u64,
|
||||
window_max_push_latency_ms: u64,
|
||||
window_egress_fps_sum: f64,
|
||||
window_egress_fps_samples: u32,
|
||||
stable_windows: u32,
|
||||
}
|
||||
|
||||
impl AdaptiveVideoController {
|
||||
pub fn new(requested_fps: f64, min_fps: f64, adaptive: bool, now_ms: u64) -> Self {
|
||||
let requested_fps = sanitize_fps(requested_fps, 30.0);
|
||||
let min_fps = sanitize_fps(min_fps, DEFAULT_MIN_VIDEO_FPS).min(requested_fps);
|
||||
Self {
|
||||
requested_fps,
|
||||
min_fps,
|
||||
adaptive,
|
||||
state: Mutex::new(AdaptiveVideoState {
|
||||
current_fps: requested_fps,
|
||||
tier: "full".to_string(),
|
||||
reason: "stable".to_string(),
|
||||
window_started_ms: now_ms,
|
||||
window_produced: 0,
|
||||
window_coalesced: 0,
|
||||
window_dropped: 0,
|
||||
window_max_queue_age_ms: 0,
|
||||
window_max_push_latency_ms: 0,
|
||||
window_egress_fps_sum: 0.0,
|
||||
window_egress_fps_samples: 0,
|
||||
stable_windows: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_fps(&self) -> f64 {
|
||||
self.state.lock().current_fps
|
||||
}
|
||||
|
||||
pub fn tier_and_reason(&self) -> (String, String) {
|
||||
let state = self.state.lock();
|
||||
(state.tier.clone(), state.reason.clone())
|
||||
}
|
||||
|
||||
pub fn record_enqueue(&self, now_ms: u64, coalesced: bool) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_produced += 1;
|
||||
if coalesced {
|
||||
state.window_coalesced += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_drop(&self, now_ms: u64) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_dropped += 1;
|
||||
}
|
||||
|
||||
pub fn record_capture(&self, now_ms: u64, queue_age_ms: u64, push_latency_ms: u64) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_max_queue_age_ms = state.window_max_queue_age_ms.max(queue_age_ms);
|
||||
state.window_max_push_latency_ms = state.window_max_push_latency_ms.max(push_latency_ms);
|
||||
}
|
||||
|
||||
pub fn record_egress_fps(&self, now_ms: u64, fps: f64) {
|
||||
if !fps.is_finite() || fps < 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_egress_fps_sum += fps;
|
||||
state.window_egress_fps_samples += 1;
|
||||
}
|
||||
|
||||
fn rotate_window(&self, state: &mut AdaptiveVideoState, now_ms: u64) {
|
||||
if now_ms.saturating_sub(state.window_started_ms) < PRESSURE_WINDOW_MS {
|
||||
return;
|
||||
}
|
||||
self.apply_window(state);
|
||||
state.window_started_ms = now_ms;
|
||||
state.window_produced = 0;
|
||||
state.window_coalesced = 0;
|
||||
state.window_dropped = 0;
|
||||
state.window_max_queue_age_ms = 0;
|
||||
state.window_max_push_latency_ms = 0;
|
||||
state.window_egress_fps_sum = 0.0;
|
||||
state.window_egress_fps_samples = 0;
|
||||
}
|
||||
|
||||
fn apply_window(&self, state: &mut AdaptiveVideoState) {
|
||||
if !self.adaptive {
|
||||
state.current_fps = self.requested_fps;
|
||||
state.tier = "full".to_string();
|
||||
state.reason = "adaptiveDisabled".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_interval_ms = (1000.0 / state.current_fps.max(1.0)).ceil() as u64;
|
||||
let latency_pressure = state.window_max_queue_age_ms > frame_interval_ms * 2
|
||||
|| state.window_max_push_latency_ms > frame_interval_ms * 2;
|
||||
let drop_ratio = state.window_dropped as f64 / state.window_produced.max(1) as f64;
|
||||
let encoder_drop_pressure = state.window_produced >= 10 && drop_ratio > 0.05;
|
||||
let average_egress_fps = if state.window_egress_fps_samples == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(state.window_egress_fps_sum / state.window_egress_fps_samples as f64)
|
||||
};
|
||||
let egress_pressure = average_egress_fps.is_some_and(|fps| {
|
||||
state.window_egress_fps_samples >= 2
|
||||
&& state.window_produced >= 10
|
||||
&& fps < state.current_fps * 0.75
|
||||
});
|
||||
let pressure = latency_pressure || encoder_drop_pressure || egress_pressure;
|
||||
|
||||
if pressure {
|
||||
let next = if state.current_fps > 30.0 {
|
||||
30.0
|
||||
} else if state.current_fps > self.min_fps {
|
||||
self.min_fps
|
||||
} else {
|
||||
state.current_fps
|
||||
};
|
||||
if next < state.current_fps {
|
||||
state.current_fps = next;
|
||||
state.tier = tier_for_fps(self.requested_fps, state.current_fps);
|
||||
}
|
||||
state.reason = if latency_pressure {
|
||||
"sendLatencyPressure".to_string()
|
||||
} else if encoder_drop_pressure {
|
||||
"encoderDropPressure".to_string()
|
||||
} else {
|
||||
"encoderEgressPressure".to_string()
|
||||
};
|
||||
state.stable_windows = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.stable_windows += 1;
|
||||
if state.current_fps >= self.requested_fps {
|
||||
state.reason = "stable".to_string();
|
||||
}
|
||||
if state.stable_windows >= RECOVERY_WINDOW_COUNT && state.current_fps < self.requested_fps {
|
||||
state.current_fps = (state.current_fps * 2.0).min(self.requested_fps);
|
||||
state.tier = tier_for_fps(self.requested_fps, state.current_fps);
|
||||
state.stable_windows = 0;
|
||||
if state.current_fps >= self.requested_fps {
|
||||
state.reason = "stable".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveVideoStats {
|
||||
produced: AtomicU64,
|
||||
accepted: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
coalesced: AtomicU64,
|
||||
captured: AtomicU64,
|
||||
capture_failures: AtomicU64,
|
||||
queue_depth: AtomicU64,
|
||||
max_queue_depth: AtomicU64,
|
||||
max_queue_age_ms: AtomicU64,
|
||||
max_push_latency_ms: AtomicU64,
|
||||
first_capture_ms: AtomicU64,
|
||||
last_capture_ms: AtomicU64,
|
||||
controller: AdaptiveVideoController,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoTelemetryExtras {
|
||||
pub pacing_mode: String,
|
||||
pub pacing_target_fps: f64,
|
||||
pub queue_capacity: u64,
|
||||
pub bus_active: bool,
|
||||
}
|
||||
|
||||
impl Default for VideoTelemetryExtras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pacing_mode: "unknown".to_string(),
|
||||
pacing_target_fps: 0.0,
|
||||
queue_capacity: 0,
|
||||
bus_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdaptiveVideoStats {
|
||||
pub fn new(requested_fps: f64, min_fps: f64, adaptive: bool, now_ms: u64) -> Self {
|
||||
Self {
|
||||
produced: AtomicU64::new(0),
|
||||
accepted: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
coalesced: AtomicU64::new(0),
|
||||
captured: AtomicU64::new(0),
|
||||
capture_failures: AtomicU64::new(0),
|
||||
queue_depth: AtomicU64::new(0),
|
||||
max_queue_depth: AtomicU64::new(0),
|
||||
max_queue_age_ms: AtomicU64::new(0),
|
||||
max_push_latency_ms: AtomicU64::new(0),
|
||||
first_capture_ms: AtomicU64::new(0),
|
||||
last_capture_ms: AtomicU64::new(0),
|
||||
controller: AdaptiveVideoController::new(requested_fps, min_fps, adaptive, now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_enqueue(&self, now_ms: u64, replaced_pending: bool) {
|
||||
self.record_enqueue_with_depth(now_ms, replaced_pending, 1);
|
||||
}
|
||||
|
||||
pub fn record_enqueue_with_depth(&self, now_ms: u64, replaced_pending: bool, queue_depth: u64) {
|
||||
self.produced.fetch_add(1, Ordering::Relaxed);
|
||||
self.accepted.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(queue_depth, Ordering::Relaxed);
|
||||
update_max(&self.max_queue_depth, queue_depth);
|
||||
if replaced_pending {
|
||||
self.coalesced.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.controller.record_enqueue(now_ms, replaced_pending);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_drop(&self, now_ms: u64) {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.controller.record_drop(now_ms);
|
||||
}
|
||||
|
||||
pub fn record_reject(&self) {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_capture(&self, now_ms: u64, queue_age_ms: u64, push_latency_ms: u64) {
|
||||
update_min_nonzero(&self.first_capture_ms, now_ms);
|
||||
update_max(&self.last_capture_ms, now_ms);
|
||||
self.captured.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
update_max(&self.max_queue_age_ms, queue_age_ms);
|
||||
update_max(&self.max_push_latency_ms, push_latency_ms);
|
||||
self.controller
|
||||
.record_capture(now_ms, queue_age_ms, push_latency_ms);
|
||||
}
|
||||
|
||||
pub fn record_egress_fps(&self, now_ms: u64, fps: f64) {
|
||||
self.controller.record_egress_fps(now_ms, fps);
|
||||
}
|
||||
|
||||
pub fn record_capture_failure(&self) {
|
||||
self.capture_failures.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_queue_cleared(&self) {
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn current_fps(&self) -> f64 {
|
||||
self.controller.current_fps()
|
||||
}
|
||||
|
||||
fn effective_fps(&self) -> f64 {
|
||||
let first = self.first_capture_ms.load(Ordering::Relaxed);
|
||||
let last = self.last_capture_ms.load(Ordering::Relaxed);
|
||||
let captured = self.captured.load(Ordering::Relaxed);
|
||||
if first == 0 || last <= first || captured <= 1 {
|
||||
return 0.0;
|
||||
}
|
||||
let elapsed_s = (last - first) as f64 / 1000.0;
|
||||
((captured - 1) as f64 / elapsed_s * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
pub fn snapshot(
|
||||
&self,
|
||||
audio: &AdaptiveAudioStats,
|
||||
extras: VideoTelemetryExtras,
|
||||
) -> SendHealthSnapshot {
|
||||
let (tier, reason) = self.controller.tier_and_reason();
|
||||
SendHealthSnapshot {
|
||||
outgoing_video_queue_depth: self.queue_depth.load(Ordering::Relaxed),
|
||||
outgoing_video_queue_capacity: extras.queue_capacity,
|
||||
outgoing_video_max_queue_depth: self.max_queue_depth.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_produced: self.produced.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_accepted: self.accepted.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_dropped: self.dropped.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_coalesced: self.coalesced.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_captured: self.captured.load(Ordering::Relaxed),
|
||||
outgoing_video_capture_failures: self.capture_failures.load(Ordering::Relaxed),
|
||||
outgoing_video_effective_fps: self.effective_fps(),
|
||||
outgoing_video_target_fps: (self.current_fps() * 100.0).round() / 100.0,
|
||||
outgoing_video_pacing_target_fps: (extras.pacing_target_fps * 100.0).round() / 100.0,
|
||||
outgoing_video_max_queue_age_ms: self.max_queue_age_ms.load(Ordering::Relaxed),
|
||||
outgoing_video_max_push_latency_ms: self.max_push_latency_ms.load(Ordering::Relaxed),
|
||||
outgoing_video_pacing_mode: extras.pacing_mode,
|
||||
outgoing_video_bus_active: extras.bus_active,
|
||||
outgoing_audio_buffer_target_ms: audio.target_buffer_ms(),
|
||||
outgoing_audio_buffer_max_ms: audio.max_buffer_ms(),
|
||||
outgoing_audio_underruns: audio.underruns.load(Ordering::Relaxed),
|
||||
outgoing_audio_rebuffers: audio.rebuffers.load(Ordering::Relaxed),
|
||||
outgoing_audio_max_frame_gap_ms: audio.max_frame_gap_ms.load(Ordering::Relaxed),
|
||||
adaptive_send_tier: tier,
|
||||
adaptive_send_reason: reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveAudioStats {
|
||||
max_buffer_ms: AtomicU64,
|
||||
target_buffer_ms: AtomicU64,
|
||||
underruns: AtomicU64,
|
||||
rebuffers: AtomicU64,
|
||||
max_frame_gap_ms: AtomicU64,
|
||||
last_push_ms: AtomicU64,
|
||||
stable_started_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl AdaptiveAudioStats {
|
||||
pub fn new(max_buffer_ms: u32, now_ms: u64) -> Self {
|
||||
let max_buffer_ms = clamp_audio_buffer_ms(max_buffer_ms);
|
||||
Self {
|
||||
max_buffer_ms: AtomicU64::new(max_buffer_ms as u64),
|
||||
target_buffer_ms: AtomicU64::new(
|
||||
DEFAULT_AUDIO_BUFFER_TARGET_MS.min(max_buffer_ms) as u64
|
||||
),
|
||||
underruns: AtomicU64::new(0),
|
||||
rebuffers: AtomicU64::new(0),
|
||||
max_frame_gap_ms: AtomicU64::new(0),
|
||||
last_push_ms: AtomicU64::new(0),
|
||||
stable_started_ms: AtomicU64::new(now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&self, max_buffer_ms: u32, now_ms: u64) {
|
||||
let max_buffer_ms = clamp_audio_buffer_ms(max_buffer_ms);
|
||||
self.max_buffer_ms
|
||||
.store(max_buffer_ms as u64, Ordering::Relaxed);
|
||||
self.target_buffer_ms.store(
|
||||
DEFAULT_AUDIO_BUFFER_TARGET_MS.min(max_buffer_ms) as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
self.underruns.store(0, Ordering::Relaxed);
|
||||
self.rebuffers.store(0, Ordering::Relaxed);
|
||||
self.max_frame_gap_ms.store(0, Ordering::Relaxed);
|
||||
self.last_push_ms.store(0, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_push(&self, now_ms: u64) {
|
||||
let Some(previous) = advance_monotonic(&self.last_push_ms, now_ms) else {
|
||||
return;
|
||||
};
|
||||
if previous == 0 {
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let gap = now_ms - previous;
|
||||
update_max(&self.max_frame_gap_ms, gap);
|
||||
if gap > AUDIO_REBUFFER_GAP_MS {
|
||||
self.rebuffers.fetch_add(1, Ordering::Relaxed);
|
||||
if gap > self.target_buffer_ms() as u64 {
|
||||
self.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let next = (self.target_buffer_ms() + AUDIO_BUFFER_STEP_MS).min(self.max_buffer_ms());
|
||||
self.target_buffer_ms.store(next as u64, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if gap <= AUDIO_STABLE_GAP_MS {
|
||||
let stable_started = self.stable_started_ms.load(Ordering::Relaxed);
|
||||
if now_ms.saturating_sub(stable_started) >= 30_000 {
|
||||
let current = self.target_buffer_ms();
|
||||
let next = current
|
||||
.saturating_sub(AUDIO_BUFFER_STEP_MS)
|
||||
.max(DEFAULT_AUDIO_BUFFER_TARGET_MS.min(self.max_buffer_ms()));
|
||||
self.target_buffer_ms.store(next as u64, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
} else {
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_buffer_ms(&self) -> u32 {
|
||||
self.max_buffer_ms.load(Ordering::Relaxed) as u32
|
||||
}
|
||||
|
||||
pub fn target_buffer_ms(&self) -> u32 {
|
||||
self.target_buffer_ms.load(Ordering::Relaxed) as u32
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clamp_audio_buffer_ms(value: u32) -> u32 {
|
||||
let clamped = value.clamp(DEFAULT_AUDIO_BUFFER_TARGET_MS, DEFAULT_AUDIO_BUFFER_MAX_MS);
|
||||
clamped - (clamped % 10)
|
||||
}
|
||||
|
||||
fn sanitize_fps(value: f64, fallback: f64) -> f64 {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
value
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_for_fps(requested_fps: f64, current_fps: f64) -> String {
|
||||
if current_fps >= requested_fps {
|
||||
"full".to_string()
|
||||
} else if current_fps >= 30.0 {
|
||||
"fps30".to_string()
|
||||
} else {
|
||||
"fps15".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn update_max(slot: &AtomicU64, value: u64) {
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
while value > current {
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_min_nonzero(slot: &AtomicU64, value: u64) {
|
||||
if value == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current != 0 && current <= value {
|
||||
return;
|
||||
}
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_monotonic(slot: &AtomicU64, value: u64) -> Option<u64> {
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current != 0 && value < current {
|
||||
return None;
|
||||
}
|
||||
if value == current {
|
||||
return Some(current);
|
||||
}
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return Some(current),
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_pure_coalescing_jitter() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(1_000, true);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "full");
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_sustained_coalescing_jitter_across_windows() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(1_000, true);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(6_000, true);
|
||||
}
|
||||
controller.record_enqueue(10_002, false);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_encoder_drop_pressure() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..100 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
for _ in 0..20 {
|
||||
controller.record_drop(1_000);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderDropPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_continues_degrading_under_sustained_latency_pressure() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
controller.record_capture(6_000, 80, 1);
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "sendLatencyPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_latency_pressure_without_coalescing() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 5);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "sendLatencyPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_encoder_egress_pressure() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
controller.record_egress_fps(2_000, 8.0);
|
||||
controller.record_egress_fps(4_000, 7.0);
|
||||
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_preserves_pressure_reason_while_degraded_but_stable() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
controller.record_egress_fps(2_000, 8.0);
|
||||
controller.record_egress_fps(4_000, 7.0);
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
|
||||
for index in 2..=12 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(
|
||||
controller.tier_and_reason(),
|
||||
("full".to_string(), "stable".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_single_encoder_egress_sample() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_keeps_requested_fps_when_adaptive_send_is_disabled() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, false, 0);
|
||||
for _ in 0..100 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_capture(5_001, 200, 200);
|
||||
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(
|
||||
controller.tier_and_reason(),
|
||||
("full".to_string(), "adaptiveDisabled".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_recovers_after_stable_windows() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 1..=12 {
|
||||
controller.record_capture(5_001 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_recovers_from_minimum_in_two_stable_steps() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
controller.record_capture(6_000, 70, 1);
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
|
||||
for index in 1..=12 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 13..=24 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_requires_a_full_stable_recovery_window() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 1..12 {
|
||||
controller.record_capture(5_001 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
controller.record_capture(5_001 + 12 * 5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_snapshot_counts_coalescing_drops_failures_and_effective_fps() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = AdaptiveVideoStats::new(60.0, 15.0, true, 0);
|
||||
|
||||
stats.record_enqueue(1_000, false);
|
||||
stats.record_enqueue(1_010, true);
|
||||
stats.record_drop(1_011);
|
||||
stats.record_capture(1_020, 20, 24);
|
||||
stats.record_enqueue(2_000, false);
|
||||
stats.record_capture(2_020, 20, 26);
|
||||
stats.record_capture_failure();
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 3);
|
||||
assert_eq!(snapshot.outgoing_video_frames_accepted, 3);
|
||||
assert_eq!(snapshot.outgoing_video_frames_dropped, 1);
|
||||
assert_eq!(snapshot.outgoing_video_frames_coalesced, 1);
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 2);
|
||||
assert_eq!(snapshot.outgoing_video_capture_failures, 1);
|
||||
assert_eq!(snapshot.outgoing_video_effective_fps, 1.0);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 20);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 26);
|
||||
assert_eq!(snapshot.outgoing_video_queue_depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_effective_fps_uses_capture_time_bounds_for_out_of_order_records() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = AdaptiveVideoStats::new(60.0, 15.0, true, 0);
|
||||
|
||||
stats.record_capture(2_000, 5, 6);
|
||||
stats.record_capture(1_000, 7, 8);
|
||||
stats.record_capture(3_000, 9, 10);
|
||||
stats.record_capture(2_500, 11, 12);
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 4);
|
||||
assert_eq!(snapshot.outgoing_video_effective_fps, 1.5);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 11);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_handles_concurrent_recording_without_lost_counts() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = Arc::new(AdaptiveVideoStats::new(60.0, 15.0, true, 0));
|
||||
let mut workers = Vec::new();
|
||||
for worker in 0..8 {
|
||||
let stats = stats.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for index in 0..250 {
|
||||
let now_ms = 1_000 + worker * 1_000 + index;
|
||||
stats.record_enqueue(now_ms, index % 3 == 0);
|
||||
if index % 5 == 0 {
|
||||
stats.record_drop(now_ms);
|
||||
}
|
||||
if index % 7 == 0 {
|
||||
stats.record_capture(now_ms + 1, 1, 2);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("worker should not panic");
|
||||
}
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 2_000);
|
||||
assert_eq!(snapshot.outgoing_video_frames_accepted, 2_000);
|
||||
assert_eq!(snapshot.outgoing_video_frames_coalesced, 672);
|
||||
assert_eq!(snapshot.outgoing_video_frames_dropped, 400);
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 288);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 1);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_snapshot_reflects_audio_pressure_without_video_state() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
audio.record_push(1_000);
|
||||
audio.record_push(1_300);
|
||||
|
||||
let snapshot = SendHealthSnapshot::idle(&audio);
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 0);
|
||||
assert_eq!(snapshot.outgoing_audio_buffer_target_ms, 300);
|
||||
assert_eq!(snapshot.outgoing_audio_buffer_max_ms, 750);
|
||||
assert_eq!(snapshot.outgoing_audio_rebuffers, 1);
|
||||
assert_eq!(snapshot.outgoing_audio_underruns, 1);
|
||||
assert_eq!(snapshot.outgoing_audio_max_frame_gap_ms, 300);
|
||||
assert_eq!(snapshot.adaptive_send_tier, "idle");
|
||||
assert_eq!(snapshot.adaptive_send_reason, "notPublishing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_stress_keeps_target_inside_configured_bounds() {
|
||||
let controller = AdaptiveVideoController::new(144.0, 24.0, true, 0);
|
||||
for window in 0..240 {
|
||||
let base = window * 5_001;
|
||||
match window % 4 {
|
||||
0 => controller.record_capture(base + 1_000, 200, 1),
|
||||
1 => {
|
||||
for _ in 0..30 {
|
||||
controller.record_enqueue(base + 1_000, true);
|
||||
}
|
||||
controller.record_capture(base + 1_500, 1, 1);
|
||||
}
|
||||
2 => {
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(base + 1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(base + 1_500, 30.0);
|
||||
controller.record_egress_fps(base + 2_500, 30.0);
|
||||
}
|
||||
_ => controller.record_capture(base + 1_000, 1, 1),
|
||||
}
|
||||
controller.record_capture(base + 5_001, 1, 1);
|
||||
let fps = controller.current_fps();
|
||||
assert!(
|
||||
(24.0..=144.0).contains(&fps),
|
||||
"fps target escaped configured bounds: {fps}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_expands_on_gaps_and_shrinks_after_stability() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
audio.record_push(10);
|
||||
audio.record_push(200);
|
||||
assert_eq!(audio.target_buffer_ms(), 300);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 1);
|
||||
|
||||
for now_ms in (220..=30_240).step_by(20) {
|
||||
audio.record_push(now_ms);
|
||||
}
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_ignores_clock_regression_without_false_rebuffer() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
|
||||
audio.record_push(1_000);
|
||||
audio.record_push(1_020);
|
||||
audio.record_push(900);
|
||||
audio.record_push(1_040);
|
||||
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_ignores_concurrent_stale_pushes_without_moving_last_push_backwards() {
|
||||
let audio = Arc::new(AdaptiveAudioStats::new(750, 0));
|
||||
audio.record_push(10_000);
|
||||
|
||||
let mut workers = Vec::new();
|
||||
for worker in 0..8 {
|
||||
let audio = audio.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for index in 0..100 {
|
||||
audio.record_push(1_000 + worker * 100 + index);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("worker should not panic");
|
||||
}
|
||||
|
||||
audio.record_push(10_020);
|
||||
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_growth_caps_at_configured_max_and_reset_clears_pressure() {
|
||||
let audio = AdaptiveAudioStats::new(350, 0);
|
||||
audio.record_push(10);
|
||||
for index in 1..=10 {
|
||||
audio.record_push(10 + index * 500);
|
||||
}
|
||||
assert_eq!(audio.target_buffer_ms(), 350);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 10);
|
||||
|
||||
audio.reset(250, 10_000);
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.max_buffer_ms(), 250);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_stress_stays_within_realtime_bounds_under_jitter() {
|
||||
let audio = AdaptiveAudioStats::new(620, 0);
|
||||
let mut now_ms = 10;
|
||||
audio.record_push(now_ms);
|
||||
|
||||
for index in 0..5_000 {
|
||||
now_ms += match index % 11 {
|
||||
0 => 180,
|
||||
1 | 2 => 80,
|
||||
_ => 20,
|
||||
};
|
||||
audio.record_push(now_ms);
|
||||
assert!(
|
||||
(DEFAULT_AUDIO_BUFFER_TARGET_MS..=620).contains(&audio.target_buffer_ms()),
|
||||
"audio target escaped configured bounds"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(audio.max_buffer_ms(), 620);
|
||||
assert!(audio.rebuffers.load(Ordering::Relaxed) > 0);
|
||||
assert!(audio.max_frame_gap_ms.load(Ordering::Relaxed) >= 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_max_is_clamped_to_real_time_bounds() {
|
||||
assert_eq!(clamp_audio_buffer_ms(50), 200);
|
||||
assert_eq!(clamp_audio_buffer_ms(777), 750);
|
||||
assert_eq!(clamp_audio_buffer_ms(333), 330);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user