cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Merge pull request #90 from FelineStateMachine/feat/server-module-tree

FelineStateMachine committed 78cce98
refactor(server): split host server into domain modules
commit
78cce98bb09542f3a8758a8e2c9e779d4029a5b7
parent
1f447d46610428431323b13a7efd769854788807
parent
dc79b65b7c894d8232d7fab7df7e4cc5e718b10c
author
FelineStateMachine <dami.etoile@gmail.com>
9 files changed, +7906 -7785
Dcrates/sigil-host/src/server.rs7785------------------------------
Acrates/sigil-host/src/server/adaptive.rs2309+++++++++
Acrates/sigil-host/src/server/handlers.rs813+++
Acrates/sigil-host/src/server/media_v2.rs821+++
Acrates/sigil-host/src/server/media_v3.rs1356+++++
Acrates/sigil-host/src/server/mod.rs460++
Acrates/sigil-host/src/server/moq.rs727+++
Acrates/sigil-host/src/server/session.rs1099++++
Acrates/sigil-host/src/server/startup.rs321+
D crates/sigil-host/src/server.rs +0 -7785
@@ -1,7785 +0,0 @@
1-use std::future::Future;2-use std::pin::Pin;3-use std::sync::atomic::{AtomicU64, Ordering};4-use std::sync::{Arc, Mutex};5-use std::time::{Duration, Instant};6-7-use anyhow::{Context, Result, bail, ensure};8-use iroh::EndpointId;9-use iroh::endpoint::{Connection, SendStream};10-use iroh::protocol::ProtocolHandler;11-use moq_net::{12- Broadcast, BroadcastConsumer, BroadcastProducer, Error as MoqError, GroupProducer, Origin,13- Track, TrackProducer,14-};15-use sigil_protocol::{16- AUDIO_HEADER_LEN, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1,17- AdaptiveBitrateStateV1, AudioFlags, AudioPacket, AudioPacketHeader, Capability, ClientHello,18- FrameFlags, HostHello, InputAck, InvitationGrants, KeyframeRequestReasonV3,19- MAX_AUDIO_PAYLOAD_LEN, MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS,20- MAX_MEDIA_OBJECT_ID_V3, MIN_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MOQ_VIDEO_H264_TRACK,21- MOQ_VIDEO_TRACK_PRIORITY, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1,22- MediaFrame, MediaFrameHeader, MediaObjectHeaderV3, MediaObjectV3, encode_media_frame_object,23- media_moq_broadcast_name, read_client_hello, read_input_event, read_media_control_request_v3,24- read_media_feedback_report_v1, write_adaptive_bitrate_decision_v1, write_host_hello,25- write_input_ack, write_media_frame, write_media_object_v3,26-};27-use tracing::{debug, error, info, warn};28-29-use crate::audio::spawn_pipewire_audio;30-use crate::authorization::{AuthorizationPolicy, unix_timestamp_now};31-use crate::clock::SessionClock;32-use crate::config::{GamescopeEncoderBackend, HostConfig, VaapiRateControl, VideoSource};33-use crate::cursor::{PointerPositionTracker, PointerState};34-use crate::input::{InputBackend, InputDisposition};35-use crate::moq_catalog::publish_goq_catalog;36-use crate::source::{37- EncodedFrame, EncodedGop, EncodedSource, EncoderControl,38- spawn_gamescope_pipewire_after_static_preflight, spawn_test_pattern,39-};40-41-const MEDIA_CAPABILITIES: &[Capability] = &[Capability::VideoH264];42-const AUDIO_CAPABILITIES: &[Capability] = &[Capability::AudioOpus];43-const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);44-const MEDIA_WRITE_TIMEOUT: Duration = Duration::from_secs(5);45-const MEDIA_V2_PEER_ACK_TIMEOUT: Duration = Duration::from_secs(2);46-const MEDIA_V2_IN_FLIGHT_CAPACITY: usize = 4;47-const MEDIA_V2_KEYFRAME_PRIORITY: i32 = 10;48-const MEDIA_V2_DELTA_PRIORITY: i32 = 0;49-const MEDIA_V2_RESET_CODE: u32 = 0x5356;50-const MEDIA_V3_IN_FLIGHT_CAPACITY: usize = 4;51-const MEDIA_V3_KEYFRAME_PUBLISHER_PRIORITY: u8 = 0;52-const MEDIA_V3_DELTA_PUBLISHER_PRIORITY: u8 = 128;53-const MEDIA_V3_KEYFRAME_TRANSPORT_PRIORITY: i32 = 10;54-const MEDIA_V3_DELTA_TRANSPORT_PRIORITY: i32 = 0;55-// Wire-visible application reset code. Keep stable across compatible v3 releases.56-const MEDIA_V3_RESET_CODE: u32 = 0x5357;57-const MEDIA_V3_DELIVERY_FRAME_PERIODS: u64 = 4;58-const MEDIA_V3_MAX_CONTROL_REQUESTS_PER_SECOND: u32 = 10;59-const MEDIA_V3_CONTROL_REQUEST_INTERVAL: Duration =60- Duration::from_millis(1_000 / MEDIA_V3_MAX_CONTROL_REQUESTS_PER_SECOND as u64);61-const INPUT_ACK_TIMEOUT: Duration = Duration::from_secs(1);62-const REJECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);63-const MAX_PENDING_HANDSHAKES: usize = 4;64-// Allow one frame of ordinary scheduler/write jitter beyond the frame being65-// sent, but never replay a suffix already more than two configured periods old.66-const MAX_MEDIA_REPLAY_AGE_FRAME_PERIODS: u64 = 2;67-const GAMESCOPE_STARTUP_SAMPLE_WINDOW: Duration = Duration::from_millis(500);68-const GAMESCOPE_STARTUP_MIN_OBSERVATION_SPAN: Duration = Duration::from_millis(100);69-const GAMESCOPE_STARTUP_TARGET_MIN_FRAMES: u64 = 8;70-const GAMESCOPE_STARTUP_TARGET_MIN_FPS: f64 = 45.0;71-const SOURCE_REAP_GRACE_TIMEOUT: Duration = Duration::from_secs(1);72-const MOQ_ATTACHMENT_TIMEOUT: Duration = Duration::from_secs(10);73-const MOQ_REJECT_CODE: u32 = 0x534d;74-const ADAPTIVE_BITRATE_FLOOR_KBPS: u32 = 1_000;75-const SHADOW_BITRATE_CEILING_KBPS: u32 = 12_000;76-const ADAPTIVE_BITRATE_QUANTUM_KBPS: u32 = 250;77-const ADAPTIVE_BITRATE_CLEAN_WINDOWS: u8 = 10;78-const ADAPTIVE_BITRATE_MODERATE_WINDOWS: u8 = 2;79-const ADAPTIVE_BITRATE_COOLDOWN_WINDOWS: u8 = 10;80-const MOTION_RESOLUTION_MODERATE_WINDOWS: u8 = 2;81-const MOTION_RESOLUTION_CLEAN_WINDOWS: u8 = 3;82-const MOTION_RESOLUTION_ACTIVITY_FPS: u64 = 10;83-const MOTION_RESOLUTION_STILL_SETTLE: Duration = Duration::from_secs(2);84-const MOTION_RESOLUTION_UPSCALE_COOLDOWN: Duration = Duration::from_secs(3);85-const FEEDBACK_FRESH_INTERVAL_MIN_MS: u16 = 750;86-const FEEDBACK_FRESH_INTERVAL_MAX_MS: u16 = 1_500;87-const FEEDBACK_FRESH_ARRIVAL_GAP_MIN: Duration = Duration::from_millis(750);88-const FEEDBACK_STALE_ARRIVAL_GAP: Duration = Duration::from_millis(2_500);89-const FEEDBACK_MIN_READ_INTERVAL: Duration = Duration::from_millis(250);90-const FEEDBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(1);91-const ENCODER_CONTROL_COMMIT_TIMEOUT: Duration = Duration::from_secs(2);92-// Mirrors the fixed v1 wire bound. Aggregates clamp here so a stalled93-// consumer observes a stale 5-second window instead of a deceptively fresh94-// latest report.95-const MEDIA_FEEDBACK_INTERVAL_MAX_MS: u64 = 5_000;96-97-#[derive(Clone, Copy, Debug, PartialEq, Eq)]98-enum FeedbackSeverity {99- Clean,100- InsufficientEvidence,101- Moderate,102- Severe,103- Stale,104-}105-106-#[derive(Clone, Copy, Debug, PartialEq, Eq)]107-pub(crate) struct VideoDimensions {108- pub width: u16,109- pub height: u16,110-}111-112-impl VideoDimensions {113- fn three_quarter(self) -> Result<Self> {114- ensure!(115- self.width.is_multiple_of(4) && self.height.is_multiple_of(4),116- "native dimensions must be divisible by four for an exact three-quarter tier"117- );118- let reduced = Self {119- width: self.width / 4 * 3,120- height: self.height / 4 * 3,121- };122- ensure!(123- reduced.width >= 64 && reduced.height >= 64,124- "three-quarter dimensions are below the H.264 minimum"125- );126- ensure!(127- reduced.width.is_multiple_of(2) && reduced.height.is_multiple_of(2),128- "three-quarter H.264 dimensions must be even"129- );130- ensure!(131- u32::from(self.width) * u32::from(reduced.height)132- == u32::from(self.height) * u32::from(reduced.width),133- "three-quarter dimensions must preserve the native aspect ratio"134- );135- Ok(reduced)136- }137-}138-139-#[derive(Clone, Copy, Debug, PartialEq, Eq)]140-enum ResolutionTier {141- Native,142- Reduced,143-}144-145-#[derive(Clone, Copy, Debug, PartialEq, Eq)]146-enum ResolutionTrigger {147- Hold,148- Motion,149- Pressure,150- Recovery,151-}152-153-#[derive(Clone, Copy, Debug, PartialEq, Eq)]154-struct MotionResolutionDecision {155- tier: ResolutionTier,156- target: VideoDimensions,157- trigger: ResolutionTrigger,158- changed: bool,159-}160-161-/// Pure two-tier resolution policy. Fresh sequence progress from Gamescope's162-/// damage-driven stream is the bounded motion proxy while the authenticated163-/// feedback path supplies pressure. A downshift is safety-biased and164-/// immediate; an upscale requires both settled motion and fresh clean165-/// feedback, so stale reports can never restore detail.166-#[derive(Clone, Debug)]167-pub(crate) struct MotionResolutionPolicy {168- native: VideoDimensions,169- reduced: VideoDimensions,170- tier: ResolutionTier,171- motion_active: bool,172- still_since: Option<Instant>,173- pressure_active: bool,174- moderate_windows: u8,175- clean_windows: u8,176- last_transition_at: Option<Instant>,177- last_motion_sequence: Option<u64>,178-}179-180-impl MotionResolutionPolicy {181- pub(crate) fn new(native: VideoDimensions) -> Result<Self> {182- ensure!(183- native.width >= 64 && native.height >= 64,184- "native dimensions are too small"185- );186- ensure!(187- native.width.is_multiple_of(2) && native.height.is_multiple_of(2),188- "native H.264 dimensions must be even"189- );190- Ok(Self {191- native,192- reduced: native.three_quarter()?,193- tier: ResolutionTier::Native,194- motion_active: false,195- still_since: None,196- pressure_active: false,197- moderate_windows: 0,198- clean_windows: 0,199- last_transition_at: None,200- last_motion_sequence: None,201- })202- }203-204- pub(crate) fn target(&self) -> VideoDimensions {205- match self.tier {206- ResolutionTier::Native => self.native,207- ResolutionTier::Reduced => self.reduced,208- }209- }210-211- /// Host motion hook. Repeated still observations preserve the original212- /// settle boundary instead of indefinitely postponing recovery.213- #[cfg(test)]214- fn observe_motion(215- &mut self,216- motion_active: bool,217- observed_at: Instant,218- ) -> MotionResolutionDecision {219- self.update_motion(motion_active, observed_at);220- self.evaluate_target(observed_at)221- }222-223- fn update_motion(&mut self, motion_active: bool, observed_at: Instant) {224- if motion_active {225- self.motion_active = true;226- self.still_since = None;227- return;228- }229- if self.motion_active || self.still_since.is_none() {230- self.still_since = Some(observed_at);231- }232- self.motion_active = false;233- }234-235- /// Observe one classified feedback window. Only fresh moderate/severe236- /// pressure can initiate a downshift, and only fresh clean windows count237- /// toward restoring native detail.238- #[cfg(test)]239- fn observe_feedback(240- &mut self,241- severity: FeedbackSeverity,242- fresh: bool,243- observed_at: Instant,244- ) -> MotionResolutionDecision {245- self.update_feedback(severity, fresh);246- self.evaluate_target(observed_at)247- }248-249- /// Gamescope output is damage-driven, so fresh media sequence progress is250- /// a conservative constant-space motion proxy for this first slice. A251- /// future raw-frame observer can call `observe_motion` without changing252- /// pressure hysteresis or encoder actuation.253- fn observe_window(254- &mut self,255- report: &MediaFeedbackReportV1,256- evaluation: &AdaptiveBitrateEvaluation,257- observed_at: Instant,258- ) -> MotionResolutionDecision {259- let previous_sequence = self.last_motion_sequence;260- if let Some(sequence) = report.last_sequence {261- self.last_motion_sequence = Some(262- self.last_motion_sequence263- .map_or(sequence, |last| last.max(sequence)),264- );265- }266- if evaluation.fresh {267- let motion_active = report.last_sequence.is_some_and(|sequence| {268- let delta = previous_sequence.map_or(0, |last| sequence.saturating_sub(last));269- delta.saturating_mul(1_000)270- >= MOTION_RESOLUTION_ACTIVITY_FPS.saturating_mul(u64::from(report.interval_ms))271- });272- self.update_motion(motion_active, observed_at);273- }274- let resolution_severity = if evaluation.pressure_severity == FeedbackSeverity::Clean275- && !evaluation.clean_recovery_evidence276- {277- FeedbackSeverity::InsufficientEvidence278- } else {279- evaluation.pressure_severity280- };281- self.update_feedback(resolution_severity, evaluation.fresh);282- self.evaluate_target(observed_at)283- }284-285- fn update_feedback(&mut self, severity: FeedbackSeverity, fresh: bool) {286- if !fresh287- || matches!(288- severity,289- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale290- )291- {292- self.moderate_windows = 0;293- self.clean_windows = 0;294- return;295- }296-297- match severity {298- FeedbackSeverity::Severe => {299- self.pressure_active = true;300- self.moderate_windows = 0;301- self.clean_windows = 0;302- }303- FeedbackSeverity::Moderate => {304- self.clean_windows = 0;305- self.moderate_windows = self.moderate_windows.saturating_add(1);306- if self.moderate_windows >= MOTION_RESOLUTION_MODERATE_WINDOWS {307- self.moderate_windows = 0;308- self.pressure_active = true;309- }310- }311- FeedbackSeverity::Clean => {312- self.moderate_windows = 0;313- self.clean_windows = self.clean_windows.saturating_add(1);314- if self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS {315- self.pressure_active = false;316- }317- }318- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {}319- }320- }321-322- fn evaluate_target(&mut self, observed_at: Instant) -> MotionResolutionDecision {323- if self.motion_active {324- self.downshift(observed_at, ResolutionTrigger::Motion)325- } else if self.pressure_active {326- self.downshift(observed_at, ResolutionTrigger::Pressure)327- } else {328- self.maybe_restore(observed_at)329- }330- }331-332- fn commit_observation_from(&mut self, candidate: &Self) {333- self.motion_active = candidate.motion_active;334- self.still_since = candidate.still_since;335- self.pressure_active = candidate.pressure_active;336- self.moderate_windows = candidate.moderate_windows;337- self.clean_windows = candidate.clean_windows;338- self.last_motion_sequence = candidate.last_motion_sequence;339- }340-341- fn downshift(342- &mut self,343- observed_at: Instant,344- trigger: ResolutionTrigger,345- ) -> MotionResolutionDecision {346- let changed = self.tier != ResolutionTier::Reduced;347- if changed {348- self.tier = ResolutionTier::Reduced;349- self.last_transition_at = Some(observed_at);350- }351- MotionResolutionDecision {352- tier: self.tier,353- target: self.target(),354- trigger,355- changed,356- }357- }358-359- fn maybe_restore(&mut self, observed_at: Instant) -> MotionResolutionDecision {360- let still_settled = !self.motion_active361- && self.still_since.is_some_and(|still_since| {362- observed_at.saturating_duration_since(still_since) >= MOTION_RESOLUTION_STILL_SETTLE363- });364- let cooldown_complete = self.last_transition_at.is_none_or(|last| {365- observed_at.saturating_duration_since(last) >= MOTION_RESOLUTION_UPSCALE_COOLDOWN366- });367- let clean = self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS;368- if self.tier == ResolutionTier::Reduced369- && still_settled370- && cooldown_complete371- && clean372- && !self.pressure_active373- {374- self.tier = ResolutionTier::Native;375- self.clean_windows = 0;376- self.last_transition_at = Some(observed_at);377- return MotionResolutionDecision {378- tier: self.tier,379- target: self.target(),380- trigger: ResolutionTrigger::Recovery,381- changed: true,382- };383- }384- self.hold()385- }386-387- fn hold(&self) -> MotionResolutionDecision {388- MotionResolutionDecision {389- tier: self.tier,390- target: self.target(),391- trigger: ResolutionTrigger::Hold,392- changed: false,393- }394- }395-}396-397-fn motion_resolution_policy(width: u32, height: u32) -> Result<Option<MotionResolutionPolicy>> {398- let native = VideoDimensions {399- width: u16::try_from(width).context("native width exceeds protocol")?,400- height: u16::try_from(height).context("native height exceeds protocol")?,401- };402- match MotionResolutionPolicy::new(native) {403- Ok(policy) => Ok(Some(policy)),404- Err(error) => {405- warn!(406- %error,407- width,408- height,409- "motion resolution disabled because this native mode has no exact reduced tier"410- );411- Ok(None)412- }413- }414-}415-416-fn motion_resolution_policy_for_encoder(417- control: &EncoderControl,418-) -> Result<Option<MotionResolutionPolicy>> {419- let (width, height) = control.initial_dimensions();420- motion_resolution_policy(u32::from(width), u32::from(height))421-}422-423-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]424-struct FeedbackIngressCursor {425- last_report_id: Option<u64>,426- interval_ms: u64,427- transport_dropped: u64,428- frontend_dropped: u64,429- decoder_dropped: u64,430- presenter_dropped: u64,431- resync_reports: u64,432-}433-434-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]435-struct CumulativeFeedbackIngress {436- latest: Option<MediaFeedbackReportV1>,437- interval_ms: u64,438- transport_dropped: u64,439- frontend_dropped: u64,440- decoder_dropped: u64,441- presenter_dropped: u64,442- resync_reports: u64,443-}444-445-impl CumulativeFeedbackIngress {446- fn observe(&mut self, report: MediaFeedbackReportV1) {447- self.interval_ms = self.interval_ms.wrapping_add(u64::from(report.interval_ms));448- self.transport_dropped = self449- .transport_dropped450- .wrapping_add(u64::from(report.transport_dropped_delta));451- self.frontend_dropped = self452- .frontend_dropped453- .wrapping_add(u64::from(report.frontend_dropped_delta));454- self.decoder_dropped = self455- .decoder_dropped456- .wrapping_add(u64::from(report.decoder_dropped_delta));457- self.presenter_dropped = self458- .presenter_dropped459- .wrapping_add(u64::from(report.presenter_dropped_delta));460- if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE) {461- self.resync_reports = self.resync_reports.wrapping_add(1);462- }463- self.latest = Some(report);464- }465-466- fn report_since(467- &self,468- cursor: FeedbackIngressCursor,469- ) -> Result<Option<(MediaFeedbackReportV1, FeedbackIngressCursor)>> {470- let Some(latest) = self.latest else {471- return Ok(None);472- };473- if cursor474- .last_report_id475- .is_some_and(|report_id| latest.report_id <= report_id)476- {477- return Ok(None);478- }479- let mut report = latest;480- report.interval_ms = u16::try_from(481- self.interval_ms482- .wrapping_sub(cursor.interval_ms)483- .min(MEDIA_FEEDBACK_INTERVAL_MAX_MS),484- )485- .expect("feedback interval is clamped to u16");486- report.transport_dropped_delta =487- cumulative_feedback_delta(self.transport_dropped, cursor.transport_dropped);488- report.frontend_dropped_delta =489- cumulative_feedback_delta(self.frontend_dropped, cursor.frontend_dropped);490- report.decoder_dropped_delta =491- cumulative_feedback_delta(self.decoder_dropped, cursor.decoder_dropped);492- report.presenter_dropped_delta =493- cumulative_feedback_delta(self.presenter_dropped, cursor.presenter_dropped);494- report.flags = if self.resync_reports.wrapping_sub(cursor.resync_reports) > 0 {495- MediaFeedbackFlags::RESYNC_ACTIVE496- } else {497- MediaFeedbackFlags::NONE498- };499- report.validate()?;500- Ok(Some((501- report,502- FeedbackIngressCursor {503- last_report_id: Some(latest.report_id),504- interval_ms: self.interval_ms,505- transport_dropped: self.transport_dropped,506- frontend_dropped: self.frontend_dropped,507- decoder_dropped: self.decoder_dropped,508- presenter_dropped: self.presenter_dropped,509- resync_reports: self.resync_reports,510- },511- )))512- }513-}514-515-fn cumulative_feedback_delta(total: u64, baseline: u64) -> u32 {516- u32::try_from(total.wrapping_sub(baseline).min(u64::from(u32::MAX)))517- .expect("feedback delta is clamped to u32")518-}519-520-#[derive(Clone, Debug)]521-struct ShadowBitrateController {522- floor_kbps: u32,523- ceiling_kbps: u32,524- target_kbps: u32,525- next_decision_id: u64,526- last_report_id: Option<u64>,527- last_report_received_at: Option<Instant>,528- previous_telemetry: MediaV3TelemetrySnapshot,529- baseline_rtt_micros: Option<u64>,530- last_sequence: Option<u64>,531- moderate_windows: u8,532- clean_windows: u8,533- cooldown_windows: u8,534-}535-536-#[derive(Clone, Copy, Debug)]537-struct AdaptiveBitrateEvaluation {538- decision: AdaptiveBitrateDecisionV1,539- pressure_severity: FeedbackSeverity,540- clean_recovery_evidence: bool,541- fresh: bool,542-}543-544-impl ShadowBitrateController {545- fn new(configured_ceiling_kbps: u32, telemetry: MediaV3TelemetrySnapshot) -> Self {546- let ceiling_kbps = quantize_down(configured_ceiling_kbps).max(ADAPTIVE_BITRATE_FLOOR_KBPS);547- Self {548- floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,549- ceiling_kbps,550- target_kbps: ceiling_kbps,551- next_decision_id: 1,552- last_report_id: None,553- last_report_received_at: None,554- previous_telemetry: telemetry,555- baseline_rtt_micros: None,556- last_sequence: None,557- moderate_windows: 0,558- clean_windows: 0,559- cooldown_windows: 0,560- }561- }562-563- fn evaluate(564- &mut self,565- report: &MediaFeedbackReportV1,566- telemetry: MediaV3TelemetrySnapshot,567- received_at: Instant,568- ) -> Result<AdaptiveBitrateEvaluation> {569- ensure!(570- self.last_report_id571- .is_none_or(|last| report.report_id > last),572- "feedback report IDs must increase monotonically"573- );574- let (severity, pressure_severity, reasons, stale, trusted_host_pressure) =575- self.classify(report, telemetry, received_at);576- let mut state = AdaptiveBitrateStateV1::Hold;577-578- if self.cooldown_windows > 0 {579- self.cooldown_windows -= 1;580- }581- match severity {582- FeedbackSeverity::Severe => {583- self.moderate_windows = 0;584- self.clean_windows = 0;585- self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;586- let next =587- quantize_down(self.target_kbps.saturating_mul(3) / 4).max(self.floor_kbps);588- if next < self.target_kbps {589- self.target_kbps = next;590- state = AdaptiveBitrateStateV1::Decrease;591- }592- }593- FeedbackSeverity::Moderate => {594- self.moderate_windows = self.moderate_windows.saturating_add(1);595- self.clean_windows = 0;596- if self.moderate_windows >= ADAPTIVE_BITRATE_MODERATE_WINDOWS {597- self.moderate_windows = 0;598- self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;599- let next =600- quantize_down(self.target_kbps.saturating_mul(4) / 5).max(self.floor_kbps);601- if next < self.target_kbps {602- self.target_kbps = next;603- state = AdaptiveBitrateStateV1::Decrease;604- }605- }606- }607- FeedbackSeverity::Clean => {608- self.moderate_windows = 0;609- self.clean_windows = self.clean_windows.saturating_add(1);610- if self.clean_windows >= ADAPTIVE_BITRATE_CLEAN_WINDOWS611- && self.cooldown_windows == 0612- {613- self.clean_windows = 0;614- let maximum_step =615- (self.target_kbps / 20).clamp(ADAPTIVE_BITRATE_QUANTUM_KBPS, 500);616- let next = quantize_down(617- self.target_kbps618- .saturating_add(maximum_step)619- .min(self.ceiling_kbps),620- );621- if next > self.target_kbps {622- self.target_kbps = next;623- state = AdaptiveBitrateStateV1::Increase;624- }625- }626- }627- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {628- self.moderate_windows = 0;629- self.clean_windows = 0;630- }631- }632-633- self.target_kbps = self.target_kbps.clamp(self.floor_kbps, self.ceiling_kbps);634- let decision = AdaptiveBitrateDecisionV1 {635- decision_id: self.next_decision_id,636- report_id: report.report_id,637- target_kbps: self.target_kbps,638- floor_kbps: self.floor_kbps,639- ceiling_kbps: self.ceiling_kbps,640- state,641- reasons,642- applied: false,643- };644- decision.validate()?;645- self.next_decision_id = self646- .next_decision_id647- .checked_add(1)648- .context("adaptive bitrate decision ID exhausted")?;649- self.last_report_id = Some(report.report_id);650- self.last_report_received_at = Some(received_at);651- if !stale652- || !trusted_host_pressure653- || state == AdaptiveBitrateStateV1::Decrease654- || self.target_kbps == self.floor_kbps655- {656- self.previous_telemetry = telemetry;657- }658- if !stale && let Some(sequence) = report.last_sequence {659- self.last_sequence = Some(660- self.last_sequence661- .map_or(sequence, |last| last.max(sequence)),662- );663- }664- Ok(AdaptiveBitrateEvaluation {665- decision,666- pressure_severity,667- clean_recovery_evidence: severity == FeedbackSeverity::Clean,668- fresh: !stale,669- })670- }671-672- #[cfg(test)]673- fn decide(674- &mut self,675- report: &MediaFeedbackReportV1,676- telemetry: MediaV3TelemetrySnapshot,677- received_at: Instant,678- ) -> Result<AdaptiveBitrateDecisionV1> {679- self.evaluate(report, telemetry, received_at)680- .map(|evaluation| evaluation.decision)681- }682-683- fn classify(684- &mut self,685- report: &MediaFeedbackReportV1,686- telemetry: MediaV3TelemetrySnapshot,687- received_at: Instant,688- ) -> (689- FeedbackSeverity,690- FeedbackSeverity,691- AdaptiveBitrateReasonFlagsV1,692- bool,693- bool,694- ) {695- let stale = !(FEEDBACK_FRESH_INTERVAL_MIN_MS..=FEEDBACK_FRESH_INTERVAL_MAX_MS)696- .contains(&report.interval_ms)697- || self.last_report_received_at.is_none()698- || self.last_report_received_at.is_some_and(|last| {699- let gap = received_at.saturating_duration_since(last);700- !(FEEDBACK_FRESH_ARRIVAL_GAP_MIN..=FEEDBACK_STALE_ARRIVAL_GAP).contains(&gap)701- });702- let mut severity = FeedbackSeverity::Clean;703- let mut reasons = AdaptiveBitrateReasonFlagsV1::NONE;704-705- let lost_delta = telemetry706- .selected_path_lost_packets707- .saturating_sub(self.previous_telemetry.selected_path_lost_packets);708- let congestion_delta = telemetry709- .selected_path_congestion_events710- .saturating_sub(self.previous_telemetry.selected_path_congestion_events);711- let cancellation_delta = telemetry712- .scheduler_cancellations713- .saturating_sub(self.previous_telemetry.scheduler_cancellations);714- let failure_delta = telemetry715- .send_failures716- .saturating_sub(self.previous_telemetry.send_failures);717-718- if failure_delta > 0 || lost_delta > 0 || cancellation_delta >= 2 {719- add_feedback_signal(720- &mut severity,721- &mut reasons,722- FeedbackSeverity::Severe,723- AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,724- );725- } else if cancellation_delta > 0 || congestion_delta > 0 {726- add_feedback_signal(727- &mut severity,728- &mut reasons,729- FeedbackSeverity::Moderate,730- AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,731- );732- }733- if cancellation_delta >= 2 || failure_delta > 0 {734- add_feedback_signal(735- &mut severity,736- &mut reasons,737- FeedbackSeverity::Severe,738- AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,739- );740- } else if cancellation_delta > 0 {741- add_feedback_signal(742- &mut severity,743- &mut reasons,744- FeedbackSeverity::Moderate,745- AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,746- );747- }748-749- let rtt = telemetry.selected_path_rtt_micros;750- if rtt > 0 {751- let baseline = self.baseline_rtt_micros.get_or_insert(rtt);752- if rtt < *baseline {753- *baseline = rtt;754- } else if rtt >= baseline.saturating_mul(2).max(80_000) {755- add_feedback_signal(756- &mut severity,757- &mut reasons,758- FeedbackSeverity::Severe,759- AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,760- );761- } else if rtt >= baseline.saturating_mul(3) / 2 {762- add_feedback_signal(763- &mut severity,764- &mut reasons,765- FeedbackSeverity::Moderate,766- AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,767- );768- }769- }770-771- let trusted_host_pressure = severity != FeedbackSeverity::Clean;772- if stale {773- reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE);774- if !trusted_host_pressure {775- severity = FeedbackSeverity::Stale;776- }777- return (severity, severity, reasons, true, trusted_host_pressure);778- }779-780- let receiver_drops = report781- .transport_dropped_delta782- .saturating_add(report.frontend_dropped_delta)783- .saturating_add(report.decoder_dropped_delta)784- .saturating_add(report.presenter_dropped_delta);785- let queue_severe = queue_ratio_at_least(786- report.frontend_queue_depth,787- report.frontend_queue_capacity,788- 3,789- 4,790- ) || queue_ratio_at_least(791- report.decode_queue_depth,792- report.decode_queue_capacity,793- 3,794- 4,795- ) || queue_ratio_at_least(796- report.presenter_queue_depth,797- report.presenter_queue_capacity,798- 3,799- 4,800- );801- let queue_moderate = queue_ratio_at_least(802- report.frontend_queue_depth,803- report.frontend_queue_capacity,804- 1,805- 2,806- ) || queue_ratio_at_least(807- report.decode_queue_depth,808- report.decode_queue_capacity,809- 1,810- 2,811- ) || queue_ratio_at_least(812- report.presenter_queue_depth,813- report.presenter_queue_capacity,814- 1,815- 2,816- );817- if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)818- || queue_severe819- || receiver_drops >= 4820- {821- add_feedback_signal(822- &mut severity,823- &mut reasons,824- FeedbackSeverity::Severe,825- AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,826- );827- } else if queue_moderate || receiver_drops > 0 {828- add_feedback_signal(829- &mut severity,830- &mut reasons,831- FeedbackSeverity::Moderate,832- AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,833- );834- }835- if report.decode_queue_depth == report.decode_queue_capacity836- || report.decoder_dropped_delta > 0837- {838- add_feedback_signal(839- &mut severity,840- &mut reasons,841- FeedbackSeverity::Severe,842- AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,843- );844- } else if report.decode_queue_depth >= 2 {845- add_feedback_signal(846- &mut severity,847- &mut reasons,848- FeedbackSeverity::Moderate,849- AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,850- );851- }852-853- let delivery_p95 = report.transport_delivery_p95_ms.unwrap_or(0);854- let decode_p95 = report.decode_p95_ms.unwrap_or(0);855- let presentation_p95 = report.presentation_p95_ms.unwrap_or(0);856- if delivery_p95 >= 200 || decode_p95 >= 100 || presentation_p95 >= 100 {857- add_feedback_signal(858- &mut severity,859- &mut reasons,860- FeedbackSeverity::Severe,861- AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,862- );863- } else if delivery_p95 >= 100 || decode_p95 >= 50 || presentation_p95 >= 50 {864- add_feedback_signal(865- &mut severity,866- &mut reasons,867- FeedbackSeverity::Moderate,868- AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,869- );870- }871-872- let pressure_severity = severity;873- if severity == FeedbackSeverity::Clean {874- let sequence_progressed = self875- .last_sequence876- .zip(report.last_sequence)877- .is_some_and(|(last, current)| current > last);878- let complete_latency = report.transport_delivery_p95_ms.is_some()879- && report.decode_p95_ms.is_some()880- && report.presentation_p95_ms.is_some();881- if sequence_progressed && complete_latency {882- if self.target_kbps < self.ceiling_kbps {883- reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY);884- }885- } else {886- severity = FeedbackSeverity::InsufficientEvidence;887- }888- }889- (890- severity,891- pressure_severity,892- reasons,893- false,894- trusted_host_pressure,895- )896- }897-898- fn commit_observation_from(&mut self, candidate: &Self) {899- self.next_decision_id = candidate.next_decision_id;900- self.last_report_id = candidate.last_report_id;901- self.last_report_received_at = candidate.last_report_received_at;902- self.previous_telemetry = candidate.previous_telemetry;903- self.baseline_rtt_micros = candidate.baseline_rtt_micros;904- self.last_sequence = candidate.last_sequence;905- }906-}907-908-fn add_feedback_signal(909- severity: &mut FeedbackSeverity,910- reasons: &mut AdaptiveBitrateReasonFlagsV1,911- signal: FeedbackSeverity,912- reason: AdaptiveBitrateReasonFlagsV1,913-) {914- *reasons = reasons.union(reason);915- if signal == FeedbackSeverity::Severe916- || (*severity == FeedbackSeverity::Clean && signal == FeedbackSeverity::Moderate)917- {918- *severity = signal;919- }920-}921-922-fn quantize_down(kbps: u32) -> u32 {923- kbps / ADAPTIVE_BITRATE_QUANTUM_KBPS * ADAPTIVE_BITRATE_QUANTUM_KBPS924-}925-926-fn queue_ratio_at_least(depth: u8, capacity: u8, numerator: u16, denominator: u16) -> bool {927- capacity > 0928- && u16::from(depth).saturating_mul(denominator)929- >= u16::from(capacity).saturating_mul(numerator)930-}931-932-#[derive(Debug)]933-pub struct SessionRegistry {934- active: Mutex<Option<ActiveSession>>,935- pending_moq: Mutex<Option<PendingMoqAttachment>>,936- next_session_id: AtomicU64,937- session_changed: tokio::sync::Notify,938- pending_handshakes: tokio::sync::Semaphore,939-}940-941-struct PendingMoqAttachment {942- remote: EndpointId,943- session_id: u64,944- broadcast_name: String,945- broadcast: BroadcastConsumer,946- attached: tokio::sync::oneshot::Sender<()>,947- closed: tokio::sync::oneshot::Sender<()>,948- telemetry: Arc<MediaV3Telemetry>,949-}950-951-impl std::fmt::Debug for PendingMoqAttachment {952- fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {953- formatter954- .debug_struct("PendingMoqAttachment")955- .field("remote", &self.remote)956- .field("session_id", &self.session_id)957- .field("broadcast_name", &self.broadcast_name)958- .finish_non_exhaustive()959- }960-}961-962-struct ClaimedMoqAttachment {963- session_id: u64,964- broadcast_name: String,965- broadcast: BroadcastConsumer,966- attached: tokio::sync::oneshot::Sender<()>,967- closed: tokio::sync::oneshot::Sender<()>,968- telemetry: Arc<MediaV3Telemetry>,969-}970-971-struct MoqAttachmentWait {972- attached: tokio::sync::oneshot::Receiver<()>,973- closed: tokio::sync::oneshot::Receiver<()>,974-}975-976-#[derive(Clone, Debug)]977-struct ActiveSession {978- remote: EndpointId,979- session_id: u64,980- nonce: [u8; 16],981- session_clock: SessionClock,982- grants: InvitationGrants,983- media_active: bool,984- input_claimed: bool,985- audio_claimed: bool,986- feedback_claimed: bool,987- media_v3_telemetry: Arc<MediaV3Telemetry>,988- encoder_control: Option<EncoderControl>,989-}990-991-#[derive(Debug, Default)]992-struct MediaV3Telemetry {993- scheduler_cancellations: AtomicU64,994- send_failures: AtomicU64,995- selected_path_rtt_micros: AtomicU64,996- selected_path_lost_packets: AtomicU64,997- selected_path_congestion_events: AtomicU64,998- keyframe_control_requests: AtomicU64,999- encoder_force_requests: AtomicU64,1000- encoder_force_acknowledgements: AtomicU64,1001- encoder_force_failures: AtomicU64,1002- last_encoder_force_ack_micros: AtomicU64,1003-}1004-1005-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]1006-struct MediaV3TelemetrySnapshot {1007- scheduler_cancellations: u64,1008- send_failures: u64,1009- selected_path_rtt_micros: u64,1010- selected_path_lost_packets: u64,1011- selected_path_congestion_events: u64,1012-}1013-1014-#[derive(Clone, Debug)]1015-struct AdaptiveEncoderProposal {1016- control: EncoderControl,1017- target_kbps: u32,1018- bitrate_revision: u64,1019- force_keyframe_revision: Option<u64>,1020-}1021-1022-#[derive(Clone, Debug)]1023-struct ResolutionEncoderProposal {1024- control: EncoderControl,1025- target: VideoDimensions,1026- revision: u64,1027-}1028-1029-#[derive(Debug, PartialEq, Eq)]1030-enum ForcedIdrDisposition {1031- JoinReplay,1032- Unavailable,1033- Requested { revision: u64 },1034- Coalesced { revision: u64 },1035- Failed { error: String },1036-}1037-1038-struct ForcedIdrAcknowledgement {1039- requested_revision: u64,1040- elapsed: Duration,1041- result: Result<crate::source::EncoderControlStatus>,1042-}1043-1044-struct ForcedIdrCoordinator {1045- control: Option<EncoderControl>,1046- pending_revision: Option<u64>,1047- acknowledgements: tokio::task::JoinSet<ForcedIdrAcknowledgement>,1048- telemetry: Arc<MediaV3Telemetry>,1049-}1050-1051-impl ForcedIdrCoordinator {1052- fn new(control: Option<EncoderControl>, telemetry: Arc<MediaV3Telemetry>) -> Self {1053- Self {1054- control,1055- pending_revision: None,1056- acknowledgements: tokio::task::JoinSet::new(),1057- telemetry,1058- }1059- }1060-1061- fn request(&mut self, reason: KeyframeRequestReasonV3) -> ForcedIdrDisposition {1062- self.telemetry1063- .keyframe_control_requests1064- .fetch_add(1, Ordering::Relaxed);1065- if reason == KeyframeRequestReasonV3::Join {1066- return ForcedIdrDisposition::JoinReplay;1067- }1068- if let Some(revision) = self.pending_revision {1069- return ForcedIdrDisposition::Coalesced { revision };1070- }1071- let Some(control) = self.control.clone() else {1072- return ForcedIdrDisposition::Unavailable;1073- };1074- let revision = match control.request_force_keyframe() {1075- Ok(revision) => revision,1076- Err(error) => {1077- self.telemetry1078- .encoder_force_failures1079- .fetch_add(1, Ordering::Relaxed);1080- return ForcedIdrDisposition::Failed {1081- error: error.to_string(),1082- };1083- }1084- };1085- self.pending_revision = Some(revision);1086- self.telemetry1087- .encoder_force_requests1088- .fetch_add(1, Ordering::Relaxed);1089- self.acknowledgements.spawn(async move {1090- let started_at = Instant::now();1091- let result = control1092- .wait_for_recovery_keyframe_acknowledged(revision, ENCODER_CONTROL_COMMIT_TIMEOUT)1093- .await;1094- ForcedIdrAcknowledgement {1095- requested_revision: revision,1096- elapsed: started_at.elapsed(),1097- result,1098- }1099- });1100- ForcedIdrDisposition::Requested { revision }1101- }1102-1103- fn complete(1104- &mut self,1105- result: Option<Result<ForcedIdrAcknowledgement, tokio::task::JoinError>>,1106- remote: EndpointId,1107- transport: &'static str,1108- ) {1109- let pending_revision = self.pending_revision.take();1110- match result {1111- Some(Ok(acknowledgement)) => match acknowledgement.result {1112- Ok(status) => {1113- let elapsed_micros =1114- u64::try_from(acknowledgement.elapsed.as_micros()).unwrap_or(u64::MAX);1115- self.telemetry1116- .encoder_force_acknowledgements1117- .fetch_add(1, Ordering::Relaxed);1118- self.telemetry1119- .last_encoder_force_ack_micros1120- .store(elapsed_micros, Ordering::Relaxed);1121- debug!(1122- %remote,1123- transport,1124- requested_revision = acknowledgement.requested_revision,1125- acknowledged_revision = ?status.acknowledged_force_keyframe_revision,1126- elapsed_micros,1127- "forced-IDR recovery acknowledged"1128- );1129- }1130- Err(error) => {1131- self.telemetry1132- .encoder_force_failures1133- .fetch_add(1, Ordering::Relaxed);1134- warn!(1135- %remote,1136- transport,1137- requested_revision = acknowledgement.requested_revision,1138- %error,1139- "forced-IDR recovery was not acknowledged; retaining natural-IDR fallback"1140- );1141- }1142- },1143- Some(Err(error)) => {1144- self.telemetry1145- .encoder_force_failures1146- .fetch_add(1, Ordering::Relaxed);1147- warn!(1148- %remote,1149- transport,1150- ?pending_revision,1151- %error,1152- "forced-IDR acknowledgement task failed; retaining natural-IDR fallback"1153- );1154- }1155- None => {1156- self.telemetry1157- .encoder_force_failures1158- .fetch_add(1, Ordering::Relaxed);1159- warn!(1160- %remote,1161- transport,1162- ?pending_revision,1163- "forced-IDR acknowledgement task ended without a result"1164- );1165- }1166- }1167- }1168-1169- async fn abort_and_drain(&mut self, remote: EndpointId, transport: &'static str) {1170- self.pending_revision = None;1171- self.acknowledgements.abort_all();1172- while self.acknowledgements.join_next().await.is_some() {}1173- debug!(1174- %remote,1175- transport,1176- keyframe_control_requests = self1177- .telemetry1178- .keyframe_control_requests1179- .load(Ordering::Relaxed),1180- encoder_force_requests = self1181- .telemetry1182- .encoder_force_requests1183- .load(Ordering::Relaxed),1184- encoder_force_acknowledgements = self1185- .telemetry1186- .encoder_force_acknowledgements1187- .load(Ordering::Relaxed),1188- encoder_force_failures = self1189- .telemetry1190- .encoder_force_failures1191- .load(Ordering::Relaxed),1192- last_encoder_force_ack_micros = self1193- .telemetry1194- .last_encoder_force_ack_micros1195- .load(Ordering::Relaxed),1196- "forced-IDR recovery session summary"1197- );1198- }1199-}1200-1201-impl MediaV3Telemetry {1202- fn snapshot(&self) -> MediaV3TelemetrySnapshot {1203- MediaV3TelemetrySnapshot {1204- scheduler_cancellations: self.scheduler_cancellations.load(Ordering::Relaxed),1205- send_failures: self.send_failures.load(Ordering::Relaxed),1206- selected_path_rtt_micros: self.selected_path_rtt_micros.load(Ordering::Relaxed),1207- selected_path_lost_packets: self.selected_path_lost_packets.load(Ordering::Relaxed),1208- selected_path_congestion_events: self1209- .selected_path_congestion_events1210- .load(Ordering::Relaxed),1211- }1212- }1213-1214- fn record_selected_path(&self, connection: &Connection) {1215- let paths = connection.paths();1216- let Some(path) = paths.iter().find(|path| path.is_selected()) else {1217- return;1218- };1219- let stats = path.stats();1220- self.selected_path_rtt_micros.store(1221- u64::try_from(stats.rtt.as_micros()).unwrap_or(u64::MAX),1222- Ordering::Relaxed,1223- );1224- self.selected_path_lost_packets1225- .store(stats.lost_packets, Ordering::Relaxed);1226- self.selected_path_congestion_events1227- .store(stats.congestion_events, Ordering::Relaxed);1228- }1229-}1230-1231-impl Default for SessionRegistry {1232- fn default() -> Self {1233- Self {1234- active: Mutex::new(None),1235- pending_moq: Mutex::new(None),1236- next_session_id: AtomicU64::new(0),1237- session_changed: tokio::sync::Notify::new(),1238- pending_handshakes: tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES),1239- }1240- }1241-}1242-1243-impl SessionRegistry {1244- pub fn has_session(&self) -> bool {1245- self.active1246- .lock()1247- .expect("session registry poisoned")1248- .is_some()1249- }1250-1251- fn claim(1252- self: &Arc<Self>,1253- remote: EndpointId,1254- nonce: [u8; 16],1255- grants: InvitationGrants,1256- ) -> Result<SessionLease> {1257- let mut active = self.active.lock().expect("session registry poisoned");1258- if let Some(current) = active.as_ref() {1259- bail!("host already has active client {}", current.remote);1260- }1261- let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed) + 1;1262- let session_clock = SessionClock::start();1263- let media_v3_telemetry = Arc::new(MediaV3Telemetry::default());1264- *active = Some(ActiveSession {1265- remote,1266- session_id,1267- nonce,1268- session_clock,1269- grants,1270- media_active: true,1271- input_claimed: false,1272- audio_claimed: false,1273- feedback_claimed: false,1274- media_v3_telemetry: Arc::clone(&media_v3_telemetry),1275- encoder_control: None,1276- });1277- Ok(SessionLease {1278- registry: Arc::clone(self),1279- remote,1280- session_id,1281- session_clock,1282- media_v3_telemetry,1283- })1284- }1285-1286- fn claim_input(self: &Arc<Self>, remote: EndpointId, nonce: [u8; 16]) -> Result<InputLease> {1287- let mut active = self.active.lock().expect("session registry poisoned");1288- let session = active1289- .as_mut()1290- .filter(|session| {1291- session.media_active && session.remote == remote && session.nonce == nonce1292- })1293- .context("input connection does not match the active media session")?;1294- ensure!(1295- !session.input_claimed,1296- "active client already has an input stream"1297- );1298- session.input_claimed = true;1299- Ok(InputLease {1300- registry: Arc::clone(self),1301- remote,1302- session_id: session.session_id,1303- grants: session.grants,1304- })1305- }1306-1307- fn install_encoder_control(1308- &self,1309- remote: EndpointId,1310- session_id: u64,1311- encoder_control: Option<EncoderControl>,1312- ) -> Result<()> {1313- let mut active = self.active.lock().expect("session registry poisoned");1314- let session = active1315- .as_mut()1316- .filter(|session| {1317- session.media_active && session.remote == remote && session.session_id == session_id1318- })1319- .context("encoder control does not match the active media session")?;1320- ensure!(1321- session.encoder_control.is_none(),1322- "active media session already has encoder control"1323- );1324- session.encoder_control = encoder_control;1325- Ok(())1326- }1327-1328- fn propose_adaptive_encoder_update(1329- &self,1330- remote: EndpointId,1331- session_id: u64,1332- target_kbps: u32,1333- force_keyframe: bool,1334- ) -> Result<Option<AdaptiveEncoderProposal>> {1335- let active = self.active.lock().expect("session registry poisoned");1336- let session = active1337- .as_ref()1338- .filter(|session| {1339- session.media_active && session.remote == remote && session.session_id == session_id1340- })1341- .context("adaptive encoder update does not match the active media session")?;1342- let Some(control) = session.encoder_control.clone() else {1343- return Ok(None);1344- };1345- let bitrate_revision = control.request_bitrate_kbps(target_kbps)?;1346- let force_keyframe_revision = force_keyframe1347- .then(|| control.request_force_keyframe())1348- .transpose()?;1349- Ok(Some(AdaptiveEncoderProposal {1350- control,1351- target_kbps,1352- bitrate_revision,1353- force_keyframe_revision,1354- }))1355- }1356-1357- fn propose_resolution_update(1358- &self,1359- remote: EndpointId,1360- session_id: u64,1361- target: VideoDimensions,1362- ) -> Result<Option<ResolutionEncoderProposal>> {1363- let active = self.active.lock().expect("session registry poisoned");1364- let session = active1365- .as_ref()1366- .filter(|session| {1367- session.media_active && session.remote == remote && session.session_id == session_id1368- })1369- .context("resolution update does not match the active media session")?;1370- let Some(control) = session.encoder_control.clone() else {1371- return Ok(None);1372- };1373- let revision = control.request_resolution(target.width, target.height)?;1374- Ok(Some(ResolutionEncoderProposal {1375- control,1376- target,1377- revision,1378- }))1379- }1380-1381- fn claim_feedback(1382- self: &Arc<Self>,1383- remote: EndpointId,1384- nonce: [u8; 16],1385- ) -> Result<FeedbackLease> {1386- let mut active = self.active.lock().expect("session registry poisoned");1387- let session = active1388- .as_mut()1389- .filter(|session| {1390- session.media_active && session.remote == remote && session.nonce == nonce1391- })1392- .context("feedback connection does not match the active media session")?;1393- ensure!(1394- session.grants.contains(InvitationGrants::VIEW),1395- "active Portal session lacks feedback view permission"1396- );1397- ensure!(1398- !session.feedback_claimed,1399- "active client already has a feedback connection"1400- );1401- session.feedback_claimed = true;1402- Ok(FeedbackLease {1403- registry: Arc::clone(self),1404- remote,1405- session_id: session.session_id,1406- telemetry: Arc::clone(&session.media_v3_telemetry),1407- encoder_control: session.encoder_control.clone(),1408- })1409- }1410-1411- fn claim_audio(self: &Arc<Self>, remote: EndpointId, nonce: [u8; 16]) -> Result<AudioLease> {1412- let mut active = self.active.lock().expect("session registry poisoned");1413- let session = active1414- .as_mut()1415- .filter(|session| {1416- session.media_active && session.remote == remote && session.nonce == nonce1417- })1418- .context("audio connection does not match the active media session")?;1419- ensure!(1420- !session.audio_claimed,1421- "active client already has an audio connection"1422- );1423- session.audio_claimed = true;1424- Ok(AudioLease {1425- registry: Arc::clone(self),1426- remote,1427- session_id: session.session_id,1428- session_clock: session.session_clock,1429- grants: session.grants,1430- })1431- }1432-1433- fn expect_moq(1434- &self,1435- remote: EndpointId,1436- session_id: u64,1437- broadcast_name: String,1438- broadcast: BroadcastConsumer,1439- ) -> Result<MoqAttachmentWait> {1440- let active = self.active.lock().expect("session registry poisoned");1441- let telemetry = active1442- .as_ref()1443- .filter(|session| {1444- session.media_active && session.remote == remote && session.session_id == session_id1445- })1446- .map(|session| Arc::clone(&session.media_v3_telemetry))1447- .context("MoQ expectation does not match the active control session")?;1448- let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");1449- ensure!(1450- pending.is_none(),1451- "active control session already expects MoQ"1452- );1453- let (attached, attached_rx) = tokio::sync::oneshot::channel();1454- let (closed, closed_rx) = tokio::sync::oneshot::channel();1455- *pending = Some(PendingMoqAttachment {1456- remote,1457- session_id,1458- broadcast_name,1459- broadcast,1460- attached,1461- closed,1462- telemetry,1463- });1464- Ok(MoqAttachmentWait {1465- attached: attached_rx,1466- closed: closed_rx,1467- })1468- }1469-1470- fn claim_moq(&self, remote: EndpointId) -> Result<ClaimedMoqAttachment> {1471- let active = self.active.lock().expect("session registry poisoned");1472- let session = active1473- .as_ref()1474- .filter(|session| session.media_active && session.remote == remote)1475- .context("MoQ connection does not match the active control session")?;1476- let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");1477- let attachment = pending1478- .as_ref()1479- .filter(|attachment| {1480- attachment.remote == remote && attachment.session_id == session.session_id1481- })1482- .context("active control session is not expecting a MoQ connection")?;1483- debug_assert_eq!(attachment.remote, session.remote);1484- let attachment = pending1485- .take()1486- .expect("validated pending MoQ attachment disappeared");1487- Ok(ClaimedMoqAttachment {1488- session_id: attachment.session_id,1489- broadcast_name: attachment.broadcast_name,1490- broadcast: attachment.broadcast,1491- attached: attachment.attached,1492- closed: attachment.closed,1493- telemetry: attachment.telemetry,1494- })1495- }1496-1497- fn release(&self, remote: EndpointId, session_id: u64) {1498- let mut active = self.active.lock().expect("session registry poisoned");1499- if let Some(session) = active.as_mut()1500- && session.remote == remote1501- && session.session_id == session_id1502- {1503- let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");1504- if pending.as_ref().is_some_and(|attachment| {1505- attachment.remote == remote && attachment.session_id == session_id1506- }) {1507- *pending = None;1508- }1509- // Keep the registry occupied until the input handler has observed1510- // media shutdown and released all held uinput transitions. This1511- // prevents a reconnect from sharing the device with a draining1512- // predecessor session.1513- session.media_active = false;1514- session.encoder_control = None;1515- if !session.input_claimed && !session.audio_claimed && !session.feedback_claimed {1516- *active = None;1517- }1518- drop(active);1519- self.session_changed.notify_waiters();1520- }1521- }1522-1523- fn is_active(&self, remote: EndpointId, session_id: u64) -> bool {1524- self.active1525- .lock()1526- .expect("session registry poisoned")1527- .as_ref()1528- .is_some_and(|active| {1529- active.media_active && active.remote == remote && active.session_id == session_id1530- })1531- }1532-1533- fn release_input(&self, remote: EndpointId, session_id: u64) {1534- let mut active = self.active.lock().expect("session registry poisoned");1535- if let Some(session) = active.as_mut()1536- && session.remote == remote1537- && session.session_id == session_id1538- {1539- session.input_claimed = false;1540- if !session.media_active && !session.audio_claimed && !session.feedback_claimed {1541- *active = None;1542- }1543- }1544- }1545-1546- fn release_audio(&self, remote: EndpointId, session_id: u64) {1547- let mut active = self.active.lock().expect("session registry poisoned");1548- if let Some(session) = active.as_mut()1549- && session.remote == remote1550- && session.session_id == session_id1551- {1552- session.audio_claimed = false;1553- if !session.media_active && !session.input_claimed && !session.feedback_claimed {1554- *active = None;1555- }1556- }1557- }1558-1559- fn release_feedback(&self, remote: EndpointId, session_id: u64) {1560- let mut active = self.active.lock().expect("session registry poisoned");1561- if let Some(session) = active.as_mut()1562- && session.remote == remote1563- && session.session_id == session_id1564- {1565- session.feedback_claimed = false;1566- if !session.media_active && !session.input_claimed && !session.audio_claimed {1567- *active = None;1568- }1569- }1570- }1571-}1572-1573-#[derive(Debug)]1574-struct SessionLease {1575- registry: Arc<SessionRegistry>,1576- remote: EndpointId,1577- session_id: u64,1578- session_clock: SessionClock,1579- media_v3_telemetry: Arc<MediaV3Telemetry>,1580-}1581-1582-impl Drop for SessionLease {1583- fn drop(&mut self) {1584- self.registry.release(self.remote, self.session_id);1585- }1586-}1587-1588-#[derive(Debug)]1589-struct InputLease {1590- registry: Arc<SessionRegistry>,1591- remote: EndpointId,1592- session_id: u64,1593- grants: InvitationGrants,1594-}1595-1596-#[derive(Debug)]1597-struct AudioLease {1598- registry: Arc<SessionRegistry>,1599- remote: EndpointId,1600- session_id: u64,1601- session_clock: SessionClock,1602- grants: InvitationGrants,1603-}1604-1605-#[derive(Debug)]1606-struct FeedbackLease {1607- registry: Arc<SessionRegistry>,1608- remote: EndpointId,1609- session_id: u64,1610- telemetry: Arc<MediaV3Telemetry>,1611- encoder_control: Option<EncoderControl>,1612-}1613-1614-#[derive(Debug)]1615-struct SourceTaskGuard(Option<tokio::task::JoinHandle<Result<()>>>);1616-1617-impl SourceTaskGuard {1618- fn new(task: tokio::task::JoinHandle<Result<()>>) -> Self {1619- Self(Some(task))1620- }1621-1622- async fn abort_and_wait(mut self) {1623- if let Some(task) = self.0.take() {1624- task.abort();1625- let _ = task.await;1626- }1627- }1628-1629- async fn wait_or_abort(mut self, grace_timeout: Duration) {1630- let Some(mut task) = self.0.take() else {1631- return;1632- };1633- if tokio::time::timeout(grace_timeout, &mut task)1634- .await1635- .is_err()1636- {1637- task.abort();1638- let _ = task.await;1639- }1640- }1641-}1642-1643-impl Drop for SourceTaskGuard {1644- fn drop(&mut self) {1645- if let Some(task) = self.0.take() {1646- task.abort();1647- }1648- }1649-}1650-1651-#[derive(Clone, Copy, Debug, Default, PartialEq)]1652-struct StartupCadenceSample {1653- first_sequence: Option<u64>,1654- last_sequence: Option<u64>,1655- first_observed_at: Option<Instant>,1656- last_observed_at: Option<Instant>,1657- receiver_open: bool,1658- decodable_gop_ready: bool,1659-}1660-1661-impl StartupCadenceSample {1662- fn observe(&mut self, frame: &EncodedFrame) {1663- if self.first_sequence.is_none() {1664- self.first_sequence = Some(frame.sequence);1665- self.first_observed_at = Some(frame.observed_at);1666- }1667- if self1668- .last_sequence1669- .is_none_or(|sequence| frame.sequence >= sequence)1670- {1671- self.last_sequence = Some(frame.sequence);1672- self.last_observed_at = Some(frame.observed_at);1673- }1674- }1675-1676- fn frame_progress(self) -> u64 {1677- self.first_sequence1678- .zip(self.last_sequence)1679- .map_or(0, |(first, last)| last.saturating_sub(first) + 1)1680- }1681-1682- fn fps(self) -> f64 {1683- let frames = self.frame_progress();1684- let elapsed = self.observation_span();1685- if frames < 2 || elapsed.is_zero() {1686- return 0.0;1687- }1688- (frames - 1) as f64 / elapsed.as_secs_f64()1689- }1690-1691- fn observation_span(self) -> Duration {1692- self.first_observed_at1693- .zip(self.last_observed_at)1694- .map_or(Duration::ZERO, |(first, last)| {1695- last.saturating_duration_since(first)1696- })1697- }1698-1699- fn has_representative_span(self) -> bool {1700- self.observation_span() >= GAMESCOPE_STARTUP_MIN_OBSERVATION_SPAN1701- }1702-1703- fn is_usable(self) -> bool {1704- self.receiver_open && self.frame_progress() > 0 && self.decodable_gop_ready1705- }1706-1707- fn meets_target_cadence(self) -> bool {1708- self.receiver_open1709- && self.has_representative_span()1710- && self.frame_progress() >= GAMESCOPE_STARTUP_TARGET_MIN_FRAMES1711- && self.fps() >= GAMESCOPE_STARTUP_TARGET_MIN_FPS1712- }1713-}1714-1715-fn startup_source_needs_restart(sample: StartupCadenceSample) -> bool {1716- !sample.is_usable()1717-}1718-1719-async fn sample_startup_cadence(1720- receiver: &mut tokio::sync::watch::Receiver<Option<EncodedFrame>>,1721- current_gop: &tokio::sync::watch::Receiver<Option<EncodedGop>>,1722- window: Duration,1723-) -> StartupCadenceSample {1724- let mut sample = StartupCadenceSample {1725- receiver_open: true,1726- ..StartupCadenceSample::default()1727- };1728- if let Some(frame) = receiver.borrow_and_update().as_ref() {1729- sample.observe(frame);1730- }1731- sample.decodable_gop_ready = current_gop.borrow().is_some();1732- let deadline = tokio::time::Instant::now() + window;1733- while !sample.is_usable() || !sample.has_representative_span() {1734- match tokio::time::timeout_at(deadline, receiver.changed()).await {1735- Ok(Ok(())) => {1736- if let Some(frame) = receiver.borrow_and_update().as_ref() {1737- sample.observe(frame);1738- }1739- sample.decodable_gop_ready = current_gop.borrow().is_some();1740- }1741- Ok(Err(_)) => {1742- sample.receiver_open = false;1743- break;1744- }1745- Err(_) => break,1746- }1747- }1748- sample1749-}1750-1751-async fn reap_encoded_source(source: EncodedSource) {1752- reap_encoded_source_with_timeout(source, SOURCE_REAP_GRACE_TIMEOUT).await;1753-}1754-1755-async fn reap_encoded_source_with_timeout(source: EncodedSource, grace_timeout: Duration) {1756- let EncodedSource {1757- frames,1758- current_gop,1759- task,1760- pointer_surface_dimensions: _,1761- encoder_control: _,1762- } = source;1763- // Closing both bounded outputs gives the capture task a chance to kill and1764- // wait for its GStreamer child itself. Abort is only the bounded fallback.1765- drop(frames);1766- drop(current_gop);1767- SourceTaskGuard::new(task)1768- .wait_or_abort(grace_timeout)1769- .await;1770-}1771-1772-async fn select_gamescope_startup_source(1773- config: HostConfig,1774- session_clock: SessionClock,1775- mut primary: EncodedSource,1776-) -> Result<EncodedSource> {1777- let primary_sample = sample_startup_cadence(1778- &mut primary.frames,1779- &primary.current_gop,1780- GAMESCOPE_STARTUP_SAMPLE_WINDOW,1781- )1782- .await;1783- info!(1784- frames = primary_sample.frame_progress(),1785- fps = primary_sample.fps(),1786- receiver_open = primary_sample.receiver_open,1787- decodable_gop_ready = primary_sample.decodable_gop_ready,1788- target_cadence = primary_sample.meets_target_cadence(),1789- "sampled primary Gamescope capture startup"1790- );1791- if !startup_source_needs_restart(primary_sample) {1792- return Ok(primary);1793- }1794-1795- // Gamescope's PipeWire export does not reliably fan out full cadence to1796- // two simultaneous consumers. Reap an unhealthy startup pipeline before1797- // opening its replacement; overlapping them can divide vblank deliveries1798- // and permanently strand the selected source at half rate.1799- reap_encoded_source(primary).await;1800- let mut replacement = spawn_gamescope_pipewire_after_static_preflight(config, session_clock)1801- .await1802- .context("restarting unhealthy Gamescope capture pipeline")?;1803- let replacement_sample = sample_startup_cadence(1804- &mut replacement.frames,1805- &replacement.current_gop,1806- GAMESCOPE_STARTUP_SAMPLE_WINDOW,1807- )1808- .await;1809- info!(1810- frames = replacement_sample.frame_progress(),1811- fps = replacement_sample.fps(),1812- receiver_open = replacement_sample.receiver_open,1813- decodable_gop_ready = replacement_sample.decodable_gop_ready,1814- target_cadence = replacement_sample.meets_target_cadence(),1815- "sampled sequential Gamescope capture startup replacement"1816- );1817- if !replacement_sample.is_usable() {1818- let frames = replacement_sample.frame_progress();1819- let fps = replacement_sample.fps();1820- let receiver_open = replacement_sample.receiver_open;1821- let decodable_gop_ready = replacement_sample.decodable_gop_ready;1822- reap_encoded_source(replacement).await;1823- bail!(1824- "replacement Gamescope capture pipeline remained unhealthy: \1825- frames={frames}, fps={fps:.2}, receiver_open={receiver_open}, \1826- decodable_gop_ready={decodable_gop_ready}"1827- );1828- }1829- Ok(replacement)1830-}1831-1832-#[derive(Clone, Copy, Debug, PartialEq, Eq)]1833-enum MediaActivity {1834- SourceChanged,1835- PeerDisconnected,1836-}1837-1838-#[derive(Clone, Copy, Debug, PartialEq, Eq)]1839-enum MediaReplayDecision {1840- Send { discontinuity: bool },1841- SkipUntilKeyframe,1842- DiscardStaleSuffix { through_sequence: u64 },1843-}1844-1845-#[derive(Debug, PartialEq, Eq)]1846-enum MediaV2ScheduleDecision {1847- Send {1848- discontinuity: bool,1849- cancel_sequences: Vec<u64>,1850- },1851- SkipUntilKeyframe,1852- EnterResync {1853- cancel_sequences: Vec<u64>,1854- },1855-}1856-1857-#[derive(Debug)]1858-struct MediaV2Scheduler {1859- in_flight: Vec<u64>,1860- last_scheduled_sequence: Option<u64>,1861- waiting_for_keyframe: bool,1862- discontinuity_pending: bool,1863-}1864-1865-impl Default for MediaV2Scheduler {1866- fn default() -> Self {1867- Self {1868- in_flight: Vec::with_capacity(MEDIA_V2_IN_FLIGHT_CAPACITY),1869- last_scheduled_sequence: None,1870- waiting_for_keyframe: true,1871- discontinuity_pending: false,1872- }1873- }1874-}1875-1876-impl MediaV2Scheduler {1877- fn schedule(1878- &mut self,1879- sequence: u64,1880- independently_decodable: bool,1881- ) -> MediaV2ScheduleDecision {1882- if self1883- .last_scheduled_sequence1884- .is_some_and(|last| sequence <= last)1885- {1886- return MediaV2ScheduleDecision::SkipUntilKeyframe;1887- }1888-1889- let sequence_discontinuity = self1890- .last_scheduled_sequence1891- .is_some_and(|last| last.checked_add(1) != Some(sequence));1892- if independently_decodable {1893- let cancel_sequences = std::mem::take(&mut self.in_flight);1894- let discontinuity = self.discontinuity_pending1895- || sequence_discontinuity1896- || !cancel_sequences.is_empty();1897- self.in_flight.push(sequence);1898- self.last_scheduled_sequence = Some(sequence);1899- self.waiting_for_keyframe = false;1900- self.discontinuity_pending = false;1901- return MediaV2ScheduleDecision::Send {1902- discontinuity,1903- cancel_sequences,1904- };1905- }1906-1907- if self.waiting_for_keyframe {1908- return MediaV2ScheduleDecision::SkipUntilKeyframe;1909- }1910- if sequence_discontinuity || self.in_flight.len() == MEDIA_V2_IN_FLIGHT_CAPACITY {1911- return MediaV2ScheduleDecision::EnterResync {1912- cancel_sequences: self.enter_resync(),1913- };1914- }1915-1916- self.in_flight.push(sequence);1917- self.last_scheduled_sequence = Some(sequence);1918- MediaV2ScheduleDecision::Send {1919- discontinuity: false,1920- cancel_sequences: Vec::new(),1921- }1922- }1923-1924- fn complete(&mut self, sequence: u64) {1925- if let Some(index) = self.in_flight.iter().position(|value| *value == sequence) {1926- self.in_flight.swap_remove(index);1927- }1928- }1929-1930- fn fail(&mut self, sequence: u64) -> Vec<u64> {1931- let Some(index) = self.in_flight.iter().position(|value| *value == sequence) else {1932- // A completion from an already-cancelled GOP must not poison the1933- // newer GOP which replaced it.1934- return Vec::new();1935- };1936- self.in_flight.swap_remove(index);1937- self.enter_resync()1938- }1939-1940- fn fail_all(&mut self) -> Vec<u64> {1941- self.enter_resync()1942- }1943-1944- fn enter_resync(&mut self) -> Vec<u64> {1945- self.waiting_for_keyframe = true;1946- self.discontinuity_pending = true;1947- std::mem::take(&mut self.in_flight)1948- }1949-}1950-1951-#[derive(Clone, Copy, Debug, PartialEq, Eq)]1952-struct MediaV3ObjectPosition {1953- group_id: u64,1954- object_id: u32,1955- discontinuity: bool,1956-}1957-1958-#[derive(Clone, Copy, Debug, PartialEq, Eq)]1959-enum MediaV3GroupDecision {1960- Send(MediaV3ObjectPosition),1961- SkipUntilKeyframe,1962- EnterResync,1963-}1964-1965-#[derive(Debug)]1966-struct MediaV3GroupCursor {1967- group_id: Option<u64>,1968- last_sequence: Option<u64>,1969- last_object_id: Option<u32>,1970- payload_bytes: usize,1971- waiting_for_keyframe: bool,1972- discontinuity_pending: bool,1973-}1974-1975-impl Default for MediaV3GroupCursor {1976- fn default() -> Self {1977- Self {1978- group_id: None,1979- last_sequence: None,1980- last_object_id: None,1981- payload_bytes: 0,1982- waiting_for_keyframe: true,1983- discontinuity_pending: false,1984- }1985- }1986-}1987-1988-impl MediaV3GroupCursor {1989- fn classify(&mut self, frame: &EncodedFrame) -> MediaV3GroupDecision {1990- let independently_decodable = frame.keyframe && frame.codec_config;1991- if independently_decodable {1992- if frame.data.len() > MAX_MEDIA_GROUP_BYTES_V3 {1993- self.enter_resync();1994- return MediaV3GroupDecision::EnterResync;1995- }1996- let sequence_discontinuity = self1997- .last_sequence1998- .is_some_and(|last| last.checked_add(1) != Some(frame.sequence));1999- let position = MediaV3ObjectPosition {2000- group_id: frame.sequence,2001- object_id: 0,2002- discontinuity: self.discontinuity_pending || sequence_discontinuity,2003- };2004- self.group_id = Some(frame.sequence);2005- self.last_sequence = Some(frame.sequence);2006- self.last_object_id = Some(0);2007- self.payload_bytes = frame.data.len();2008- self.waiting_for_keyframe = false;2009- self.discontinuity_pending = false;2010- return MediaV3GroupDecision::Send(position);2011- }2012-2013- if self.waiting_for_keyframe {2014- return MediaV3GroupDecision::SkipUntilKeyframe;2015- }2016- if frame.keyframe || frame.codec_config {2017- self.enter_resync();2018- return MediaV3GroupDecision::EnterResync;2019- }2020- let Some(group_id) = self.group_id else {2021- self.enter_resync();2022- return MediaV3GroupDecision::EnterResync;2023- };2024- let contiguous =2025- self.last_sequence.and_then(|last| last.checked_add(1)) == Some(frame.sequence);2026- let object_id = self.last_object_id.and_then(|last| last.checked_add(1));2027- let payload_bytes = self.payload_bytes.checked_add(frame.data.len());2028- let (Some(object_id), Some(payload_bytes)) = (object_id, payload_bytes) else {2029- self.enter_resync();2030- return MediaV3GroupDecision::EnterResync;2031- };2032- if !contiguous2033- || object_id > MAX_MEDIA_OBJECT_ID_V32034- || payload_bytes > MAX_MEDIA_GROUP_BYTES_V32035- {2036- self.enter_resync();2037- return MediaV3GroupDecision::EnterResync;2038- }2039-2040- self.last_sequence = Some(frame.sequence);2041- self.last_object_id = Some(object_id);2042- self.payload_bytes = payload_bytes;2043- MediaV3GroupDecision::Send(MediaV3ObjectPosition {2044- group_id,2045- object_id,2046- discontinuity: false,2047- })2048- }2049-2050- fn request_keyframe(&mut self) {2051- self.enter_resync();2052- }2053-2054- fn enter_resync(&mut self) {2055- self.group_id = None;2056- self.last_object_id = None;2057- self.payload_bytes = 0;2058- self.waiting_for_keyframe = true;2059- self.discontinuity_pending = true;2060- }2061-}2062-2063-#[derive(Clone, Copy, Debug, PartialEq, Eq)]2064-enum MoqGroupDecision {2065- Published {2066- group_id: u64,2067- frame_id: u32,2068- cancelled_previous: bool,2069- },2070- SkipUntilKeyframe,2071- EnterResync,2072-}2073-2074-/// Owns the single bounded live MoQ track. One configured H.264 GOP maps to2075-/// one native MoQ group; its application frame sequence remains inside the2076-/// encoded object envelope and is never reused as the transport group id.2077-struct MoqGroupPublisher {2078- track: TrackProducer,2079- current_group: Option<GroupProducer>,2080- cursor: MediaV3GroupCursor,2081- object_bytes: usize,2082-}2083-2084-impl MoqGroupPublisher {2085- fn new(track: TrackProducer) -> Self {2086- Self {2087- track,2088- current_group: None,2089- cursor: MediaV3GroupCursor::default(),2090- object_bytes: 0,2091- }2092- }2093-2094- fn publish(2095- &mut self,2096- config: &HostConfig,2097- frame: &EncodedFrame,2098- replay_discontinuity: bool,2099- ) -> Result<MoqGroupDecision> {2100- let position = match self.cursor.classify(frame) {2101- MediaV3GroupDecision::Send(position) => position,2102- MediaV3GroupDecision::SkipUntilKeyframe => {2103- return Ok(MoqGroupDecision::SkipUntilKeyframe);2104- }2105- MediaV3GroupDecision::EnterResync => {2106- self.abort_current();2107- return Ok(MoqGroupDecision::EnterResync);2108- }2109- };2110- let object = encode_media_frame_object(&media_frame_for_encoded(2111- config,2112- frame,2113- replay_discontinuity || position.discontinuity,2114- )?)?;2115- let next_object_bytes = if position.object_id == 0 {2116- Some(object.len())2117- } else {2118- self.object_bytes.checked_add(object.len())2119- };2120- let Some(next_object_bytes) =2121- next_object_bytes.filter(|bytes| *bytes <= MAX_MEDIA_GROUP_BYTES_V3)2122- else {2123- self.cursor.request_keyframe();2124- self.abort_current();2125- return Ok(MoqGroupDecision::EnterResync);2126- };2127-2128- if position.object_id == 0 {2129- // A new independently-decodable GOP supersedes the previous one.2130- // Actively aborting it cancels a slow subscriber rather than2131- // retaining a playable history behind the live edge.2132- let cancelled_previous = self.abort_current().is_some();2133- let mut group = self2134- .track2135- .append_group()2136- .context("creating sequential MoQ video group")?;2137- let group_id = group.sequence;2138- group2139- .write_frame(object)2140- .context("writing configured keyframe to MoQ group")?;2141- self.object_bytes = next_object_bytes;2142- self.current_group = Some(group);2143- return Ok(MoqGroupDecision::Published {2144- group_id,2145- frame_id: 0,2146- cancelled_previous,2147- });2148- }2149-2150- let group = self2151- .current_group2152- .as_mut()2153- .context("MoQ delta frame has no active configured-keyframe group")?;2154- let group_id = group.sequence;2155- group2156- .write_frame(object)2157- .context("writing delta access unit to MoQ group")?;2158- self.object_bytes = next_object_bytes;2159- Ok(MoqGroupDecision::Published {2160- group_id,2161- frame_id: position.object_id,2162- cancelled_previous: false,2163- })2164- }2165-2166- fn request_keyframe(&mut self) -> Option<u64> {2167- self.cursor.request_keyframe();2168- self.abort_current()2169- }2170-2171- fn abort_current(&mut self) -> Option<u64> {2172- self.object_bytes = 0;2173- let mut group = self.current_group.take()?;2174- let group_id = group.sequence;2175- let _ = group.abort(MoqError::Cancel);2176- Some(group_id)2177- }2178-2179- fn abort(mut self) {2180- self.abort_current();2181- let _ = self.track.abort(MoqError::Cancel);2182- }2183-}2184-2185-#[derive(Debug, PartialEq, Eq)]2186-enum MediaV3ScheduleDecision {2187- Send {2188- discontinuity: bool,2189- cancel_sequences: Vec<u64>,2190- },2191- SkipUntilKeyframe,2192- EnterResync {2193- cancel_sequences: Vec<u64>,2194- },2195-}2196-2197-#[derive(Debug)]2198-struct MediaV3Scheduler {2199- in_flight: Vec<u64>,2200- last_scheduled_sequence: Option<u64>,2201- waiting_for_keyframe: bool,2202- discontinuity_pending: bool,2203-}2204-2205-impl Default for MediaV3Scheduler {2206- fn default() -> Self {2207- Self {2208- in_flight: Vec::with_capacity(MEDIA_V3_IN_FLIGHT_CAPACITY),2209- last_scheduled_sequence: None,2210- waiting_for_keyframe: true,2211- discontinuity_pending: false,2212- }2213- }2214-}2215-2216-impl MediaV3Scheduler {2217- fn schedule(2218- &mut self,2219- sequence: u64,2220- independently_decodable: bool,2221- ) -> MediaV3ScheduleDecision {2222- if self2223- .last_scheduled_sequence2224- .is_some_and(|last| sequence <= last)2225- {2226- return MediaV3ScheduleDecision::SkipUntilKeyframe;2227- }2228-2229- let sequence_discontinuity = self2230- .last_scheduled_sequence2231- .is_some_and(|last| last.checked_add(1) != Some(sequence));2232- if independently_decodable {2233- let cancel_sequences = std::mem::take(&mut self.in_flight);2234- let discontinuity = self.discontinuity_pending2235- || sequence_discontinuity2236- || !cancel_sequences.is_empty();2237- self.in_flight.push(sequence);2238- self.last_scheduled_sequence = Some(sequence);2239- self.waiting_for_keyframe = false;2240- self.discontinuity_pending = false;2241- return MediaV3ScheduleDecision::Send {2242- discontinuity,2243- cancel_sequences,2244- };2245- }2246-2247- if self.waiting_for_keyframe {2248- return MediaV3ScheduleDecision::SkipUntilKeyframe;2249- }2250- if sequence_discontinuity || self.in_flight.len() == MEDIA_V3_IN_FLIGHT_CAPACITY {2251- return MediaV3ScheduleDecision::EnterResync {2252- cancel_sequences: self.enter_resync(),2253- };2254- }2255-2256- self.in_flight.push(sequence);2257- self.last_scheduled_sequence = Some(sequence);2258- MediaV3ScheduleDecision::Send {2259- discontinuity: false,2260- cancel_sequences: Vec::new(),2261- }2262- }2263-2264- fn complete(&mut self, sequence: u64) {2265- if let Some(index) = self.in_flight.iter().position(|value| *value == sequence) {2266- self.in_flight.swap_remove(index);2267- }2268- }2269-2270- fn fail(&mut self, sequence: u64) -> Option<Vec<u64>> {2271- let index = self.in_flight.iter().position(|value| *value == sequence)?;2272- self.in_flight.swap_remove(index);2273- Some(self.enter_resync())2274- }2275-2276- fn fail_all(&mut self) -> Vec<u64> {2277- self.enter_resync()2278- }2279-2280- fn request_keyframe(&mut self) -> Vec<u64> {2281- if self.waiting_for_keyframe {2282- self.discontinuity_pending = true;2283- return Vec::new();2284- }2285- self.enter_resync()2286- }2287-2288- fn enter_resync(&mut self) -> Vec<u64> {2289- self.waiting_for_keyframe = true;2290- self.discontinuity_pending = true;2291- std::mem::take(&mut self.in_flight)2292- }2293-}2294-2295-fn apply_media_v3_keyframe_request(2296- scheduler: &mut MediaV3Scheduler,2297- group_cursor: &mut MediaV3GroupCursor,2298- replay_cursor: &mut MediaReplayCursor,2299- through_sequence: Option<u64>,2300- reason: KeyframeRequestReasonV3,2301-) -> (bool, Vec<u64>) {2302- // Every v3 session already begins by replaying the bounded current GOP2303- // from object zero. A Join arriving after that replay was scheduled must2304- // not cancel the only decodable image on a damage-driven static source.2305- if reason == KeyframeRequestReasonV3::Join || scheduler.waiting_for_keyframe {2306- return (false, Vec::new());2307- }2308- let cancel_sequences = scheduler.request_keyframe();2309- group_cursor.request_keyframe();2310- replay_cursor.enter_resync_through(through_sequence);2311- (true, cancel_sequences)2312-}2313-2314-fn apply_moq_keyframe_request(2315- publisher: &mut MoqGroupPublisher,2316- replay_cursor: &mut MediaReplayCursor,2317- through_sequence: Option<u64>,2318- reason: KeyframeRequestReasonV3,2319-) -> Option<u64> {2320- // The bounded current group is already the late joiner's decodable replay.2321- // Aborting it on Join can strand a static source until its next natural IDR.2322- if reason == KeyframeRequestReasonV3::Join {2323- return None;2324- }2325- let cancelled_group = publisher.request_keyframe();2326- replay_cursor.enter_resync_through(through_sequence);2327- cancelled_group2328-}2329-2330-fn apply_media_v3_send_failure(2331- scheduler: &mut MediaV3Scheduler,2332- group_cursor: &mut MediaV3GroupCursor,2333- replay_cursor: &mut MediaReplayCursor,2334- sequence: u64,2335- through_sequence: Option<u64>,2336-) -> Option<Vec<u64>> {2337- let cancel_sequences = scheduler.fail(sequence)?;2338- group_cursor.request_keyframe();2339- replay_cursor.enter_resync_through(through_sequence);2340- Some(cancel_sequences)2341-}2342-2343-#[derive(Clone, Copy, Debug, PartialEq, Eq)]2344-enum MediaV3ControlDecision {2345- Accept,2346- Pace(Duration),2347-}2348-2349-#[derive(Debug, Default)]2350-struct MediaV3ControlGate {2351- last_request_id: Option<u64>,2352- last_accepted_at: Option<Instant>,2353-}2354-2355-impl MediaV3ControlGate {2356- fn accept(2357- &mut self,2358- request: MediaControlRequestV3,2359- now: Instant,2360- ) -> Result<MediaV3ControlDecision> {2361- ensure!(2362- self.last_request_id2363- .is_none_or(|last| request.request_id > last),2364- "v3 media control request IDs must be strictly increasing"2365- );2366- self.last_request_id = Some(request.request_id);2367- if let Some(last) = self.last_accepted_at {2368- let elapsed = now.saturating_duration_since(last);2369- if elapsed < MEDIA_V3_CONTROL_REQUEST_INTERVAL {2370- return Ok(MediaV3ControlDecision::Pace(2371- MEDIA_V3_CONTROL_REQUEST_INTERVAL - elapsed,2372- ));2373- }2374- }2375- self.last_accepted_at = Some(now);2376- Ok(MediaV3ControlDecision::Accept)2377- }2378-}2379-2380-async fn forward_media_v3_control_requests<R>(2381- mut reader: R,2382- sender: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,2383-) -> Result<()>2384-where2385- R: tokio::io::AsyncRead + Unpin,2386-{2387- let mut gate = MediaV3ControlGate::default();2388- while let Some(request) = read_media_control_request_v3(&mut reader).await? {2389- match gate.accept(request, Instant::now())? {2390- MediaV3ControlDecision::Accept => {}2391- MediaV3ControlDecision::Pace(retry_after) => {2392- // Stop reading during the rejection interval so QUIC flow2393- // control, rather than this task, absorbs an abusive burst.2394- // This also bounds rejection logging to the configured rate.2395- debug!(2396- request_id = request.request_id,2397- retry_after_ms = retry_after.as_millis(),2398- "paced rate-limited v3 keyframe request"2399- );2400- tokio::time::sleep(retry_after).await;2401- continue;2402- }2403- }2404- sender.send_replace(Some(request));2405- if sender.is_closed() {2406- return Ok(());2407- }2408- }2409- Ok(())2410-}2411-2412-struct ResetOnDropSendStreamV3(Option<SendStream>);2413-2414-impl ResetOnDropSendStreamV3 {2415- fn new(stream: SendStream) -> Self {2416- Self(Some(stream))2417- }2418-2419- fn stream(&self) -> &SendStream {2420- self.0.as_ref().expect("send stream guard is armed")2421- }2422-2423- fn stream_mut(&mut self) -> &mut SendStream {2424- self.0.as_mut().expect("send stream guard is armed")2425- }2426-2427- fn disarm(&mut self) {2428- self.0.take();2429- }2430-}2431-2432-impl Drop for ResetOnDropSendStreamV3 {2433- fn drop(&mut self) {2434- if let Some(stream) = self.0.as_mut() {2435- let _ = stream.reset(MEDIA_V3_RESET_CODE.into());2436- }2437- }2438-}2439-2440-struct ResetOnDropSendStream(Option<SendStream>);2441-2442-impl ResetOnDropSendStream {2443- fn new(stream: SendStream) -> Self {2444- Self(Some(stream))2445- }2446-2447- fn stream(&self) -> &SendStream {2448- self.0.as_ref().expect("send stream guard is armed")2449- }2450-2451- fn stream_mut(&mut self) -> &mut SendStream {2452- self.0.as_mut().expect("send stream guard is armed")2453- }2454-2455- fn disarm(&mut self) {2456- self.0.take();2457- }2458-}2459-2460-impl Drop for ResetOnDropSendStream {2461- fn drop(&mut self) {2462- if let Some(stream) = self.0.as_mut() {2463- let _ = stream.reset(MEDIA_V2_RESET_CODE.into());2464- }2465- }2466-}2467-2468-#[derive(Clone, Copy, Debug, PartialEq, Eq)]2469-struct MediaReplayCursor {2470- last_sequence: Option<u64>,2471- waiting_for_keyframe: bool,2472- discontinuity_pending: bool,2473-}2474-2475-impl Default for MediaReplayCursor {2476- fn default() -> Self {2477- Self {2478- last_sequence: None,2479- waiting_for_keyframe: true,2480- discontinuity_pending: false,2481- }2482- }2483-}2484-2485-impl MediaReplayCursor {2486- fn classify(2487- &mut self,2488- frame: &EncodedFrame,2489- replay_through_sequence: u64,2490- initial_replay_started_at: Option<Instant>,2491- observed_now: Instant,2492- maximum_replay_age: Duration,2493- ) -> MediaReplayDecision {2494- let replay_age = observed_now.saturating_duration_since(frame.observed_at);2495- let initial_replay_within_budget = initial_replay_started_at.is_some_and(|started_at| {2496- observed_now.saturating_duration_since(started_at) <= maximum_replay_age2497- });2498- if !initial_replay_within_budget && replay_age > maximum_replay_age {2499- self.last_sequence = Some(replay_through_sequence);2500- self.waiting_for_keyframe = true;2501- self.discontinuity_pending = true;2502- return MediaReplayDecision::DiscardStaleSuffix {2503- through_sequence: replay_through_sequence,2504- };2505- }2506-2507- let sequence_discontinuity = self2508- .last_sequence2509- .is_some_and(|previous| previous.checked_add(1) != Some(frame.sequence));2510- if sequence_discontinuity {2511- self.waiting_for_keyframe = true;2512- self.discontinuity_pending = true;2513- }2514- if self.waiting_for_keyframe && !(frame.keyframe && frame.codec_config) {2515- return MediaReplayDecision::SkipUntilKeyframe;2516- }2517- MediaReplayDecision::Send {2518- discontinuity: self.discontinuity_pending,2519- }2520- }2521-2522- fn commit_sent(&mut self, frame: &EncodedFrame) {2523- self.last_sequence = Some(frame.sequence);2524- self.waiting_for_keyframe = false;2525- self.discontinuity_pending = false;2526- }2527-2528- fn enter_resync_through(&mut self, through_sequence: Option<u64>) {2529- if let Some(through_sequence) = through_sequence {2530- self.last_sequence = Some(2531- self.last_sequence2532- .map_or(through_sequence, |last| last.max(through_sequence)),2533- );2534- }2535- self.waiting_for_keyframe = true;2536- self.discontinuity_pending = true;2537- }2538-}2539-2540-fn maximum_media_replay_age(framerate: u32) -> Duration {2541- debug_assert!(framerate > 0);2542- let frame_period_nanos = 1_000_000_000_u64.div_ceil(u64::from(framerate.max(1)));2543- Duration::from_nanos(frame_period_nanos.saturating_mul(MAX_MEDIA_REPLAY_AGE_FRAME_PERIODS))2544-}2545-2546-async fn wait_for_media_activity<T, F>(2547- receiver: &mut tokio::sync::watch::Receiver<T>,2548- peer_disconnected: Pin<&mut F>,2549-) -> Result<MediaActivity>2550-where2551- T: Clone + Send + Sync,2552- F: Future<Output = ()>,2553-{2554- tokio::select! {2555- changed = receiver.changed() => {2556- changed.context("encoded source stopped")?;2557- Ok(MediaActivity::SourceChanged)2558- }2559- () = peer_disconnected => Ok(MediaActivity::PeerDisconnected),2560- }2561-}2562-2563-impl Drop for InputLease {2564- fn drop(&mut self) {2565- self.registry.release_input(self.remote, self.session_id);2566- }2567-}2568-2569-impl Drop for AudioLease {2570- fn drop(&mut self) {2571- self.registry.release_audio(self.remote, self.session_id);2572- }2573-}2574-2575-impl Drop for FeedbackLease {2576- fn drop(&mut self) {2577- self.registry.release_feedback(self.remote, self.session_id);2578- }2579-}2580-2581-#[derive(Clone, Debug)]2582-pub struct MediaHandler {2583- pub config: HostConfig,2584- pub sessions: Arc<SessionRegistry>,2585- pub authorization: AuthorizationPolicy,2586-}2587-2588-#[derive(Clone, Debug)]2589-pub struct MediaV2Handler {2590- pub config: HostConfig,2591- pub sessions: Arc<SessionRegistry>,2592- pub authorization: AuthorizationPolicy,2593-}2594-2595-#[derive(Clone, Debug)]2596-pub struct MediaV3Handler {2597- pub config: HostConfig,2598- pub sessions: Arc<SessionRegistry>,2599- pub authorization: AuthorizationPolicy,2600-}2601-2602-#[derive(Clone, Debug)]2603-pub struct ControlHandler {2604- pub config: HostConfig,2605- pub sessions: Arc<SessionRegistry>,2606- pub authorization: AuthorizationPolicy,2607-}2608-2609-#[derive(Clone, Debug)]2610-pub struct MediaFeedbackHandler {2611- pub config: HostConfig,2612- pub sessions: Arc<SessionRegistry>,2613- pub authorization: AuthorizationPolicy,2614-}2615-2616-/// Upstream MoQ admission guarded by an already-authenticated control lease.2617-///2618-/// This deliberately does not use `iroh_moq::Moq::protocol_handler`: that2619-/// actor makes a completed session globally visible before application-level2620-/// acceptance. Consuming the exact pending attachment first prevents MoQ from2621-/// bypassing Sigil's invitation, enrollment, and one-client gate.2622-#[derive(Clone, Debug)]2623-pub struct AuthorizedMoqHandler {2624- pub sessions: Arc<SessionRegistry>,2625- pub origin: Origin,2626-}2627-2628-impl ProtocolHandler for MediaHandler {2629- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2630- let remote = connection.remote_id();2631- if let Err(error) = serve_media(2632- connection,2633- self.config.clone(),2634- &self.sessions,2635- &self.authorization,2636- )2637- .await2638- {2639- warn!(%remote, %error, "media connection ended");2640- }2641- Ok(())2642- }2643-}2644-2645-impl ProtocolHandler for MediaV2Handler {2646- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2647- let remote = connection.remote_id();2648- if let Err(error) = serve_media_v2(2649- connection,2650- self.config.clone(),2651- &self.sessions,2652- &self.authorization,2653- )2654- .await2655- {2656- warn!(%remote, %error, "media v2 connection ended");2657- }2658- Ok(())2659- }2660-}2661-2662-impl ProtocolHandler for MediaV3Handler {2663- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2664- let remote = connection.remote_id();2665- if let Err(error) = serve_media_v3(2666- connection,2667- self.config.clone(),2668- &self.sessions,2669- &self.authorization,2670- )2671- .await2672- {2673- warn!(%remote, %error, "media v3 connection ended");2674- }2675- Ok(())2676- }2677-}2678-2679-impl ProtocolHandler for ControlHandler {2680- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2681- let remote = connection.remote_id();2682- if let Err(error) = serve_control_moq(2683- connection,2684- self.config.clone(),2685- &self.sessions,2686- &self.authorization,2687- )2688- .await2689- {2690- warn!(%remote, %error, "MoQ control connection ended");2691- }2692- Ok(())2693- }2694-}2695-2696-impl ProtocolHandler for MediaFeedbackHandler {2697- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2698- let remote = connection.remote_id();2699- if let Err(error) = serve_media_feedback(2700- connection,2701- &self.config,2702- &self.sessions,2703- &self.authorization,2704- )2705- .await2706- {2707- warn!(%remote, %error, "media feedback connection ended");2708- }2709- Ok(())2710- }2711-}2712-2713-impl ProtocolHandler for AuthorizedMoqHandler {2714- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2715- let remote = connection.remote_id();2716- let attachment = match self.sessions.claim_moq(remote) {2717- Ok(attachment) => attachment,2718- Err(error) => {2719- connection.close(MOQ_REJECT_CODE.into(), b"unauthorized MoQ attachment");2720- warn!(%remote, %error, "rejected unsolicited MoQ connection");2721- return Ok(());2722- }2723- };2724- if let Err(error) = serve_authorized_moq(connection, self.origin, attachment).await {2725- warn!(%remote, %error, "authorized MoQ connection ended");2726- }2727- Ok(())2728- }2729-}2730-2731-#[derive(Clone, Debug)]2732-pub struct InputHandler {2733- pub backend: InputBackend,2734- pub pointer_positions: Option<PointerPositionTracker>,2735- pub sessions: Arc<SessionRegistry>,2736-}2737-2738-#[derive(Clone, Debug)]2739-pub struct AudioHandler {2740- pub config: HostConfig,2741- pub sessions: Arc<SessionRegistry>,2742-}2743-2744-impl ProtocolHandler for AudioHandler {2745- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2746- let remote = connection.remote_id();2747- if let Err(error) = serve_audio(connection, self.config.clone(), &self.sessions).await {2748- warn!(%remote, %error, "audio connection ended");2749- }2750- Ok(())2751- }2752-}2753-2754-impl ProtocolHandler for InputHandler {2755- async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2756- let remote = connection.remote_id();2757- if let Err(error) = serve_input(2758- connection,2759- &self.backend,2760- self.pointer_positions.as_ref(),2761- &self.sessions,2762- )2763- .await2764- {2765- warn!(%remote, %error, "input connection ended");2766- }2767- Ok(())2768- }2769-}2770-2771-async fn serve_authorized_moq(2772- connection: Connection,2773- origin: Origin,2774- attachment: ClaimedMoqAttachment,2775-) -> Result<()> {2776- let ClaimedMoqAttachment {2777- session_id,2778- broadcast_name,2779- broadcast,2780- attached,2781- closed,2782- telemetry,2783- } = attachment;2784- let result: Result<()> = async {2785- let web_transport = web_transport_iroh::Session::raw(connection);2786- let session = tokio::time::timeout(2787- MOQ_ATTACHMENT_TIMEOUT,2788- iroh_moq::MoqSession::session_accept(web_transport, origin),2789- )2790- .await2791- .context("timed out completing authorized MoQ handshake")?2792- .context("completing authorized MoQ handshake")?;2793- let broadcast_closed = broadcast.clone();2794- session.publish(&broadcast_name, broadcast);2795- ensure!(2796- attached.send(()).is_ok(),2797- "control session ended before MoQ attachment completed"2798- );2799- info!(2800- remote = %session.remote_id(),2801- session_id,2802- %broadcast_name,2803- track = MOQ_VIDEO_H264_TRACK,2804- "authorized MoQ media attachment accepted"2805- );2806- let mut telemetry_interval = tokio::time::interval(Duration::from_secs(1));2807- telemetry_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);2808- loop {2809- tokio::select! {2810- reason = session.closed() => {2811- debug!(remote = %session.remote_id(), ?reason, "MoQ media session closed");2812- break;2813- }2814- reason = broadcast_closed.closed() => {2815- debug!(remote = %session.remote_id(), ?reason, "control-owned MoQ broadcast closed");2816- session.close(0, b"control session ended");2817- break;2818- }2819- _ = telemetry_interval.tick() => {2820- telemetry.record_selected_path(session.conn());2821- }2822- }2823- }2824- Ok(())2825- }2826- .await;2827- let _ = closed.send(());2828- result2829-}2830-2831-async fn serve_control_moq(2832- connection: Connection,2833- config: HostConfig,2834- sessions: &Arc<SessionRegistry>,2835- authorization: &AuthorizationPolicy,2836-) -> Result<()> {2837- let remote = connection.remote_id();2838- let handshake_permit = sessions2839- .pending_handshakes2840- .try_acquire()2841- .context("too many pending handshakes")?;2842- let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())2843- .await2844- .context("timed out accepting MoQ control stream")?2845- .context("accepting MoQ control stream")?;2846- let hello = receive_hello(&mut recv, Capability::VideoH264).await?;2847- drop(handshake_permit);2848- debug!(%remote, agent = %hello.agent, "MoQ control hello received");2849-2850- let grants = match authorization.authorize_or_redeem(2851- remote,2852- hello.invitation.as_deref(),2853- unix_timestamp_now()?,2854- ) {2855- Ok(grants) => grants,2856- Err(error) => {2857- send_rejection(&mut send, "Portal peer is not authorized").await?;2858- return Err(error.context("authorizing MoQ control peer"));2859- }2860- };2861- ensure!(2862- grants.contains(InvitationGrants::VIEW),2863- "authorized MoQ control peer lacks view permission"2864- );2865- let lease = match sessions.claim(remote, hello.nonce, grants) {2866- Ok(lease) => lease,2867- Err(error) => {2868- send_rejection(&mut send, "host already has an active client").await?;2869- return Err(error);2870- }2871- };2872-2873- let source = match config.source {2874- VideoSource::TestPattern => Ok(spawn_test_pattern(config.clone(), lease.session_clock)),2875- VideoSource::GamescopePipewire => {2876- let primary = spawn_gamescope_pipewire_after_static_preflight(2877- config.clone(),2878- lease.session_clock,2879- )2880- .await?;2881- select_gamescope_startup_source(config.clone(), lease.session_clock, primary).await2882- }2883- };2884- let EncodedSource {2885- frames: frame_receiver,2886- current_gop: mut current_gop_receiver,2887- task: source_task,2888- pointer_surface_dimensions,2889- encoder_control,2890- } = match source {2891- Ok(source) => source,2892- Err(error) => {2893- send_rejection(&mut send, "video source is unavailable").await?;2894- return Err(error);2895- }2896- };2897- let source_task = SourceTaskGuard::new(source_task);2898- sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;2899-2900- let mut broadcast = Broadcast::new().produce();2901- let track = broadcast2902- .create_track(Track {2903- name: MOQ_VIDEO_H264_TRACK.to_owned(),2904- priority: MOQ_VIDEO_TRACK_PRIORITY,2905- })2906- .context("creating static MoQ H.264 track")?;2907- let catalog = publish_goq_catalog(&mut broadcast)?;2908- let broadcast_name = media_moq_broadcast_name(lease.session_id)?;2909- let attachment = sessions.expect_moq(2910- remote,2911- lease.session_id,2912- broadcast_name.clone(),2913- broadcast.consume(),2914- )?;2915-2916- let mut control_hello = HostHello::accepted(2917- lease.session_id,2918- negotiated_capabilities(&hello, MEDIA_CAPABILITIES),2919- );2920- if let Some(dimensions) = pointer_surface_dimensions {2921- control_hello = control_hello.with_pointer_surface_dimensions(dimensions);2922- }2923- write_host_hello(&mut send, &control_hello).await?;2924- send.finish().context("finishing MoQ control response")?;2925- drop(send);2926- info!(2927- %remote,2928- session_id = lease.session_id,2929- %broadcast_name,2930- "MoQ control client accepted; awaiting authorized media attachment"2931- );2932-2933- let MoqAttachmentWait {2934- mut attached,2935- closed,2936- } = attachment;2937- tokio::time::timeout(MOQ_ATTACHMENT_TIMEOUT, async {2938- tokio::select! {2939- result = &mut attached => {2940- result.context("authorized MoQ handler ended before attachment")2941- }2942- reason = connection.closed() => {2943- Err(anyhow::anyhow!("control connection closed before MoQ attachment: {reason:?}"))2944- }2945- }2946- })2947- .await2948- .context("timed out waiting for authorized MoQ attachment")??;2949-2950- let session_result = run_control_moq_session(2951- &connection,2952- &config,2953- &mut current_gop_receiver,2954- recv,2955- remote,2956- closed,2957- track,2958- &mut broadcast,2959- encoder_control,2960- Arc::clone(&lease.media_v3_telemetry),2961- )2962- .await;2963- let catalog_result = catalog.finish();2964-2965- drop(current_gop_receiver);2966- drop(frame_receiver);2967- source_task.wait_or_abort(SOURCE_REAP_GRACE_TIMEOUT).await;2968- drop(lease);2969- info!(%remote, "MoQ control client released");2970- match session_result {2971- Err(error) => Err(error),2972- Ok(()) => catalog_result,2973- }2974-}2975-2976-#[allow(clippy::too_many_arguments)]2977-async fn run_control_moq_session(2978- connection: &Connection,2979- config: &HostConfig,2980- current_gop_receiver: &mut tokio::sync::watch::Receiver<Option<EncodedGop>>,2981- control_recv: iroh::endpoint::RecvStream,2982- remote: EndpointId,2983- mut moq_closed: tokio::sync::oneshot::Receiver<()>,2984- track: TrackProducer,2985- broadcast: &mut BroadcastProducer,2986- encoder_control: Option<EncoderControl>,2987- telemetry: Arc<MediaV3Telemetry>,2988-) -> Result<()> {2989- let maximum_replay_age = maximum_media_replay_age(config.framerate);2990- let mut replay_cursor = MediaReplayCursor::default();2991- let mut publisher = MoqGroupPublisher::new(track);2992- let (control_sender, mut control_requests) = tokio::sync::watch::channel(None);2993- let mut control_task = tokio::spawn(forward_media_v3_control_requests(2994- control_recv,2995- control_sender,2996- ));2997- let mut control_task_finished = false;2998- let mut control_receiver_open = true;2999- let mut forced_idr = ForcedIdrCoordinator::new(encoder_control, Arc::clone(&telemetry));3000-
diff truncated
A crates/sigil-host/src/server/adaptive.rs +2309 -0
@@ -0,0 +1,2309 @@
1+use super::session::{2+ AdaptiveEncoderProposal, MediaV3TelemetrySnapshot, ResolutionEncoderProposal, SessionRegistry,3+};4+use super::*;5+6+const ADAPTIVE_BITRATE_FLOOR_KBPS: u32 = 1_000;7+const SHADOW_BITRATE_CEILING_KBPS: u32 = 12_000;8+const ADAPTIVE_BITRATE_QUANTUM_KBPS: u32 = 250;9+const ADAPTIVE_BITRATE_CLEAN_WINDOWS: u8 = 10;10+const ADAPTIVE_BITRATE_MODERATE_WINDOWS: u8 = 2;11+const ADAPTIVE_BITRATE_COOLDOWN_WINDOWS: u8 = 10;12+const MOTION_RESOLUTION_MODERATE_WINDOWS: u8 = 2;13+const MOTION_RESOLUTION_CLEAN_WINDOWS: u8 = 3;14+const MOTION_RESOLUTION_ACTIVITY_FPS: u64 = 10;15+const MOTION_RESOLUTION_STILL_SETTLE: Duration = Duration::from_secs(2);16+const MOTION_RESOLUTION_UPSCALE_COOLDOWN: Duration = Duration::from_secs(3);17+const FEEDBACK_FRESH_INTERVAL_MIN_MS: u16 = 750;18+const FEEDBACK_FRESH_INTERVAL_MAX_MS: u16 = 1_500;19+const FEEDBACK_FRESH_ARRIVAL_GAP_MIN: Duration = Duration::from_millis(750);20+const FEEDBACK_STALE_ARRIVAL_GAP: Duration = Duration::from_millis(2_500);21+const FEEDBACK_MIN_READ_INTERVAL: Duration = Duration::from_millis(250);22+const FEEDBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(1);23+// Mirrors the fixed v1 wire bound. Aggregates clamp here so a stalled24+// consumer observes a stale 5-second window instead of a deceptively fresh25+// latest report.26+const MEDIA_FEEDBACK_INTERVAL_MAX_MS: u64 = 5_000;27+28+#[derive(Clone, Copy, Debug, PartialEq, Eq)]29+enum FeedbackSeverity {30+ Clean,31+ InsufficientEvidence,32+ Moderate,33+ Severe,34+ Stale,35+}36+37+#[derive(Clone, Copy, Debug, PartialEq, Eq)]38+pub(crate) struct VideoDimensions {39+ pub width: u16,40+ pub height: u16,41+}42+43+impl VideoDimensions {44+ fn three_quarter(self) -> Result<Self> {45+ ensure!(46+ self.width.is_multiple_of(4) && self.height.is_multiple_of(4),47+ "native dimensions must be divisible by four for an exact three-quarter tier"48+ );49+ let reduced = Self {50+ width: self.width / 4 * 3,51+ height: self.height / 4 * 3,52+ };53+ ensure!(54+ reduced.width >= 64 && reduced.height >= 64,55+ "three-quarter dimensions are below the H.264 minimum"56+ );57+ ensure!(58+ reduced.width.is_multiple_of(2) && reduced.height.is_multiple_of(2),59+ "three-quarter H.264 dimensions must be even"60+ );61+ ensure!(62+ u32::from(self.width) * u32::from(reduced.height)63+ == u32::from(self.height) * u32::from(reduced.width),64+ "three-quarter dimensions must preserve the native aspect ratio"65+ );66+ Ok(reduced)67+ }68+}69+70+#[derive(Clone, Copy, Debug, PartialEq, Eq)]71+enum ResolutionTier {72+ Native,73+ Reduced,74+}75+76+#[derive(Clone, Copy, Debug, PartialEq, Eq)]77+enum ResolutionTrigger {78+ Hold,79+ Motion,80+ Pressure,81+ Recovery,82+}83+84+#[derive(Clone, Copy, Debug, PartialEq, Eq)]85+struct MotionResolutionDecision {86+ tier: ResolutionTier,87+ target: VideoDimensions,88+ trigger: ResolutionTrigger,89+ changed: bool,90+}91+92+/// Pure two-tier resolution policy. Fresh sequence progress from Gamescope's93+/// damage-driven stream is the bounded motion proxy while the authenticated94+/// feedback path supplies pressure. A downshift is safety-biased and95+/// immediate; an upscale requires both settled motion and fresh clean96+/// feedback, so stale reports can never restore detail.97+#[derive(Clone, Debug)]98+pub(crate) struct MotionResolutionPolicy {99+ native: VideoDimensions,100+ reduced: VideoDimensions,101+ tier: ResolutionTier,102+ motion_active: bool,103+ still_since: Option<Instant>,104+ pressure_active: bool,105+ moderate_windows: u8,106+ clean_windows: u8,107+ last_transition_at: Option<Instant>,108+ last_motion_sequence: Option<u64>,109+}110+111+impl MotionResolutionPolicy {112+ pub(crate) fn new(native: VideoDimensions) -> Result<Self> {113+ ensure!(114+ native.width >= 64 && native.height >= 64,115+ "native dimensions are too small"116+ );117+ ensure!(118+ native.width.is_multiple_of(2) && native.height.is_multiple_of(2),119+ "native H.264 dimensions must be even"120+ );121+ Ok(Self {122+ native,123+ reduced: native.three_quarter()?,124+ tier: ResolutionTier::Native,125+ motion_active: false,126+ still_since: None,127+ pressure_active: false,128+ moderate_windows: 0,129+ clean_windows: 0,130+ last_transition_at: None,131+ last_motion_sequence: None,132+ })133+ }134+135+ pub(crate) fn target(&self) -> VideoDimensions {136+ match self.tier {137+ ResolutionTier::Native => self.native,138+ ResolutionTier::Reduced => self.reduced,139+ }140+ }141+142+ /// Host motion hook. Repeated still observations preserve the original143+ /// settle boundary instead of indefinitely postponing recovery.144+ #[cfg(test)]145+ fn observe_motion(146+ &mut self,147+ motion_active: bool,148+ observed_at: Instant,149+ ) -> MotionResolutionDecision {150+ self.update_motion(motion_active, observed_at);151+ self.evaluate_target(observed_at)152+ }153+154+ fn update_motion(&mut self, motion_active: bool, observed_at: Instant) {155+ if motion_active {156+ self.motion_active = true;157+ self.still_since = None;158+ return;159+ }160+ if self.motion_active || self.still_since.is_none() {161+ self.still_since = Some(observed_at);162+ }163+ self.motion_active = false;164+ }165+166+ /// Observe one classified feedback window. Only fresh moderate/severe167+ /// pressure can initiate a downshift, and only fresh clean windows count168+ /// toward restoring native detail.169+ #[cfg(test)]170+ fn observe_feedback(171+ &mut self,172+ severity: FeedbackSeverity,173+ fresh: bool,174+ observed_at: Instant,175+ ) -> MotionResolutionDecision {176+ self.update_feedback(severity, fresh);177+ self.evaluate_target(observed_at)178+ }179+180+ /// Gamescope output is damage-driven, so fresh media sequence progress is181+ /// a conservative constant-space motion proxy for this first slice. A182+ /// future raw-frame observer can call `observe_motion` without changing183+ /// pressure hysteresis or encoder actuation.184+ fn observe_window(185+ &mut self,186+ report: &MediaFeedbackReportV1,187+ evaluation: &AdaptiveBitrateEvaluation,188+ observed_at: Instant,189+ ) -> MotionResolutionDecision {190+ let previous_sequence = self.last_motion_sequence;191+ if let Some(sequence) = report.last_sequence {192+ self.last_motion_sequence = Some(193+ self.last_motion_sequence194+ .map_or(sequence, |last| last.max(sequence)),195+ );196+ }197+ if evaluation.fresh {198+ let motion_active = report.last_sequence.is_some_and(|sequence| {199+ let delta = previous_sequence.map_or(0, |last| sequence.saturating_sub(last));200+ delta.saturating_mul(1_000)201+ >= MOTION_RESOLUTION_ACTIVITY_FPS.saturating_mul(u64::from(report.interval_ms))202+ });203+ self.update_motion(motion_active, observed_at);204+ }205+ let resolution_severity = if evaluation.pressure_severity == FeedbackSeverity::Clean206+ && !evaluation.clean_recovery_evidence207+ {208+ FeedbackSeverity::InsufficientEvidence209+ } else {210+ evaluation.pressure_severity211+ };212+ self.update_feedback(resolution_severity, evaluation.fresh);213+ self.evaluate_target(observed_at)214+ }215+216+ fn update_feedback(&mut self, severity: FeedbackSeverity, fresh: bool) {217+ if !fresh218+ || matches!(219+ severity,220+ FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale221+ )222+ {223+ self.moderate_windows = 0;224+ self.clean_windows = 0;225+ return;226+ }227+228+ match severity {229+ FeedbackSeverity::Severe => {230+ self.pressure_active = true;231+ self.moderate_windows = 0;232+ self.clean_windows = 0;233+ }234+ FeedbackSeverity::Moderate => {235+ self.clean_windows = 0;236+ self.moderate_windows = self.moderate_windows.saturating_add(1);237+ if self.moderate_windows >= MOTION_RESOLUTION_MODERATE_WINDOWS {238+ self.moderate_windows = 0;239+ self.pressure_active = true;240+ }241+ }242+ FeedbackSeverity::Clean => {243+ self.moderate_windows = 0;244+ self.clean_windows = self.clean_windows.saturating_add(1);245+ if self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS {246+ self.pressure_active = false;247+ }248+ }249+ FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {}250+ }251+ }252+253+ fn evaluate_target(&mut self, observed_at: Instant) -> MotionResolutionDecision {254+ if self.motion_active {255+ self.downshift(observed_at, ResolutionTrigger::Motion)256+ } else if self.pressure_active {257+ self.downshift(observed_at, ResolutionTrigger::Pressure)258+ } else {259+ self.maybe_restore(observed_at)260+ }261+ }262+263+ fn commit_observation_from(&mut self, candidate: &Self) {264+ self.motion_active = candidate.motion_active;265+ self.still_since = candidate.still_since;266+ self.pressure_active = candidate.pressure_active;267+ self.moderate_windows = candidate.moderate_windows;268+ self.clean_windows = candidate.clean_windows;269+ self.last_motion_sequence = candidate.last_motion_sequence;270+ }271+272+ fn downshift(273+ &mut self,274+ observed_at: Instant,275+ trigger: ResolutionTrigger,276+ ) -> MotionResolutionDecision {277+ let changed = self.tier != ResolutionTier::Reduced;278+ if changed {279+ self.tier = ResolutionTier::Reduced;280+ self.last_transition_at = Some(observed_at);281+ }282+ MotionResolutionDecision {283+ tier: self.tier,284+ target: self.target(),285+ trigger,286+ changed,287+ }288+ }289+290+ fn maybe_restore(&mut self, observed_at: Instant) -> MotionResolutionDecision {291+ let still_settled = !self.motion_active292+ && self.still_since.is_some_and(|still_since| {293+ observed_at.saturating_duration_since(still_since) >= MOTION_RESOLUTION_STILL_SETTLE294+ });295+ let cooldown_complete = self.last_transition_at.is_none_or(|last| {296+ observed_at.saturating_duration_since(last) >= MOTION_RESOLUTION_UPSCALE_COOLDOWN297+ });298+ let clean = self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS;299+ if self.tier == ResolutionTier::Reduced300+ && still_settled301+ && cooldown_complete302+ && clean303+ && !self.pressure_active304+ {305+ self.tier = ResolutionTier::Native;306+ self.clean_windows = 0;307+ self.last_transition_at = Some(observed_at);308+ return MotionResolutionDecision {309+ tier: self.tier,310+ target: self.target(),311+ trigger: ResolutionTrigger::Recovery,312+ changed: true,313+ };314+ }315+ self.hold()316+ }317+318+ fn hold(&self) -> MotionResolutionDecision {319+ MotionResolutionDecision {320+ tier: self.tier,321+ target: self.target(),322+ trigger: ResolutionTrigger::Hold,323+ changed: false,324+ }325+ }326+}327+328+fn motion_resolution_policy(width: u32, height: u32) -> Result<Option<MotionResolutionPolicy>> {329+ let native = VideoDimensions {330+ width: u16::try_from(width).context("native width exceeds protocol")?,331+ height: u16::try_from(height).context("native height exceeds protocol")?,332+ };333+ match MotionResolutionPolicy::new(native) {334+ Ok(policy) => Ok(Some(policy)),335+ Err(error) => {336+ warn!(337+ %error,338+ width,339+ height,340+ "motion resolution disabled because this native mode has no exact reduced tier"341+ );342+ Ok(None)343+ }344+ }345+}346+347+fn motion_resolution_policy_for_encoder(348+ control: &EncoderControl,349+) -> Result<Option<MotionResolutionPolicy>> {350+ let (width, height) = control.initial_dimensions();351+ motion_resolution_policy(u32::from(width), u32::from(height))352+}353+354+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]355+struct FeedbackIngressCursor {356+ last_report_id: Option<u64>,357+ interval_ms: u64,358+ transport_dropped: u64,359+ frontend_dropped: u64,360+ decoder_dropped: u64,361+ presenter_dropped: u64,362+ resync_reports: u64,363+}364+365+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]366+struct CumulativeFeedbackIngress {367+ latest: Option<MediaFeedbackReportV1>,368+ interval_ms: u64,369+ transport_dropped: u64,370+ frontend_dropped: u64,371+ decoder_dropped: u64,372+ presenter_dropped: u64,373+ resync_reports: u64,374+}375+376+impl CumulativeFeedbackIngress {377+ fn observe(&mut self, report: MediaFeedbackReportV1) {378+ self.interval_ms = self.interval_ms.wrapping_add(u64::from(report.interval_ms));379+ self.transport_dropped = self380+ .transport_dropped381+ .wrapping_add(u64::from(report.transport_dropped_delta));382+ self.frontend_dropped = self383+ .frontend_dropped384+ .wrapping_add(u64::from(report.frontend_dropped_delta));385+ self.decoder_dropped = self386+ .decoder_dropped387+ .wrapping_add(u64::from(report.decoder_dropped_delta));388+ self.presenter_dropped = self389+ .presenter_dropped390+ .wrapping_add(u64::from(report.presenter_dropped_delta));391+ if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE) {392+ self.resync_reports = self.resync_reports.wrapping_add(1);393+ }394+ self.latest = Some(report);395+ }396+397+ fn report_since(398+ &self,399+ cursor: FeedbackIngressCursor,400+ ) -> Result<Option<(MediaFeedbackReportV1, FeedbackIngressCursor)>> {401+ let Some(latest) = self.latest else {402+ return Ok(None);403+ };404+ if cursor405+ .last_report_id406+ .is_some_and(|report_id| latest.report_id <= report_id)407+ {408+ return Ok(None);409+ }410+ let mut report = latest;411+ report.interval_ms = u16::try_from(412+ self.interval_ms413+ .wrapping_sub(cursor.interval_ms)414+ .min(MEDIA_FEEDBACK_INTERVAL_MAX_MS),415+ )416+ .expect("feedback interval is clamped to u16");417+ report.transport_dropped_delta =418+ cumulative_feedback_delta(self.transport_dropped, cursor.transport_dropped);419+ report.frontend_dropped_delta =420+ cumulative_feedback_delta(self.frontend_dropped, cursor.frontend_dropped);421+ report.decoder_dropped_delta =422+ cumulative_feedback_delta(self.decoder_dropped, cursor.decoder_dropped);423+ report.presenter_dropped_delta =424+ cumulative_feedback_delta(self.presenter_dropped, cursor.presenter_dropped);425+ report.flags = if self.resync_reports.wrapping_sub(cursor.resync_reports) > 0 {426+ MediaFeedbackFlags::RESYNC_ACTIVE427+ } else {428+ MediaFeedbackFlags::NONE429+ };430+ report.validate()?;431+ Ok(Some((432+ report,433+ FeedbackIngressCursor {434+ last_report_id: Some(latest.report_id),435+ interval_ms: self.interval_ms,436+ transport_dropped: self.transport_dropped,437+ frontend_dropped: self.frontend_dropped,438+ decoder_dropped: self.decoder_dropped,439+ presenter_dropped: self.presenter_dropped,440+ resync_reports: self.resync_reports,441+ },442+ )))443+ }444+}445+446+fn cumulative_feedback_delta(total: u64, baseline: u64) -> u32 {447+ u32::try_from(total.wrapping_sub(baseline).min(u64::from(u32::MAX)))448+ .expect("feedback delta is clamped to u32")449+}450+451+#[derive(Clone, Debug)]452+struct ShadowBitrateController {453+ floor_kbps: u32,454+ ceiling_kbps: u32,455+ target_kbps: u32,456+ next_decision_id: u64,457+ last_report_id: Option<u64>,458+ last_report_received_at: Option<Instant>,459+ previous_telemetry: MediaV3TelemetrySnapshot,460+ baseline_rtt_micros: Option<u64>,461+ last_sequence: Option<u64>,462+ moderate_windows: u8,463+ clean_windows: u8,464+ cooldown_windows: u8,465+}466+467+#[derive(Clone, Copy, Debug)]468+struct AdaptiveBitrateEvaluation {469+ decision: AdaptiveBitrateDecisionV1,470+ pressure_severity: FeedbackSeverity,471+ clean_recovery_evidence: bool,472+ fresh: bool,473+}474+475+impl ShadowBitrateController {476+ fn new(configured_ceiling_kbps: u32, telemetry: MediaV3TelemetrySnapshot) -> Self {477+ let ceiling_kbps = quantize_down(configured_ceiling_kbps).max(ADAPTIVE_BITRATE_FLOOR_KBPS);478+ Self {479+ floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,480+ ceiling_kbps,481+ target_kbps: ceiling_kbps,482+ next_decision_id: 1,483+ last_report_id: None,484+ last_report_received_at: None,485+ previous_telemetry: telemetry,486+ baseline_rtt_micros: None,487+ last_sequence: None,488+ moderate_windows: 0,489+ clean_windows: 0,490+ cooldown_windows: 0,491+ }492+ }493+494+ fn evaluate(495+ &mut self,496+ report: &MediaFeedbackReportV1,497+ telemetry: MediaV3TelemetrySnapshot,498+ received_at: Instant,499+ ) -> Result<AdaptiveBitrateEvaluation> {500+ ensure!(501+ self.last_report_id502+ .is_none_or(|last| report.report_id > last),503+ "feedback report IDs must increase monotonically"504+ );505+ let (severity, pressure_severity, reasons, stale, trusted_host_pressure) =506+ self.classify(report, telemetry, received_at);507+ let mut state = AdaptiveBitrateStateV1::Hold;508+509+ if self.cooldown_windows > 0 {510+ self.cooldown_windows -= 1;511+ }512+ match severity {513+ FeedbackSeverity::Severe => {514+ self.moderate_windows = 0;515+ self.clean_windows = 0;516+ self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;517+ let next =518+ quantize_down(self.target_kbps.saturating_mul(3) / 4).max(self.floor_kbps);519+ if next < self.target_kbps {520+ self.target_kbps = next;521+ state = AdaptiveBitrateStateV1::Decrease;522+ }523+ }524+ FeedbackSeverity::Moderate => {525+ self.moderate_windows = self.moderate_windows.saturating_add(1);526+ self.clean_windows = 0;527+ if self.moderate_windows >= ADAPTIVE_BITRATE_MODERATE_WINDOWS {528+ self.moderate_windows = 0;529+ self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;530+ let next =531+ quantize_down(self.target_kbps.saturating_mul(4) / 5).max(self.floor_kbps);532+ if next < self.target_kbps {533+ self.target_kbps = next;534+ state = AdaptiveBitrateStateV1::Decrease;535+ }536+ }537+ }538+ FeedbackSeverity::Clean => {539+ self.moderate_windows = 0;540+ self.clean_windows = self.clean_windows.saturating_add(1);541+ if self.clean_windows >= ADAPTIVE_BITRATE_CLEAN_WINDOWS542+ && self.cooldown_windows == 0543+ {544+ self.clean_windows = 0;545+ let maximum_step =546+ (self.target_kbps / 20).clamp(ADAPTIVE_BITRATE_QUANTUM_KBPS, 500);547+ let next = quantize_down(548+ self.target_kbps549+ .saturating_add(maximum_step)550+ .min(self.ceiling_kbps),551+ );552+ if next > self.target_kbps {553+ self.target_kbps = next;554+ state = AdaptiveBitrateStateV1::Increase;555+ }556+ }557+ }558+ FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {559+ self.moderate_windows = 0;560+ self.clean_windows = 0;561+ }562+ }563+564+ self.target_kbps = self.target_kbps.clamp(self.floor_kbps, self.ceiling_kbps);565+ let decision = AdaptiveBitrateDecisionV1 {566+ decision_id: self.next_decision_id,567+ report_id: report.report_id,568+ target_kbps: self.target_kbps,569+ floor_kbps: self.floor_kbps,570+ ceiling_kbps: self.ceiling_kbps,571+ state,572+ reasons,573+ applied: false,574+ };575+ decision.validate()?;576+ self.next_decision_id = self577+ .next_decision_id578+ .checked_add(1)579+ .context("adaptive bitrate decision ID exhausted")?;580+ self.last_report_id = Some(report.report_id);581+ self.last_report_received_at = Some(received_at);582+ if !stale583+ || !trusted_host_pressure584+ || state == AdaptiveBitrateStateV1::Decrease585+ || self.target_kbps == self.floor_kbps586+ {587+ self.previous_telemetry = telemetry;588+ }589+ if !stale && let Some(sequence) = report.last_sequence {590+ self.last_sequence = Some(591+ self.last_sequence592+ .map_or(sequence, |last| last.max(sequence)),593+ );594+ }595+ Ok(AdaptiveBitrateEvaluation {596+ decision,597+ pressure_severity,598+ clean_recovery_evidence: severity == FeedbackSeverity::Clean,599+ fresh: !stale,600+ })601+ }602+603+ #[cfg(test)]604+ fn decide(605+ &mut self,606+ report: &MediaFeedbackReportV1,607+ telemetry: MediaV3TelemetrySnapshot,608+ received_at: Instant,609+ ) -> Result<AdaptiveBitrateDecisionV1> {610+ self.evaluate(report, telemetry, received_at)611+ .map(|evaluation| evaluation.decision)612+ }613+614+ fn classify(615+ &mut self,616+ report: &MediaFeedbackReportV1,617+ telemetry: MediaV3TelemetrySnapshot,618+ received_at: Instant,619+ ) -> (620+ FeedbackSeverity,621+ FeedbackSeverity,622+ AdaptiveBitrateReasonFlagsV1,623+ bool,624+ bool,625+ ) {626+ let stale = !(FEEDBACK_FRESH_INTERVAL_MIN_MS..=FEEDBACK_FRESH_INTERVAL_MAX_MS)627+ .contains(&report.interval_ms)628+ || self.last_report_received_at.is_none()629+ || self.last_report_received_at.is_some_and(|last| {630+ let gap = received_at.saturating_duration_since(last);631+ !(FEEDBACK_FRESH_ARRIVAL_GAP_MIN..=FEEDBACK_STALE_ARRIVAL_GAP).contains(&gap)632+ });633+ let mut severity = FeedbackSeverity::Clean;634+ let mut reasons = AdaptiveBitrateReasonFlagsV1::NONE;635+636+ let lost_delta = telemetry637+ .selected_path_lost_packets638+ .saturating_sub(self.previous_telemetry.selected_path_lost_packets);639+ let congestion_delta = telemetry640+ .selected_path_congestion_events641+ .saturating_sub(self.previous_telemetry.selected_path_congestion_events);642+ let cancellation_delta = telemetry643+ .scheduler_cancellations644+ .saturating_sub(self.previous_telemetry.scheduler_cancellations);645+ let failure_delta = telemetry646+ .send_failures647+ .saturating_sub(self.previous_telemetry.send_failures);648+649+ if failure_delta > 0 || lost_delta > 0 || cancellation_delta >= 2 {650+ add_feedback_signal(651+ &mut severity,652+ &mut reasons,653+ FeedbackSeverity::Severe,654+ AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,655+ );656+ } else if cancellation_delta > 0 || congestion_delta > 0 {657+ add_feedback_signal(658+ &mut severity,659+ &mut reasons,660+ FeedbackSeverity::Moderate,661+ AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,662+ );663+ }664+ if cancellation_delta >= 2 || failure_delta > 0 {665+ add_feedback_signal(666+ &mut severity,667+ &mut reasons,668+ FeedbackSeverity::Severe,669+ AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,670+ );671+ } else if cancellation_delta > 0 {672+ add_feedback_signal(673+ &mut severity,674+ &mut reasons,675+ FeedbackSeverity::Moderate,676+ AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,677+ );678+ }679+680+ let rtt = telemetry.selected_path_rtt_micros;681+ if rtt > 0 {682+ let baseline = self.baseline_rtt_micros.get_or_insert(rtt);683+ if rtt < *baseline {684+ *baseline = rtt;685+ } else if rtt >= baseline.saturating_mul(2).max(80_000) {686+ add_feedback_signal(687+ &mut severity,688+ &mut reasons,689+ FeedbackSeverity::Severe,690+ AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,691+ );692+ } else if rtt >= baseline.saturating_mul(3) / 2 {693+ add_feedback_signal(694+ &mut severity,695+ &mut reasons,696+ FeedbackSeverity::Moderate,697+ AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,698+ );699+ }700+ }701+702+ let trusted_host_pressure = severity != FeedbackSeverity::Clean;703+ if stale {704+ reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE);705+ if !trusted_host_pressure {706+ severity = FeedbackSeverity::Stale;707+ }708+ return (severity, severity, reasons, true, trusted_host_pressure);709+ }710+711+ let receiver_drops = report712+ .transport_dropped_delta713+ .saturating_add(report.frontend_dropped_delta)714+ .saturating_add(report.decoder_dropped_delta)715+ .saturating_add(report.presenter_dropped_delta);716+ let queue_severe = queue_ratio_at_least(717+ report.frontend_queue_depth,718+ report.frontend_queue_capacity,719+ 3,720+ 4,721+ ) || queue_ratio_at_least(722+ report.decode_queue_depth,723+ report.decode_queue_capacity,724+ 3,725+ 4,726+ ) || queue_ratio_at_least(727+ report.presenter_queue_depth,728+ report.presenter_queue_capacity,729+ 3,730+ 4,731+ );732+ let queue_moderate = queue_ratio_at_least(733+ report.frontend_queue_depth,734+ report.frontend_queue_capacity,735+ 1,736+ 2,737+ ) || queue_ratio_at_least(738+ report.decode_queue_depth,739+ report.decode_queue_capacity,740+ 1,741+ 2,742+ ) || queue_ratio_at_least(743+ report.presenter_queue_depth,744+ report.presenter_queue_capacity,745+ 1,746+ 2,747+ );748+ if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)749+ || queue_severe750+ || receiver_drops >= 4751+ {752+ add_feedback_signal(753+ &mut severity,754+ &mut reasons,755+ FeedbackSeverity::Severe,756+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,757+ );758+ } else if queue_moderate || receiver_drops > 0 {759+ add_feedback_signal(760+ &mut severity,761+ &mut reasons,762+ FeedbackSeverity::Moderate,763+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,764+ );765+ }766+ if report.decode_queue_depth == report.decode_queue_capacity767+ || report.decoder_dropped_delta > 0768+ {769+ add_feedback_signal(770+ &mut severity,771+ &mut reasons,772+ FeedbackSeverity::Severe,773+ AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,774+ );775+ } else if report.decode_queue_depth >= 2 {776+ add_feedback_signal(777+ &mut severity,778+ &mut reasons,779+ FeedbackSeverity::Moderate,780+ AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,781+ );782+ }783+784+ let delivery_p95 = report.transport_delivery_p95_ms.unwrap_or(0);785+ let decode_p95 = report.decode_p95_ms.unwrap_or(0);786+ let presentation_p95 = report.presentation_p95_ms.unwrap_or(0);787+ if delivery_p95 >= 200 || decode_p95 >= 100 || presentation_p95 >= 100 {788+ add_feedback_signal(789+ &mut severity,790+ &mut reasons,791+ FeedbackSeverity::Severe,792+ AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,793+ );794+ } else if delivery_p95 >= 100 || decode_p95 >= 50 || presentation_p95 >= 50 {795+ add_feedback_signal(796+ &mut severity,797+ &mut reasons,798+ FeedbackSeverity::Moderate,799+ AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,800+ );801+ }802+803+ let pressure_severity = severity;804+ if severity == FeedbackSeverity::Clean {805+ let sequence_progressed = self806+ .last_sequence807+ .zip(report.last_sequence)808+ .is_some_and(|(last, current)| current > last);809+ let complete_latency = report.transport_delivery_p95_ms.is_some()810+ && report.decode_p95_ms.is_some()811+ && report.presentation_p95_ms.is_some();812+ if sequence_progressed && complete_latency {813+ if self.target_kbps < self.ceiling_kbps {814+ reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY);815+ }816+ } else {817+ severity = FeedbackSeverity::InsufficientEvidence;818+ }819+ }820+ (821+ severity,822+ pressure_severity,823+ reasons,824+ false,825+ trusted_host_pressure,826+ )827+ }828+829+ fn commit_observation_from(&mut self, candidate: &Self) {830+ self.next_decision_id = candidate.next_decision_id;831+ self.last_report_id = candidate.last_report_id;832+ self.last_report_received_at = candidate.last_report_received_at;833+ self.previous_telemetry = candidate.previous_telemetry;834+ self.baseline_rtt_micros = candidate.baseline_rtt_micros;835+ self.last_sequence = candidate.last_sequence;836+ }837+}838+839+fn add_feedback_signal(840+ severity: &mut FeedbackSeverity,841+ reasons: &mut AdaptiveBitrateReasonFlagsV1,842+ signal: FeedbackSeverity,843+ reason: AdaptiveBitrateReasonFlagsV1,844+) {845+ *reasons = reasons.union(reason);846+ if signal == FeedbackSeverity::Severe847+ || (*severity == FeedbackSeverity::Clean && signal == FeedbackSeverity::Moderate)848+ {849+ *severity = signal;850+ }851+}852+853+fn quantize_down(kbps: u32) -> u32 {854+ kbps / ADAPTIVE_BITRATE_QUANTUM_KBPS * ADAPTIVE_BITRATE_QUANTUM_KBPS855+}856+857+fn queue_ratio_at_least(depth: u8, capacity: u8, numerator: u16, denominator: u16) -> bool {858+ capacity > 0859+ && u16::from(depth).saturating_mul(denominator)860+ >= u16::from(capacity).saturating_mul(numerator)861+}862+863+pub(super) async fn serve_media_feedback(864+ connection: Connection,865+ config: &HostConfig,866+ sessions: &Arc<SessionRegistry>,867+ authorization: &AuthorizationPolicy,868+) -> Result<()> {869+ let remote = connection.remote_id();870+ let handshake_permit = sessions871+ .pending_handshakes872+ .try_acquire()873+ .context("too many pending handshakes")?;874+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())875+ .await876+ .context("timed out accepting media feedback stream")?877+ .context("accepting media feedback stream")?;878+ let hello = receive_hello(&mut recv, Capability::VideoH264).await?;879+ drop(handshake_permit);880+ debug!(%remote, agent = %hello.agent, "media feedback hello received");881+ ensure!(882+ hello.invitation.is_none(),883+ "invitations are accepted only on the first media handshake"884+ );885+886+ let grants = match authorization.authorize_or_redeem(remote, None, unix_timestamp_now()?) {887+ Ok(grants) => grants,888+ Err(error) => {889+ send_rejection(&mut send, "Portal peer is not authorized").await?;890+ return Err(error.context("authorizing media feedback peer"));891+ }892+ };893+ ensure!(894+ grants.contains(InvitationGrants::VIEW),895+ "authorized feedback peer lacks view permission"896+ );897+ let ceiling_kbps = adaptive_bitrate_ceiling_kbps(config)?;898+ let lease = match sessions.claim_feedback(remote, hello.nonce) {899+ Ok(lease) => lease,900+ Err(error) => {901+ send_rejection(&mut send, error.to_string()).await?;902+ return Err(error);903+ }904+ };905+ let encoder_actuation_available =906+ adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();907+908+ write_host_hello(909+ &mut send,910+ &HostHello::accepted(911+ lease.session_id,912+ negotiated_capabilities(&hello, MEDIA_CAPABILITIES),913+ ),914+ )915+ .await?;916+ info!(917+ %remote,918+ session_id = lease.session_id,919+ ceiling_kbps,920+ applied = false,921+ mode = if encoder_actuation_available { "active" } else { "shadow" },922+ "media feedback client accepted for bitrate control"923+ );924+925+ let (feedback_sender, mut feedback_receiver) =926+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());927+ let mut reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));928+ let mut reader_finished = false;929+ let mut receiver_open = true;930+ let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());931+ let mut resolution_controller = if encoder_actuation_available {932+ lease933+ .encoder_control934+ .as_ref()935+ .map(motion_resolution_policy_for_encoder)936+ .transpose()?937+ .flatten()938+ } else {939+ None940+ };941+ let mut feedback_cursor = FeedbackIngressCursor::default();942+943+ let result: Result<()> = loop {944+ let session_changed = sessions.session_changed.notified();945+ tokio::pin!(session_changed);946+ session_changed.as_mut().enable();947+ if !sessions.is_active(remote, lease.session_id) {948+ break Ok(());949+ }950+ tokio::select! {951+ biased;952+ reader_result = &mut reader_task, if !reader_finished => {953+ reader_finished = true;954+ match reader_result {955+ Ok(Ok(())) => {956+ debug!(%remote, "media feedback report stream closed");957+ }958+ Ok(Err(error)) => {959+ break Err(error).context("reading media feedback reports");960+ }961+ Err(error) => {962+ break Err(error).context("media feedback reader task failed");963+ }964+ }965+ }966+ changed = feedback_receiver.changed(), if receiver_open => {967+ if changed.is_err() {968+ receiver_open = false;969+ if reader_finished {970+ break Ok(());971+ }972+ continue;973+ }974+ let ingress = *feedback_receiver.borrow_and_update();975+ let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {976+ continue;977+ };978+ // This branch now owns the complete aggregate. Advance the979+ // ingress baseline exactly once before evaluation; reports980+ // arriving during an encoder wait accumulate beyond it.981+ feedback_cursor = next_cursor;982+ let telemetry = lease.telemetry.snapshot();983+ let observed_at = Instant::now();984+ let mut candidate = controller.clone();985+ let evaluation = candidate.evaluate(&report, telemetry, observed_at)?;986+ let mut decision = evaluation.decision;987+ let mut resolution_candidate = resolution_controller.clone();988+ let resolution_decision = resolution_candidate989+ .as_mut()990+ .map(|policy| policy.observe_window(&report, &evaluation, observed_at));991+ let changes_bitrate = decision.state != AdaptiveBitrateStateV1::Hold;992+ let changes_resolution = resolution_decision.is_some_and(|change| change.changed);993+ let can_actuate_bitrate = changes_bitrate && encoder_actuation_available;994+ let can_actuate_resolution = changes_resolution && encoder_actuation_available;995+ let mut recovery_acknowledged = None;996+ let bitrate_proposal = if can_actuate_bitrate {997+ let force_keyframe =998+ adaptive_recovery_keyframe_required(&report, &decision)999+ && !changes_resolution;1000+ match sessions.propose_adaptive_encoder_update(1001+ remote,1002+ lease.session_id,1003+ decision.target_kbps,1004+ force_keyframe,1005+ ) {1006+ Ok(Some(proposal)) => Some(proposal),1007+ Ok(None) => {1008+ warn!(1009+ %remote,1010+ session_id = lease.session_id,1011+ "active adaptive session lost encoder control; retaining committed bitrate"1012+ );1013+ None1014+ }1015+ Err(error) => {1016+ warn!(1017+ %remote,1018+ session_id = lease.session_id,1019+ %error,1020+ "adaptive encoder proposal failed; retaining committed bitrate"1021+ );1022+ None1023+ }1024+ }1025+ } else {1026+ None1027+ };1028+ let resolution_proposal = if can_actuate_resolution {1029+ let target = resolution_decision1030+ .expect("resolution change is required for actuation")1031+ .target;1032+ match sessions.propose_resolution_update(remote, lease.session_id, target) {1033+ Ok(Some(proposal)) => Some(proposal),1034+ Ok(None) => {1035+ warn!(1036+ %remote,1037+ session_id = lease.session_id,1038+ "active adaptive session lost encoder control; retaining committed resolution"1039+ );1040+ None1041+ }1042+ Err(error) => {1043+ warn!(1044+ %remote,1045+ session_id = lease.session_id,1046+ %error,1047+ "adaptive resolution proposal failed; retaining committed resolution"1048+ );1049+ None1050+ }1051+ }1052+ } else {1053+ None1054+ };1055+1056+ if let Some(proposal) = bitrate_proposal {1057+ match commit_adaptive_encoder_proposal(1058+ sessions,1059+ remote,1060+ lease.session_id,1061+ &proposal,1062+ )1063+ .await1064+ {1065+ AdaptiveEncoderCommit::GenerationEnded => break Ok(()),1066+ AdaptiveEncoderCommit::NotApplied(error) => {1067+ warn!(1068+ %remote,1069+ session_id = lease.session_id,1070+ %error,1071+ "adaptive encoder application failed; retaining committed bitrate"1072+ );1073+ }1074+ AdaptiveEncoderCommit::Applied => {1075+ decision.applied = true;1076+ controller = candidate.clone();1077+ if proposal.force_keyframe_revision.is_some() {1078+ match await_adaptive_recovery_keyframe(1079+ sessions,1080+ remote,1081+ lease.session_id,1082+ &proposal,1083+ )1084+ .await1085+ {1086+ None => break Ok(()),1087+ Some(Ok(())) => recovery_acknowledged = Some(true),1088+ Some(Err(error)) => {1089+ recovery_acknowledged = Some(false);1090+ warn!(1091+ %remote,1092+ session_id = lease.session_id,1093+ %error,1094+ "adaptive bitrate applied but forced-IDR recovery was not acknowledged"1095+ );1096+ }1097+ }1098+ }1099+ }1100+ }1101+ }1102+ if can_actuate_bitrate {1103+ if !decision.applied {1104+ controller.commit_observation_from(&candidate);1105+ }1106+ } else {1107+ // External gst-launch and sources without EncoderControl1108+ // remain intentionally shadow-only. Hold decisions do not1109+ // need encoder actuation.1110+ controller = candidate;1111+ }1112+1113+ let mut resolution_applied = None;1114+ if let Some(proposal) = resolution_proposal {1115+ match commit_resolution_encoder_proposal(1116+ sessions,1117+ remote,1118+ lease.session_id,1119+ &proposal,1120+ )1121+ .await1122+ {1123+ AdaptiveEncoderCommit::GenerationEnded => break Ok(()),1124+ AdaptiveEncoderCommit::NotApplied(error) => {1125+ resolution_applied = Some(false);1126+ warn!(1127+ %remote,1128+ session_id = lease.session_id,1129+ target_width = proposal.target.width,1130+ target_height = proposal.target.height,1131+ %error,1132+ "adaptive resolution application failed; retaining committed resolution"1133+ );1134+ }1135+ AdaptiveEncoderCommit::Applied => {1136+ resolution_applied = Some(true);1137+ resolution_controller = resolution_candidate.clone();1138+ }1139+ }1140+ }1141+ if can_actuate_resolution && resolution_applied != Some(true) {1142+ if let (Some(committed), Some(candidate)) =1143+ (resolution_controller.as_mut(), resolution_candidate.as_ref())1144+ {1145+ committed.commit_observation_from(candidate);1146+ }1147+ } else if !changes_resolution {1148+ resolution_controller = resolution_candidate;1149+ }1150+ tokio::time::timeout(1151+ FEEDBACK_WRITE_TIMEOUT,1152+ write_adaptive_bitrate_decision_v1(&mut send, &decision),1153+ )1154+ .await1155+ .context("timed out writing adaptive bitrate decision")??;1156+ debug!(1157+ %remote,1158+ session_id = lease.session_id,1159+ report_id = decision.report_id,1160+ decision_id = decision.decision_id,1161+ target_kbps = decision.target_kbps,1162+ ?decision.state,1163+ reasons = decision.reasons.bits(),1164+ applied = decision.applied,1165+ ?recovery_acknowledged,1166+ resolution_width = resolution_decision.map(|change| change.target.width),1167+ resolution_height = resolution_decision.map(|change| change.target.height),1168+ resolution_trigger = ?resolution_decision.map(|change| change.trigger),1169+ ?resolution_applied,1170+ path_rtt_micros = telemetry.selected_path_rtt_micros,1171+ path_lost_packets = telemetry.selected_path_lost_packets,1172+ path_congestion_events = telemetry.selected_path_congestion_events,1173+ scheduler_cancellations = telemetry.scheduler_cancellations,1174+ send_failures = telemetry.send_failures,1175+ mode = if encoder_actuation_available { "active" } else { "shadow" },1176+ "adaptive bitrate decision"1177+ );1178+ }1179+ _ = &mut session_changed => {1180+ if !sessions.is_active(remote, lease.session_id) {1181+ break Ok(());1182+ }1183+ }1184+ closed = connection.closed() => {1185+ debug!(%remote, ?closed, "media feedback connection closed");1186+ break Ok(());1187+ }1188+ }1189+ if reader_finished && !receiver_open {1190+ break Ok(());1191+ }1192+ };1193+1194+ if !reader_finished {1195+ reader_task.abort();1196+ let _ = reader_task.await;1197+ }1198+ let _ = send.finish();1199+ drop(lease);1200+ info!(%remote, "media feedback client released");1201+ result1202+}1203+1204+async fn forward_media_feedback_reports<R>(1205+ mut reader: R,1206+ sender: tokio::sync::watch::Sender<CumulativeFeedbackIngress>,1207+) -> Result<()>1208+where1209+ R: tokio::io::AsyncRead + Unpin,1210+{1211+ let mut last_report_id = None;1212+ let mut next_read_at = tokio::time::Instant::now();1213+ loop {1214+ tokio::time::sleep_until(next_read_at).await;1215+ let Some(report) = read_media_feedback_report_v1(&mut reader).await? else {1216+ return Ok(());1217+ };1218+ ensure!(1219+ last_report_id.is_none_or(|last| report.report_id > last),1220+ "feedback report IDs must increase monotonically"1221+ );1222+ last_report_id = Some(report.report_id);1223+ sender.send_modify(|ingress| ingress.observe(report));1224+ if sender.is_closed() {1225+ return Ok(());1226+ }1227+ next_read_at = tokio::time::Instant::now() + FEEDBACK_MIN_READ_INTERVAL;1228+ }1229+}1230+1231+fn adaptive_bitrate_ceiling_kbps(config: &HostConfig) -> Result<u32> {1232+ let configured = match config.source {1233+ VideoSource::TestPattern => SHADOW_BITRATE_CEILING_KBPS,1234+ VideoSource::GamescopePipewire => {1235+ let gamescope = config1236+ .gamescope_pipewire1237+ .as_ref()1238+ .context("Gamescope feedback requires gamescope_pipewire configuration")?;1239+ match gamescope.rate_control {1240+ VaapiRateControl::Cbr => gamescope1241+ .bitrate_kbps1242+ .context("adaptive bitrate shadow mode requires a Gamescope CBR bitrate")?,1243+ VaapiRateControl::Cqp => SHADOW_BITRATE_CEILING_KBPS,1244+ }1245+ }1246+ };1247+ let ceiling = quantize_down(configured);1248+ ensure!(1249+ ceiling >= ADAPTIVE_BITRATE_FLOOR_KBPS,1250+ "adaptive bitrate ceiling is below the 1000 kbps floor"1251+ );1252+ Ok(ceiling)1253+}1254+1255+fn adaptive_bitrate_actuation_enabled(config: &HostConfig) -> bool {1256+ config.source == VideoSource::GamescopePipewire1257+ && config.gamescope_pipewire.as_ref().is_some_and(|gamescope| {1258+ gamescope.encoder_backend == GamescopeEncoderBackend::InProcessGstreamer1259+ && gamescope.rate_control == VaapiRateControl::Cbr1260+ })1261+}1262+1263+fn adaptive_recovery_keyframe_required(1264+ report: &MediaFeedbackReportV1,1265+ decision: &AdaptiveBitrateDecisionV1,1266+) -> bool {1267+ decision.state != AdaptiveBitrateStateV1::Hold1268+ && report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)1269+}1270+1271+#[derive(Debug)]1272+enum AdaptiveEncoderCommit {1273+ GenerationEnded,1274+ NotApplied(anyhow::Error),1275+ Applied,1276+}1277+1278+async fn await_while_session_active<F, T>(1279+ sessions: &SessionRegistry,1280+ remote: EndpointId,1281+ session_id: u64,1282+ future: F,1283+) -> Option<T>1284+where1285+ F: Future<Output = T>,1286+{1287+ tokio::pin!(future);1288+ loop {1289+ let session_changed = sessions.session_changed.notified();1290+ tokio::pin!(session_changed);1291+ session_changed.as_mut().enable();1292+ if !sessions.is_active(remote, session_id) {1293+ return None;1294+ }1295+ tokio::select! {1296+ result = &mut future => return sessions.is_active(remote, session_id).then_some(result),1297+ _ = &mut session_changed => {}1298+ }1299+ }1300+}1301+1302+async fn commit_adaptive_encoder_proposal(1303+ sessions: &SessionRegistry,1304+ remote: EndpointId,1305+ session_id: u64,1306+ proposal: &AdaptiveEncoderProposal,1307+) -> AdaptiveEncoderCommit {1308+ let bitrate_result = await_while_session_active(1309+ sessions,1310+ remote,1311+ session_id,1312+ proposal1313+ .control1314+ .wait_for_bitrate_applied(proposal.bitrate_revision, ENCODER_CONTROL_COMMIT_TIMEOUT),1315+ )1316+ .await;1317+ let Some(bitrate_result) = bitrate_result else {1318+ return AdaptiveEncoderCommit::GenerationEnded;1319+ };1320+ let status = match bitrate_result {1321+ Ok(status) => status,1322+ Err(error) => return AdaptiveEncoderCommit::NotApplied(error),1323+ };1324+ if status.applied_bitrate_revision != Some(proposal.bitrate_revision)1325+ || status.applied_bitrate_kbps != Some(proposal.target_kbps)1326+ {1327+ return AdaptiveEncoderCommit::NotApplied(anyhow::anyhow!(1328+ "encoder bitrate commit mismatch: revision {:?}, readback {:?}, expected revision {} at {} kbps",1329+ status.applied_bitrate_revision,1330+ status.applied_bitrate_kbps,1331+ proposal.bitrate_revision,1332+ proposal.target_kbps1333+ ));1334+ }1335+1336+ AdaptiveEncoderCommit::Applied1337+}1338+1339+async fn commit_resolution_encoder_proposal(1340+ sessions: &SessionRegistry,1341+ remote: EndpointId,1342+ session_id: u64,1343+ proposal: &ResolutionEncoderProposal,1344+) -> AdaptiveEncoderCommit {1345+ let result = await_while_session_active(1346+ sessions,1347+ remote,1348+ session_id,1349+ proposal.control.wait_for_resolution_applied(1350+ proposal.revision,1351+ proposal.target.width,1352+ proposal.target.height,1353+ ENCODER_CONTROL_COMMIT_TIMEOUT,1354+ ),1355+ )1356+ .await;1357+ let Some(result) = result else {1358+ return AdaptiveEncoderCommit::GenerationEnded;1359+ };1360+ match result {1361+ Ok(status)1362+ if status.applied_resolution_revision == Some(proposal.revision)1363+ && status.applied_width == Some(proposal.target.width)1364+ && status.applied_height == Some(proposal.target.height) =>1365+ {1366+ AdaptiveEncoderCommit::Applied1367+ }1368+ Ok(status) => AdaptiveEncoderCommit::NotApplied(anyhow::anyhow!(1369+ "encoder resolution commit mismatch: revision {:?}, dimensions {:?}x{:?}, expected revision {} at {}x{}",1370+ status.applied_resolution_revision,1371+ status.applied_width,1372+ status.applied_height,1373+ proposal.revision,1374+ proposal.target.width,1375+ proposal.target.height1376+ )),1377+ Err(error) => AdaptiveEncoderCommit::NotApplied(error),1378+ }1379+}1380+1381+async fn await_adaptive_recovery_keyframe(1382+ sessions: &SessionRegistry,1383+ remote: EndpointId,1384+ session_id: u64,1385+ proposal: &AdaptiveEncoderProposal,1386+) -> Option<Result<()>> {1387+ let force_keyframe_revision = proposal.force_keyframe_revision?;1388+ await_while_session_active(1389+ sessions,1390+ remote,1391+ session_id,1392+ proposal.control.wait_for_force_keyframe_acknowledged(1393+ force_keyframe_revision,1394+ ENCODER_CONTROL_COMMIT_TIMEOUT,1395+ ),1396+ )1397+ .await1398+ .map(|result| result.map(|_| ()))1399+}1400+1401+#[cfg(test)]1402+mod tests {1403+ use super::super::{endpoint, moq_test_config};1404+ use super::*;1405+1406+ fn clean_feedback(report_id: u64) -> MediaFeedbackReportV1 {1407+ MediaFeedbackReportV1 {1408+ report_id,1409+ interval_ms: 1_000,1410+ flags: MediaFeedbackFlags::NONE,1411+ last_sequence: Some(report_id),1412+ transport_dropped_delta: 0,1413+ frontend_dropped_delta: 0,1414+ decoder_dropped_delta: 0,1415+ presenter_dropped_delta: 0,1416+ frontend_queue_depth: 0,1417+ frontend_queue_capacity: 4,1418+ decode_queue_depth: 0,1419+ decode_queue_capacity: 4,1420+ presenter_queue_depth: 0,1421+ presenter_queue_capacity: 2,1422+ transport_delivery_p95_ms: Some(10),1423+ decode_p95_ms: Some(3),1424+ presentation_p95_ms: Some(5),1425+ }1426+ }1427+1428+ fn controller_decide(1429+ controller: &mut ShadowBitrateController,1430+ report: &MediaFeedbackReportV1,1431+ second: u64,1432+ ) -> AdaptiveBitrateDecisionV1 {1433+ controller1434+ .decide(1435+ report,1436+ MediaV3TelemetrySnapshot::default(),1437+ Instant::now() + Duration::from_secs(second),1438+ )1439+ .unwrap()1440+ }1441+1442+ fn native_dimensions() -> VideoDimensions {1443+ VideoDimensions {1444+ width: 1_280,1445+ height: 800,1446+ }1447+ }1448+1449+ #[test]1450+ fn motion_resolution_tier_is_exact_even_and_same_aspect() {1451+ let native = native_dimensions();1452+ let policy = MotionResolutionPolicy::new(native).unwrap();1453+ assert_eq!(policy.target(), native);1454+ assert_eq!(1455+ policy.reduced,1456+ VideoDimensions {1457+ width: 960,1458+ height: 600,1459+ }1460+ );1461+ assert_eq!(1462+ u32::from(native.width) * u32::from(policy.reduced.height),1463+ u32::from(native.height) * u32::from(policy.reduced.width)1464+ );1465+ assert!(policy.reduced.width.is_multiple_of(2));1466+ assert!(policy.reduced.height.is_multiple_of(2));1467+1468+ assert!(1469+ MotionResolutionPolicy::new(VideoDimensions {1470+ width: 1_278,1471+ height: 800,1472+ })1473+ .is_err()1474+ );1475+ assert!(1476+ MotionResolutionPolicy::new(VideoDimensions {1477+ width: 80,1478+ height: 64,1479+ })1480+ .is_err()1481+ );1482+ assert!(motion_resolution_policy(1_920, 1_080).unwrap().is_some());1483+ assert!(motion_resolution_policy(1_366, 768).unwrap().is_none());1484+ }1485+1486+ #[test]1487+ fn feedback_resolution_policy_uses_encoder_generation_dimensions() {1488+ let full_hd = crate::source::EncoderControlTestHarness::with_dimensions(1_920, 1_080);1489+ let policy = motion_resolution_policy_for_encoder(&full_hd.control)1490+ .unwrap()1491+ .unwrap();1492+ assert_eq!(1493+ policy.native,1494+ VideoDimensions {1495+ width: 1_920,1496+ height: 1_080,1497+ }1498+ );1499+1500+ let no_exact_tier = crate::source::EncoderControlTestHarness::with_dimensions(1_366, 768);1501+ assert!(1502+ motion_resolution_policy_for_encoder(&no_exact_tier.control)1503+ .unwrap()1504+ .is_none()1505+ );1506+ }1507+1508+ #[test]1509+ fn motion_downshifts_immediately_and_recovers_only_after_settle_and_clean_windows() {1510+ let now = Instant::now();1511+ let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();1512+ let down = policy.observe_motion(true, now);1513+ assert_eq!(down.tier, ResolutionTier::Reduced);1514+ assert_eq!(down.trigger, ResolutionTrigger::Motion);1515+ assert!(down.changed);1516+1517+ let still = policy.observe_motion(false, now + Duration::from_secs(1));1518+ assert!(!still.changed);1519+ for second in [2, 3] {1520+ assert!(1521+ !policy1522+ .observe_feedback(1523+ FeedbackSeverity::Clean,1524+ true,1525+ now + Duration::from_secs(second),1526+ )1527+ .changed1528+ );1529+ }1530+ let recovered =1531+ policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(4));1532+ assert_eq!(recovered.tier, ResolutionTier::Native);1533+ assert_eq!(recovered.target, native_dimensions());1534+ assert_eq!(recovered.trigger, ResolutionTrigger::Recovery);1535+ assert!(recovered.changed);1536+ }1537+1538+ #[test]1539+ fn repeated_still_observations_do_not_postpone_resolution_recovery() {1540+ let now = Instant::now();1541+ let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();1542+ policy.observe_motion(true, now);1543+ policy.observe_motion(false, now + Duration::from_secs(1));1544+ policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(2));1545+ policy.observe_motion(false, now + Duration::from_millis(2_500));1546+ policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(3));1547+ let recovered =1548+ policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(4));1549+ assert!(recovered.changed);1550+ assert_eq!(recovered.tier, ResolutionTier::Native);1551+ }1552+1553+ #[test]1554+ fn resolution_pressure_requires_fresh_hysteresis_but_severe_is_immediate() {1555+ let now = Instant::now();1556+ let mut moderate = MotionResolutionPolicy::new(native_dimensions()).unwrap();1557+ moderate.observe_motion(false, now);1558+ assert!(1559+ !moderate1560+ .observe_feedback(1561+ FeedbackSeverity::Moderate,1562+ true,1563+ now + Duration::from_secs(1),1564+ )1565+ .changed1566+ );1567+ assert!(1568+ !moderate1569+ .observe_feedback(1570+ FeedbackSeverity::Moderate,1571+ false,1572+ now + Duration::from_secs(2),1573+ )1574+ .changed1575+ );1576+ assert!(1577+ !moderate1578+ .observe_feedback(1579+ FeedbackSeverity::Moderate,1580+ true,1581+ now + Duration::from_secs(3),1582+ )1583+ .changed1584+ );1585+ let down = moderate.observe_feedback(1586+ FeedbackSeverity::Moderate,1587+ true,1588+ now + Duration::from_secs(4),1589+ );1590+ assert!(down.changed);1591+ assert_eq!(down.trigger, ResolutionTrigger::Pressure);1592+1593+ let mut severe = MotionResolutionPolicy::new(native_dimensions()).unwrap();1594+ let immediate =1595+ severe.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));1596+ assert!(immediate.changed);1597+ assert_eq!(immediate.tier, ResolutionTier::Reduced);1598+ }1599+1600+ #[test]1601+ fn stale_feedback_cannot_restore_native_resolution() {1602+ let now = Instant::now();1603+ let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();1604+ policy.observe_motion(false, now);1605+ policy.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));1606+ for second in [2, 3, 4, 5] {1607+ let decision = policy.observe_feedback(1608+ FeedbackSeverity::Clean,1609+ false,1610+ now + Duration::from_secs(second),1611+ );1612+ assert_eq!(decision.tier, ResolutionTier::Reduced);1613+ assert!(!decision.changed);1614+ }1615+ assert!(policy.pressure_active);1616+1617+ for second in [6, 7] {1618+ assert!(1619+ !policy1620+ .observe_feedback(1621+ FeedbackSeverity::Clean,1622+ true,1623+ now + Duration::from_secs(second),1624+ )1625+ .changed1626+ );1627+ }1628+ let recovered =1629+ policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(8));1630+ assert!(recovered.changed);1631+ assert_eq!(recovered.tier, ResolutionTier::Native);1632+ }1633+1634+ #[test]1635+ fn motion_keeps_resolution_reduced_after_pressure_clears() {1636+ let now = Instant::now();1637+ let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();1638+ policy.observe_motion(true, now);1639+ policy.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));1640+ for second in 2..=5 {1641+ let decision = policy.observe_feedback(1642+ FeedbackSeverity::Clean,1643+ true,1644+ now + Duration::from_secs(second),1645+ );1646+ assert_eq!(decision.tier, ResolutionTier::Reduced);1647+ assert!(!decision.changed);1648+ }1649+ assert!(!policy.pressure_active);1650+ assert!(policy.motion_active);1651+ }1652+1653+ #[test]1654+ fn damage_driven_motion_proxy_seeds_then_downshifts_and_stale_blocks_upscale() {1655+ let now = Instant::now();1656+ let telemetry = MediaV3TelemetrySnapshot::default();1657+ let mut bitrate = ShadowBitrateController::new(12_000, telemetry);1658+ let mut resolution = MotionResolutionPolicy::new(native_dimensions()).unwrap();1659+1660+ let mut first = clean_feedback(1);1661+ first.last_sequence = Some(100);1662+ let first_evaluation = bitrate.evaluate(&first, telemetry, now).unwrap();1663+ assert!(!first_evaluation.fresh);1664+ let seeded = resolution.observe_window(&first, &first_evaluation, now);1665+ assert_eq!(seeded.tier, ResolutionTier::Native);1666+ assert!(!seeded.changed);1667+1668+ let mut second = clean_feedback(2);1669+ second.last_sequence = Some(110);1670+ let second_evaluation = bitrate1671+ .evaluate(&second, telemetry, now + Duration::from_secs(1))1672+ .unwrap();1673+ assert!(second_evaluation.fresh);1674+ let down =1675+ resolution.observe_window(&second, &second_evaluation, now + Duration::from_secs(1));1676+ assert_eq!(down.trigger, ResolutionTrigger::Motion);1677+ assert_eq!(down.tier, ResolutionTier::Reduced);1678+ assert!(down.changed);1679+1680+ let mut stale = clean_feedback(3);1681+ stale.interval_ms = 5_000;1682+ stale.last_sequence = Some(110);1683+ let stale_evaluation = bitrate1684+ .evaluate(&stale, telemetry, now + Duration::from_secs(5))1685+ .unwrap();1686+ assert!(!stale_evaluation.fresh);1687+ let held =1688+ resolution.observe_window(&stale, &stale_evaluation, now + Duration::from_secs(5));1689+ assert_eq!(held.tier, ResolutionTier::Reduced);1690+ assert!(!held.changed);1691+ }1692+1693+ #[test]1694+ fn damage_driven_no_progress_cannot_restore_native_resolution() {1695+ let now = Instant::now();1696+ let telemetry = MediaV3TelemetrySnapshot::default();1697+ let mut bitrate = ShadowBitrateController::new(12_000, telemetry);1698+ let mut resolution = MotionResolutionPolicy::new(native_dimensions()).unwrap();1699+1700+ let mut seed = clean_feedback(1);1701+ seed.last_sequence = Some(100);1702+ let seed_evaluation = bitrate.evaluate(&seed, telemetry, now).unwrap();1703+ resolution.observe_window(&seed, &seed_evaluation, now);1704+1705+ let mut pressure = clean_feedback(2);1706+ pressure.last_sequence = Some(110);1707+ pressure.flags = MediaFeedbackFlags::RESYNC_ACTIVE;1708+ let pressure_evaluation = bitrate1709+ .evaluate(&pressure, telemetry, now + Duration::from_secs(1))1710+ .unwrap();1711+ let downshift = resolution.observe_window(1712+ &pressure,1713+ &pressure_evaluation,1714+ now + Duration::from_secs(1),1715+ );1716+ assert_eq!(downshift.tier, ResolutionTier::Reduced);1717+ assert!(downshift.changed);1718+1719+ for second in 2..=4 {1720+ let mut no_progress = clean_feedback(second + 1);1721+ no_progress.last_sequence = Some(110);1722+ let evaluation = bitrate1723+ .evaluate(&no_progress, telemetry, now + Duration::from_secs(second))1724+ .unwrap();1725+ assert_eq!(evaluation.pressure_severity, FeedbackSeverity::Clean);1726+ assert!(!evaluation.clean_recovery_evidence);1727+ let decision = resolution.observe_window(1728+ &no_progress,1729+ &evaluation,1730+ now + Duration::from_secs(second),1731+ );1732+ assert_eq!(decision.tier, ResolutionTier::Reduced);1733+ assert_eq!(decision.trigger, ResolutionTrigger::Pressure);1734+ assert!(!decision.changed);1735+ assert_eq!(resolution.clean_windows, 0);1736+ }1737+1738+ for (report_id, second, sequence) in [(6, 5, 111), (7, 6, 112), (8, 7, 113)] {1739+ let mut low_motion = clean_feedback(report_id);1740+ low_motion.last_sequence = Some(sequence);1741+ let evaluation = bitrate1742+ .evaluate(&low_motion, telemetry, now + Duration::from_secs(second))1743+ .unwrap();1744+ assert!(evaluation.clean_recovery_evidence);1745+ let decision = resolution.observe_window(1746+ &low_motion,1747+ &evaluation,1748+ now + Duration::from_secs(second),1749+ );1750+ if report_id < 8 {1751+ assert_eq!(decision.tier, ResolutionTier::Reduced);1752+ assert!(!decision.changed);1753+ } else {1754+ assert_eq!(decision.tier, ResolutionTier::Native);1755+ assert_eq!(decision.trigger, ResolutionTrigger::Recovery);1756+ assert!(decision.changed);1757+ }1758+ }1759+ }1760+1761+ #[test]1762+ fn shadow_controller_immediately_decreases_for_severe_feedback_and_stays_bounded() {1763+ let mut controller =1764+ ShadowBitrateController::new(12_137, MediaV3TelemetrySnapshot::default());1765+ assert_eq!(controller.ceiling_kbps, 12_000);1766+ let first = controller_decide(&mut controller, &clean_feedback(1), 0);1767+ assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);1768+ assert!(1769+ first1770+ .reasons1771+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)1772+ );1773+ for report_id in 2..=21 {1774+ let mut report = clean_feedback(report_id);1775+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;1776+ let decision = controller_decide(&mut controller, &report, report_id - 1);1777+ assert!(!decision.applied);1778+ assert!(decision.target_kbps >= ADAPTIVE_BITRATE_FLOOR_KBPS);1779+ assert!(decision.target_kbps <= 12_000);1780+ assert_eq!(decision.target_kbps % ADAPTIVE_BITRATE_QUANTUM_KBPS, 0);1781+ assert!(1782+ decision1783+ .reasons1784+ .contains(AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE)1785+ );1786+ }1787+ assert_eq!(controller.target_kbps, ADAPTIVE_BITRATE_FLOOR_KBPS);1788+ }1789+1790+ #[test]1791+ fn shadow_controller_requires_two_moderate_windows() {1792+ let mut controller =1793+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1794+ controller_decide(&mut controller, &clean_feedback(1), 0);1795+ let mut first = clean_feedback(2);1796+ first.frontend_queue_depth = 2;1797+ assert_eq!(1798+ controller_decide(&mut controller, &first, 1).state,1799+ AdaptiveBitrateStateV1::Hold1800+ );1801+ let mut second = clean_feedback(3);1802+ second.frontend_queue_depth = 2;1803+ let decision = controller_decide(&mut controller, &second, 2);1804+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);1805+ assert_eq!(decision.target_kbps, 9_500);1806+ }1807+1808+ #[test]1809+ fn shadow_controller_increases_only_after_clean_cooldown() {1810+ let mut controller =1811+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1812+ controller_decide(&mut controller, &clean_feedback(1), 0);1813+ let mut severe = clean_feedback(2);1814+ severe.decoder_dropped_delta = 1;1815+ assert_eq!(1816+ controller_decide(&mut controller, &severe, 1).target_kbps,1817+ 9_0001818+ );1819+ for report_id in 3..=11 {1820+ let decision =1821+ controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);1822+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);1823+ assert_eq!(decision.target_kbps, 9_000);1824+ }1825+ let decision = controller_decide(&mut controller, &clean_feedback(12), 11);1826+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);1827+ assert_eq!(decision.target_kbps, 9_250);1828+ assert!(1829+ decision1830+ .reasons1831+ .contains(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY)1832+ );1833+ }1834+1835+ #[test]1836+ fn stale_or_non_monotonic_feedback_cannot_drive_an_increase() {1837+ let mut controller =1838+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1839+ controller_decide(&mut controller, &clean_feedback(1), 0);1840+ let mut severe = clean_feedback(2);1841+ severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;1842+ controller_decide(&mut controller, &severe, 1);1843+ let stale = controller_decide(&mut controller, &clean_feedback(3), 4);1844+ assert_eq!(stale.state, AdaptiveBitrateStateV1::Hold);1845+ assert_eq!(stale.target_kbps, 9_000);1846+ assert!(1847+ stale1848+ .reasons1849+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)1850+ );1851+ assert!(1852+ controller1853+ .decide(1854+ &clean_feedback(2),1855+ MediaV3TelemetrySnapshot::default(),1856+ Instant::now() + Duration::from_secs(4),1857+ )1858+ .is_err()1859+ );1860+1861+ let now = Instant::now();1862+ let mut rapid = ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1863+ rapid1864+ .decide(&clean_feedback(1), MediaV3TelemetrySnapshot::default(), now)1865+ .unwrap();1866+ let too_fast = rapid1867+ .decide(1868+ &clean_feedback(2),1869+ MediaV3TelemetrySnapshot::default(),1870+ now + Duration::from_millis(500),1871+ )1872+ .unwrap();1873+ assert_eq!(too_fast.state, AdaptiveBitrateStateV1::Hold);1874+ assert!(1875+ too_fast1876+ .reasons1877+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)1878+ );1879+ }1880+1881+ #[test]1882+ fn full_decode_queue_is_severe_even_at_capacity_two() {1883+ let mut controller =1884+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1885+ controller_decide(&mut controller, &clean_feedback(1), 0);1886+ let mut report = clean_feedback(2);1887+ report.decode_queue_capacity = 2;1888+ report.decode_queue_depth = 2;1889+ let decision = controller_decide(&mut controller, &report, 1);1890+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);1891+ assert!(1892+ decision1893+ .reasons1894+ .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)1895+ );1896+ }1897+1898+ #[test]1899+ fn decision_id_exhaustion_fails_instead_of_repeating() {1900+ let mut controller =1901+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1902+ controller.next_decision_id = u64::MAX;1903+ assert!(1904+ controller1905+ .decide(1906+ &clean_feedback(1),1907+ MediaV3TelemetrySnapshot::default(),1908+ Instant::now(),1909+ )1910+ .is_err()1911+ );1912+ assert_eq!(controller.next_decision_id, u64::MAX);1913+ }1914+1915+ #[test]1916+ fn stale_feedback_applies_trusted_loss_and_send_failure_pressure_once() {1917+ for telemetry in [1918+ MediaV3TelemetrySnapshot {1919+ selected_path_lost_packets: 1,1920+ ..MediaV3TelemetrySnapshot::default()1921+ },1922+ MediaV3TelemetrySnapshot {1923+ send_failures: 1,1924+ ..MediaV3TelemetrySnapshot::default()1925+ },1926+ ] {1927+ let now = Instant::now();1928+ let mut controller =1929+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1930+ let decision = controller1931+ .decide(&clean_feedback(1), telemetry, now)1932+ .unwrap();1933+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);1934+ assert_eq!(decision.target_kbps, 9_000);1935+ assert!(1936+ decision1937+ .reasons1938+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)1939+ );1940+ assert!(1941+ decision1942+ .reasons1943+ .contains(AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION)1944+ );1945+1946+ let repeated = controller1947+ .decide(1948+ &clean_feedback(2),1949+ telemetry,1950+ now + Duration::from_millis(500),1951+ )1952+ .unwrap();1953+ assert_eq!(repeated.state, AdaptiveBitrateStateV1::Hold);1954+ assert_eq!(repeated.target_kbps, 9_000);1955+ }1956+ }1957+1958+ #[test]1959+ fn stale_moderate_cancellation_is_retained_until_hysteresis_reduces() {1960+ let now = Instant::now();1961+ let telemetry = MediaV3TelemetrySnapshot {1962+ scheduler_cancellations: 1,1963+ ..MediaV3TelemetrySnapshot::default()1964+ };1965+ let mut controller =1966+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1967+ let first = controller1968+ .decide(&clean_feedback(1), telemetry, now)1969+ .unwrap();1970+ assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);1971+ assert_eq!(controller.previous_telemetry.scheduler_cancellations, 0);1972+1973+ let second = controller1974+ .decide(1975+ &clean_feedback(2),1976+ telemetry,1977+ now + Duration::from_millis(500),1978+ )1979+ .unwrap();1980+ assert_eq!(second.state, AdaptiveBitrateStateV1::Decrease);1981+ assert_eq!(second.target_kbps, 9_500);1982+ assert_eq!(controller.previous_telemetry.scheduler_cancellations, 1);1983+ }1984+1985+ #[test]1986+ fn clean_recovery_below_five_mbps_advances_by_one_quantum() {1987+ let mut controller =1988+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());1989+ controller.target_kbps = 4_000;1990+ controller_decide(&mut controller, &clean_feedback(1), 0);1991+ controller_decide(&mut controller, &clean_feedback(2), 1);1992+ for report_id in 3..=11 {1993+ let decision =1994+ controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);1995+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);1996+ }1997+ let decision = controller_decide(&mut controller, &clean_feedback(12), 11);1998+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);1999+ assert_eq!(decision.target_kbps, 4_250);2000+ }2001+2002+ #[test]2003+ fn recovery_requires_latency_samples_and_sequence_progress_but_pressure_still_reduces() {2004+ let mut controller =2005+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2006+ controller.target_kbps = 9_000;2007+ controller_decide(&mut controller, &clean_feedback(1), 0);2008+ controller_decide(&mut controller, &clean_feedback(2), 1);2009+2010+ for report_id in 3..=14 {2011+ let mut report = clean_feedback(report_id);2012+ report.last_sequence = Some(2);2013+ let decision = controller_decide(&mut controller, &report, report_id - 1);2014+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);2015+ assert_eq!(decision.target_kbps, 9_000);2016+ }2017+ assert_eq!(controller.clean_windows, 0);2018+2019+ for report_id in 15..=26 {2020+ let mut report = clean_feedback(report_id);2021+ report.decode_p95_ms = None;2022+ let decision = controller_decide(&mut controller, &report, report_id - 1);2023+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);2024+ assert_eq!(decision.target_kbps, 9_000);2025+ }2026+ assert_eq!(controller.clean_windows, 0);2027+2028+ let mut pressure = clean_feedback(27);2029+ pressure.decode_p95_ms = None;2030+ pressure.decode_queue_capacity = 2;2031+ pressure.decode_queue_depth = 2;2032+ let decision = controller_decide(&mut controller, &pressure, 26);2033+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);2034+ assert_eq!(decision.target_kbps, 6_750);2035+ assert!(2036+ decision2037+ .reasons2038+ .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)2039+ );2040+ }2041+2042+ fn gamescope_feedback_test_config(rate_control: VaapiRateControl) -> HostConfig {2043+ let (bitrate_kbps, quantizer) = match rate_control {2044+ VaapiRateControl::Cbr => (Some(12_123), None),2045+ VaapiRateControl::Cqp => (None, Some(24)),2046+ };2047+ let mut config = moq_test_config();2048+ config.source = VideoSource::GamescopePipewire;2049+ config.width = None;2050+ config.height = None;2051+ config.gamescope_pipewire = Some(crate::config::GamescopePipewireConfig {2052+ node_name: "gamescope".into(),2053+ media_class: "Video/Source".into(),2054+ match_properties: std::collections::BTreeMap::new(),2055+ xwayland_display: None,2056+ pw_dump_path: "/usr/bin/pw-dump".into(),2057+ gst_launch_path: "/usr/bin/gst-launch-1.0".into(),2058+ gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),2059+ encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,2060+ vaapi_encoder: "vah264enc".into(),2061+ vaapi_render_node: "/dev/dri/renderD128".into(),2062+ rate_control,2063+ bitrate_kbps,2064+ quantizer,2065+ });2066+ config.validate().unwrap();2067+ config2068+ }2069+2070+ #[test]2071+ fn external_cqp_uses_a_fixed_shadow_bitrate_ceiling() {2072+ let config = gamescope_feedback_test_config(VaapiRateControl::Cqp);2073+2074+ assert_eq!(2075+ adaptive_bitrate_ceiling_kbps(&config).unwrap(),2076+ SHADOW_BITRATE_CEILING_KBPS2077+ );2078+ assert!(!adaptive_bitrate_actuation_enabled(&config));2079+ }2080+2081+ #[test]2082+ fn gamescope_cbr_keeps_its_quantized_configured_ceiling() {2083+ let config = gamescope_feedback_test_config(VaapiRateControl::Cbr);2084+2085+ assert_eq!(adaptive_bitrate_ceiling_kbps(&config).unwrap(), 12_000);2086+ }2087+2088+ #[test]2089+ fn malformed_cbr_without_a_bitrate_still_fails_closed() {2090+ let mut config = gamescope_feedback_test_config(VaapiRateControl::Cbr);2091+ config.gamescope_pipewire.as_mut().unwrap().bitrate_kbps = None;2092+2093+ assert_eq!(2094+ adaptive_bitrate_ceiling_kbps(&config)2095+ .unwrap_err()2096+ .to_string(),2097+ "adaptive bitrate shadow mode requires a Gamescope CBR bitrate"2098+ );2099+ }2100+2101+ #[tokio::test]2102+ async fn adaptive_encoder_commit_requires_exact_readback_and_tracks_recovery_separately() {2103+ let sessions = Arc::new(SessionRegistry::default());2104+ let remote = endpoint(1);2105+ let media = sessions2106+ .claim(remote, [4; 16], InvitationGrants::VIEW)2107+ .unwrap();2108+ let harness = crate::source::EncoderControlTestHarness::new();2109+ sessions2110+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))2111+ .unwrap();2112+2113+ let proposal = sessions2114+ .propose_adaptive_encoder_update(remote, media.session_id, 8_000, true)2115+ .unwrap()2116+ .unwrap();2117+ harness.status.send_modify(|status| {2118+ status.applied_bitrate_revision = Some(proposal.bitrate_revision);2119+ status.applied_bitrate_kbps = Some(8_000);2120+ status.requested_force_keyframe_revision = proposal.force_keyframe_revision;2121+ status.acknowledged_force_keyframe_revision = proposal.force_keyframe_revision;2122+ });2123+ assert!(matches!(2124+ commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &proposal).await,2125+ AdaptiveEncoderCommit::Applied2126+ ));2127+ assert!(matches!(2128+ await_adaptive_recovery_keyframe(&sessions, remote, media.session_id, &proposal).await,2129+ Some(Ok(()))2130+ ));2131+2132+ let mismatch = sessions2133+ .propose_adaptive_encoder_update(remote, media.session_id, 7_000, false)2134+ .unwrap()2135+ .unwrap();2136+ harness.status.send_modify(|status| {2137+ status.applied_bitrate_revision = Some(mismatch.bitrate_revision);2138+ status.applied_bitrate_kbps = Some(7_250);2139+ });2140+ assert!(matches!(2141+ commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &mismatch).await,2142+ AdaptiveEncoderCommit::NotApplied(_)2143+ ));2144+2145+ let stale_generation = sessions2146+ .propose_adaptive_encoder_update(remote, media.session_id, 6_000, false)2147+ .unwrap()2148+ .unwrap();2149+ let session_id = media.session_id;2150+ drop(media);2151+ assert!(matches!(2152+ commit_adaptive_encoder_proposal(&sessions, remote, session_id, &stale_generation)2153+ .await,2154+ AdaptiveEncoderCommit::GenerationEnded2155+ ));2156+ }2157+2158+ #[test]2159+ fn failed_adaptive_application_commits_observation_but_not_target() {2160+ let mut committed =2161+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2162+ controller_decide(&mut committed, &clean_feedback(1), 0);2163+ let old_target = committed.target_kbps;2164+ let old_decision_id = committed.next_decision_id;2165+ let mut severe = clean_feedback(2);2166+ severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2167+2168+ let mut candidate = committed.clone();2169+ let decision = controller_decide(&mut candidate, &severe, 1);2170+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);2171+ assert!(candidate.target_kbps < old_target);2172+ committed.commit_observation_from(&candidate);2173+2174+ assert_eq!(committed.target_kbps, old_target);2175+ assert_eq!(committed.last_report_id, Some(2));2176+ assert_eq!(committed.next_decision_id, old_decision_id + 1);2177+ assert!(2178+ committed2179+ .decide(2180+ &clean_feedback(3),2181+ MediaV3TelemetrySnapshot::default(),2182+ Instant::now() + Duration::from_secs(2)2183+ )2184+ .is_ok()2185+ );2186+ }2187+2188+ #[test]2189+ fn adaptive_recovery_idr_is_only_requested_for_a_bitrate_change_during_resync() {2190+ let mut report = clean_feedback(1);2191+ let mut decision = AdaptiveBitrateDecisionV1 {2192+ decision_id: 1,2193+ report_id: 1,2194+ target_kbps: 8_000,2195+ floor_kbps: 1_000,2196+ ceiling_kbps: 12_000,2197+ state: AdaptiveBitrateStateV1::Decrease,2198+ reasons: AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,2199+ applied: false,2200+ };2201+ assert!(!adaptive_recovery_keyframe_required(&report, &decision));2202+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2203+ assert!(adaptive_recovery_keyframe_required(&report, &decision));2204+ decision.state = AdaptiveBitrateStateV1::Hold;2205+ assert!(!adaptive_recovery_keyframe_required(&report, &decision));2206+ }2207+2208+ #[tokio::test]2209+ async fn stalled_adaptive_apply_coalesces_feedback_without_losing_pressure() {2210+ use tokio::io::AsyncWriteExt as _;2211+2212+ let (sender, mut receiver) =2213+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());2214+ let (mut writer, reader) = tokio::io::duplex(512);2215+ let reader_task = tokio::spawn(forward_media_feedback_reports(reader, sender.clone()));2216+ let mut cursor = FeedbackIngressCursor::default();2217+2218+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &clean_feedback(1))2219+ .await2220+ .unwrap();2221+ receiver.changed().await.unwrap();2222+ let (first, next_cursor) = receiver2223+ .borrow_and_update()2224+ .report_since(cursor)2225+ .unwrap()2226+ .unwrap();2227+ assert_eq!(first.report_id, 1);2228+ cursor = next_cursor;2229+2230+ // The consumer is now notionally stalled on an encoder commit. Two2231+ // reports overwrite one watch notification, but their interval,2232+ // deltas, and resync pressure must remain cumulative.2233+ let mut pressured = clean_feedback(2);2234+ pressured.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2235+ pressured.transport_dropped_delta = 2;2236+ pressured.decoder_dropped_delta = 1;2237+ pressured.decode_queue_depth = 3;2238+ pressured.transport_delivery_p95_ms = Some(180);2239+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &pressured)2240+ .await2241+ .unwrap();2242+2243+ let mut latest = clean_feedback(3);2244+ latest.frontend_dropped_delta = 4;2245+ latest.presenter_dropped_delta = 3;2246+ latest.frontend_queue_depth = 3;2247+ latest.decode_queue_depth = 1;2248+ latest.transport_delivery_p95_ms = Some(40);2249+ latest.decode_p95_ms = Some(9);2250+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &latest)2251+ .await2252+ .unwrap();2253+ writer.shutdown().await.unwrap();2254+ reader_task.await.unwrap().unwrap();2255+2256+ receiver.changed().await.unwrap();2257+ let (aggregate, next_cursor) = receiver2258+ .borrow_and_update()2259+ .report_since(cursor)2260+ .unwrap()2261+ .unwrap();2262+ assert_eq!(aggregate.report_id, 3);2263+ assert_eq!(aggregate.interval_ms, 2_000);2264+ assert_eq!(aggregate.transport_dropped_delta, 2);2265+ assert_eq!(aggregate.frontend_dropped_delta, 4);2266+ assert_eq!(aggregate.decoder_dropped_delta, 1);2267+ assert_eq!(aggregate.presenter_dropped_delta, 3);2268+ assert!(aggregate.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE));2269+ assert_eq!(aggregate.last_sequence, latest.last_sequence);2270+ assert_eq!(aggregate.frontend_queue_depth, latest.frontend_queue_depth);2271+ assert_eq!(aggregate.decode_queue_depth, latest.decode_queue_depth);2272+ assert_eq!(2273+ aggregate.transport_delivery_p95_ms,2274+ latest.transport_delivery_p95_ms2275+ );2276+ assert_eq!(aggregate.decode_p95_ms, latest.decode_p95_ms);2277+ cursor = next_cursor;2278+ assert!(2279+ receiver.borrow().report_since(cursor).unwrap().is_none(),2280+ "one cumulative ingress generation must be processed only once"2281+ );2282+2283+ for report_id in 4..=9 {2284+ sender.send_modify(|ingress| ingress.observe(clean_feedback(report_id)));2285+ }2286+ receiver.changed().await.unwrap();2287+ let (clamped, _) = receiver2288+ .borrow_and_update()2289+ .report_since(cursor)2290+ .unwrap()2291+ .unwrap();2292+ assert_eq!(clamped.interval_ms, 5_000);2293+ let mut controller =2294+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2295+ let decision = controller2296+ .decide(2297+ &clamped,2298+ MediaV3TelemetrySnapshot::default(),2299+ Instant::now(),2300+ )2301+ .unwrap();2302+ assert!(2303+ decision2304+ .reasons2305+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE),2306+ "a clamped aggregate must be stale rather than disguised as one fresh report"2307+ );2308+ }2309+}
A crates/sigil-host/src/server/handlers.rs +813 -0
@@ -0,0 +1,813 @@
1+use super::*;2+3+pub(super) const MEDIA_CAPABILITIES: &[Capability] = &[Capability::VideoH264];4+const AUDIO_CAPABILITIES: &[Capability] = &[Capability::AudioOpus];5+pub(super) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);6+const INPUT_ACK_TIMEOUT: Duration = Duration::from_secs(1);7+const REJECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);8+9+#[derive(Clone, Debug)]10+pub struct MediaHandler {11+ pub config: HostConfig,12+ pub sessions: Arc<SessionRegistry>,13+ pub authorization: AuthorizationPolicy,14+}15+16+#[derive(Clone, Debug)]17+pub struct MediaV2Handler {18+ pub config: HostConfig,19+ pub sessions: Arc<SessionRegistry>,20+ pub authorization: AuthorizationPolicy,21+}22+23+#[derive(Clone, Debug)]24+pub struct MediaV3Handler {25+ pub config: HostConfig,26+ pub sessions: Arc<SessionRegistry>,27+ pub authorization: AuthorizationPolicy,28+}29+30+#[derive(Clone, Debug)]31+pub struct ControlHandler {32+ pub config: HostConfig,33+ pub sessions: Arc<SessionRegistry>,34+ pub authorization: AuthorizationPolicy,35+}36+37+#[derive(Clone, Debug)]38+pub struct MediaFeedbackHandler {39+ pub config: HostConfig,40+ pub sessions: Arc<SessionRegistry>,41+ pub authorization: AuthorizationPolicy,42+}43+44+/// Upstream MoQ admission guarded by an already-authenticated control lease.45+///46+/// This deliberately does not use `iroh_moq::Moq::protocol_handler`: that47+/// actor makes a completed session globally visible before application-level48+/// acceptance. Consuming the exact pending attachment first prevents MoQ from49+/// bypassing Sigil's invitation, enrollment, and one-client gate.50+#[derive(Clone, Debug)]51+pub struct AuthorizedMoqHandler {52+ pub sessions: Arc<SessionRegistry>,53+ pub origin: Origin,54+}55+56+impl ProtocolHandler for MediaHandler {57+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {58+ let remote = connection.remote_id();59+ if let Err(error) = serve_media(60+ connection,61+ self.config.clone(),62+ &self.sessions,63+ &self.authorization,64+ )65+ .await66+ {67+ warn!(%remote, %error, "media connection ended");68+ }69+ Ok(())70+ }71+}72+73+impl ProtocolHandler for MediaV2Handler {74+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {75+ let remote = connection.remote_id();76+ if let Err(error) = serve_media_v2(77+ connection,78+ self.config.clone(),79+ &self.sessions,80+ &self.authorization,81+ )82+ .await83+ {84+ warn!(%remote, %error, "media v2 connection ended");85+ }86+ Ok(())87+ }88+}89+90+impl ProtocolHandler for MediaV3Handler {91+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {92+ let remote = connection.remote_id();93+ if let Err(error) = serve_media_v3(94+ connection,95+ self.config.clone(),96+ &self.sessions,97+ &self.authorization,98+ )99+ .await100+ {101+ warn!(%remote, %error, "media v3 connection ended");102+ }103+ Ok(())104+ }105+}106+107+impl ProtocolHandler for ControlHandler {108+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {109+ let remote = connection.remote_id();110+ if let Err(error) = serve_control_moq(111+ connection,112+ self.config.clone(),113+ &self.sessions,114+ &self.authorization,115+ )116+ .await117+ {118+ warn!(%remote, %error, "MoQ control connection ended");119+ }120+ Ok(())121+ }122+}123+124+impl ProtocolHandler for MediaFeedbackHandler {125+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {126+ let remote = connection.remote_id();127+ if let Err(error) = serve_media_feedback(128+ connection,129+ &self.config,130+ &self.sessions,131+ &self.authorization,132+ )133+ .await134+ {135+ warn!(%remote, %error, "media feedback connection ended");136+ }137+ Ok(())138+ }139+}140+141+impl ProtocolHandler for AuthorizedMoqHandler {142+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {143+ let remote = connection.remote_id();144+ let attachment = match self.sessions.claim_moq(remote) {145+ Ok(attachment) => attachment,146+ Err(error) => {147+ connection.close(MOQ_REJECT_CODE.into(), b"unauthorized MoQ attachment");148+ warn!(%remote, %error, "rejected unsolicited MoQ connection");149+ return Ok(());150+ }151+ };152+ if let Err(error) = serve_authorized_moq(connection, self.origin, attachment).await {153+ warn!(%remote, %error, "authorized MoQ connection ended");154+ }155+ Ok(())156+ }157+}158+159+#[derive(Clone, Debug)]160+pub struct InputHandler {161+ pub backend: InputBackend,162+ pub pointer_positions: Option<PointerPositionTracker>,163+ pub sessions: Arc<SessionRegistry>,164+}165+166+#[derive(Clone, Debug)]167+pub struct AudioHandler {168+ pub config: HostConfig,169+ pub sessions: Arc<SessionRegistry>,170+}171+172+impl ProtocolHandler for AudioHandler {173+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {174+ let remote = connection.remote_id();175+ if let Err(error) = serve_audio(connection, self.config.clone(), &self.sessions).await {176+ warn!(%remote, %error, "audio connection ended");177+ }178+ Ok(())179+ }180+}181+182+impl ProtocolHandler for InputHandler {183+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {184+ let remote = connection.remote_id();185+ if let Err(error) = serve_input(186+ connection,187+ &self.backend,188+ self.pointer_positions.as_ref(),189+ &self.sessions,190+ )191+ .await192+ {193+ warn!(%remote, %error, "input connection ended");194+ }195+ Ok(())196+ }197+}198+199+async fn serve_input(200+ connection: Connection,201+ backend: &InputBackend,202+ pointer_positions: Option<&PointerPositionTracker>,203+ sessions: &Arc<SessionRegistry>,204+) -> Result<()> {205+ let remote = connection.remote_id();206+ let handshake_permit = sessions207+ .pending_handshakes208+ .try_acquire()209+ .context("too many pending handshakes")?;210+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())211+ .await212+ .context("timed out accepting input stream")?213+ .context("accepting input stream")?;214+ let hello = receive_hello_unconstrained(&mut recv).await?;215+ drop(handshake_permit);216+ debug!(%remote, agent = %hello.agent, "input hello received");217+ ensure!(218+ hello.invitation.is_none(),219+ "invitations are accepted only on the first media handshake"220+ );221+ let lease = match sessions.claim_input(remote, hello.nonce) {222+ Ok(lease) => lease,223+ Err(error) => {224+ send_rejection(&mut send, error.to_string()).await?;225+ return Err(error);226+ }227+ };228+ let supported =229+ supported_input_capabilities(backend.capabilities(), pointer_positions.is_some());230+ let negotiated = negotiated_input_capabilities(&hello, &supported, lease.grants);231+232+ let ack_enabled = negotiated.contains(&Capability::InputAck);233+ let feedback_enabled = negotiated.contains(&Capability::PointerPositionFeedback);234+ let visibility_feedback_enabled = negotiated.contains(&Capability::PointerVisibilityFeedback);235+ let mut pointer_positions = pointer_positions236+ .filter(|_| feedback_enabled)237+ .map(PointerPositionTracker::subscribe);238+ write_host_hello(239+ &mut send,240+ &HostHello::accepted(lease.session_id, negotiated.clone()),241+ )242+ .await?;243+ info!(%remote, session_id = lease.session_id, "input client accepted");244+245+ let session_result: Result<()> = async {246+ let mut received_events = 0_u64;247+ if let Some(pointer_positions) = pointer_positions.as_ref() {248+ let pointer_state = *pointer_positions.borrow();249+ let (pointer_position, pointer_visible) =250+ pointer_feedback_fields(Some(pointer_state), visibility_feedback_enabled);251+ tokio::time::timeout(252+ INPUT_ACK_TIMEOUT,253+ write_input_ack(254+ &mut send,255+ &InputAck {256+ sequence: received_events,257+ pointer_position,258+ pointer_visible,259+ },260+ ),261+ )262+ .await263+ .context("timed out writing initial pointer position")??;264+ }265+ loop {266+ if !sessions.is_active(remote, lease.session_id) {267+ debug!(%remote, session_id = lease.session_id, "media session ended; closing input");268+ break;269+ }270+ tokio::select! {271+ _ = sessions.session_changed.notified() => continue,272+ changed = async {273+ pointer_positions274+ .as_mut()275+ .expect("feedback branch is guarded")276+ .changed()277+ .await278+ }, if pointer_positions.is_some() => {279+ changed.context("Xwayland pointer tracker stopped")?;280+ let pointer_state = {281+ let receiver = pointer_positions282+ .as_mut()283+ .expect("feedback branch is guarded");284+ *receiver.borrow_and_update()285+ };286+ let (pointer_position, pointer_visible) =287+ pointer_feedback_fields(Some(pointer_state), visibility_feedback_enabled);288+ tokio::time::timeout(289+ INPUT_ACK_TIMEOUT,290+ write_input_ack(291+ &mut send,292+ &InputAck {293+ sequence: received_events,294+ pointer_position,295+ pointer_visible,296+ },297+ ),298+ )299+ .await300+ .context("timed out writing pointer position feedback")??;301+ }302+ event = read_input_event(&mut recv) => {303+ let Some(event) = event? else {304+ break;305+ };306+ if !sessions.is_active(remote, lease.session_id) {307+ debug!(%remote, session_id = lease.session_id, "discarding input after media ended");308+ break;309+ }310+ match backend.apply(&event, &negotiated)? {311+ InputDisposition::Probed => {312+ debug!(%remote, "input liveness probe acknowledged");313+ }314+ InputDisposition::Observed => {315+ info!(%remote, event_type = input_event_type(&event), "input event observed");316+ }317+ InputDisposition::Disabled => {318+ debug!(%remote, event_type = input_event_type(&event), "input event ignored because injection is disabled");319+ }320+ #[cfg(target_os = "linux")]321+ InputDisposition::Injected => {322+ debug!(%remote, event_type = input_event_type(&event), "input event injected");323+ }324+ InputDisposition::TextIgnored => {325+ debug!(%remote, event_type = "text", "text input is unsupported and was ignored");326+ }327+ }328+ received_events = received_events.saturating_add(1);329+ if ack_enabled {330+ let pointer_state = pointer_positions331+ .as_ref()332+ .map(|positions| *positions.borrow());333+ let (pointer_position, pointer_visible) =334+ pointer_feedback_fields(pointer_state, visibility_feedback_enabled);335+ tokio::time::timeout(336+ INPUT_ACK_TIMEOUT,337+ write_input_ack(338+ &mut send,339+ &InputAck {340+ sequence: received_events,341+ pointer_position,342+ pointer_visible,343+ },344+ ),345+ )346+ .await347+ .context("timed out writing input acknowledgment")??;348+ }349+ }350+ }351+ }352+ Ok(())353+ }354+ .await;355+ let reset_result = backend356+ .reset_session()357+ .context("releasing held input transitions at session end");358+ let result = session_result.and(reset_result);359+ drop(lease);360+ info!(%remote, "input client released");361+ result362+}363+364+async fn serve_audio(365+ connection: Connection,366+ config: HostConfig,367+ sessions: &Arc<SessionRegistry>,368+) -> Result<()> {369+ let remote = connection.remote_id();370+ let handshake_permit = sessions371+ .pending_handshakes372+ .try_acquire()373+ .context("too many pending handshakes")?;374+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())375+ .await376+ .context("timed out accepting audio handshake stream")?377+ .context("accepting audio handshake stream")?;378+ let hello = receive_hello(&mut recv, Capability::AudioOpus).await?;379+ drop(handshake_permit);380+ debug!(%remote, agent = %hello.agent, "audio hello received");381+ ensure!(382+ hello.invitation.is_none(),383+ "invitations are accepted only on the first media handshake"384+ );385+386+ if config.audio.is_none() {387+ send_rejection(&mut send, "audio is unavailable").await?;388+ bail!("audio is not configured");389+ }390+ let maximum_datagram = connection.max_datagram_size();391+ if maximum_datagram.is_none_or(|maximum| maximum < AUDIO_HEADER_LEN + MAX_AUDIO_PAYLOAD_LEN) {392+ send_rejection(&mut send, "peer cannot carry v1 audio datagrams").await?;393+ bail!(394+ "peer audio datagram limit {:?} is below {}",395+ maximum_datagram,396+ AUDIO_HEADER_LEN + MAX_AUDIO_PAYLOAD_LEN397+ );398+ }399+ let lease = match sessions.claim_audio(remote, hello.nonce) {400+ Ok(lease) => lease,401+ Err(error) => {402+ send_rejection(&mut send, error.to_string()).await?;403+ return Err(error);404+ }405+ };406+ ensure!(407+ lease.grants.contains(InvitationGrants::VIEW),408+ "active Portal session lacks audio view permission"409+ );410+ let (mut audio_receiver, audio_task) =411+ match spawn_pipewire_audio(config, lease.session_clock).await {412+ Ok(source) => source,413+ Err(error) => {414+ send_rejection(&mut send, "audio source is unavailable").await?;415+ return Err(error);416+ }417+ };418+ let audio_task = SourceTaskGuard::new(audio_task);419+ write_host_hello(420+ &mut send,421+ &HostHello::accepted(422+ lease.session_id,423+ negotiated_capabilities(&hello, AUDIO_CAPABILITIES),424+ ),425+ )426+ .await?;427+ send.finish()?;428+ info!(%remote, session_id = lease.session_id, "audio client accepted");429+430+ let session_result: Result<()> = async {431+ loop {432+ if !sessions.is_active(remote, lease.session_id) {433+ break;434+ }435+ tokio::select! {436+ _ = sessions.session_changed.notified() => continue,437+ packet = audio_receiver.recv() => {438+ let packet = packet.context("audio source stopped")?;439+ let flags = if packet.discontinuity {440+ AudioFlags::DISCONTINUITY441+ } else {442+ AudioFlags::NONE443+ };444+ let header = AudioPacketHeader::opus(445+ packet.payload.len(),446+ packet.sequence,447+ packet.capture_timestamp_us,448+ packet.pts_us,449+ flags,450+ )?;451+ let datagram = AudioPacket::new(header, packet.payload.as_ref().to_vec())?452+ .encode_datagram()?;453+ match connection.send_datagram(datagram.into()) {454+ Ok(()) => {}455+ Err(error) => {456+ // The non-waiting API bounds the QUIC datagram buffer by457+ // evicting stale datagrams. Its errors mean the negotiated458+ // path cannot carry the fixed v1 packet and are terminal.459+ return Err(error).context("sending bounded audio datagram");460+ }461+ }462+ }463+ result = connection.closed() => {464+ debug!(%remote, ?result, "audio connection closed");465+ break;466+ }467+ }468+ }469+ Ok(())470+ }471+ .await;472+ audio_task.abort_and_wait().await;473+ drop(lease);474+ info!(%remote, "audio client released");475+ session_result476+}477+478+pub(super) async fn send_rejection(479+ send: &mut iroh::endpoint::SendStream,480+ message: impl Into<String>,481+) -> Result<()> {482+ write_host_hello(send, &HostHello::rejected(message)).await?;483+ send.finish()?;484+ if tokio::time::timeout(REJECTION_DRAIN_TIMEOUT, send.stopped())485+ .await486+ .is_err()487+ {488+ debug!("timed out waiting for peer to acknowledge handshake rejection");489+ }490+ Ok(())491+}492+493+pub(super) fn negotiated_capabilities(494+ hello: &ClientHello,495+ supported: &[Capability],496+) -> Vec<Capability> {497+ supported498+ .iter()499+ .copied()500+ .filter(|capability| hello.capabilities.contains(capability))501+ .collect()502+}503+504+fn negotiated_input_capabilities(505+ hello: &ClientHello,506+ supported: &[Capability],507+ grants: InvitationGrants,508+) -> Vec<Capability> {509+ let mut negotiated = negotiated_capabilities(hello, supported);510+ negotiated.retain(|capability| input_capability_authorized(*capability, grants));511+ if !negotiated.contains(&Capability::PointerPositionFeedback) {512+ negotiated.retain(|capability| *capability != Capability::PointerVisibilityFeedback);513+ }514+ negotiated515+}516+517+fn input_capability_authorized(capability: Capability, grants: InvitationGrants) -> bool {518+ match capability {519+ Capability::AbsolutePointer520+ | Capability::RelativePointer521+ | Capability::Keyboard522+ | Capability::Text523+ | Capability::PointerPositionFeedback524+ | Capability::PointerVisibilityFeedback => {525+ grants.contains(InvitationGrants::POINTER_KEYBOARD)526+ }527+ Capability::Gamepad => grants.contains(InvitationGrants::GAMEPAD),528+ Capability::InputAck => {529+ grants.contains(InvitationGrants::POINTER_KEYBOARD)530+ || grants.contains(InvitationGrants::GAMEPAD)531+ }532+ Capability::VideoH264 | Capability::AudioOpus => false,533+ }534+}535+536+fn pointer_feedback_fields(537+ pointer_state: Option<PointerState>,538+ visibility_feedback_enabled: bool,539+) -> (Option<sigil_protocol::PointerPosition>, Option<bool>) {540+ match pointer_state {541+ Some(state) if visibility_feedback_enabled => (state.position, Some(state.visible)),542+ Some(state) => (state.position.filter(|_| state.visible), None),543+ None => (None, None),544+ }545+}546+547+fn supported_input_capabilities(548+ backend: &[Capability],549+ pointer_feedback_available: bool,550+) -> Vec<Capability> {551+ let mut supported = backend.to_vec();552+ if pointer_feedback_available && supported.contains(&Capability::RelativePointer) {553+ supported.push(Capability::PointerPositionFeedback);554+ supported.push(Capability::PointerVisibilityFeedback);555+ }556+ supported557+}558+559+fn input_event_type(event: &sigil_protocol::InputEvent) -> &'static str {560+ match event {561+ sigil_protocol::InputEvent::Probe => "probe",562+ sigil_protocol::InputEvent::MouseMove { .. } => "mouse-move",563+ sigil_protocol::InputEvent::MouseMoveRelative { .. } => "mouse-move-relative",564+ sigil_protocol::InputEvent::MousePositionSync { .. } => "mouse-position-sync",565+ sigil_protocol::InputEvent::MouseClick { .. } => "mouse-click",566+ sigil_protocol::InputEvent::MouseDown { .. } => "mouse-down",567+ sigil_protocol::InputEvent::MouseUp { .. } => "mouse-up",568+ sigil_protocol::InputEvent::MouseScroll { .. } => "mouse-scroll",569+ sigil_protocol::InputEvent::KeyDown { .. } => "key-down",570+ sigil_protocol::InputEvent::KeyUp { .. } => "key-up",571+ sigil_protocol::InputEvent::KeyClick { .. } => "key-click",572+ sigil_protocol::InputEvent::Text { .. } => "text",573+ sigil_protocol::InputEvent::Gamepad { .. } => "gamepad-snapshot",574+ }575+}576+577+pub(super) async fn receive_hello<R>(reader: &mut R, required: Capability) -> Result<ClientHello>578+where579+ R: tokio::io::AsyncRead + Unpin,580+{581+ let hello = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello(reader))582+ .await583+ .context("timed out waiting for client hello")??584+ .context("client closed before hello")?;585+ ensure!(586+ hello.capabilities.contains(&required),587+ "client did not offer required capability {required:?}"588+ );589+ Ok(hello)590+}591+592+async fn receive_hello_unconstrained<R>(reader: &mut R) -> Result<ClientHello>593+where594+ R: tokio::io::AsyncRead + Unpin,595+{596+ tokio::time::timeout(HANDSHAKE_TIMEOUT, read_client_hello(reader))597+ .await598+ .context("timed out waiting for client hello")??599+ .context("client closed before hello")600+}601+602+#[cfg(test)]603+mod tests {604+ use super::*;605+606+ #[test]607+ fn capability_negotiation_is_an_exact_intersection() {608+ let hello = ClientHello::new(609+ "test",610+ [0; 16],611+ vec![612+ Capability::AbsolutePointer,613+ Capability::RelativePointer,614+ Capability::Keyboard,615+ Capability::Text,616+ Capability::Gamepad,617+ Capability::VideoH264,618+ Capability::AudioOpus,619+ ],620+ );621+ assert_eq!(622+ negotiated_capabilities(623+ &hello,624+ &[625+ Capability::RelativePointer,626+ Capability::Keyboard,627+ Capability::Gamepad,628+ ]629+ ),630+ vec![631+ Capability::RelativePointer,632+ Capability::Keyboard,633+ Capability::Gamepad,634+ ]635+ );636+ assert!(negotiated_capabilities(&hello, &[Capability::InputAck]).is_empty());637+ assert_eq!(638+ negotiated_capabilities(&hello, MEDIA_CAPABILITIES),639+ vec![Capability::VideoH264]640+ );641+ assert_eq!(642+ negotiated_capabilities(&hello, AUDIO_CAPABILITIES),643+ vec![Capability::AudioOpus]644+ );645+ }646+647+ #[test]648+ fn enrollment_grants_are_a_strict_input_capability_ceiling() {649+ let hello = ClientHello::new(650+ "test",651+ [0; 16],652+ vec![653+ Capability::RelativePointer,654+ Capability::AbsolutePointer,655+ Capability::Keyboard,656+ Capability::Text,657+ Capability::Gamepad,658+ Capability::InputAck,659+ Capability::PointerPositionFeedback,660+ Capability::PointerVisibilityFeedback,661+ ],662+ );663+ let supported = hello.capabilities.clone();664+665+ assert!(666+ negotiated_input_capabilities(&hello, &supported, InvitationGrants::VIEW).is_empty()667+ );668+ assert_eq!(669+ negotiated_input_capabilities(670+ &hello,671+ &supported,672+ InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),673+ ),674+ vec![675+ Capability::RelativePointer,676+ Capability::AbsolutePointer,677+ Capability::Keyboard,678+ Capability::Text,679+ Capability::InputAck,680+ Capability::PointerPositionFeedback,681+ Capability::PointerVisibilityFeedback,682+ ]683+ );684+ assert_eq!(685+ negotiated_input_capabilities(686+ &hello,687+ &supported,688+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),689+ ),690+ vec![Capability::Gamepad, Capability::InputAck]691+ );692+ }693+694+ #[test]695+ fn pointer_feedback_is_advertised_only_with_tracker_and_relative_input() {696+ assert_eq!(697+ supported_input_capabilities(&[Capability::RelativePointer], false),698+ vec![Capability::RelativePointer]699+ );700+ assert_eq!(701+ supported_input_capabilities(&[Capability::RelativePointer], true),702+ vec![703+ Capability::RelativePointer,704+ Capability::PointerPositionFeedback,705+ Capability::PointerVisibilityFeedback,706+ ]707+ );708+ assert_eq!(709+ supported_input_capabilities(&[Capability::InputAck], true),710+ vec![Capability::InputAck]711+ );712+ }713+714+ #[test]715+ fn old_pointer_feedback_client_gets_legacy_host_hello_and_ack_shape() {716+ let hello = ClientHello::new(717+ "old-client",718+ [0; 16],719+ vec![720+ Capability::RelativePointer,721+ Capability::PointerPositionFeedback,722+ ],723+ );724+ let supported = supported_input_capabilities(&[Capability::RelativePointer], true);725+ let negotiated = negotiated_input_capabilities(&hello, &supported, InvitationGrants::ALL);726+ assert_eq!(727+ negotiated,728+ vec![729+ Capability::RelativePointer,730+ Capability::PointerPositionFeedback,731+ ]732+ );733+ assert_eq!(734+ serde_json::to_string(&HostHello::accepted(7, negotiated)).unwrap(),735+ r#"{"version":1,"accepted":true,"session_id":7,"capabilities":["relative_pointer","pointer_position_feedback"],"message":null}"#736+ );737+738+ let position = sigil_protocol::PointerPosition { x: 320, y: 200 };739+ let (pointer_position, pointer_visible) = pointer_feedback_fields(740+ Some(PointerState {741+ position: Some(position),742+ visible: true,743+ }),744+ false,745+ );746+ assert_eq!(747+ serde_json::to_string(&InputAck {748+ sequence: 1,749+ pointer_position,750+ pointer_visible,751+ })752+ .unwrap(),753+ r#"{"sequence":1,"pointer_position":{"x":320,"y":200}}"#754+ );755+756+ let (pointer_position, pointer_visible) = pointer_feedback_fields(757+ Some(PointerState {758+ position: Some(position),759+ visible: false,760+ }),761+ false,762+ );763+ assert_eq!(764+ serde_json::to_string(&InputAck {765+ sequence: 1,766+ pointer_position,767+ pointer_visible,768+ })769+ .unwrap(),770+ r#"{"sequence":1}"#771+ );772+ }773+774+ #[test]775+ fn pointer_visibility_feedback_requires_position_feedback() {776+ let visibility_only = ClientHello::new(777+ "invalid-client",778+ [0; 16],779+ vec![Capability::PointerVisibilityFeedback],780+ );781+ let supported = supported_input_capabilities(&[Capability::RelativePointer], true);782+ assert!(783+ negotiated_input_capabilities(&visibility_only, &supported, InvitationGrants::ALL)784+ .is_empty()785+ );786+787+ let upgraded = ClientHello::new(788+ "upgraded-client",789+ [0; 16],790+ vec![791+ Capability::RelativePointer,792+ Capability::PointerPositionFeedback,793+ Capability::PointerVisibilityFeedback,794+ ],795+ );796+ assert_eq!(797+ negotiated_input_capabilities(&upgraded, &supported, InvitationGrants::ALL),798+ supported799+ );800+801+ let position = sigil_protocol::PointerPosition { x: 320, y: 200 };802+ assert_eq!(803+ pointer_feedback_fields(804+ Some(PointerState {805+ position: Some(position),806+ visible: false,807+ }),808+ true,809+ ),810+ (Some(position), Some(false))811+ );812+ }813+}
A crates/sigil-host/src/server/media_v2.rs +821 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-host/src/server/media_v3.rs +1356 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-host/src/server/mod.rs +460 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-host/src/server/moq.rs +727 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-host/src/server/session.rs +1099 -0

Not shown: this page already shows 6000 lines of diff.

A crates/sigil-host/src/server/startup.rs +321 -0

Not shown: this page already shows 6000 lines of diff.