- commit
- 6aa71e0356f4f1f5f9b1acb011e0bb9bdb5fdb73
- parent
- bf395a4f0cc3ef0a4af09484faf697afcb4ee254
- author
- Dami <dami.etoile@gmail.com>
| M | crates/ | 2382 | +----------------------------- |
| A | crates/ | 2309 | +++++++++++++++++++++++++++++ |
@@ -70,876 +70,20 @@ const GAMESCOPE_STARTUP_TARGET_MIN_FPS: f64 = 45.0;
7070 const SOURCE_REAP_GRACE_TIMEOUT: Duration = Duration::from_secs(1);7171 const MOQ_ATTACHMENT_TIMEOUT: Duration = Duration::from_secs(10);7272 const MOQ_REJECT_CODE: u32 = 0x534d;73-const ADAPTIVE_BITRATE_FLOOR_KBPS: u32 = 1_000;74-const SHADOW_BITRATE_CEILING_KBPS: u32 = 12_000;75-const ADAPTIVE_BITRATE_QUANTUM_KBPS: u32 = 250;76-const ADAPTIVE_BITRATE_CLEAN_WINDOWS: u8 = 10;77-const ADAPTIVE_BITRATE_MODERATE_WINDOWS: u8 = 2;78-const ADAPTIVE_BITRATE_COOLDOWN_WINDOWS: u8 = 10;79-const MOTION_RESOLUTION_MODERATE_WINDOWS: u8 = 2;80-const MOTION_RESOLUTION_CLEAN_WINDOWS: u8 = 3;81-const MOTION_RESOLUTION_ACTIVITY_FPS: u64 = 10;82-const MOTION_RESOLUTION_STILL_SETTLE: Duration = Duration::from_secs(2);83-const MOTION_RESOLUTION_UPSCALE_COOLDOWN: Duration = Duration::from_secs(3);84-const FEEDBACK_FRESH_INTERVAL_MIN_MS: u16 = 750;85-const FEEDBACK_FRESH_INTERVAL_MAX_MS: u16 = 1_500;86-const FEEDBACK_FRESH_ARRIVAL_GAP_MIN: Duration = Duration::from_millis(750);87-const FEEDBACK_STALE_ARRIVAL_GAP: Duration = Duration::from_millis(2_500);88-const FEEDBACK_MIN_READ_INTERVAL: Duration = Duration::from_millis(250);89-const FEEDBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(1);9073 const ENCODER_CONTROL_COMMIT_TIMEOUT: Duration = Duration::from_secs(2);91-// Mirrors the fixed v1 wire bound. Aggregates clamp here so a stalled92-// consumer observes a stale 5-second window instead of a deceptively fresh93-// latest report.94-const MEDIA_FEEDBACK_INTERVAL_MAX_MS: u64 = 5_000;95-96-97-enum FeedbackSeverity {98- Clean,99- InsufficientEvidence,100- Moderate,101- Severe,102- Stale,103-}104-105-106-pub(crate) struct VideoDimensions {107- pub width: u16,108- pub height: u16,109-}110-111-impl VideoDimensions {112- fn three_quarter(self) -> Result<Self> {113- ensure!(114- self.width.is_multiple_of(4) && self.height.is_multiple_of(4),115- "native dimensions must be divisible by four for an exact three-quarter tier"116- );117- let reduced = Self {118- width: self.width / 4 * 3,119- height: self.height / 4 * 3,120- };121- ensure!(122- reduced.width >= 64 && reduced.height >= 64,123- "three-quarter dimensions are below the H.264 minimum"124- );125- ensure!(126- reduced.width.is_multiple_of(2) && reduced.height.is_multiple_of(2),127- "three-quarter H.264 dimensions must be even"128- );129- ensure!(130- u32::from(self.width) * u32::from(reduced.height)131- == u32::from(self.height) * u32::from(reduced.width),132- "three-quarter dimensions must preserve the native aspect ratio"133- );134- Ok(reduced)135- }136-}137-138-139-enum ResolutionTier {140- Native,141- Reduced,142-}143-144-145-enum ResolutionTrigger {146- Hold,147- Motion,148- Pressure,149- Recovery,150-}151-152-153-struct MotionResolutionDecision {154- tier: ResolutionTier,155- target: VideoDimensions,156- trigger: ResolutionTrigger,157- changed: bool,158-}159-160-/// Pure two-tier resolution policy. Fresh sequence progress from Gamescope's161-/// damage-driven stream is the bounded motion proxy while the authenticated162-/// feedback path supplies pressure. A downshift is safety-biased and163-/// immediate; an upscale requires both settled motion and fresh clean164-/// feedback, so stale reports can never restore detail.165-166-pub(crate) struct MotionResolutionPolicy {167- native: VideoDimensions,168- reduced: VideoDimensions,169- tier: ResolutionTier,170- motion_active: bool,171- still_since: Option<Instant>,172- pressure_active: bool,173- moderate_windows: u8,174- clean_windows: u8,175- last_transition_at: Option<Instant>,176- last_motion_sequence: Option<u64>,177-}178-179-impl MotionResolutionPolicy {180- pub(crate) fn new(native: VideoDimensions) -> Result<Self> {181- ensure!(182- native.width >= 64 && native.height >= 64,183- "native dimensions are too small"184- );185- ensure!(186- native.width.is_multiple_of(2) && native.height.is_multiple_of(2),187- "native H.264 dimensions must be even"188- );189- Ok(Self {190- native,191- reduced: native.three_quarter()?,192- tier: ResolutionTier::Native,193- motion_active: false,194- still_since: None,195- pressure_active: false,196- moderate_windows: 0,197- clean_windows: 0,198- last_transition_at: None,199- last_motion_sequence: None,200- })201- }202-203- pub(crate) fn target(&self) -> VideoDimensions {204- match self.tier {205- ResolutionTier::Native => self.native,206- ResolutionTier::Reduced => self.reduced,207- }208- }209-210- /// Host motion hook. Repeated still observations preserve the original211- /// settle boundary instead of indefinitely postponing recovery.212- 213- fn observe_motion(214- &mut self,215- motion_active: bool,216- observed_at: Instant,217- ) -> MotionResolutionDecision {218- self.update_motion(motion_active, observed_at);219- self.evaluate_target(observed_at)220- }221-222- fn update_motion(&mut self, motion_active: bool, observed_at: Instant) {223- if motion_active {224- self.motion_active = true;225- self.still_since = None;226- return;227- }228- if self.motion_active || self.still_since.is_none() {229- self.still_since = Some(observed_at);230- }231- self.motion_active = false;232- }233-234- /// Observe one classified feedback window. Only fresh moderate/severe235- /// pressure can initiate a downshift, and only fresh clean windows count236- /// toward restoring native detail.237- 238- fn observe_feedback(239- &mut self,240- severity: FeedbackSeverity,241- fresh: bool,242- observed_at: Instant,243- ) -> MotionResolutionDecision {244- self.update_feedback(severity, fresh);245- self.evaluate_target(observed_at)246- }247-248- /// Gamescope output is damage-driven, so fresh media sequence progress is249- /// a conservative constant-space motion proxy for this first slice. A250- /// future raw-frame observer can call `observe_motion` without changing251- /// pressure hysteresis or encoder actuation.252- fn observe_window(253- &mut self,254- report: &MediaFeedbackReportV1,255- evaluation: &AdaptiveBitrateEvaluation,256- observed_at: Instant,257- ) -> MotionResolutionDecision {258- let previous_sequence = self.last_motion_sequence;259- if let Some(sequence) = report.last_sequence {260- self.last_motion_sequence = Some(261- self.last_motion_sequence262- .map_or(sequence, |last| last.max(sequence)),263- );264- }265- if evaluation.fresh {266- let motion_active = report.last_sequence.is_some_and(|sequence| {267- let delta = previous_sequence.map_or(0, |last| sequence.saturating_sub(last));268- delta.saturating_mul(1_000)269- >= MOTION_RESOLUTION_ACTIVITY_FPS.saturating_mul(u64::from(report.interval_ms))270- });271- self.update_motion(motion_active, observed_at);272- }273- let resolution_severity = if evaluation.pressure_severity == FeedbackSeverity::Clean274- && !evaluation.clean_recovery_evidence275- {276- FeedbackSeverity::InsufficientEvidence277- } else {278- evaluation.pressure_severity279- };280- self.update_feedback(resolution_severity, evaluation.fresh);281- self.evaluate_target(observed_at)282- }283-284- fn update_feedback(&mut self, severity: FeedbackSeverity, fresh: bool) {285- if !fresh286- || matches!(287- severity,288- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale289- )290- {291- self.moderate_windows = 0;292- self.clean_windows = 0;293- return;294- }295-296- match severity {297- FeedbackSeverity::Severe => {298- self.pressure_active = true;299- self.moderate_windows = 0;300- self.clean_windows = 0;301- }302- FeedbackSeverity::Moderate => {303- self.clean_windows = 0;304- self.moderate_windows = self.moderate_windows.saturating_add(1);305- if self.moderate_windows >= MOTION_RESOLUTION_MODERATE_WINDOWS {306- self.moderate_windows = 0;307- self.pressure_active = true;308- }309- }310- FeedbackSeverity::Clean => {311- self.moderate_windows = 0;312- self.clean_windows = self.clean_windows.saturating_add(1);313- if self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS {314- self.pressure_active = false;315- }316- }317- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {}318- }319- }320-321- fn evaluate_target(&mut self, observed_at: Instant) -> MotionResolutionDecision {322- if self.motion_active {323- self.downshift(observed_at, ResolutionTrigger::Motion)324- } else if self.pressure_active {325- self.downshift(observed_at, ResolutionTrigger::Pressure)326- } else {327- self.maybe_restore(observed_at)328- }329- }330-331- fn commit_observation_from(&mut self, candidate: &Self) {332- self.motion_active = candidate.motion_active;333- self.still_since = candidate.still_since;334- self.pressure_active = candidate.pressure_active;335- self.moderate_windows = candidate.moderate_windows;336- self.clean_windows = candidate.clean_windows;337- self.last_motion_sequence = candidate.last_motion_sequence;338- }339-340- fn downshift(341- &mut self,342- observed_at: Instant,343- trigger: ResolutionTrigger,344- ) -> MotionResolutionDecision {345- let changed = self.tier != ResolutionTier::Reduced;346- if changed {347- self.tier = ResolutionTier::Reduced;348- self.last_transition_at = Some(observed_at);349- }350- MotionResolutionDecision {351- tier: self.tier,352- target: self.target(),353- trigger,354- changed,355- }356- }357-358- fn maybe_restore(&mut self, observed_at: Instant) -> MotionResolutionDecision {359- let still_settled = !self.motion_active360- && self.still_since.is_some_and(|still_since| {361- observed_at.saturating_duration_since(still_since) >= MOTION_RESOLUTION_STILL_SETTLE362- });363- let cooldown_complete = self.last_transition_at.is_none_or(|last| {364- observed_at.saturating_duration_since(last) >= MOTION_RESOLUTION_UPSCALE_COOLDOWN365- });366- let clean = self.clean_windows >= MOTION_RESOLUTION_CLEAN_WINDOWS;367- if self.tier == ResolutionTier::Reduced368- && still_settled369- && cooldown_complete370- && clean371- && !self.pressure_active372- {373- self.tier = ResolutionTier::Native;374- self.clean_windows = 0;375- self.last_transition_at = Some(observed_at);376- return MotionResolutionDecision {377- tier: self.tier,378- target: self.target(),379- trigger: ResolutionTrigger::Recovery,380- changed: true,381- };382- }383- self.hold()384- }385-386- fn hold(&self) -> MotionResolutionDecision {387- MotionResolutionDecision {388- tier: self.tier,389- target: self.target(),390- trigger: ResolutionTrigger::Hold,391- changed: false,392- }393- }394-}395-396-fn motion_resolution_policy(width: u32, height: u32) -> Result<Option<MotionResolutionPolicy>> {397- let native = VideoDimensions {398- width: u16::try_from(width).context("native width exceeds protocol")?,399- height: u16::try_from(height).context("native height exceeds protocol")?,400- };401- match MotionResolutionPolicy::new(native) {402- Ok(policy) => Ok(Some(policy)),403- Err(error) => {404- warn!(405- %error,406- width,407- height,408- "motion resolution disabled because this native mode has no exact reduced tier"409- );410- Ok(None)411- }412- }413-}414-415-fn motion_resolution_policy_for_encoder(416- control: &EncoderControl,417-) -> Result<Option<MotionResolutionPolicy>> {418- let (width, height) = control.initial_dimensions();419- motion_resolution_policy(u32::from(width), u32::from(height))420-}421-422-423-struct FeedbackIngressCursor {424- last_report_id: Option<u64>,425- interval_ms: u64,426- transport_dropped: u64,427- frontend_dropped: u64,428- decoder_dropped: u64,429- presenter_dropped: u64,430- resync_reports: u64,431-}432-433-434-struct CumulativeFeedbackIngress {435- latest: Option<MediaFeedbackReportV1>,436- interval_ms: u64,437- transport_dropped: u64,438- frontend_dropped: u64,439- decoder_dropped: u64,440- presenter_dropped: u64,441- resync_reports: u64,442-}443-444-impl CumulativeFeedbackIngress {445- fn observe(&mut self, report: MediaFeedbackReportV1) {446- self.interval_ms = self.interval_ms.wrapping_add(u64::from(report.interval_ms));447- self.transport_dropped = self448- .transport_dropped449- .wrapping_add(u64::from(report.transport_dropped_delta));450- self.frontend_dropped = self451- .frontend_dropped452- .wrapping_add(u64::from(report.frontend_dropped_delta));453- self.decoder_dropped = self454- .decoder_dropped455- .wrapping_add(u64::from(report.decoder_dropped_delta));456- self.presenter_dropped = self457- .presenter_dropped458- .wrapping_add(u64::from(report.presenter_dropped_delta));459- if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE) {460- self.resync_reports = self.resync_reports.wrapping_add(1);461- }462- self.latest = Some(report);463- }464-465- fn report_since(466- &self,467- cursor: FeedbackIngressCursor,468- ) -> Result<Option<(MediaFeedbackReportV1, FeedbackIngressCursor)>> {469- let Some(latest) = self.latest else {470- return Ok(None);471- };472- if cursor473- .last_report_id474- .is_some_and(|report_id| latest.report_id <= report_id)475- {476- return Ok(None);477- }478- let mut report = latest;479- report.interval_ms = u16::try_from(480- self.interval_ms481- .wrapping_sub(cursor.interval_ms)482- .min(MEDIA_FEEDBACK_INTERVAL_MAX_MS),483- )484- .expect("feedback interval is clamped to u16");485- report.transport_dropped_delta =486- cumulative_feedback_delta(self.transport_dropped, cursor.transport_dropped);487- report.frontend_dropped_delta =488- cumulative_feedback_delta(self.frontend_dropped, cursor.frontend_dropped);489- report.decoder_dropped_delta =490- cumulative_feedback_delta(self.decoder_dropped, cursor.decoder_dropped);491- report.presenter_dropped_delta =492- cumulative_feedback_delta(self.presenter_dropped, cursor.presenter_dropped);493- report.flags = if self.resync_reports.wrapping_sub(cursor.resync_reports) > 0 {494- MediaFeedbackFlags::RESYNC_ACTIVE495- } else {496- MediaFeedbackFlags::NONE497- };498- report.validate()?;499- Ok(Some((500- report,501- FeedbackIngressCursor {502- last_report_id: Some(latest.report_id),503- interval_ms: self.interval_ms,504- transport_dropped: self.transport_dropped,505- frontend_dropped: self.frontend_dropped,506- decoder_dropped: self.decoder_dropped,507- presenter_dropped: self.presenter_dropped,508- resync_reports: self.resync_reports,509- },510- )))511- }512-}513-514-fn cumulative_feedback_delta(total: u64, baseline: u64) -> u32 {515- u32::try_from(total.wrapping_sub(baseline).min(u64::from(u32::MAX)))516- .expect("feedback delta is clamped to u32")517-}518-519-520-struct ShadowBitrateController {521- floor_kbps: u32,522- ceiling_kbps: u32,523- target_kbps: u32,524- next_decision_id: u64,525- last_report_id: Option<u64>,526- last_report_received_at: Option<Instant>,527- previous_telemetry: MediaV3TelemetrySnapshot,528- baseline_rtt_micros: Option<u64>,529- last_sequence: Option<u64>,530- moderate_windows: u8,531- clean_windows: u8,532- cooldown_windows: u8,533-}534-535-536-struct AdaptiveBitrateEvaluation {537- decision: AdaptiveBitrateDecisionV1,538- pressure_severity: FeedbackSeverity,539- clean_recovery_evidence: bool,540- fresh: bool,541-}542-543-impl ShadowBitrateController {544- fn new(configured_ceiling_kbps: u32, telemetry: MediaV3TelemetrySnapshot) -> Self {545- let ceiling_kbps = quantize_down(configured_ceiling_kbps).max(ADAPTIVE_BITRATE_FLOOR_KBPS);546- Self {547- floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,548- ceiling_kbps,549- target_kbps: ceiling_kbps,550- next_decision_id: 1,551- last_report_id: None,552- last_report_received_at: None,553- previous_telemetry: telemetry,554- baseline_rtt_micros: None,555- last_sequence: None,556- moderate_windows: 0,557- clean_windows: 0,558- cooldown_windows: 0,559- }560- }561-562- fn evaluate(563- &mut self,564- report: &MediaFeedbackReportV1,565- telemetry: MediaV3TelemetrySnapshot,566- received_at: Instant,567- ) -> Result<AdaptiveBitrateEvaluation> {568- ensure!(569- self.last_report_id570- .is_none_or(|last| report.report_id > last),571- "feedback report IDs must increase monotonically"572- );573- let (severity, pressure_severity, reasons, stale, trusted_host_pressure) =574- self.classify(report, telemetry, received_at);575- let mut state = AdaptiveBitrateStateV1::Hold;576-577- if self.cooldown_windows > 0 {578- self.cooldown_windows -= 1;579- }580- match severity {581- FeedbackSeverity::Severe => {582- self.moderate_windows = 0;583- self.clean_windows = 0;584- self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;585- let next =586- quantize_down(self.target_kbps.saturating_mul(3) / 4).max(self.floor_kbps);587- if next < self.target_kbps {588- self.target_kbps = next;589- state = AdaptiveBitrateStateV1::Decrease;590- }591- }592- FeedbackSeverity::Moderate => {593- self.moderate_windows = self.moderate_windows.saturating_add(1);594- self.clean_windows = 0;595- if self.moderate_windows >= ADAPTIVE_BITRATE_MODERATE_WINDOWS {596- self.moderate_windows = 0;597- self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;598- let next =599- quantize_down(self.target_kbps.saturating_mul(4) / 5).max(self.floor_kbps);600- if next < self.target_kbps {601- self.target_kbps = next;602- state = AdaptiveBitrateStateV1::Decrease;603- }604- }605- }606- FeedbackSeverity::Clean => {607- self.moderate_windows = 0;608- self.clean_windows = self.clean_windows.saturating_add(1);609- if self.clean_windows >= ADAPTIVE_BITRATE_CLEAN_WINDOWS610- && self.cooldown_windows == 0611- {612- self.clean_windows = 0;613- let maximum_step =614- (self.target_kbps / 20).clamp(ADAPTIVE_BITRATE_QUANTUM_KBPS, 500);615- let next = quantize_down(616- self.target_kbps617- .saturating_add(maximum_step)618- .min(self.ceiling_kbps),619- );620- if next > self.target_kbps {621- self.target_kbps = next;622- state = AdaptiveBitrateStateV1::Increase;623- }624- }625- }626- FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {627- self.moderate_windows = 0;628- self.clean_windows = 0;629- }630- }631-632- self.target_kbps = self.target_kbps.clamp(self.floor_kbps, self.ceiling_kbps);633- let decision = AdaptiveBitrateDecisionV1 {634- decision_id: self.next_decision_id,635- report_id: report.report_id,636- target_kbps: self.target_kbps,637- floor_kbps: self.floor_kbps,638- ceiling_kbps: self.ceiling_kbps,639- state,640- reasons,641- applied: false,642- };643- decision.validate()?;644- self.next_decision_id = self645- .next_decision_id646- .checked_add(1)647- .context("adaptive bitrate decision ID exhausted")?;648- self.last_report_id = Some(report.report_id);649- self.last_report_received_at = Some(received_at);650- if !stale651- || !trusted_host_pressure652- || state == AdaptiveBitrateStateV1::Decrease653- || self.target_kbps == self.floor_kbps654- {655- self.previous_telemetry = telemetry;656- }657- if !stale && let Some(sequence) = report.last_sequence {658- self.last_sequence = Some(659- self.last_sequence660- .map_or(sequence, |last| last.max(sequence)),661- );662- }663- Ok(AdaptiveBitrateEvaluation {664- decision,665- pressure_severity,666- clean_recovery_evidence: severity == FeedbackSeverity::Clean,667- fresh: !stale,668- })669- }670-671- 672- fn decide(673- &mut self,674- report: &MediaFeedbackReportV1,675- telemetry: MediaV3TelemetrySnapshot,676- received_at: Instant,677- ) -> Result<AdaptiveBitrateDecisionV1> {678- self.evaluate(report, telemetry, received_at)679- .map(|evaluation| evaluation.decision)680- }681-682- fn classify(683- &mut self,684- report: &MediaFeedbackReportV1,685- telemetry: MediaV3TelemetrySnapshot,686- received_at: Instant,687- ) -> (688- FeedbackSeverity,689- FeedbackSeverity,690- AdaptiveBitrateReasonFlagsV1,691- bool,692- bool,693- ) {694- let stale = !(FEEDBACK_FRESH_INTERVAL_MIN_MS..=FEEDBACK_FRESH_INTERVAL_MAX_MS)695- .contains(&report.interval_ms)696- || self.last_report_received_at.is_none()697- || self.last_report_received_at.is_some_and(|last| {698- let gap = received_at.saturating_duration_since(last);699- !(FEEDBACK_FRESH_ARRIVAL_GAP_MIN..=FEEDBACK_STALE_ARRIVAL_GAP).contains(&gap)700- });701- let mut severity = FeedbackSeverity::Clean;702- let mut reasons = AdaptiveBitrateReasonFlagsV1::NONE;703-704- let lost_delta = telemetry705- .selected_path_lost_packets706- .saturating_sub(self.previous_telemetry.selected_path_lost_packets);707- let congestion_delta = telemetry708- .selected_path_congestion_events709- .saturating_sub(self.previous_telemetry.selected_path_congestion_events);710- let cancellation_delta = telemetry711- .scheduler_cancellations712- .saturating_sub(self.previous_telemetry.scheduler_cancellations);713- let failure_delta = telemetry714- .send_failures715- .saturating_sub(self.previous_telemetry.send_failures);716-717- if failure_delta > 0 || lost_delta > 0 || cancellation_delta >= 2 {718- add_feedback_signal(719- &mut severity,720- &mut reasons,721- FeedbackSeverity::Severe,722- AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,723- );724- } else if cancellation_delta > 0 || congestion_delta > 0 {725- add_feedback_signal(726- &mut severity,727- &mut reasons,728- FeedbackSeverity::Moderate,729- AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,730- );731- }732- if cancellation_delta >= 2 || failure_delta > 0 {733- add_feedback_signal(734- &mut severity,735- &mut reasons,736- FeedbackSeverity::Severe,737- AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,738- );739- } else if cancellation_delta > 0 {740- add_feedback_signal(741- &mut severity,742- &mut reasons,743- FeedbackSeverity::Moderate,744- AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,745- );746- }747-748- let rtt = telemetry.selected_path_rtt_micros;749- if rtt > 0 {750- let baseline = self.baseline_rtt_micros.get_or_insert(rtt);751- if rtt < *baseline {752- *baseline = rtt;753- } else if rtt >= baseline.saturating_mul(2).max(80_000) {754- add_feedback_signal(755- &mut severity,756- &mut reasons,757- FeedbackSeverity::Severe,758- AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,759- );760- } else if rtt >= baseline.saturating_mul(3) / 2 {761- add_feedback_signal(762- &mut severity,763- &mut reasons,764- FeedbackSeverity::Moderate,765- AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,766- );767- }768- }769-770- let trusted_host_pressure = severity != FeedbackSeverity::Clean;771- if stale {772- reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE);773- if !trusted_host_pressure {774- severity = FeedbackSeverity::Stale;775- }776- return (severity, severity, reasons, true, trusted_host_pressure);777- }778-779- let receiver_drops = report780- .transport_dropped_delta781- .saturating_add(report.frontend_dropped_delta)782- .saturating_add(report.decoder_dropped_delta)783- .saturating_add(report.presenter_dropped_delta);784- let queue_severe = queue_ratio_at_least(785- report.frontend_queue_depth,786- report.frontend_queue_capacity,787- 3,788- 4,789- ) || queue_ratio_at_least(790- report.decode_queue_depth,791- report.decode_queue_capacity,792- 3,793- 4,794- ) || queue_ratio_at_least(795- report.presenter_queue_depth,796- report.presenter_queue_capacity,797- 3,798- 4,799- );800- let queue_moderate = queue_ratio_at_least(801- report.frontend_queue_depth,802- report.frontend_queue_capacity,803- 1,804- 2,805- ) || queue_ratio_at_least(806- report.decode_queue_depth,807- report.decode_queue_capacity,808- 1,809- 2,810- ) || queue_ratio_at_least(811- report.presenter_queue_depth,812- report.presenter_queue_capacity,813- 1,814- 2,815- );816- if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)817- || queue_severe818- || receiver_drops >= 4819- {820- add_feedback_signal(821- &mut severity,822- &mut reasons,823- FeedbackSeverity::Severe,824- AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,825- );826- } else if queue_moderate || receiver_drops > 0 {827- add_feedback_signal(828- &mut severity,829- &mut reasons,830- FeedbackSeverity::Moderate,831- AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,832- );833- }834- if report.decode_queue_depth == report.decode_queue_capacity835- || report.decoder_dropped_delta > 0836- {837- add_feedback_signal(838- &mut severity,839- &mut reasons,840- FeedbackSeverity::Severe,841- AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,842- );843- } else if report.decode_queue_depth >= 2 {844- add_feedback_signal(845- &mut severity,846- &mut reasons,847- FeedbackSeverity::Moderate,848- AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,849- );850- }851-852- let delivery_p95 = report.transport_delivery_p95_ms.unwrap_or(0);853- let decode_p95 = report.decode_p95_ms.unwrap_or(0);854- let presentation_p95 = report.presentation_p95_ms.unwrap_or(0);855- if delivery_p95 >= 200 || decode_p95 >= 100 || presentation_p95 >= 100 {856- add_feedback_signal(857- &mut severity,858- &mut reasons,859- FeedbackSeverity::Severe,860- AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,861- );862- } else if delivery_p95 >= 100 || decode_p95 >= 50 || presentation_p95 >= 50 {863- add_feedback_signal(864- &mut severity,865- &mut reasons,866- FeedbackSeverity::Moderate,867- AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,868- );869- }870-871- let pressure_severity = severity;872- if severity == FeedbackSeverity::Clean {873- let sequence_progressed = self874- .last_sequence875- .zip(report.last_sequence)876- .is_some_and(|(last, current)| current > last);877- let complete_latency = report.transport_delivery_p95_ms.is_some()878- && report.decode_p95_ms.is_some()879- && report.presentation_p95_ms.is_some();880- if sequence_progressed && complete_latency {881- if self.target_kbps < self.ceiling_kbps {882- reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY);883- }884- } else {885- severity = FeedbackSeverity::InsufficientEvidence;886- }887- }888- (889- severity,890- pressure_severity,891- reasons,892- false,893- trusted_host_pressure,894- )895- }896-897- fn commit_observation_from(&mut self, candidate: &Self) {898- self.next_decision_id = candidate.next_decision_id;899- self.last_report_id = candidate.last_report_id;900- self.last_report_received_at = candidate.last_report_received_at;901- self.previous_telemetry = candidate.previous_telemetry;902- self.baseline_rtt_micros = candidate.baseline_rtt_micros;903- self.last_sequence = candidate.last_sequence;904- }905-}906-907-fn add_feedback_signal(908- severity: &mut FeedbackSeverity,909- reasons: &mut AdaptiveBitrateReasonFlagsV1,910- signal: FeedbackSeverity,911- reason: AdaptiveBitrateReasonFlagsV1,912-) {913- *reasons = reasons.union(reason);914- if signal == FeedbackSeverity::Severe915- || (*severity == FeedbackSeverity::Clean && signal == FeedbackSeverity::Moderate)916- {917- *severity = signal;918- }919-}920-921-fn quantize_down(kbps: u32) -> u32 {922- kbps / ADAPTIVE_BITRATE_QUANTUM_KBPS * ADAPTIVE_BITRATE_QUANTUM_KBPS923-}924-925-fn queue_ratio_at_least(depth: u8, capacity: u8, numerator: u16, denominator: u16) -> bool {926- capacity > 0927- && u16::from(depth).saturating_mul(denominator)928- >= u16::from(capacity).saturating_mul(numerator)929-}930-931-932-fn endpoint(byte: u8) -> EndpointId {933- iroh::SecretKey::from_bytes(&[byte; 32]).public()934-}93574 75+mod adaptive;93676 mod session;93777 78+79+pub(crate) use adaptive::MotionResolutionPolicy;80+pub(crate) use adaptive::VideoDimensions;81+use adaptive::serve_media_feedback;82+93883 pub use session::SessionRegistry;93984 use session::{940- AdaptiveEncoderProposal, ClaimedMoqAttachment, ForcedIdrCoordinator, ForcedIdrDisposition,941- MediaV3Telemetry, MediaV3TelemetrySnapshot, MoqAttachmentWait, ResolutionEncoderProposal,942- SourceTaskGuard,85+ ClaimedMoqAttachment, ForcedIdrCoordinator, ForcedIdrDisposition, MediaV3Telemetry,86+ MoqAttachmentWait, SourceTaskGuard,94387 };94488 94589 struct StartupCadenceSample {@@ -2448,9 +1592,9 @@ async fn run_control_moq_session(
24481592 result24491593 }24501594 2451-async fn serve_media_feedback(1595+async fn serve_media(24521596 connection: Connection,2453- config: &HostConfig,1597+ config: HostConfig,24541598 sessions: &Arc<SessionRegistry>,24551599 authorization: &AuthorizationPolicy,24561600 ) -> Result<()> {@@ -2461,570 +1605,32 @@ async fn serve_media_feedback(
24611605 .context("too many pending handshakes")?;24621606 let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())24631607 .await2464- .context("timed out accepting media feedback stream")?2465- .context("accepting media feedback stream")?;1608+ .context("timed out accepting media stream")?1609+ .context("accepting media stream")?;24661610 let hello = receive_hello(&mut recv, Capability::VideoH264).await?;24671611 drop(handshake_permit);2468- debug!(%remote, agent = %hello.agent, "media feedback hello received");2469- ensure!(2470- hello.invitation.is_none(),2471- "invitations are accepted only on the first media handshake"2472- );1612+ debug!(%remote, agent = %hello.agent, "media hello received");24731613 2474- let grants = match authorization.authorize_or_redeem(remote, None, unix_timestamp_now()?) {1614+ let grants = match authorization.authorize_or_redeem(1615+ remote,1616+ hello.invitation.as_deref(),1617+ unix_timestamp_now()?,1618+ ) {24751619 Ok(grants) => grants,24761620 Err(error) => {24771621 send_rejection(&mut send, "Portal peer is not authorized").await?;2478- return Err(error.context("authorizing media feedback peer"));1622+ return Err(error.context("authorizing media peer"));24791623 }24801624 };24811625 ensure!(24821626 grants.contains(InvitationGrants::VIEW),2483- "authorized feedback peer lacks view permission"1627+ "authorized media peer lacks view permission"24841628 );2485- let ceiling_kbps = adaptive_bitrate_ceiling_kbps(config)?;2486- let lease = match sessions.claim_feedback(remote, hello.nonce) {1629+1630+ let lease = match sessions.claim(remote, hello.nonce, grants) {24871631 Ok(lease) => lease,24881632 Err(error) => {2489- send_rejection(&mut send, error.to_string()).await?;2490- return Err(error);2491- }2492- };2493- let encoder_actuation_available =2494- adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();2495-2496- write_host_hello(2497- &mut send,2498- &HostHello::accepted(2499- lease.session_id,2500- negotiated_capabilities(&hello, MEDIA_CAPABILITIES),2501- ),2502- )2503- .await?;2504- info!(2505- %remote,2506- session_id = lease.session_id,2507- ceiling_kbps,2508- applied = false,2509- mode = if encoder_actuation_available { "active" } else { "shadow" },2510- "media feedback client accepted for bitrate control"2511- );2512-2513- let (feedback_sender, mut feedback_receiver) =2514- tokio::sync::watch::channel(CumulativeFeedbackIngress::default());2515- let mut reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));2516- let mut reader_finished = false;2517- let mut receiver_open = true;2518- let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());2519- let mut resolution_controller = if encoder_actuation_available {2520- lease2521- .encoder_control2522- .as_ref()2523- .map(motion_resolution_policy_for_encoder)2524- .transpose()?2525- .flatten()2526- } else {2527- None2528- };2529- let mut feedback_cursor = FeedbackIngressCursor::default();2530-2531- let result: Result<()> = loop {2532- let session_changed = sessions.session_changed.notified();2533- tokio::pin!(session_changed);2534- session_changed.as_mut().enable();2535- if !sessions.is_active(remote, lease.session_id) {2536- break Ok(());2537- }2538- tokio::select! {2539- biased;2540- reader_result = &mut reader_task, if !reader_finished => {2541- reader_finished = true;2542- match reader_result {2543- Ok(Ok(())) => {2544- debug!(%remote, "media feedback report stream closed");2545- }2546- Ok(Err(error)) => {2547- break Err(error).context("reading media feedback reports");2548- }2549- Err(error) => {2550- break Err(error).context("media feedback reader task failed");2551- }2552- }2553- }2554- changed = feedback_receiver.changed(), if receiver_open => {2555- if changed.is_err() {2556- receiver_open = false;2557- if reader_finished {2558- break Ok(());2559- }2560- continue;2561- }2562- let ingress = *feedback_receiver.borrow_and_update();2563- let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {2564- continue;2565- };2566- // This branch now owns the complete aggregate. Advance the2567- // ingress baseline exactly once before evaluation; reports2568- // arriving during an encoder wait accumulate beyond it.2569- feedback_cursor = next_cursor;2570- let telemetry = lease.telemetry.snapshot();2571- let observed_at = Instant::now();2572- let mut candidate = controller.clone();2573- let evaluation = candidate.evaluate(&report, telemetry, observed_at)?;2574- let mut decision = evaluation.decision;2575- let mut resolution_candidate = resolution_controller.clone();2576- let resolution_decision = resolution_candidate2577- .as_mut()2578- .map(|policy| policy.observe_window(&report, &evaluation, observed_at));2579- let changes_bitrate = decision.state != AdaptiveBitrateStateV1::Hold;2580- let changes_resolution = resolution_decision.is_some_and(|change| change.changed);2581- let can_actuate_bitrate = changes_bitrate && encoder_actuation_available;2582- let can_actuate_resolution = changes_resolution && encoder_actuation_available;2583- let mut recovery_acknowledged = None;2584- let bitrate_proposal = if can_actuate_bitrate {2585- let force_keyframe =2586- adaptive_recovery_keyframe_required(&report, &decision)2587- && !changes_resolution;2588- match sessions.propose_adaptive_encoder_update(2589- remote,2590- lease.session_id,2591- decision.target_kbps,2592- force_keyframe,2593- ) {2594- Ok(Some(proposal)) => Some(proposal),2595- Ok(None) => {2596- warn!(2597- %remote,2598- session_id = lease.session_id,2599- "active adaptive session lost encoder control; retaining committed bitrate"2600- );2601- None2602- }2603- Err(error) => {2604- warn!(2605- %remote,2606- session_id = lease.session_id,2607- %error,2608- "adaptive encoder proposal failed; retaining committed bitrate"2609- );2610- None2611- }2612- }2613- } else {2614- None2615- };2616- let resolution_proposal = if can_actuate_resolution {2617- let target = resolution_decision2618- .expect("resolution change is required for actuation")2619- .target;2620- match sessions.propose_resolution_update(remote, lease.session_id, target) {2621- Ok(Some(proposal)) => Some(proposal),2622- Ok(None) => {2623- warn!(2624- %remote,2625- session_id = lease.session_id,2626- "active adaptive session lost encoder control; retaining committed resolution"2627- );2628- None2629- }2630- Err(error) => {2631- warn!(2632- %remote,2633- session_id = lease.session_id,2634- %error,2635- "adaptive resolution proposal failed; retaining committed resolution"2636- );2637- None2638- }2639- }2640- } else {2641- None2642- };2643-2644- if let Some(proposal) = bitrate_proposal {2645- match commit_adaptive_encoder_proposal(2646- sessions,2647- remote,2648- lease.session_id,2649- &proposal,2650- )2651- .await2652- {2653- AdaptiveEncoderCommit::GenerationEnded => break Ok(()),2654- AdaptiveEncoderCommit::NotApplied(error) => {2655- warn!(2656- %remote,2657- session_id = lease.session_id,2658- %error,2659- "adaptive encoder application failed; retaining committed bitrate"2660- );2661- }2662- AdaptiveEncoderCommit::Applied => {2663- decision.applied = true;2664- controller = candidate.clone();2665- if proposal.force_keyframe_revision.is_some() {2666- match await_adaptive_recovery_keyframe(2667- sessions,2668- remote,2669- lease.session_id,2670- &proposal,2671- )2672- .await2673- {2674- None => break Ok(()),2675- Some(Ok(())) => recovery_acknowledged = Some(true),2676- Some(Err(error)) => {2677- recovery_acknowledged = Some(false);2678- warn!(2679- %remote,2680- session_id = lease.session_id,2681- %error,2682- "adaptive bitrate applied but forced-IDR recovery was not acknowledged"2683- );2684- }2685- }2686- }2687- }2688- }2689- }2690- if can_actuate_bitrate {2691- if !decision.applied {2692- controller.commit_observation_from(&candidate);2693- }2694- } else {2695- // External gst-launch and sources without EncoderControl2696- // remain intentionally shadow-only. Hold decisions do not2697- // need encoder actuation.2698- controller = candidate;2699- }2700-2701- let mut resolution_applied = None;2702- if let Some(proposal) = resolution_proposal {2703- match commit_resolution_encoder_proposal(2704- sessions,2705- remote,2706- lease.session_id,2707- &proposal,2708- )2709- .await2710- {2711- AdaptiveEncoderCommit::GenerationEnded => break Ok(()),2712- AdaptiveEncoderCommit::NotApplied(error) => {2713- resolution_applied = Some(false);2714- warn!(2715- %remote,2716- session_id = lease.session_id,2717- target_width = proposal.target.width,2718- target_height = proposal.target.height,2719- %error,2720- "adaptive resolution application failed; retaining committed resolution"2721- );2722- }2723- AdaptiveEncoderCommit::Applied => {2724- resolution_applied = Some(true);2725- resolution_controller = resolution_candidate.clone();2726- }2727- }2728- }2729- if can_actuate_resolution && resolution_applied != Some(true) {2730- if let (Some(committed), Some(candidate)) =2731- (resolution_controller.as_mut(), resolution_candidate.as_ref())2732- {2733- committed.commit_observation_from(candidate);2734- }2735- } else if !changes_resolution {2736- resolution_controller = resolution_candidate;2737- }2738- tokio::time::timeout(2739- FEEDBACK_WRITE_TIMEOUT,2740- write_adaptive_bitrate_decision_v1(&mut send, &decision),2741- )2742- .await2743- .context("timed out writing adaptive bitrate decision")??;2744- debug!(2745- %remote,2746- session_id = lease.session_id,2747- report_id = decision.report_id,2748- decision_id = decision.decision_id,2749- target_kbps = decision.target_kbps,2750- ?decision.state,2751- reasons = decision.reasons.bits(),2752- applied = decision.applied,2753- ?recovery_acknowledged,2754- resolution_width = resolution_decision.map(|change| change.target.width),2755- resolution_height = resolution_decision.map(|change| change.target.height),2756- resolution_trigger = ?resolution_decision.map(|change| change.trigger),2757- ?resolution_applied,2758- path_rtt_micros = telemetry.selected_path_rtt_micros,2759- path_lost_packets = telemetry.selected_path_lost_packets,2760- path_congestion_events = telemetry.selected_path_congestion_events,2761- scheduler_cancellations = telemetry.scheduler_cancellations,2762- send_failures = telemetry.send_failures,2763- mode = if encoder_actuation_available { "active" } else { "shadow" },2764- "adaptive bitrate decision"2765- );2766- }2767- _ = &mut session_changed => {2768- if !sessions.is_active(remote, lease.session_id) {2769- break Ok(());2770- }2771- }2772- closed = connection.closed() => {2773- debug!(%remote, ?closed, "media feedback connection closed");2774- break Ok(());2775- }2776- }2777- if reader_finished && !receiver_open {2778- break Ok(());2779- }2780- };2781-2782- if !reader_finished {2783- reader_task.abort();2784- let _ = reader_task.await;2785- }2786- let _ = send.finish();2787- drop(lease);2788- info!(%remote, "media feedback client released");2789- result2790-}2791-2792-async fn forward_media_feedback_reports<R>(2793- mut reader: R,2794- sender: tokio::sync::watch::Sender<CumulativeFeedbackIngress>,2795-) -> Result<()>2796-where2797- R: tokio::io::AsyncRead + Unpin,2798-{2799- let mut last_report_id = None;2800- let mut next_read_at = tokio::time::Instant::now();2801- loop {2802- tokio::time::sleep_until(next_read_at).await;2803- let Some(report) = read_media_feedback_report_v1(&mut reader).await? else {2804- return Ok(());2805- };2806- ensure!(2807- last_report_id.is_none_or(|last| report.report_id > last),2808- "feedback report IDs must increase monotonically"2809- );2810- last_report_id = Some(report.report_id);2811- sender.send_modify(|ingress| ingress.observe(report));2812- if sender.is_closed() {2813- return Ok(());2814- }2815- next_read_at = tokio::time::Instant::now() + FEEDBACK_MIN_READ_INTERVAL;2816- }2817-}2818-2819-fn adaptive_bitrate_ceiling_kbps(config: &HostConfig) -> Result<u32> {2820- let configured = match config.source {2821- VideoSource::TestPattern => SHADOW_BITRATE_CEILING_KBPS,2822- VideoSource::GamescopePipewire => {2823- let gamescope = config2824- .gamescope_pipewire2825- .as_ref()2826- .context("Gamescope feedback requires gamescope_pipewire configuration")?;2827- match gamescope.rate_control {2828- VaapiRateControl::Cbr => gamescope2829- .bitrate_kbps2830- .context("adaptive bitrate shadow mode requires a Gamescope CBR bitrate")?,2831- VaapiRateControl::Cqp => SHADOW_BITRATE_CEILING_KBPS,2832- }2833- }2834- };2835- let ceiling = quantize_down(configured);2836- ensure!(2837- ceiling >= ADAPTIVE_BITRATE_FLOOR_KBPS,2838- "adaptive bitrate ceiling is below the 1000 kbps floor"2839- );2840- Ok(ceiling)2841-}2842-2843-fn adaptive_bitrate_actuation_enabled(config: &HostConfig) -> bool {2844- config.source == VideoSource::GamescopePipewire2845- && config.gamescope_pipewire.as_ref().is_some_and(|gamescope| {2846- gamescope.encoder_backend == GamescopeEncoderBackend::InProcessGstreamer2847- && gamescope.rate_control == VaapiRateControl::Cbr2848- })2849-}2850-2851-fn adaptive_recovery_keyframe_required(2852- report: &MediaFeedbackReportV1,2853- decision: &AdaptiveBitrateDecisionV1,2854-) -> bool {2855- decision.state != AdaptiveBitrateStateV1::Hold2856- && report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)2857-}2858-2859-2860-enum AdaptiveEncoderCommit {2861- GenerationEnded,2862- NotApplied(anyhow::Error),2863- Applied,2864-}2865-2866-async fn await_while_session_active<F, T>(2867- sessions: &SessionRegistry,2868- remote: EndpointId,2869- session_id: u64,2870- future: F,2871-) -> Option<T>2872-where2873- F: Future<Output = T>,2874-{2875- tokio::pin!(future);2876- loop {2877- let session_changed = sessions.session_changed.notified();2878- tokio::pin!(session_changed);2879- session_changed.as_mut().enable();2880- if !sessions.is_active(remote, session_id) {2881- return None;2882- }2883- tokio::select! {2884- result = &mut future => return sessions.is_active(remote, session_id).then_some(result),2885- _ = &mut session_changed => {}2886- }2887- }2888-}2889-2890-async fn commit_adaptive_encoder_proposal(2891- sessions: &SessionRegistry,2892- remote: EndpointId,2893- session_id: u64,2894- proposal: &AdaptiveEncoderProposal,2895-) -> AdaptiveEncoderCommit {2896- let bitrate_result = await_while_session_active(2897- sessions,2898- remote,2899- session_id,2900- proposal2901- .control2902- .wait_for_bitrate_applied(proposal.bitrate_revision, ENCODER_CONTROL_COMMIT_TIMEOUT),2903- )2904- .await;2905- let Some(bitrate_result) = bitrate_result else {2906- return AdaptiveEncoderCommit::GenerationEnded;2907- };2908- let status = match bitrate_result {2909- Ok(status) => status,2910- Err(error) => return AdaptiveEncoderCommit::NotApplied(error),2911- };2912- if status.applied_bitrate_revision != Some(proposal.bitrate_revision)2913- || status.applied_bitrate_kbps != Some(proposal.target_kbps)2914- {2915- return AdaptiveEncoderCommit::NotApplied(anyhow::anyhow!(2916- "encoder bitrate commit mismatch: revision {:?}, readback {:?}, expected revision {} at {} kbps",2917- status.applied_bitrate_revision,2918- status.applied_bitrate_kbps,2919- proposal.bitrate_revision,2920- proposal.target_kbps2921- ));2922- }2923-2924- AdaptiveEncoderCommit::Applied2925-}2926-2927-async fn commit_resolution_encoder_proposal(2928- sessions: &SessionRegistry,2929- remote: EndpointId,2930- session_id: u64,2931- proposal: &ResolutionEncoderProposal,2932-) -> AdaptiveEncoderCommit {2933- let result = await_while_session_active(2934- sessions,2935- remote,2936- session_id,2937- proposal.control.wait_for_resolution_applied(2938- proposal.revision,2939- proposal.target.width,2940- proposal.target.height,2941- ENCODER_CONTROL_COMMIT_TIMEOUT,2942- ),2943- )2944- .await;2945- let Some(result) = result else {2946- return AdaptiveEncoderCommit::GenerationEnded;2947- };2948- match result {2949- Ok(status)2950- if status.applied_resolution_revision == Some(proposal.revision)2951- && status.applied_width == Some(proposal.target.width)2952- && status.applied_height == Some(proposal.target.height) =>2953- {2954- AdaptiveEncoderCommit::Applied2955- }2956- Ok(status) => AdaptiveEncoderCommit::NotApplied(anyhow::anyhow!(2957- "encoder resolution commit mismatch: revision {:?}, dimensions {:?}x{:?}, expected revision {} at {}x{}",2958- status.applied_resolution_revision,2959- status.applied_width,2960- status.applied_height,2961- proposal.revision,2962- proposal.target.width,2963- proposal.target.height2964- )),2965- Err(error) => AdaptiveEncoderCommit::NotApplied(error),2966- }2967-}2968-2969-async fn await_adaptive_recovery_keyframe(2970- sessions: &SessionRegistry,2971- remote: EndpointId,2972- session_id: u64,2973- proposal: &AdaptiveEncoderProposal,2974-) -> Option<Result<()>> {2975- let force_keyframe_revision = proposal.force_keyframe_revision?;2976- await_while_session_active(2977- sessions,2978- remote,2979- session_id,2980- proposal.control.wait_for_force_keyframe_acknowledged(2981- force_keyframe_revision,2982- ENCODER_CONTROL_COMMIT_TIMEOUT,2983- ),2984- )2985- .await2986- .map(|result| result.map(|_| ()))2987-}2988-2989-async fn serve_media(2990- connection: Connection,2991- config: HostConfig,2992- sessions: &Arc<SessionRegistry>,2993- authorization: &AuthorizationPolicy,2994-) -> Result<()> {2995- let remote = connection.remote_id();2996- let handshake_permit = sessions2997- .pending_handshakes2998- .try_acquire()2999- .context("too many pending handshakes")?;3000- let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())3001- .await3002- .context("timed out accepting media stream")?3003- .context("accepting media stream")?;3004- let hello = receive_hello(&mut recv, Capability::VideoH264).await?;3005- drop(handshake_permit);3006- debug!(%remote, agent = %hello.agent, "media hello received");3007-3008- let grants = match authorization.authorize_or_redeem(3009- remote,3010- hello.invitation.as_deref(),3011- unix_timestamp_now()?,3012- ) {3013- Ok(grants) => grants,3014- Err(error) => {3015- send_rejection(&mut send, "Portal peer is not authorized").await?;3016- return Err(error.context("authorizing media peer"));3017- }3018- };3019- ensure!(3020- grants.contains(InvitationGrants::VIEW),3021- "authorized media peer lacks view permission"3022- );3023-3024- let lease = match sessions.claim(remote, hello.nonce, grants) {3025- Ok(lease) => lease,3026- Err(error) => {3027- send_rejection(&mut send, "host already has an active client").await?;1633+ send_rejection(&mut send, "host already has an active client").await?;30281634 return Err(error);30291635 }30301636 };@@ -4400,644 +3006,31 @@ pub fn install_panic_hook() {
44003006 }44013007 44023008 4403-mod tests {4404- use super::*;4405-4406- fn clean_feedback(report_id: u64) -> MediaFeedbackReportV1 {4407- MediaFeedbackReportV1 {4408- report_id,4409- interval_ms: 1_000,4410- flags: MediaFeedbackFlags::NONE,4411- last_sequence: Some(report_id),4412- transport_dropped_delta: 0,4413- frontend_dropped_delta: 0,4414- decoder_dropped_delta: 0,4415- presenter_dropped_delta: 0,4416- frontend_queue_depth: 0,4417- frontend_queue_capacity: 4,4418- decode_queue_depth: 0,4419- decode_queue_capacity: 4,4420- presenter_queue_depth: 0,4421- presenter_queue_capacity: 2,4422- transport_delivery_p95_ms: Some(10),4423- decode_p95_ms: Some(3),4424- presentation_p95_ms: Some(5),4425- }4426- }4427-4428- fn controller_decide(4429- controller: &mut ShadowBitrateController,4430- report: &MediaFeedbackReportV1,4431- second: u64,4432- ) -> AdaptiveBitrateDecisionV1 {4433- controller4434- .decide(4435- report,4436- MediaV3TelemetrySnapshot::default(),4437- Instant::now() + Duration::from_secs(second),4438- )4439- .unwrap()4440- }4441-4442- fn native_dimensions() -> VideoDimensions {4443- VideoDimensions {4444- width: 1_280,4445- height: 800,4446- }4447- }4448-4449- 4450- fn motion_resolution_tier_is_exact_even_and_same_aspect() {4451- let native = native_dimensions();4452- let policy = MotionResolutionPolicy::new(native).unwrap();4453- assert_eq!(policy.target(), native);4454- assert_eq!(4455- policy.reduced,4456- VideoDimensions {4457- width: 960,4458- height: 600,4459- }4460- );4461- assert_eq!(4462- u32::from(native.width) * u32::from(policy.reduced.height),4463- u32::from(native.height) * u32::from(policy.reduced.width)4464- );4465- assert!(policy.reduced.width.is_multiple_of(2));4466- assert!(policy.reduced.height.is_multiple_of(2));4467-4468- assert!(4469- MotionResolutionPolicy::new(VideoDimensions {4470- width: 1_278,4471- height: 800,4472- })4473- .is_err()4474- );4475- assert!(4476- MotionResolutionPolicy::new(VideoDimensions {4477- width: 80,4478- height: 64,4479- })4480- .is_err()4481- );4482- assert!(motion_resolution_policy(1_920, 1_080).unwrap().is_some());4483- assert!(motion_resolution_policy(1_366, 768).unwrap().is_none());4484- }4485-4486- 4487- fn feedback_resolution_policy_uses_encoder_generation_dimensions() {4488- let full_hd = crate::source::EncoderControlTestHarness::with_dimensions(1_920, 1_080);4489- let policy = motion_resolution_policy_for_encoder(&full_hd.control)4490- .unwrap()4491- .unwrap();4492- assert_eq!(4493- policy.native,4494- VideoDimensions {4495- width: 1_920,4496- height: 1_080,4497- }4498- );4499-4500- let no_exact_tier = crate::source::EncoderControlTestHarness::with_dimensions(1_366, 768);4501- assert!(4502- motion_resolution_policy_for_encoder(&no_exact_tier.control)4503- .unwrap()4504- .is_none()4505- );4506- }4507-4508- 4509- fn motion_downshifts_immediately_and_recovers_only_after_settle_and_clean_windows() {4510- let now = Instant::now();4511- let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();4512- let down = policy.observe_motion(true, now);4513- assert_eq!(down.tier, ResolutionTier::Reduced);4514- assert_eq!(down.trigger, ResolutionTrigger::Motion);4515- assert!(down.changed);4516-4517- let still = policy.observe_motion(false, now + Duration::from_secs(1));4518- assert!(!still.changed);4519- for second in [2, 3] {4520- assert!(4521- !policy4522- .observe_feedback(4523- FeedbackSeverity::Clean,4524- true,4525- now + Duration::from_secs(second),4526- )4527- .changed4528- );4529- }4530- let recovered =4531- policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(4));4532- assert_eq!(recovered.tier, ResolutionTier::Native);4533- assert_eq!(recovered.target, native_dimensions());4534- assert_eq!(recovered.trigger, ResolutionTrigger::Recovery);4535- assert!(recovered.changed);4536- }4537-4538- 4539- fn repeated_still_observations_do_not_postpone_resolution_recovery() {4540- let now = Instant::now();4541- let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();4542- policy.observe_motion(true, now);4543- policy.observe_motion(false, now + Duration::from_secs(1));4544- policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(2));4545- policy.observe_motion(false, now + Duration::from_millis(2_500));4546- policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(3));4547- let recovered =4548- policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(4));4549- assert!(recovered.changed);4550- assert_eq!(recovered.tier, ResolutionTier::Native);4551- }4552-4553- 4554- fn resolution_pressure_requires_fresh_hysteresis_but_severe_is_immediate() {4555- let now = Instant::now();4556- let mut moderate = MotionResolutionPolicy::new(native_dimensions()).unwrap();4557- moderate.observe_motion(false, now);4558- assert!(4559- !moderate4560- .observe_feedback(4561- FeedbackSeverity::Moderate,4562- true,4563- now + Duration::from_secs(1),4564- )4565- .changed4566- );4567- assert!(4568- !moderate4569- .observe_feedback(4570- FeedbackSeverity::Moderate,4571- false,4572- now + Duration::from_secs(2),4573- )4574- .changed4575- );4576- assert!(4577- !moderate4578- .observe_feedback(4579- FeedbackSeverity::Moderate,4580- true,4581- now + Duration::from_secs(3),4582- )4583- .changed4584- );4585- let down = moderate.observe_feedback(4586- FeedbackSeverity::Moderate,4587- true,4588- now + Duration::from_secs(4),4589- );4590- assert!(down.changed);4591- assert_eq!(down.trigger, ResolutionTrigger::Pressure);4592-4593- let mut severe = MotionResolutionPolicy::new(native_dimensions()).unwrap();4594- let immediate =4595- severe.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));4596- assert!(immediate.changed);4597- assert_eq!(immediate.tier, ResolutionTier::Reduced);4598- }4599-4600- 4601- fn stale_feedback_cannot_restore_native_resolution() {4602- let now = Instant::now();4603- let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();4604- policy.observe_motion(false, now);4605- policy.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));4606- for second in [2, 3, 4, 5] {4607- let decision = policy.observe_feedback(4608- FeedbackSeverity::Clean,4609- false,4610- now + Duration::from_secs(second),4611- );4612- assert_eq!(decision.tier, ResolutionTier::Reduced);4613- assert!(!decision.changed);4614- }4615- assert!(policy.pressure_active);4616-4617- for second in [6, 7] {4618- assert!(4619- !policy4620- .observe_feedback(4621- FeedbackSeverity::Clean,4622- true,4623- now + Duration::from_secs(second),4624- )4625- .changed4626- );4627- }4628- let recovered =4629- policy.observe_feedback(FeedbackSeverity::Clean, true, now + Duration::from_secs(8));4630- assert!(recovered.changed);4631- assert_eq!(recovered.tier, ResolutionTier::Native);4632- }4633-4634- 4635- fn motion_keeps_resolution_reduced_after_pressure_clears() {4636- let now = Instant::now();4637- let mut policy = MotionResolutionPolicy::new(native_dimensions()).unwrap();4638- policy.observe_motion(true, now);4639- policy.observe_feedback(FeedbackSeverity::Severe, true, now + Duration::from_secs(1));4640- for second in 2..=5 {4641- let decision = policy.observe_feedback(4642- FeedbackSeverity::Clean,4643- true,4644- now + Duration::from_secs(second),4645- );4646- assert_eq!(decision.tier, ResolutionTier::Reduced);4647- assert!(!decision.changed);4648- }4649- assert!(!policy.pressure_active);4650- assert!(policy.motion_active);4651- }4652-4653- 4654- fn damage_driven_motion_proxy_seeds_then_downshifts_and_stale_blocks_upscale() {4655- let now = Instant::now();4656- let telemetry = MediaV3TelemetrySnapshot::default();4657- let mut bitrate = ShadowBitrateController::new(12_000, telemetry);4658- let mut resolution = MotionResolutionPolicy::new(native_dimensions()).unwrap();4659-4660- let mut first = clean_feedback(1);4661- first.last_sequence = Some(100);4662- let first_evaluation = bitrate.evaluate(&first, telemetry, now).unwrap();4663- assert!(!first_evaluation.fresh);4664- let seeded = resolution.observe_window(&first, &first_evaluation, now);4665- assert_eq!(seeded.tier, ResolutionTier::Native);4666- assert!(!seeded.changed);4667-4668- let mut second = clean_feedback(2);4669- second.last_sequence = Some(110);4670- let second_evaluation = bitrate4671- .evaluate(&second, telemetry, now + Duration::from_secs(1))4672- .unwrap();4673- assert!(second_evaluation.fresh);4674- let down =4675- resolution.observe_window(&second, &second_evaluation, now + Duration::from_secs(1));4676- assert_eq!(down.trigger, ResolutionTrigger::Motion);4677- assert_eq!(down.tier, ResolutionTier::Reduced);4678- assert!(down.changed);4679-4680- let mut stale = clean_feedback(3);4681- stale.interval_ms = 5_000;4682- stale.last_sequence = Some(110);4683- let stale_evaluation = bitrate4684- .evaluate(&stale, telemetry, now + Duration::from_secs(5))4685- .unwrap();4686- assert!(!stale_evaluation.fresh);4687- let held =4688- resolution.observe_window(&stale, &stale_evaluation, now + Duration::from_secs(5));4689- assert_eq!(held.tier, ResolutionTier::Reduced);4690- assert!(!held.changed);4691- }4692-4693- 4694- fn damage_driven_no_progress_cannot_restore_native_resolution() {4695- let now = Instant::now();4696- let telemetry = MediaV3TelemetrySnapshot::default();4697- let mut bitrate = ShadowBitrateController::new(12_000, telemetry);4698- let mut resolution = MotionResolutionPolicy::new(native_dimensions()).unwrap();4699-4700- let mut seed = clean_feedback(1);4701- seed.last_sequence = Some(100);4702- let seed_evaluation = bitrate.evaluate(&seed, telemetry, now).unwrap();4703- resolution.observe_window(&seed, &seed_evaluation, now);4704-4705- let mut pressure = clean_feedback(2);4706- pressure.last_sequence = Some(110);4707- pressure.flags = MediaFeedbackFlags::RESYNC_ACTIVE;4708- let pressure_evaluation = bitrate4709- .evaluate(&pressure, telemetry, now + Duration::from_secs(1))4710- .unwrap();4711- let downshift = resolution.observe_window(4712- &pressure,4713- &pressure_evaluation,4714- now + Duration::from_secs(1),4715- );4716- assert_eq!(downshift.tier, ResolutionTier::Reduced);4717- assert!(downshift.changed);4718-4719- for second in 2..=4 {4720- let mut no_progress = clean_feedback(second + 1);4721- no_progress.last_sequence = Some(110);4722- let evaluation = bitrate4723- .evaluate(&no_progress, telemetry, now + Duration::from_secs(second))4724- .unwrap();4725- assert_eq!(evaluation.pressure_severity, FeedbackSeverity::Clean);4726- assert!(!evaluation.clean_recovery_evidence);4727- let decision = resolution.observe_window(4728- &no_progress,4729- &evaluation,4730- now + Duration::from_secs(second),4731- );4732- assert_eq!(decision.tier, ResolutionTier::Reduced);4733- assert_eq!(decision.trigger, ResolutionTrigger::Pressure);4734- assert!(!decision.changed);4735- assert_eq!(resolution.clean_windows, 0);4736- }4737-4738- for (report_id, second, sequence) in [(6, 5, 111), (7, 6, 112), (8, 7, 113)] {4739- let mut low_motion = clean_feedback(report_id);4740- low_motion.last_sequence = Some(sequence);4741- let evaluation = bitrate4742- .evaluate(&low_motion, telemetry, now + Duration::from_secs(second))4743- .unwrap();4744- assert!(evaluation.clean_recovery_evidence);4745- let decision = resolution.observe_window(4746- &low_motion,4747- &evaluation,4748- now + Duration::from_secs(second),4749- );4750- if report_id < 8 {4751- assert_eq!(decision.tier, ResolutionTier::Reduced);4752- assert!(!decision.changed);4753- } else {4754- assert_eq!(decision.tier, ResolutionTier::Native);4755- assert_eq!(decision.trigger, ResolutionTrigger::Recovery);4756- assert!(decision.changed);4757- }4758- }4759- }4760-4761- 4762- fn shadow_controller_immediately_decreases_for_severe_feedback_and_stays_bounded() {4763- let mut controller =4764- ShadowBitrateController::new(12_137, MediaV3TelemetrySnapshot::default());4765- assert_eq!(controller.ceiling_kbps, 12_000);4766- let first = controller_decide(&mut controller, &clean_feedback(1), 0);4767- assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);4768- assert!(4769- first4770- .reasons4771- .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4772- );4773- for report_id in 2..=21 {4774- let mut report = clean_feedback(report_id);4775- report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;4776- let decision = controller_decide(&mut controller, &report, report_id - 1);4777- assert!(!decision.applied);4778- assert!(decision.target_kbps >= ADAPTIVE_BITRATE_FLOOR_KBPS);4779- assert!(decision.target_kbps <= 12_000);4780- assert_eq!(decision.target_kbps % ADAPTIVE_BITRATE_QUANTUM_KBPS, 0);4781- assert!(4782- decision4783- .reasons4784- .contains(AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE)4785- );4786- }4787- assert_eq!(controller.target_kbps, ADAPTIVE_BITRATE_FLOOR_KBPS);4788- }4789-4790- 4791- fn shadow_controller_requires_two_moderate_windows() {4792- let mut controller =4793- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4794- controller_decide(&mut controller, &clean_feedback(1), 0);4795- let mut first = clean_feedback(2);4796- first.frontend_queue_depth = 2;4797- assert_eq!(4798- controller_decide(&mut controller, &first, 1).state,4799- AdaptiveBitrateStateV1::Hold4800- );4801- let mut second = clean_feedback(3);4802- second.frontend_queue_depth = 2;4803- let decision = controller_decide(&mut controller, &second, 2);4804- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4805- assert_eq!(decision.target_kbps, 9_500);4806- }4807-4808- 4809- fn shadow_controller_increases_only_after_clean_cooldown() {4810- let mut controller =4811- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4812- controller_decide(&mut controller, &clean_feedback(1), 0);4813- let mut severe = clean_feedback(2);4814- severe.decoder_dropped_delta = 1;4815- assert_eq!(4816- controller_decide(&mut controller, &severe, 1).target_kbps,4817- 9_0004818- );4819- for report_id in 3..=11 {4820- let decision =4821- controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);4822- assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4823- assert_eq!(decision.target_kbps, 9_000);4824- }4825- let decision = controller_decide(&mut controller, &clean_feedback(12), 11);4826- assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);4827- assert_eq!(decision.target_kbps, 9_250);4828- assert!(4829- decision4830- .reasons4831- .contains(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY)4832- );4833- }4834-4835- 4836- fn stale_or_non_monotonic_feedback_cannot_drive_an_increase() {4837- let mut controller =4838- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4839- controller_decide(&mut controller, &clean_feedback(1), 0);4840- let mut severe = clean_feedback(2);4841- severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;4842- controller_decide(&mut controller, &severe, 1);4843- let stale = controller_decide(&mut controller, &clean_feedback(3), 4);4844- assert_eq!(stale.state, AdaptiveBitrateStateV1::Hold);4845- assert_eq!(stale.target_kbps, 9_000);4846- assert!(4847- stale4848- .reasons4849- .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4850- );4851- assert!(4852- controller4853- .decide(4854- &clean_feedback(2),4855- MediaV3TelemetrySnapshot::default(),4856- Instant::now() + Duration::from_secs(4),4857- )4858- .is_err()4859- );4860-4861- let now = Instant::now();4862- let mut rapid = ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4863- rapid4864- .decide(&clean_feedback(1), MediaV3TelemetrySnapshot::default(), now)4865- .unwrap();4866- let too_fast = rapid4867- .decide(4868- &clean_feedback(2),4869- MediaV3TelemetrySnapshot::default(),4870- now + Duration::from_millis(500),4871- )4872- .unwrap();4873- assert_eq!(too_fast.state, AdaptiveBitrateStateV1::Hold);4874- assert!(4875- too_fast4876- .reasons4877- .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4878- );4879- }4880-4881- 4882- fn full_decode_queue_is_severe_even_at_capacity_two() {4883- let mut controller =4884- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4885- controller_decide(&mut controller, &clean_feedback(1), 0);4886- let mut report = clean_feedback(2);4887- report.decode_queue_capacity = 2;4888- report.decode_queue_depth = 2;4889- let decision = controller_decide(&mut controller, &report, 1);4890- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4891- assert!(4892- decision4893- .reasons4894- .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)4895- );4896- }4897-4898- 4899- fn decision_id_exhaustion_fails_instead_of_repeating() {4900- let mut controller =4901- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4902- controller.next_decision_id = u64::MAX;4903- assert!(4904- controller4905- .decide(4906- &clean_feedback(1),4907- MediaV3TelemetrySnapshot::default(),4908- Instant::now(),4909- )4910- .is_err()4911- );4912- assert_eq!(controller.next_decision_id, u64::MAX);4913- }4914-4915- 4916- fn stale_feedback_applies_trusted_loss_and_send_failure_pressure_once() {4917- for telemetry in [4918- MediaV3TelemetrySnapshot {4919- selected_path_lost_packets: 1,4920- ..MediaV3TelemetrySnapshot::default()4921- },4922- MediaV3TelemetrySnapshot {4923- send_failures: 1,4924- ..MediaV3TelemetrySnapshot::default()4925- },4926- ] {4927- let now = Instant::now();4928- let mut controller =4929- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4930- let decision = controller4931- .decide(&clean_feedback(1), telemetry, now)4932- .unwrap();4933- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4934- assert_eq!(decision.target_kbps, 9_000);4935- assert!(4936- decision4937- .reasons4938- .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4939- );4940- assert!(4941- decision4942- .reasons4943- .contains(AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION)4944- );4945-4946- let repeated = controller4947- .decide(4948- &clean_feedback(2),4949- telemetry,4950- now + Duration::from_millis(500),4951- )4952- .unwrap();4953- assert_eq!(repeated.state, AdaptiveBitrateStateV1::Hold);4954- assert_eq!(repeated.target_kbps, 9_000);4955- }4956- }4957-4958- 4959- fn stale_moderate_cancellation_is_retained_until_hysteresis_reduces() {4960- let now = Instant::now();4961- let telemetry = MediaV3TelemetrySnapshot {4962- scheduler_cancellations: 1,4963- ..MediaV3TelemetrySnapshot::default()4964- };4965- let mut controller =4966- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4967- let first = controller4968- .decide(&clean_feedback(1), telemetry, now)4969- .unwrap();4970- assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);4971- assert_eq!(controller.previous_telemetry.scheduler_cancellations, 0);4972-4973- let second = controller4974- .decide(4975- &clean_feedback(2),4976- telemetry,4977- now + Duration::from_millis(500),4978- )4979- .unwrap();4980- assert_eq!(second.state, AdaptiveBitrateStateV1::Decrease);4981- assert_eq!(second.target_kbps, 9_500);4982- assert_eq!(controller.previous_telemetry.scheduler_cancellations, 1);4983- }3009+fn endpoint(byte: u8) -> EndpointId {3010+ iroh::SecretKey::from_bytes(&[byte; 32]).public()3011+}49843012 4985- 4986- fn clean_recovery_below_five_mbps_advances_by_one_quantum() {4987- let mut controller =4988- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4989- controller.target_kbps = 4_000;4990- controller_decide(&mut controller, &clean_feedback(1), 0);4991- controller_decide(&mut controller, &clean_feedback(2), 1);4992- for report_id in 3..=11 {4993- let decision =4994- controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);4995- assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4996- }4997- let decision = controller_decide(&mut controller, &clean_feedback(12), 11);4998- assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);4999- assert_eq!(decision.target_kbps, 4_250);3013+3014+fn moq_test_config() -> HostConfig {3015+ HostConfig {3016+ identity_path: "identity".into(),3017+ state_path: "state".into(),3018+ source: VideoSource::TestPattern,3019+ width: Some(1280),3020+ height: Some(800),3021+ framerate: 60,3022+ codec: "h264".to_owned(),3023+ input_mode: crate::config::InputMode::Disabled,3024+ uinput: None,3025+ ffmpeg_path: "ffmpeg".into(),3026+ gamescope_pipewire: None,3027+ audio: None,50003028 }3029+}50013030 5002- 5003- fn recovery_requires_latency_samples_and_sequence_progress_but_pressure_still_reduces() {5004- let mut controller =5005- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());5006- controller.target_kbps = 9_000;5007- controller_decide(&mut controller, &clean_feedback(1), 0);5008- controller_decide(&mut controller, &clean_feedback(2), 1);5009-5010- for report_id in 3..=14 {5011- let mut report = clean_feedback(report_id);5012- report.last_sequence = Some(2);5013- let decision = controller_decide(&mut controller, &report, report_id - 1);5014- assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);5015- assert_eq!(decision.target_kbps, 9_000);5016- }5017- assert_eq!(controller.clean_windows, 0);5018-5019- for report_id in 15..=26 {5020- let mut report = clean_feedback(report_id);5021- report.decode_p95_ms = None;5022- let decision = controller_decide(&mut controller, &report, report_id - 1);5023- assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);5024- assert_eq!(decision.target_kbps, 9_000);5025- }5026- assert_eq!(controller.clean_windows, 0);5027-5028- let mut pressure = clean_feedback(27);5029- pressure.decode_p95_ms = None;5030- pressure.decode_queue_capacity = 2;5031- pressure.decode_queue_depth = 2;5032- let decision = controller_decide(&mut controller, &pressure, 26);5033- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);5034- assert_eq!(decision.target_kbps, 6_750);5035- assert!(5036- decision5037- .reasons5038- .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)5039- );5040- }3031+3032+mod tests {3033+ use super::*;50413034 50423035 struct DropNotify(Option<tokio::sync::oneshot::Sender<()>>);50433036 @@ -5225,82 +3218,6 @@ mod tests {
52253218 );52263219 }52273220 5228- fn moq_test_config() -> HostConfig {5229- HostConfig {5230- identity_path: "identity".into(),5231- state_path: "state".into(),5232- source: VideoSource::TestPattern,5233- width: Some(1280),5234- height: Some(800),5235- framerate: 60,5236- codec: "h264".to_owned(),5237- input_mode: crate::config::InputMode::Disabled,5238- uinput: None,5239- ffmpeg_path: "ffmpeg".into(),5240- gamescope_pipewire: None,5241- audio: None,5242- }5243- }5244-5245- fn gamescope_feedback_test_config(rate_control: VaapiRateControl) -> HostConfig {5246- let (bitrate_kbps, quantizer) = match rate_control {5247- VaapiRateControl::Cbr => (Some(12_123), None),5248- VaapiRateControl::Cqp => (None, Some(24)),5249- };5250- let mut config = moq_test_config();5251- config.source = VideoSource::GamescopePipewire;5252- config.width = None;5253- config.height = None;5254- config.gamescope_pipewire = Some(crate::config::GamescopePipewireConfig {5255- node_name: "gamescope".into(),5256- media_class: "Video/Source".into(),5257- match_properties: std::collections::BTreeMap::new(),5258- xwayland_display: None,5259- pw_dump_path: "/usr/bin/pw-dump".into(),5260- gst_launch_path: "/usr/bin/gst-launch-1.0".into(),5261- gst_inspect_path: "/usr/bin/gst-inspect-1.0".into(),5262- encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,5263- vaapi_encoder: "vah264enc".into(),5264- vaapi_render_node: "/dev/dri/renderD128".into(),5265- rate_control,5266- bitrate_kbps,5267- quantizer,5268- });5269- config.validate().unwrap();5270- config5271- }5272-5273- 5274- fn external_cqp_uses_a_fixed_shadow_bitrate_ceiling() {5275- let config = gamescope_feedback_test_config(VaapiRateControl::Cqp);5276-5277- assert_eq!(5278- adaptive_bitrate_ceiling_kbps(&config).unwrap(),5279- SHADOW_BITRATE_CEILING_KBPS5280- );5281- assert!(!adaptive_bitrate_actuation_enabled(&config));5282- }5283-5284- 5285- fn gamescope_cbr_keeps_its_quantized_configured_ceiling() {5286- let config = gamescope_feedback_test_config(VaapiRateControl::Cbr);5287-5288- assert_eq!(adaptive_bitrate_ceiling_kbps(&config).unwrap(), 12_000);5289- }5290-5291- 5292- fn malformed_cbr_without_a_bitrate_still_fails_closed() {5293- let mut config = gamescope_feedback_test_config(VaapiRateControl::Cbr);5294- config.gamescope_pipewire.as_mut().unwrap().bitrate_kbps = None;5295-5296- assert_eq!(5297- adaptive_bitrate_ceiling_kbps(&config)5298- .unwrap_err()5299- .to_string(),5300- "adaptive bitrate shadow mode requires a Gamescope CBR bitrate"5301- );5302- }5303-53043221 53053222 async fn upstream_moq_groups_are_sequential_and_cancel_the_superseded_gop() {53063223 let track_info = Track {@@ -5975,215 +3892,6 @@ mod tests {
59753892 .unwrap();59763893 }59773894 5978- 5979- async fn adaptive_encoder_commit_requires_exact_readback_and_tracks_recovery_separately() {5980- let sessions = Arc::new(SessionRegistry::default());5981- let remote = endpoint(1);5982- let media = sessions5983- .claim(remote, [4; 16], InvitationGrants::VIEW)5984- .unwrap();5985- let harness = crate::source::EncoderControlTestHarness::new();5986- sessions5987- .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))5988- .unwrap();5989-5990- let proposal = sessions5991- .propose_adaptive_encoder_update(remote, media.session_id, 8_000, true)5992- .unwrap()5993- .unwrap();5994- harness.status.send_modify(|status| {5995- status.applied_bitrate_revision = Some(proposal.bitrate_revision);5996- status.applied_bitrate_kbps = Some(8_000);5997- status.requested_force_keyframe_revision = proposal.force_keyframe_revision;5998- status.acknowledged_force_keyframe_revision = proposal.force_keyframe_revision;5999- });6000- assert!(matches!(6001- commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &proposal).await,6002- AdaptiveEncoderCommit::Applied6003- ));6004- assert!(matches!(6005- await_adaptive_recovery_keyframe(&sessions, remote, media.session_id, &proposal).await,6006- Some(Ok(()))6007- ));6008-6009- let mismatch = sessions6010- .propose_adaptive_encoder_update(remote, media.session_id, 7_000, false)6011- .unwrap()6012- .unwrap();6013- harness.status.send_modify(|status| {6014- status.applied_bitrate_revision = Some(mismatch.bitrate_revision);6015- status.applied_bitrate_kbps = Some(7_250);6016- });6017- assert!(matches!(6018- commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &mismatch).await,6019- AdaptiveEncoderCommit::NotApplied(_)6020- ));6021-6022- let stale_generation = sessions6023- .propose_adaptive_encoder_update(remote, media.session_id, 6_000, false)6024- .unwrap()6025- .unwrap();6026- let session_id = media.session_id;6027- drop(media);6028- assert!(matches!(6029- commit_adaptive_encoder_proposal(&sessions, remote, session_id, &stale_generation)6030- .await,6031- AdaptiveEncoderCommit::GenerationEnded6032- ));6033- }6034-6035- 6036- fn failed_adaptive_application_commits_observation_but_not_target() {6037- let mut committed =6038- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());6039- controller_decide(&mut committed, &clean_feedback(1), 0);6040- let old_target = committed.target_kbps;6041- let old_decision_id = committed.next_decision_id;6042- let mut severe = clean_feedback(2);6043- severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;6044-6045- let mut candidate = committed.clone();6046- let decision = controller_decide(&mut candidate, &severe, 1);6047- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);6048- assert!(candidate.target_kbps < old_target);6049- committed.commit_observation_from(&candidate);6050-6051- assert_eq!(committed.target_kbps, old_target);6052- assert_eq!(committed.last_report_id, Some(2));6053- assert_eq!(committed.next_decision_id, old_decision_id + 1);6054- assert!(6055- committed6056- .decide(6057- &clean_feedback(3),6058- MediaV3TelemetrySnapshot::default(),6059- Instant::now() + Duration::from_secs(2)6060- )6061- .is_ok()6062- );6063- }6064-6065- 6066- fn adaptive_recovery_idr_is_only_requested_for_a_bitrate_change_during_resync() {6067- let mut report = clean_feedback(1);6068- let mut decision = AdaptiveBitrateDecisionV1 {6069- decision_id: 1,6070- report_id: 1,6071- target_kbps: 8_000,6072- floor_kbps: 1_000,6073- ceiling_kbps: 12_000,6074- state: AdaptiveBitrateStateV1::Decrease,6075- reasons: AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,6076- applied: false,6077- };6078- assert!(!adaptive_recovery_keyframe_required(&report, &decision));6079- report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;6080- assert!(adaptive_recovery_keyframe_required(&report, &decision));6081- decision.state = AdaptiveBitrateStateV1::Hold;6082- assert!(!adaptive_recovery_keyframe_required(&report, &decision));6083- }6084-6085- 6086- async fn stalled_adaptive_apply_coalesces_feedback_without_losing_pressure() {6087- use tokio::io::AsyncWriteExt as _;6088-6089- let (sender, mut receiver) =6090- tokio::sync::watch::channel(CumulativeFeedbackIngress::default());6091- let (mut writer, reader) = tokio::io::duplex(512);6092- let reader_task = tokio::spawn(forward_media_feedback_reports(reader, sender.clone()));6093- let mut cursor = FeedbackIngressCursor::default();6094-6095- sigil_protocol::write_media_feedback_report_v1(&mut writer, &clean_feedback(1))6096- .await6097- .unwrap();6098- receiver.changed().await.unwrap();6099- let (first, next_cursor) = receiver6100- .borrow_and_update()6101- .report_since(cursor)6102- .unwrap()6103- .unwrap();6104- assert_eq!(first.report_id, 1);6105- cursor = next_cursor;6106-6107- // The consumer is now notionally stalled on an encoder commit. Two6108- // reports overwrite one watch notification, but their interval,6109- // deltas, and resync pressure must remain cumulative.6110- let mut pressured = clean_feedback(2);6111- pressured.flags = MediaFeedbackFlags::RESYNC_ACTIVE;6112- pressured.transport_dropped_delta = 2;6113- pressured.decoder_dropped_delta = 1;6114- pressured.decode_queue_depth = 3;6115- pressured.transport_delivery_p95_ms = Some(180);6116- sigil_protocol::write_media_feedback_report_v1(&mut writer, &pressured)6117- .await6118- .unwrap();6119-6120- let mut latest = clean_feedback(3);6121- latest.frontend_dropped_delta = 4;6122- latest.presenter_dropped_delta = 3;6123- latest.frontend_queue_depth = 3;6124- latest.decode_queue_depth = 1;6125- latest.transport_delivery_p95_ms = Some(40);6126- latest.decode_p95_ms = Some(9);6127- sigil_protocol::write_media_feedback_report_v1(&mut writer, &latest)6128- .await6129- .unwrap();6130- writer.shutdown().await.unwrap();6131- reader_task.await.unwrap().unwrap();6132-6133- receiver.changed().await.unwrap();6134- let (aggregate, next_cursor) = receiver6135- .borrow_and_update()6136- .report_since(cursor)6137- .unwrap()6138- .unwrap();6139- assert_eq!(aggregate.report_id, 3);6140- assert_eq!(aggregate.interval_ms, 2_000);6141- assert_eq!(aggregate.transport_dropped_delta, 2);6142- assert_eq!(aggregate.frontend_dropped_delta, 4);6143- assert_eq!(aggregate.decoder_dropped_delta, 1);6144- assert_eq!(aggregate.presenter_dropped_delta, 3);6145- assert!(aggregate.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE));6146- assert_eq!(aggregate.last_sequence, latest.last_sequence);6147- assert_eq!(aggregate.frontend_queue_depth, latest.frontend_queue_depth);6148- assert_eq!(aggregate.decode_queue_depth, latest.decode_queue_depth);6149- assert_eq!(6150- aggregate.transport_delivery_p95_ms,6151- latest.transport_delivery_p95_ms6152- );6153- assert_eq!(aggregate.decode_p95_ms, latest.decode_p95_ms);6154- cursor = next_cursor;6155- assert!(6156- receiver.borrow().report_since(cursor).unwrap().is_none(),6157- "one cumulative ingress generation must be processed only once"6158- );6159-6160- for report_id in 4..=9 {6161- sender.send_modify(|ingress| ingress.observe(clean_feedback(report_id)));6162- }6163- receiver.changed().await.unwrap();6164- let (clamped, _) = receiver6165- .borrow_and_update()6166- .report_since(cursor)6167- .unwrap()6168- .unwrap();6169- assert_eq!(clamped.interval_ms, 5_000);6170- let mut controller =6171- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());6172- let decision = controller6173- .decide(6174- &clamped,6175- MediaV3TelemetrySnapshot::default(),6176- Instant::now(),6177- )6178- .unwrap();6179- assert!(6180- decision6181- .reasons6182- .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE),6183- "a clamped aggregate must be stale rather than disguised as one fresh report"6184- );6185- }6186-61873895 61883896 fn capability_negotiation_is_an_exact_intersection() {61893897 let hello = ClientHello::new(@@ -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+29+enum FeedbackSeverity {30+ Clean,31+ InsufficientEvidence,32+ Moderate,33+ Severe,34+ Stale,35+}36+37+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+71+enum ResolutionTier {72+ Native,73+ Reduced,74+}75+76+77+enum ResolutionTrigger {78+ Hold,79+ Motion,80+ Pressure,81+ Recovery,82+}83+84+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+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+ 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+ 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+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+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+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+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+ 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+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+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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+ 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+}