* feat: define bounded media feedback protocol Add an authenticated feedback ALPN with fixed receiver reports and adaptive bitrate decisions, strict bounds, exact framing, and golden vectors. * feat: evaluate bounded bitrate feedback Authenticate a session-bound feedback sidecar and classify paced receiver and path telemetry through a bounded shadow controller. Keep all decisions advisory until the encoder is directly controllable. * feat: publish adaptive receiver telemetry Negotiate an optional generation-bound feedback sidecar, coalesce bounded reports at one hertz, and surface unapplied host decisions as advisory diagnostics without coupling media delivery to feedback. * fix: preserve trusted adaptive pressure Apply host-owned loss and cancellation signals even when client reports are stale, require real media progress for recovery, and guarantee a minimum recovery quantum below five megabits. * fix: bound adaptive feedback delivery Coalesce and pace host decisions, retain loss deltas until Sigil accepts a report, and stop presenting frontend IPC handoff time as network delivery latency. * fix: preserve adaptive feedback pressure * feat: apply verified adaptive bitrate decisions * docs: describe adaptive encoder actuation * fix: retain coalesced host feedback pressure
- commit
- 42b178fc2f8ae702263ff5d022ff8bdcb26dce47
- parent
- d0bb8e06159770016a49145cbb953a9a044cd94c
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 10 | + |
| M | crates/ | 14 | +- |
| M | crates/ | 1862 | +++++++++++++++++++++++++++++- |
| M | crates/ | 19 | + |
| A | crates/ | 746 | ++++++++++++ |
| M | crates/ | 10 | + |
| A | portal/ | 260 | ++++ |
| A | portal/ | 242 | ++++ |
| M | portal/ | 2 | + |
| M | portal/ | 77 | + |
| M | src-tauri/ | 672 | +++++++++++- |
| M | src-tauri/ | 91 | +- |
| M | src-tauri/ | 1 | + |
@@ -44,6 +44,11 @@ Steam and games.
4444 `in-process-gstreamer` can opt into an in-process pipeline for bounded4545 bitrate and force-keyframe control; ordinary release packages do not enable4646 that feature yet.47+- Portal reports bounded receiver queue, drop, latency, and recovery telemetry48+ over a session-authenticated feedback stream. The host combines it with49+ trusted path and scheduler pressure, applies hysteresis, and commits an50+ in-process CBR bitrate decision only after exact encoder readback. External51+ encoder sessions remain explicitly advisory-only.4752 - Portal delivers encoded frames to WebCodecs through a raw Tauri binary4853 channel. The handoff is capped at four frames, the decode queue and4954 presentation queue at two, and a bounded watchdog recovers a suspended@@ -103,6 +108,11 @@ not authentication.
103108 Opus delivery, and client playback. Quantify longer-run A/V synchronization.104109 8. **Done:** add signed, peer-bound, one-time capability enrollment with105110 durable replay protection and controller-usable Portal onboarding.111+9. **Adaptive control implemented; hardware proof pending:** feedback remains112+ constant-size and generation-scoped, coalescing retains cumulative pressure,113+ and `applied=true` requires exact in-process encoder readback. Native Ubuntu114+ x264 control is CI-proven; Bazzite GstVA loss/relay acceptance and product115+ packaging remain in issue #8.106116 107117 ## Development108118 @@ -25,7 +25,7 @@ use iroh::{Endpoint, EndpointId};
2525 use moq_net::Origin;2626 use sigil_protocol::{2727 AUDIO_ALPN_V1, CONTROL_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS,28- MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, SignedInvitation,28+ MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, SignedInvitation,2929 };3030 use tracing::{info, warn};3131 use tracing_subscriber::EnvFilter;@@ -37,8 +37,8 @@ use crate::config::{HostConfig, InputMode, VideoSource};
3737 use crate::cursor::PointerPositionTracker;3838 use crate::input::InputBackend;3939 use crate::server::{40- AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaHandler, MediaV2Handler,41- MediaV3Handler, SessionRegistry,40+ AudioHandler, AuthorizedMoqHandler, ControlHandler, InputHandler, MediaFeedbackHandler,41+ MediaHandler, MediaV2Handler, MediaV3Handler, SessionRegistry,4242 };4343 4444 const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5);@@ -681,6 +681,14 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
681681 authorization: authorization.clone(),682682 },683683 )684+ .accept(685+ MEDIA_FEEDBACK_ALPN_V1,686+ MediaFeedbackHandler {687+ config: config.clone(),688+ sessions: Arc::clone(&sessions),689+ authorization,690+ },691+ )684692 .accept(685693 INPUT_ALPN_V1,686694 InputHandler {@@ -13,26 +13,28 @@ use moq_net::{
1313 Track, TrackProducer,1414 };1515 use sigil_protocol::{16- AUDIO_HEADER_LEN, AudioFlags, AudioPacket, AudioPacketHeader, Capability, ClientHello,16+ AUDIO_HEADER_LEN, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1,17+ AdaptiveBitrateStateV1, AudioFlags, AudioPacket, AudioPacketHeader, Capability, ClientHello,1718 FrameFlags, HostHello, InputAck, InvitationGrants, KeyframeRequestReasonV3,1819 MAX_AUDIO_PAYLOAD_LEN, MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS,1920 MAX_MEDIA_OBJECT_ID_V3, MIN_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MOQ_VIDEO_H264_TRACK,20- MediaControlRequestV3, MediaFrame, MediaFrameHeader, MediaObjectHeaderV3, MediaObjectV3,21- encode_media_frame_object, media_moq_broadcast_name, read_client_hello, read_input_event,22- read_media_control_request_v3, write_host_hello, write_input_ack, write_media_frame,23- write_media_object_v3,21+ MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame, MediaFrameHeader,22+ MediaObjectHeaderV3, MediaObjectV3, encode_media_frame_object, media_moq_broadcast_name,23+ read_client_hello, read_input_event, read_media_control_request_v3,24+ read_media_feedback_report_v1, write_adaptive_bitrate_decision_v1, write_host_hello,25+ write_input_ack, write_media_frame, write_media_object_v3,2426 };2527 use tracing::{debug, error, info, warn};2628 2729 use crate::audio::spawn_pipewire_audio;2830 use crate::authorization::{AuthorizationPolicy, unix_timestamp_now};2931 use crate::clock::SessionClock;30-use crate::config::{HostConfig, VideoSource};32+use crate::config::{GamescopeEncoderBackend, HostConfig, VaapiRateControl, VideoSource};3133 use crate::cursor::{PointerPositionTracker, PointerState};3234 use crate::input::{InputBackend, InputDisposition};3335 use crate::source::{34- EncodedFrame, EncodedGop, EncodedSource, spawn_gamescope_pipewire_after_static_preflight,35- spawn_test_pattern,36+ EncodedFrame, EncodedGop, EncodedSource, EncoderControl,37+ spawn_gamescope_pipewire_after_static_preflight, spawn_test_pattern,3638 };3739 3840 const MEDIA_CAPABILITIES: &[Capability] = &[Capability::VideoH264];@@ -69,6 +71,504 @@ const SOURCE_REAP_GRACE_TIMEOUT: Duration = Duration::from_secs(1);
6971 const MOQ_ATTACHMENT_TIMEOUT: Duration = Duration::from_secs(10);7072 const MOQ_VIDEO_TRACK_PRIORITY: u8 = u8::MAX;7173 const MOQ_REJECT_CODE: u32 = 0x534d;74+const ADAPTIVE_BITRATE_FLOOR_KBPS: u32 = 1_000;75+const TEST_PATTERN_BITRATE_CEILING_KBPS: u32 = 12_000;76+const ADAPTIVE_BITRATE_QUANTUM_KBPS: u32 = 250;77+const ADAPTIVE_BITRATE_CLEAN_WINDOWS: u8 = 10;78+const ADAPTIVE_BITRATE_MODERATE_WINDOWS: u8 = 2;79+const ADAPTIVE_BITRATE_COOLDOWN_WINDOWS: u8 = 10;80+const FEEDBACK_FRESH_INTERVAL_MIN_MS: u16 = 750;81+const FEEDBACK_FRESH_INTERVAL_MAX_MS: u16 = 1_500;82+const FEEDBACK_FRESH_ARRIVAL_GAP_MIN: Duration = Duration::from_millis(750);83+const FEEDBACK_STALE_ARRIVAL_GAP: Duration = Duration::from_millis(2_500);84+const FEEDBACK_MIN_READ_INTERVAL: Duration = Duration::from_millis(250);85+const FEEDBACK_WRITE_TIMEOUT: Duration = Duration::from_secs(1);86+const ENCODER_CONTROL_COMMIT_TIMEOUT: Duration = Duration::from_secs(2);87+// Mirrors the fixed v1 wire bound. Aggregates clamp here so a stalled88+// consumer observes a stale 5-second window instead of a deceptively fresh89+// latest report.90+const MEDIA_FEEDBACK_INTERVAL_MAX_MS: u64 = 5_000;91+92+93+enum FeedbackSeverity {94+ Clean,95+ InsufficientEvidence,96+ Moderate,97+ Severe,98+ Stale,99+}100+101+102+struct FeedbackIngressCursor {103+ last_report_id: Option<u64>,104+ interval_ms: u64,105+ transport_dropped: u64,106+ frontend_dropped: u64,107+ decoder_dropped: u64,108+ presenter_dropped: u64,109+ resync_reports: u64,110+}111+112+113+struct CumulativeFeedbackIngress {114+ latest: Option<MediaFeedbackReportV1>,115+ interval_ms: u64,116+ transport_dropped: u64,117+ frontend_dropped: u64,118+ decoder_dropped: u64,119+ presenter_dropped: u64,120+ resync_reports: u64,121+}122+123+impl CumulativeFeedbackIngress {124+ fn observe(&mut self, report: MediaFeedbackReportV1) {125+ self.interval_ms = self.interval_ms.wrapping_add(u64::from(report.interval_ms));126+ self.transport_dropped = self127+ .transport_dropped128+ .wrapping_add(u64::from(report.transport_dropped_delta));129+ self.frontend_dropped = self130+ .frontend_dropped131+ .wrapping_add(u64::from(report.frontend_dropped_delta));132+ self.decoder_dropped = self133+ .decoder_dropped134+ .wrapping_add(u64::from(report.decoder_dropped_delta));135+ self.presenter_dropped = self136+ .presenter_dropped137+ .wrapping_add(u64::from(report.presenter_dropped_delta));138+ if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE) {139+ self.resync_reports = self.resync_reports.wrapping_add(1);140+ }141+ self.latest = Some(report);142+ }143+144+ fn report_since(145+ &self,146+ cursor: FeedbackIngressCursor,147+ ) -> Result<Option<(MediaFeedbackReportV1, FeedbackIngressCursor)>> {148+ let Some(latest) = self.latest else {149+ return Ok(None);150+ };151+ if cursor152+ .last_report_id153+ .is_some_and(|report_id| latest.report_id <= report_id)154+ {155+ return Ok(None);156+ }157+ let mut report = latest;158+ report.interval_ms = u16::try_from(159+ self.interval_ms160+ .wrapping_sub(cursor.interval_ms)161+ .min(MEDIA_FEEDBACK_INTERVAL_MAX_MS),162+ )163+ .expect("feedback interval is clamped to u16");164+ report.transport_dropped_delta =165+ cumulative_feedback_delta(self.transport_dropped, cursor.transport_dropped);166+ report.frontend_dropped_delta =167+ cumulative_feedback_delta(self.frontend_dropped, cursor.frontend_dropped);168+ report.decoder_dropped_delta =169+ cumulative_feedback_delta(self.decoder_dropped, cursor.decoder_dropped);170+ report.presenter_dropped_delta =171+ cumulative_feedback_delta(self.presenter_dropped, cursor.presenter_dropped);172+ report.flags = if self.resync_reports.wrapping_sub(cursor.resync_reports) > 0 {173+ MediaFeedbackFlags::RESYNC_ACTIVE174+ } else {175+ MediaFeedbackFlags::NONE176+ };177+ report.validate()?;178+ Ok(Some((179+ report,180+ FeedbackIngressCursor {181+ last_report_id: Some(latest.report_id),182+ interval_ms: self.interval_ms,183+ transport_dropped: self.transport_dropped,184+ frontend_dropped: self.frontend_dropped,185+ decoder_dropped: self.decoder_dropped,186+ presenter_dropped: self.presenter_dropped,187+ resync_reports: self.resync_reports,188+ },189+ )))190+ }191+}192+193+fn cumulative_feedback_delta(total: u64, baseline: u64) -> u32 {194+ u32::try_from(total.wrapping_sub(baseline).min(u64::from(u32::MAX)))195+ .expect("feedback delta is clamped to u32")196+}197+198+199+struct ShadowBitrateController {200+ floor_kbps: u32,201+ ceiling_kbps: u32,202+ target_kbps: u32,203+ next_decision_id: u64,204+ last_report_id: Option<u64>,205+ last_report_received_at: Option<Instant>,206+ previous_telemetry: MediaV3TelemetrySnapshot,207+ baseline_rtt_micros: Option<u64>,208+ last_sequence: Option<u64>,209+ moderate_windows: u8,210+ clean_windows: u8,211+ cooldown_windows: u8,212+}213+214+impl ShadowBitrateController {215+ fn new(configured_ceiling_kbps: u32, telemetry: MediaV3TelemetrySnapshot) -> Self {216+ let ceiling_kbps = quantize_down(configured_ceiling_kbps).max(ADAPTIVE_BITRATE_FLOOR_KBPS);217+ Self {218+ floor_kbps: ADAPTIVE_BITRATE_FLOOR_KBPS,219+ ceiling_kbps,220+ target_kbps: ceiling_kbps,221+ next_decision_id: 1,222+ last_report_id: None,223+ last_report_received_at: None,224+ previous_telemetry: telemetry,225+ baseline_rtt_micros: None,226+ last_sequence: None,227+ moderate_windows: 0,228+ clean_windows: 0,229+ cooldown_windows: 0,230+ }231+ }232+233+ fn decide(234+ &mut self,235+ report: &MediaFeedbackReportV1,236+ telemetry: MediaV3TelemetrySnapshot,237+ received_at: Instant,238+ ) -> Result<AdaptiveBitrateDecisionV1> {239+ ensure!(240+ self.last_report_id241+ .is_none_or(|last| report.report_id > last),242+ "feedback report IDs must increase monotonically"243+ );244+ let (severity, reasons, stale, trusted_host_pressure) =245+ self.classify(report, telemetry, received_at);246+ let mut state = AdaptiveBitrateStateV1::Hold;247+248+ if self.cooldown_windows > 0 {249+ self.cooldown_windows -= 1;250+ }251+ match severity {252+ FeedbackSeverity::Severe => {253+ self.moderate_windows = 0;254+ self.clean_windows = 0;255+ self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;256+ let next =257+ quantize_down(self.target_kbps.saturating_mul(3) / 4).max(self.floor_kbps);258+ if next < self.target_kbps {259+ self.target_kbps = next;260+ state = AdaptiveBitrateStateV1::Decrease;261+ }262+ }263+ FeedbackSeverity::Moderate => {264+ self.moderate_windows = self.moderate_windows.saturating_add(1);265+ self.clean_windows = 0;266+ if self.moderate_windows >= ADAPTIVE_BITRATE_MODERATE_WINDOWS {267+ self.moderate_windows = 0;268+ self.cooldown_windows = ADAPTIVE_BITRATE_COOLDOWN_WINDOWS;269+ let next =270+ quantize_down(self.target_kbps.saturating_mul(4) / 5).max(self.floor_kbps);271+ if next < self.target_kbps {272+ self.target_kbps = next;273+ state = AdaptiveBitrateStateV1::Decrease;274+ }275+ }276+ }277+ FeedbackSeverity::Clean => {278+ self.moderate_windows = 0;279+ self.clean_windows = self.clean_windows.saturating_add(1);280+ if self.clean_windows >= ADAPTIVE_BITRATE_CLEAN_WINDOWS281+ && self.cooldown_windows == 0282+ {283+ self.clean_windows = 0;284+ let maximum_step =285+ (self.target_kbps / 20).clamp(ADAPTIVE_BITRATE_QUANTUM_KBPS, 500);286+ let next = quantize_down(287+ self.target_kbps288+ .saturating_add(maximum_step)289+ .min(self.ceiling_kbps),290+ );291+ if next > self.target_kbps {292+ self.target_kbps = next;293+ state = AdaptiveBitrateStateV1::Increase;294+ }295+ }296+ }297+ FeedbackSeverity::InsufficientEvidence | FeedbackSeverity::Stale => {298+ self.moderate_windows = 0;299+ self.clean_windows = 0;300+ }301+ }302+303+ self.target_kbps = self.target_kbps.clamp(self.floor_kbps, self.ceiling_kbps);304+ let decision = AdaptiveBitrateDecisionV1 {305+ decision_id: self.next_decision_id,306+ report_id: report.report_id,307+ target_kbps: self.target_kbps,308+ floor_kbps: self.floor_kbps,309+ ceiling_kbps: self.ceiling_kbps,310+ state,311+ reasons,312+ applied: false,313+ };314+ decision.validate()?;315+ self.next_decision_id = self316+ .next_decision_id317+ .checked_add(1)318+ .context("adaptive bitrate decision ID exhausted")?;319+ self.last_report_id = Some(report.report_id);320+ self.last_report_received_at = Some(received_at);321+ if !stale322+ || !trusted_host_pressure323+ || state == AdaptiveBitrateStateV1::Decrease324+ || self.target_kbps == self.floor_kbps325+ {326+ self.previous_telemetry = telemetry;327+ }328+ if !stale && let Some(sequence) = report.last_sequence {329+ self.last_sequence = Some(330+ self.last_sequence331+ .map_or(sequence, |last| last.max(sequence)),332+ );333+ }334+ Ok(decision)335+ }336+337+ fn classify(338+ &mut self,339+ report: &MediaFeedbackReportV1,340+ telemetry: MediaV3TelemetrySnapshot,341+ received_at: Instant,342+ ) -> (FeedbackSeverity, AdaptiveBitrateReasonFlagsV1, bool, bool) {343+ let stale = !(FEEDBACK_FRESH_INTERVAL_MIN_MS..=FEEDBACK_FRESH_INTERVAL_MAX_MS)344+ .contains(&report.interval_ms)345+ || self.last_report_received_at.is_none()346+ || self.last_report_received_at.is_some_and(|last| {347+ let gap = received_at.saturating_duration_since(last);348+ !(FEEDBACK_FRESH_ARRIVAL_GAP_MIN..=FEEDBACK_STALE_ARRIVAL_GAP).contains(&gap)349+ });350+ let mut severity = FeedbackSeverity::Clean;351+ let mut reasons = AdaptiveBitrateReasonFlagsV1::NONE;352+353+ let lost_delta = telemetry354+ .selected_path_lost_packets355+ .saturating_sub(self.previous_telemetry.selected_path_lost_packets);356+ let congestion_delta = telemetry357+ .selected_path_congestion_events358+ .saturating_sub(self.previous_telemetry.selected_path_congestion_events);359+ let cancellation_delta = telemetry360+ .scheduler_cancellations361+ .saturating_sub(self.previous_telemetry.scheduler_cancellations);362+ let failure_delta = telemetry363+ .send_failures364+ .saturating_sub(self.previous_telemetry.send_failures);365+366+ if failure_delta > 0 || lost_delta > 0 || cancellation_delta >= 2 {367+ add_feedback_signal(368+ &mut severity,369+ &mut reasons,370+ FeedbackSeverity::Severe,371+ AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,372+ );373+ } else if cancellation_delta > 0 || congestion_delta > 0 {374+ add_feedback_signal(375+ &mut severity,376+ &mut reasons,377+ FeedbackSeverity::Moderate,378+ AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,379+ );380+ }381+ if cancellation_delta >= 2 || failure_delta > 0 {382+ add_feedback_signal(383+ &mut severity,384+ &mut reasons,385+ FeedbackSeverity::Severe,386+ AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,387+ );388+ } else if cancellation_delta > 0 {389+ add_feedback_signal(390+ &mut severity,391+ &mut reasons,392+ FeedbackSeverity::Moderate,393+ AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,394+ );395+ }396+397+ let rtt = telemetry.selected_path_rtt_micros;398+ if rtt > 0 {399+ let baseline = self.baseline_rtt_micros.get_or_insert(rtt);400+ if rtt < *baseline {401+ *baseline = rtt;402+ } else if rtt >= baseline.saturating_mul(2).max(80_000) {403+ add_feedback_signal(404+ &mut severity,405+ &mut reasons,406+ FeedbackSeverity::Severe,407+ AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,408+ );409+ } else if rtt >= baseline.saturating_mul(3) / 2 {410+ add_feedback_signal(411+ &mut severity,412+ &mut reasons,413+ FeedbackSeverity::Moderate,414+ AdaptiveBitrateReasonFlagsV1::RTT_INFLATION,415+ );416+ }417+ }418+419+ let trusted_host_pressure = severity != FeedbackSeverity::Clean;420+ if stale {421+ reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE);422+ if !trusted_host_pressure {423+ severity = FeedbackSeverity::Stale;424+ }425+ return (severity, reasons, true, trusted_host_pressure);426+ }427+428+ let receiver_drops = report429+ .transport_dropped_delta430+ .saturating_add(report.frontend_dropped_delta)431+ .saturating_add(report.decoder_dropped_delta)432+ .saturating_add(report.presenter_dropped_delta);433+ let queue_severe = queue_ratio_at_least(434+ report.frontend_queue_depth,435+ report.frontend_queue_capacity,436+ 3,437+ 4,438+ ) || queue_ratio_at_least(439+ report.decode_queue_depth,440+ report.decode_queue_capacity,441+ 3,442+ 4,443+ ) || queue_ratio_at_least(444+ report.presenter_queue_depth,445+ report.presenter_queue_capacity,446+ 3,447+ 4,448+ );449+ let queue_moderate = queue_ratio_at_least(450+ report.frontend_queue_depth,451+ report.frontend_queue_capacity,452+ 1,453+ 2,454+ ) || queue_ratio_at_least(455+ report.decode_queue_depth,456+ report.decode_queue_capacity,457+ 1,458+ 2,459+ ) || queue_ratio_at_least(460+ report.presenter_queue_depth,461+ report.presenter_queue_capacity,462+ 1,463+ 2,464+ );465+ if report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)466+ || queue_severe467+ || receiver_drops >= 4468+ {469+ add_feedback_signal(470+ &mut severity,471+ &mut reasons,472+ FeedbackSeverity::Severe,473+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,474+ );475+ } else if queue_moderate || receiver_drops > 0 {476+ add_feedback_signal(477+ &mut severity,478+ &mut reasons,479+ FeedbackSeverity::Moderate,480+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,481+ );482+ }483+ if report.decode_queue_depth == report.decode_queue_capacity484+ || report.decoder_dropped_delta > 0485+ {486+ add_feedback_signal(487+ &mut severity,488+ &mut reasons,489+ FeedbackSeverity::Severe,490+ AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,491+ );492+ } else if report.decode_queue_depth >= 2 {493+ add_feedback_signal(494+ &mut severity,495+ &mut reasons,496+ FeedbackSeverity::Moderate,497+ AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,498+ );499+ }500+501+ let delivery_p95 = report.transport_delivery_p95_ms.unwrap_or(0);502+ let decode_p95 = report.decode_p95_ms.unwrap_or(0);503+ let presentation_p95 = report.presentation_p95_ms.unwrap_or(0);504+ if delivery_p95 >= 200 || decode_p95 >= 100 || presentation_p95 >= 100 {505+ add_feedback_signal(506+ &mut severity,507+ &mut reasons,508+ FeedbackSeverity::Severe,509+ AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,510+ );511+ } else if delivery_p95 >= 100 || decode_p95 >= 50 || presentation_p95 >= 50 {512+ add_feedback_signal(513+ &mut severity,514+ &mut reasons,515+ FeedbackSeverity::Moderate,516+ AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,517+ );518+ }519+520+ if severity == FeedbackSeverity::Clean {521+ let sequence_progressed = self522+ .last_sequence523+ .zip(report.last_sequence)524+ .is_some_and(|(last, current)| current > last);525+ let complete_latency = report.transport_delivery_p95_ms.is_some()526+ && report.decode_p95_ms.is_some()527+ && report.presentation_p95_ms.is_some();528+ if sequence_progressed && complete_latency {529+ if self.target_kbps < self.ceiling_kbps {530+ reasons = reasons.union(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY);531+ }532+ } else {533+ severity = FeedbackSeverity::InsufficientEvidence;534+ }535+ }536+ (severity, reasons, false, trusted_host_pressure)537+ }538+539+ fn commit_observation_from(&mut self, candidate: &Self) {540+ self.next_decision_id = candidate.next_decision_id;541+ self.last_report_id = candidate.last_report_id;542+ self.last_report_received_at = candidate.last_report_received_at;543+ self.previous_telemetry = candidate.previous_telemetry;544+ self.baseline_rtt_micros = candidate.baseline_rtt_micros;545+ self.last_sequence = candidate.last_sequence;546+ }547+}548+549+fn add_feedback_signal(550+ severity: &mut FeedbackSeverity,551+ reasons: &mut AdaptiveBitrateReasonFlagsV1,552+ signal: FeedbackSeverity,553+ reason: AdaptiveBitrateReasonFlagsV1,554+) {555+ *reasons = reasons.union(reason);556+ if signal == FeedbackSeverity::Severe557+ || (*severity == FeedbackSeverity::Clean && signal == FeedbackSeverity::Moderate)558+ {559+ *severity = signal;560+ }561+}562+563+fn quantize_down(kbps: u32) -> u32 {564+ kbps / ADAPTIVE_BITRATE_QUANTUM_KBPS * ADAPTIVE_BITRATE_QUANTUM_KBPS565+}566+567+fn queue_ratio_at_least(depth: u8, capacity: u8, numerator: u16, denominator: u16) -> bool {568+ capacity > 0569+ && u16::from(depth).saturating_mul(denominator)570+ >= u16::from(capacity).saturating_mul(numerator)571+}72572 73573 74574 pub struct SessionRegistry {@@ -86,6 +586,7 @@ struct PendingMoqAttachment {
86586 broadcast: BroadcastConsumer,87587 attached: tokio::sync::oneshot::Sender<()>,88588 closed: tokio::sync::oneshot::Sender<()>,589+ telemetry: Arc<MediaV3Telemetry>,89590 }90591 91592 impl std::fmt::Debug for PendingMoqAttachment {@@ -105,6 +606,7 @@ struct ClaimedMoqAttachment {
105606 broadcast: BroadcastConsumer,106607 attached: tokio::sync::oneshot::Sender<()>,107608 closed: tokio::sync::oneshot::Sender<()>,609+ telemetry: Arc<MediaV3Telemetry>,108610 }109611 110612 struct MoqAttachmentWait {@@ -112,7 +614,7 @@ struct MoqAttachmentWait {
112614 closed: tokio::sync::oneshot::Receiver<()>,113615 }114616 115-617+116618 struct ActiveSession {117619 remote: EndpointId,118620 session_id: u64,@@ -122,6 +624,65 @@ struct ActiveSession {
122624 media_active: bool,123625 input_claimed: bool,124626 audio_claimed: bool,627+ feedback_claimed: bool,628+ media_v3_telemetry: Arc<MediaV3Telemetry>,629+ encoder_control: Option<EncoderControl>,630+}631+632+633+struct MediaV3Telemetry {634+ scheduler_cancellations: AtomicU64,635+ send_failures: AtomicU64,636+ selected_path_rtt_micros: AtomicU64,637+ selected_path_lost_packets: AtomicU64,638+ selected_path_congestion_events: AtomicU64,639+}640+641+642+struct MediaV3TelemetrySnapshot {643+ scheduler_cancellations: u64,644+ send_failures: u64,645+ selected_path_rtt_micros: u64,646+ selected_path_lost_packets: u64,647+ selected_path_congestion_events: u64,648+}649+650+651+struct AdaptiveEncoderProposal {652+ control: EncoderControl,653+ target_kbps: u32,654+ bitrate_revision: u64,655+ force_keyframe_revision: Option<u64>,656+}657+658+impl MediaV3Telemetry {659+ fn snapshot(&self) -> MediaV3TelemetrySnapshot {660+ MediaV3TelemetrySnapshot {661+ scheduler_cancellations: self.scheduler_cancellations.load(Ordering::Relaxed),662+ send_failures: self.send_failures.load(Ordering::Relaxed),663+ selected_path_rtt_micros: self.selected_path_rtt_micros.load(Ordering::Relaxed),664+ selected_path_lost_packets: self.selected_path_lost_packets.load(Ordering::Relaxed),665+ selected_path_congestion_events: self666+ .selected_path_congestion_events667+ .load(Ordering::Relaxed),668+ }669+ }670+671+ fn record_selected_path(&self, connection: &Connection) {672+ let paths = connection.paths();673+ let Some(path) = paths.iter().find(|path| path.is_selected()) else {674+ return;675+ };676+ let stats = path.stats();677+ self.selected_path_rtt_micros.store(678+ u64::try_from(stats.rtt.as_micros()).unwrap_or(u64::MAX),679+ Ordering::Relaxed,680+ );681+ self.selected_path_lost_packets682+ .store(stats.lost_packets, Ordering::Relaxed);683+ self.selected_path_congestion_events684+ .store(stats.congestion_events, Ordering::Relaxed);685+ }125686 }126687 127688 impl Default for SessionRegistry {@@ -144,11 +705,12 @@ impl SessionRegistry {
144705 grants: InvitationGrants,145706 ) -> Result<SessionLease> {146707 let mut active = self.active.lock().expect("session registry poisoned");147- if let Some(current) = *active {708+ if let Some(current) = active.as_ref() {148709 bail!("host already has active client {}", current.remote);149710 }150711 let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed) + 1;151712 let session_clock = SessionClock::start();713+ let media_v3_telemetry = Arc::new(MediaV3Telemetry::default());152714 *active = Some(ActiveSession {153715 remote,154716 session_id,@@ -158,12 +720,16 @@ impl SessionRegistry {
158720 media_active: true,159721 input_claimed: false,160722 audio_claimed: false,723+ feedback_claimed: false,724+ media_v3_telemetry: Arc::clone(&media_v3_telemetry),725+ encoder_control: None,161726 });162727 Ok(SessionLease {163728 registry: Arc::clone(self),164729 remote,165730 session_id,166731 session_clock,732+ media_v3_telemetry,167733 })168734 }169735 @@ -188,6 +754,86 @@ impl SessionRegistry {
188754 })189755 }190756 757+ fn install_encoder_control(758+ &self,759+ remote: EndpointId,760+ session_id: u64,761+ encoder_control: Option<EncoderControl>,762+ ) -> Result<()> {763+ let mut active = self.active.lock().expect("session registry poisoned");764+ let session = active765+ .as_mut()766+ .filter(|session| {767+ session.media_active && session.remote == remote && session.session_id == session_id768+ })769+ .context("encoder control does not match the active media session")?;770+ ensure!(771+ session.encoder_control.is_none(),772+ "active media session already has encoder control"773+ );774+ session.encoder_control = encoder_control;775+ Ok(())776+ }777+778+ fn propose_adaptive_encoder_update(779+ &self,780+ remote: EndpointId,781+ session_id: u64,782+ target_kbps: u32,783+ force_keyframe: bool,784+ ) -> Result<Option<AdaptiveEncoderProposal>> {785+ let active = self.active.lock().expect("session registry poisoned");786+ let session = active787+ .as_ref()788+ .filter(|session| {789+ session.media_active && session.remote == remote && session.session_id == session_id790+ })791+ .context("adaptive encoder update does not match the active media session")?;792+ let Some(control) = session.encoder_control.clone() else {793+ return Ok(None);794+ };795+ let bitrate_revision = control.request_bitrate_kbps(target_kbps)?;796+ let force_keyframe_revision = force_keyframe797+ .then(|| control.request_force_keyframe())798+ .transpose()?;799+ Ok(Some(AdaptiveEncoderProposal {800+ control,801+ target_kbps,802+ bitrate_revision,803+ force_keyframe_revision,804+ }))805+ }806+807+ fn claim_feedback(808+ self: &Arc<Self>,809+ remote: EndpointId,810+ nonce: [u8; 16],811+ ) -> Result<FeedbackLease> {812+ let mut active = self.active.lock().expect("session registry poisoned");813+ let session = active814+ .as_mut()815+ .filter(|session| {816+ session.media_active && session.remote == remote && session.nonce == nonce817+ })818+ .context("feedback connection does not match the active media session")?;819+ ensure!(820+ session.grants.contains(InvitationGrants::VIEW),821+ "active Portal session lacks feedback view permission"822+ );823+ ensure!(824+ !session.feedback_claimed,825+ "active client already has a feedback connection"826+ );827+ session.feedback_claimed = true;828+ Ok(FeedbackLease {829+ registry: Arc::clone(self),830+ remote,831+ session_id: session.session_id,832+ telemetry: Arc::clone(&session.media_v3_telemetry),833+ encoder_control: session.encoder_control.clone(),834+ })835+ }836+191837 fn claim_audio(self: &Arc<Self>, remote: EndpointId, nonce: [u8; 16]) -> Result<AudioLease> {192838 let mut active = self.active.lock().expect("session registry poisoned");193839 let session = active@@ -218,12 +864,13 @@ impl SessionRegistry {
218864 broadcast: BroadcastConsumer,219865 ) -> Result<MoqAttachmentWait> {220866 let active = self.active.lock().expect("session registry poisoned");221- ensure!(222- active.is_some_and(|session| {867+ let telemetry = active868+ .as_ref()869+ .filter(|session| {223870 session.media_active && session.remote == remote && session.session_id == session_id224- }),225- "MoQ expectation does not match the active control session"226- );871+ })872+ .map(|session| Arc::clone(&session.media_v3_telemetry))873+ .context("MoQ expectation does not match the active control session")?;227874 let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");228875 ensure!(229876 pending.is_none(),@@ -238,6 +885,7 @@ impl SessionRegistry {
238885 broadcast,239886 attached,240887 closed,888+ telemetry,241889 });242890 Ok(MoqAttachmentWait {243891 attached: attached_rx,@@ -268,6 +916,7 @@ impl SessionRegistry {
268916 broadcast: attachment.broadcast,269917 attached: attachment.attached,270918 closed: attachment.closed,919+ telemetry: attachment.telemetry,271920 })272921 }273922 @@ -288,11 +937,12 @@ impl SessionRegistry {
288937 // prevents a reconnect from sharing the device with a draining289938 // predecessor session.290939 session.media_active = false;291- if !session.input_claimed && !session.audio_claimed {940+ session.encoder_control = None;941+ if !session.input_claimed && !session.audio_claimed && !session.feedback_claimed {292942 *active = None;293943 }294944 drop(active);295- self.session_changed.notify_one();945+ self.session_changed.notify_waiters();296946 }297947 }298948 @@ -300,6 +950,7 @@ impl SessionRegistry {
300950 self.active301951 .lock()302952 .expect("session registry poisoned")953+ .as_ref()303954 .is_some_and(|active| {304955 active.media_active && active.remote == remote && active.session_id == session_id305956 })@@ -312,7 +963,7 @@ impl SessionRegistry {
312963 && session.session_id == session_id313964 {314965 session.input_claimed = false;315- if !session.media_active && !session.audio_claimed {966+ if !session.media_active && !session.audio_claimed && !session.feedback_claimed {316967 *active = None;317968 }318969 }@@ -325,7 +976,20 @@ impl SessionRegistry {
325976 && session.session_id == session_id326977 {327978 session.audio_claimed = false;328- if !session.media_active && !session.input_claimed {979+ if !session.media_active && !session.input_claimed && !session.feedback_claimed {980+ *active = None;981+ }982+ }983+ }984+985+ fn release_feedback(&self, remote: EndpointId, session_id: u64) {986+ let mut active = self.active.lock().expect("session registry poisoned");987+ if let Some(session) = active.as_mut()988+ && session.remote == remote989+ && session.session_id == session_id990+ {991+ session.feedback_claimed = false;992+ if !session.media_active && !session.input_claimed && !session.audio_claimed {329993 *active = None;330994 }331995 }@@ -338,6 +1002,7 @@ struct SessionLease {
3381002 remote: EndpointId,3391003 session_id: u64,3401004 session_clock: SessionClock,1005+ media_v3_telemetry: Arc<MediaV3Telemetry>,3411006 }3421007 3431008 impl Drop for SessionLease {@@ -363,6 +1028,15 @@ struct AudioLease {
3631028 grants: InvitationGrants,3641029 }3651030 1031+1032+struct FeedbackLease {1033+ registry: Arc<SessionRegistry>,1034+ remote: EndpointId,1035+ session_id: u64,1036+ telemetry: Arc<MediaV3Telemetry>,1037+ encoder_control: Option<EncoderControl>,1038+}1039+3661040 3671041 struct SourceTaskGuard(Option<tokio::task::JoinHandle<Result<()>>>);3681042 @@ -1308,6 +1982,12 @@ impl Drop for AudioLease {
13081982 }13091983 }13101984 1985+impl Drop for FeedbackLease {1986+ fn drop(&mut self) {1987+ self.registry.release_feedback(self.remote, self.session_id);1988+ }1989+}1990+13111991 13121992 pub struct MediaHandler {13131993 pub config: HostConfig,@@ -1336,6 +2016,13 @@ pub struct ControlHandler {
13362016 pub authorization: AuthorizationPolicy,13372017 }13382018 2019+2020+pub struct MediaFeedbackHandler {2021+ pub config: HostConfig,2022+ pub sessions: Arc<SessionRegistry>,2023+ pub authorization: AuthorizationPolicy,2024+}2025+13392026 /// Upstream MoQ admission guarded by an already-authenticated control lease.13402027 ///13412028 /// This deliberately does not use `iroh_moq::Moq::protocol_handler`: that@@ -1416,6 +2103,23 @@ impl ProtocolHandler for ControlHandler {
14162103 }14172104 }14182105 2106+impl ProtocolHandler for MediaFeedbackHandler {2107+ async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {2108+ let remote = connection.remote_id();2109+ if let Err(error) = serve_media_feedback(2110+ connection,2111+ &self.config,2112+ &self.sessions,2113+ &self.authorization,2114+ )2115+ .await2116+ {2117+ warn!(%remote, %error, "media feedback connection ended");2118+ }2119+ Ok(())2120+ }2121+}2122+14192123 impl ProtocolHandler for AuthorizedMoqHandler {14202124 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {14212125 let remote = connection.remote_id();@@ -1485,6 +2189,7 @@ async fn serve_authorized_moq(
14852189 broadcast,14862190 attached,14872191 closed,2192+ telemetry,14882193 } = attachment;14892194 let result: Result<()> = async {14902195 let web_transport = web_transport_iroh::Session::raw(connection);@@ -1508,13 +2213,22 @@ async fn serve_authorized_moq(
15082213 track = MOQ_VIDEO_H264_TRACK,15092214 "authorized MoQ media attachment accepted"15102215 );1511- tokio::select! {1512- reason = session.closed() => {1513- debug!(remote = %session.remote_id(), ?reason, "MoQ media session closed");1514- }1515- reason = broadcast_closed.closed() => {1516- debug!(remote = %session.remote_id(), ?reason, "control-owned MoQ broadcast closed");1517- session.close(0, b"control session ended");2216+ let mut telemetry_interval = tokio::time::interval(Duration::from_secs(1));2217+ telemetry_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);2218+ loop {2219+ tokio::select! {2220+ reason = session.closed() => {2221+ debug!(remote = %session.remote_id(), ?reason, "MoQ media session closed");2222+ break;2223+ }2224+ reason = broadcast_closed.closed() => {2225+ debug!(remote = %session.remote_id(), ?reason, "control-owned MoQ broadcast closed");2226+ session.close(0, b"control session ended");2227+ break;2228+ }2229+ _ = telemetry_interval.tick() => {2230+ telemetry.record_selected_path(session.conn());2231+ }15182232 }15192233 }15202234 Ok(())@@ -1582,7 +2296,7 @@ async fn serve_control_moq(
15822296 current_gop: mut current_gop_receiver,15832297 task: source_task,15842298 pointer_surface_dimensions,1585- encoder_control: _encoder_control,2299+ encoder_control,15862300 } = match source {15872301 Ok(source) => source,15882302 Err(error) => {@@ -1591,6 +2305,8 @@ async fn serve_control_moq(
15912305 }15922306 };15932307 let source_task = SourceTaskGuard::new(source_task);2308+ sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;2309+ let _encoder_control = encoder_control;15942310 15952311 let mut broadcast = Broadcast::new().produce();15962312 let track = broadcast@@ -1650,6 +2366,7 @@ async fn serve_control_moq(
16502366 closed,16512367 track,16522368 &mut broadcast,2369+ Arc::clone(&lease.media_v3_telemetry),16532370 )16542371 .await;16552372 @@ -1671,6 +2388,7 @@ async fn run_control_moq_session(
16712388 mut moq_closed: tokio::sync::oneshot::Receiver<()>,16722389 track: TrackProducer,16732390 broadcast: &mut BroadcastProducer,2391+ telemetry: Arc<MediaV3Telemetry>,16742392 ) -> Result<()> {16752393 let maximum_replay_age = maximum_media_replay_age(config.framerate);16762394 let mut replay_cursor = MediaReplayCursor::default();@@ -1715,6 +2433,11 @@ async fn run_control_moq_session(
17152433 .and_then(|gop| gop.frames.last())17162434 .map(|frame| frame.sequence);17172435 let cancelled_group = publisher.request_keyframe();2436+ if cancelled_group.is_some() {2437+ telemetry2438+ .scheduler_cancellations2439+ .fetch_add(1, Ordering::Relaxed);2440+ }17182441 replay_cursor.enter_resync_through(through_sequence);17192442 debug!(17202443 %remote,@@ -1758,12 +2481,21 @@ async fn run_control_moq_session(
17582481 ) {17592482 MediaReplayDecision::Send { discontinuity } => discontinuity,17602483 MediaReplayDecision::SkipUntilKeyframe => {1761- publisher.request_keyframe();2484+ if publisher.request_keyframe().is_some() {2485+ telemetry2486+ .scheduler_cancellations2487+ .fetch_add(1, Ordering::Relaxed);2488+ }17622489 replay_cursor.enter_resync_through(Some(replay_through_sequence));17632490 break;17642491 }17652492 MediaReplayDecision::DiscardStaleSuffix { through_sequence } => {17662493 let cancelled_group = publisher.request_keyframe();2494+ if cancelled_group.is_some() {2495+ telemetry2496+ .scheduler_cancellations2497+ .fetch_add(1, Ordering::Relaxed);2498+ }17672499 debug!(17682500 %remote,17692501 through_sequence,@@ -1773,12 +2505,22 @@ async fn run_control_moq_session(
17732505 break;17742506 }17752507 };1776- match publisher.publish(config, &frame, replay_discontinuity)? {2508+ let decision = publisher2509+ .publish(config, &frame, replay_discontinuity)2510+ .inspect_err(|_error| {2511+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);2512+ })?;2513+ match decision {17772514 MoqGroupDecision::Published {17782515 group_id,17792516 frame_id,17802517 cancelled_previous,17812518 } => {2519+ if cancelled_previous {2520+ telemetry2521+ .scheduler_cancellations2522+ .fetch_add(1, Ordering::Relaxed);2523+ }17822524 debug!(17832525 sequence = frame.sequence,17842526 group_id,@@ -1802,15 +2544,411 @@ async fn run_control_moq_session(
18022544 }18032545 }18042546 }1805- .await;2547+ .await;2548+2549+ publisher.abort();2550+ let _ = broadcast.abort(MoqError::Cancel);2551+ if !control_task_finished {2552+ control_task.abort();2553+ let _ = control_task.await;2554+ }2555+ result2556+}2557+2558+async fn serve_media_feedback(2559+ connection: Connection,2560+ config: &HostConfig,2561+ sessions: &Arc<SessionRegistry>,2562+ authorization: &AuthorizationPolicy,2563+) -> Result<()> {2564+ let remote = connection.remote_id();2565+ let handshake_permit = sessions2566+ .pending_handshakes2567+ .try_acquire()2568+ .context("too many pending handshakes")?;2569+ let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.accept_bi())2570+ .await2571+ .context("timed out accepting media feedback stream")?2572+ .context("accepting media feedback stream")?;2573+ let hello = receive_hello(&mut recv, Capability::VideoH264).await?;2574+ drop(handshake_permit);2575+ debug!(%remote, agent = %hello.agent, "media feedback hello received");2576+ ensure!(2577+ hello.invitation.is_none(),2578+ "invitations are accepted only on the first media handshake"2579+ );2580+2581+ let grants = match authorization.authorize_or_redeem(remote, None, unix_timestamp_now()?) {2582+ Ok(grants) => grants,2583+ Err(error) => {2584+ send_rejection(&mut send, "Portal peer is not authorized").await?;2585+ return Err(error.context("authorizing media feedback peer"));2586+ }2587+ };2588+ ensure!(2589+ grants.contains(InvitationGrants::VIEW),2590+ "authorized feedback peer lacks view permission"2591+ );2592+ let ceiling_kbps = adaptive_bitrate_ceiling_kbps(config)?;2593+ let lease = match sessions.claim_feedback(remote, hello.nonce) {2594+ Ok(lease) => lease,2595+ Err(error) => {2596+ send_rejection(&mut send, error.to_string()).await?;2597+ return Err(error);2598+ }2599+ };2600+ let encoder_actuation_available =2601+ adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();2602+2603+ write_host_hello(2604+ &mut send,2605+ &HostHello::accepted(2606+ lease.session_id,2607+ negotiated_capabilities(&hello, MEDIA_CAPABILITIES),2608+ ),2609+ )2610+ .await?;2611+ info!(2612+ %remote,2613+ session_id = lease.session_id,2614+ ceiling_kbps,2615+ applied = false,2616+ mode = if encoder_actuation_available { "active" } else { "shadow" },2617+ "media feedback client accepted for bitrate control"2618+ );2619+2620+ let (feedback_sender, mut feedback_receiver) =2621+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());2622+ let mut reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));2623+ let mut reader_finished = false;2624+ let mut receiver_open = true;2625+ let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());2626+ let mut feedback_cursor = FeedbackIngressCursor::default();2627+2628+ let result: Result<()> = loop {2629+ let session_changed = sessions.session_changed.notified();2630+ tokio::pin!(session_changed);2631+ session_changed.as_mut().enable();2632+ if !sessions.is_active(remote, lease.session_id) {2633+ break Ok(());2634+ }2635+ tokio::select! {2636+ biased;2637+ reader_result = &mut reader_task, if !reader_finished => {2638+ reader_finished = true;2639+ match reader_result {2640+ Ok(Ok(())) => {2641+ debug!(%remote, "media feedback report stream closed");2642+ }2643+ Ok(Err(error)) => {2644+ break Err(error).context("reading media feedback reports");2645+ }2646+ Err(error) => {2647+ break Err(error).context("media feedback reader task failed");2648+ }2649+ }2650+ }2651+ changed = feedback_receiver.changed(), if receiver_open => {2652+ if changed.is_err() {2653+ receiver_open = false;2654+ if reader_finished {2655+ break Ok(());2656+ }2657+ continue;2658+ }2659+ let ingress = *feedback_receiver.borrow_and_update();2660+ let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {2661+ continue;2662+ };2663+ // This branch now owns the complete aggregate. Advance the2664+ // ingress baseline exactly once before evaluation; reports2665+ // arriving during an encoder wait accumulate beyond it.2666+ feedback_cursor = next_cursor;2667+ let telemetry = lease.telemetry.snapshot();2668+ let mut candidate = controller.clone();2669+ let mut decision = candidate.decide(&report, telemetry, Instant::now())?;2670+ let changes_bitrate = decision.state != AdaptiveBitrateStateV1::Hold;2671+ let can_actuate = changes_bitrate && encoder_actuation_available;2672+ let mut recovery_acknowledged = None;2673+ if can_actuate {2674+ let force_keyframe =2675+ adaptive_recovery_keyframe_required(&report, &decision);2676+ let proposal = match sessions.propose_adaptive_encoder_update(2677+ remote,2678+ lease.session_id,2679+ decision.target_kbps,2680+ force_keyframe,2681+ ) {2682+ Ok(Some(proposal)) => Some(proposal),2683+ Ok(None) => {2684+ warn!(2685+ %remote,2686+ session_id = lease.session_id,2687+ "active adaptive session lost encoder control; retaining committed bitrate"2688+ );2689+ None2690+ }2691+ Err(error) => {2692+ warn!(2693+ %remote,2694+ session_id = lease.session_id,2695+ %error,2696+ "adaptive encoder proposal failed; retaining committed bitrate"2697+ );2698+ None2699+ }2700+ };2701+ if let Some(proposal) = proposal {2702+ match commit_adaptive_encoder_proposal(2703+ sessions,2704+ remote,2705+ lease.session_id,2706+ &proposal,2707+ )2708+ .await2709+ {2710+ AdaptiveEncoderCommit::GenerationEnded => break Ok(()),2711+ AdaptiveEncoderCommit::NotApplied(error) => {2712+ warn!(2713+ %remote,2714+ session_id = lease.session_id,2715+ %error,2716+ "adaptive encoder application failed; retaining committed bitrate"2717+ );2718+ }2719+ AdaptiveEncoderCommit::Applied => {2720+ decision.applied = true;2721+ controller = candidate.clone();2722+ if proposal.force_keyframe_revision.is_some() {2723+ match await_adaptive_recovery_keyframe(2724+ sessions,2725+ remote,2726+ lease.session_id,2727+ &proposal,2728+ )2729+ .await2730+ {2731+ None => break Ok(()),2732+ Some(Ok(())) => recovery_acknowledged = Some(true),2733+ Some(Err(error)) => {2734+ recovery_acknowledged = Some(false);2735+ warn!(2736+ %remote,2737+ session_id = lease.session_id,2738+ %error,2739+ "adaptive bitrate applied but forced-IDR recovery was not acknowledged"2740+ );2741+ }2742+ }2743+ }2744+ }2745+ }2746+ }2747+ if !decision.applied {2748+ controller.commit_observation_from(&candidate);2749+ }2750+ } else {2751+ // External gst-launch and sources without EncoderControl2752+ // remain intentionally shadow-only. Hold decisions do not2753+ // need encoder actuation.2754+ controller = candidate;2755+ }2756+ tokio::time::timeout(2757+ FEEDBACK_WRITE_TIMEOUT,2758+ write_adaptive_bitrate_decision_v1(&mut send, &decision),2759+ )2760+ .await2761+ .context("timed out writing adaptive bitrate decision")??;2762+ debug!(2763+ %remote,2764+ session_id = lease.session_id,2765+ report_id = decision.report_id,2766+ decision_id = decision.decision_id,2767+ target_kbps = decision.target_kbps,2768+ ?decision.state,2769+ reasons = decision.reasons.bits(),2770+ applied = decision.applied,2771+ ?recovery_acknowledged,2772+ path_rtt_micros = telemetry.selected_path_rtt_micros,2773+ path_lost_packets = telemetry.selected_path_lost_packets,2774+ path_congestion_events = telemetry.selected_path_congestion_events,2775+ scheduler_cancellations = telemetry.scheduler_cancellations,2776+ send_failures = telemetry.send_failures,2777+ mode = if can_actuate { "active" } else { "shadow" },2778+ "adaptive bitrate decision"2779+ );2780+ }2781+ _ = &mut session_changed => {2782+ if !sessions.is_active(remote, lease.session_id) {2783+ break Ok(());2784+ }2785+ }2786+ closed = connection.closed() => {2787+ debug!(%remote, ?closed, "media feedback connection closed");2788+ break Ok(());2789+ }2790+ }2791+ if reader_finished && !receiver_open {2792+ break Ok(());2793+ }2794+ };2795+2796+ if !reader_finished {2797+ reader_task.abort();2798+ let _ = reader_task.await;2799+ }2800+ let _ = send.finish();2801+ drop(lease);2802+ info!(%remote, "media feedback client released");2803+ result2804+}2805+2806+async fn forward_media_feedback_reports<R>(2807+ mut reader: R,2808+ sender: tokio::sync::watch::Sender<CumulativeFeedbackIngress>,2809+) -> Result<()>2810+where2811+ R: tokio::io::AsyncRead + Unpin,2812+{2813+ let mut last_report_id = None;2814+ let mut next_read_at = tokio::time::Instant::now();2815+ loop {2816+ tokio::time::sleep_until(next_read_at).await;2817+ let Some(report) = read_media_feedback_report_v1(&mut reader).await? else {2818+ return Ok(());2819+ };2820+ ensure!(2821+ last_report_id.is_none_or(|last| report.report_id > last),2822+ "feedback report IDs must increase monotonically"2823+ );2824+ last_report_id = Some(report.report_id);2825+ sender.send_modify(|ingress| ingress.observe(report));2826+ if sender.is_closed() {2827+ return Ok(());2828+ }2829+ next_read_at = tokio::time::Instant::now() + FEEDBACK_MIN_READ_INTERVAL;2830+ }2831+}18062832 1807- publisher.abort();1808- let _ = broadcast.abort(MoqError::Cancel);1809- if !control_task_finished {1810- control_task.abort();1811- let _ = control_task.await;2833+fn adaptive_bitrate_ceiling_kbps(config: &HostConfig) -> Result<u32> {2834+ let configured = match config.source {2835+ VideoSource::TestPattern => TEST_PATTERN_BITRATE_CEILING_KBPS,2836+ VideoSource::GamescopePipewire => config2837+ .gamescope_pipewire2838+ .as_ref()2839+ .and_then(|gamescope| gamescope.bitrate_kbps)2840+ .context("adaptive bitrate shadow mode requires a Gamescope CBR bitrate")?,2841+ };2842+ let ceiling = quantize_down(configured);2843+ ensure!(2844+ ceiling >= ADAPTIVE_BITRATE_FLOOR_KBPS,2845+ "adaptive bitrate ceiling is below the 1000 kbps floor"2846+ );2847+ Ok(ceiling)2848+}2849+2850+fn adaptive_bitrate_actuation_enabled(config: &HostConfig) -> bool {2851+ config.source == VideoSource::GamescopePipewire2852+ && config.gamescope_pipewire.as_ref().is_some_and(|gamescope| {2853+ gamescope.encoder_backend == GamescopeEncoderBackend::InProcessGstreamer2854+ && gamescope.rate_control == VaapiRateControl::Cbr2855+ })2856+}2857+2858+fn adaptive_recovery_keyframe_required(2859+ report: &MediaFeedbackReportV1,2860+ decision: &AdaptiveBitrateDecisionV1,2861+) -> bool {2862+ decision.state != AdaptiveBitrateStateV1::Hold2863+ && report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE)2864+}2865+2866+2867+enum AdaptiveEncoderCommit {2868+ GenerationEnded,2869+ NotApplied(anyhow::Error),2870+ Applied,2871+}2872+2873+async fn await_while_session_active<F, T>(2874+ sessions: &SessionRegistry,2875+ remote: EndpointId,2876+ session_id: u64,2877+ future: F,2878+) -> Option<T>2879+where2880+ F: Future<Output = T>,2881+{2882+ tokio::pin!(future);2883+ loop {2884+ let session_changed = sessions.session_changed.notified();2885+ tokio::pin!(session_changed);2886+ session_changed.as_mut().enable();2887+ if !sessions.is_active(remote, session_id) {2888+ return None;2889+ }2890+ tokio::select! {2891+ result = &mut future => return sessions.is_active(remote, session_id).then_some(result),2892+ _ = &mut session_changed => {}2893+ }18122894 }1813- result2895+}2896+2897+async fn commit_adaptive_encoder_proposal(2898+ sessions: &SessionRegistry,2899+ remote: EndpointId,2900+ session_id: u64,2901+ proposal: &AdaptiveEncoderProposal,2902+) -> AdaptiveEncoderCommit {2903+ let bitrate_result = await_while_session_active(2904+ sessions,2905+ remote,2906+ session_id,2907+ proposal2908+ .control2909+ .wait_for_bitrate_applied(proposal.bitrate_revision, ENCODER_CONTROL_COMMIT_TIMEOUT),2910+ )2911+ .await;2912+ let Some(bitrate_result) = bitrate_result else {2913+ return AdaptiveEncoderCommit::GenerationEnded;2914+ };2915+ let status = match bitrate_result {2916+ Ok(status) => status,2917+ Err(error) => return AdaptiveEncoderCommit::NotApplied(error),2918+ };2919+ if status.applied_bitrate_revision != Some(proposal.bitrate_revision)2920+ || status.applied_bitrate_kbps != Some(proposal.target_kbps)2921+ {2922+ return AdaptiveEncoderCommit::NotApplied(anyhow::anyhow!(2923+ "encoder bitrate commit mismatch: revision {:?}, readback {:?}, expected revision {} at {} kbps",2924+ status.applied_bitrate_revision,2925+ status.applied_bitrate_kbps,2926+ proposal.bitrate_revision,2927+ proposal.target_kbps2928+ ));2929+ }2930+2931+ AdaptiveEncoderCommit::Applied2932+}2933+2934+async fn await_adaptive_recovery_keyframe(2935+ sessions: &SessionRegistry,2936+ remote: EndpointId,2937+ session_id: u64,2938+ proposal: &AdaptiveEncoderProposal,2939+) -> Option<Result<()>> {2940+ let force_keyframe_revision = proposal.force_keyframe_revision?;2941+ await_while_session_active(2942+ sessions,2943+ remote,2944+ session_id,2945+ proposal.control.wait_for_force_keyframe_acknowledged(2946+ force_keyframe_revision,2947+ ENCODER_CONTROL_COMMIT_TIMEOUT,2948+ ),2949+ )2950+ .await2951+ .map(|result| result.map(|_| ()))18142952 }18152953 18162954 async fn serve_media(@@ -1876,7 +3014,7 @@ async fn serve_media(
18763014 current_gop: mut current_gop_receiver,18773015 task: source_task,18783016 pointer_surface_dimensions,1879- encoder_control: _encoder_control,3017+ encoder_control,18803018 } = match source {18813019 Ok(source) => source,18823020 Err(error) => {@@ -1885,6 +3023,8 @@ async fn serve_media(
18853023 }18863024 };18873025 let source_task = SourceTaskGuard::new(source_task);3026+ sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;3027+ let _encoder_control = encoder_control;18883028 18893029 let mut media_hello = HostHello::accepted(18903030 lease.session_id,@@ -2049,7 +3189,7 @@ async fn serve_media_v2(
20493189 current_gop: mut current_gop_receiver,20503190 task: source_task,20513191 pointer_surface_dimensions,2052- encoder_control: _encoder_control,3192+ encoder_control,20533193 } = match source {20543194 Ok(source) => source,20553195 Err(error) => {@@ -2058,6 +3198,8 @@ async fn serve_media_v2(
20583198 }20593199 };20603200 let source_task = SourceTaskGuard::new(source_task);3201+ sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;3202+ let _encoder_control = encoder_control;20613203 20623204 let mut media_hello = HostHello::accepted(20633205 lease.session_id,@@ -2143,7 +3285,7 @@ async fn serve_media_v3(
21433285 current_gop: mut current_gop_receiver,21443286 task: source_task,21453287 pointer_surface_dimensions,2146- encoder_control: _encoder_control,3288+ encoder_control,21473289 } = match source {21483290 Ok(source) => source,21493291 Err(error) => {@@ -2152,6 +3294,8 @@ async fn serve_media_v3(
21523294 }21533295 };21543296 let source_task = SourceTaskGuard::new(source_task);3297+ sessions.install_encoder_control(remote, lease.session_id, encoder_control.clone())?;3298+ let _encoder_control = encoder_control;21553299 21563300 let mut media_hello = HostHello::accepted(21573301 lease.session_id,@@ -2172,6 +3316,7 @@ async fn serve_media_v3(
21723316 &mut current_gop_receiver,21733317 recv,21743318 remote,3319+ Arc::clone(&lease.media_v3_telemetry),21753320 )21763321 .await;21773322 @@ -2397,6 +3542,7 @@ async fn run_media_v3_session(
23973542 current_gop_receiver: &mut tokio::sync::watch::Receiver<Option<EncodedGop>>,23983543 control_recv: iroh::endpoint::RecvStream,23993544 remote: EndpointId,3545+ telemetry: Arc<MediaV3Telemetry>,24003546 ) -> Result<()> {24013547 let maximum_replay_age = maximum_media_replay_age(config.framerate);24023548 let delivery_timeout_ms = media_v3_delivery_timeout_ms(config.framerate);@@ -2413,6 +3559,7 @@ async fn run_media_v3_session(
24133559 let mut control_receiver_open = true;24143560 24153561 let result = loop {3562+ telemetry.record_selected_path(connection);24163563 tokio::select! {24173564 biased;24183565 control_result = &mut control_task, if !control_task_finished => {@@ -2454,6 +3601,10 @@ async fn run_media_v3_session(
24543601 request.reason,24553602 );24563603 if !cancel_sequences.is_empty() {3604+ telemetry.scheduler_cancellations.fetch_add(3605+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3606+ Ordering::Relaxed,3607+ );24573608 send_tasks.abort_all();24583609 }24593610 debug!(@@ -2486,6 +3637,11 @@ async fn run_media_v3_session(
24863637 sequence,24873638 through_sequence,24883639 ) {3640+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);3641+ telemetry.scheduler_cancellations.fetch_add(3642+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3643+ Ordering::Relaxed,3644+ );24893645 warn!(24903646 sequence,24913647 %error,@@ -2506,6 +3662,11 @@ async fn run_media_v3_session(
25063662 Err(error) => {25073663 warn!(%error, "media v3 object task failed; waiting for keyframe");25083664 let cancel_sequences = scheduler.fail_all();3665+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);3666+ telemetry.scheduler_cancellations.fetch_add(3667+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3668+ Ordering::Relaxed,3669+ );25093670 group_cursor.request_keyframe();25103671 let through_sequence = current_gop_receiver25113672 .borrow()@@ -2550,6 +3711,10 @@ async fn run_media_v3_session(
25503711 MediaReplayDecision::DiscardStaleSuffix { .. } => {25513712 group_cursor.request_keyframe();25523713 let cancel_sequences = scheduler.fail_all();3714+ telemetry.scheduler_cancellations.fetch_add(3715+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3716+ Ordering::Relaxed,3717+ );25533718 if !cancel_sequences.is_empty() {25543719 send_tasks.abort_all();25553720 }@@ -2565,6 +3730,10 @@ async fn run_media_v3_session(
25653730 MediaV3GroupDecision::EnterResync => {25663731 replay_cursor.enter_resync_through(Some(replay_through_sequence));25673732 let cancel_sequences = scheduler.fail_all();3733+ telemetry.scheduler_cancellations.fetch_add(3734+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3735+ Ordering::Relaxed,3736+ );25683737 if !cancel_sequences.is_empty() {25693738 send_tasks.abort_all();25703739 }@@ -2582,6 +3751,10 @@ async fn run_media_v3_session(
25823751 MediaV3ScheduleDecision::EnterResync { cancel_sequences } => {25833752 group_cursor.request_keyframe();25843753 replay_cursor.enter_resync_through(Some(replay_through_sequence));3754+ telemetry.scheduler_cancellations.fetch_add(3755+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3756+ Ordering::Relaxed,3757+ );25853758 if !cancel_sequences.is_empty() {25863759 send_tasks.abort_all();25873760 }@@ -2589,6 +3762,10 @@ async fn run_media_v3_session(
25893762 }25903763 };25913764 if !cancel_sequences.is_empty() {3765+ telemetry.scheduler_cancellations.fetch_add(3766+ u64::try_from(cancel_sequences.len()).unwrap_or(u64::MAX),3767+ Ordering::Relaxed,3768+ );25923769 debug!(25933770 sequence = frame.sequence,25943771 group_id = position.group_id,@@ -2616,6 +3793,7 @@ async fn run_media_v3_session(
26163793 {26173794 Ok(Ok(stream)) => stream,26183795 Ok(Err(error)) => {3796+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);26193797 warn!(sequence, %error, "opening media v3 object stream failed");26203798 if let Some(cancel_sequences) = apply_media_v3_send_failure(26213799 &mut scheduler,@@ -2630,6 +3808,7 @@ async fn run_media_v3_session(
26303808 break;26313809 }26323810 Err(_) => {3811+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);26333812 warn!(sequence, "opening media v3 object stream timed out");26343813 if let Some(cancel_sequences) = apply_media_v3_send_failure(26353814 &mut scheduler,@@ -2648,6 +3827,7 @@ async fn run_media_v3_session(
26483827 let transport_priority =26493828 media_v3_transport_priority(media_object.header.publisher_priority)?;26503829 if let Err(error) = stream.stream().set_priority(transport_priority) {3830+ telemetry.send_failures.fetch_add(1, Ordering::Relaxed);26513831 warn!(sequence, %error, "setting media v3 object priority failed");26523832 if let Some(cancel_sequences) = apply_media_v3_send_failure(26533833 &mut scheduler,@@ -3192,6 +4372,323 @@ pub fn install_panic_hook() {
31924372 mod tests {31934373 use super::*;31944374 4375+ fn clean_feedback(report_id: u64) -> MediaFeedbackReportV1 {4376+ MediaFeedbackReportV1 {4377+ report_id,4378+ interval_ms: 1_000,4379+ flags: MediaFeedbackFlags::NONE,4380+ last_sequence: Some(report_id),4381+ transport_dropped_delta: 0,4382+ frontend_dropped_delta: 0,4383+ decoder_dropped_delta: 0,4384+ presenter_dropped_delta: 0,4385+ frontend_queue_depth: 0,4386+ frontend_queue_capacity: 4,4387+ decode_queue_depth: 0,4388+ decode_queue_capacity: 4,4389+ presenter_queue_depth: 0,4390+ presenter_queue_capacity: 2,4391+ transport_delivery_p95_ms: Some(10),4392+ decode_p95_ms: Some(3),4393+ presentation_p95_ms: Some(5),4394+ }4395+ }4396+4397+ fn controller_decide(4398+ controller: &mut ShadowBitrateController,4399+ report: &MediaFeedbackReportV1,4400+ second: u64,4401+ ) -> AdaptiveBitrateDecisionV1 {4402+ controller4403+ .decide(4404+ report,4405+ MediaV3TelemetrySnapshot::default(),4406+ Instant::now() + Duration::from_secs(second),4407+ )4408+ .unwrap()4409+ }4410+4411+ 4412+ fn shadow_controller_immediately_decreases_for_severe_feedback_and_stays_bounded() {4413+ let mut controller =4414+ ShadowBitrateController::new(12_137, MediaV3TelemetrySnapshot::default());4415+ assert_eq!(controller.ceiling_kbps, 12_000);4416+ let first = controller_decide(&mut controller, &clean_feedback(1), 0);4417+ assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);4418+ assert!(4419+ first4420+ .reasons4421+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4422+ );4423+ for report_id in 2..=21 {4424+ let mut report = clean_feedback(report_id);4425+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;4426+ let decision = controller_decide(&mut controller, &report, report_id - 1);4427+ assert!(!decision.applied);4428+ assert!(decision.target_kbps >= ADAPTIVE_BITRATE_FLOOR_KBPS);4429+ assert!(decision.target_kbps <= 12_000);4430+ assert_eq!(decision.target_kbps % ADAPTIVE_BITRATE_QUANTUM_KBPS, 0);4431+ assert!(4432+ decision4433+ .reasons4434+ .contains(AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE)4435+ );4436+ }4437+ assert_eq!(controller.target_kbps, ADAPTIVE_BITRATE_FLOOR_KBPS);4438+ }4439+4440+ 4441+ fn shadow_controller_requires_two_moderate_windows() {4442+ let mut controller =4443+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4444+ controller_decide(&mut controller, &clean_feedback(1), 0);4445+ let mut first = clean_feedback(2);4446+ first.frontend_queue_depth = 2;4447+ assert_eq!(4448+ controller_decide(&mut controller, &first, 1).state,4449+ AdaptiveBitrateStateV1::Hold4450+ );4451+ let mut second = clean_feedback(3);4452+ second.frontend_queue_depth = 2;4453+ let decision = controller_decide(&mut controller, &second, 2);4454+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4455+ assert_eq!(decision.target_kbps, 9_500);4456+ }4457+4458+ 4459+ fn shadow_controller_increases_only_after_clean_cooldown() {4460+ let mut controller =4461+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4462+ controller_decide(&mut controller, &clean_feedback(1), 0);4463+ let mut severe = clean_feedback(2);4464+ severe.decoder_dropped_delta = 1;4465+ assert_eq!(4466+ controller_decide(&mut controller, &severe, 1).target_kbps,4467+ 9_0004468+ );4469+ for report_id in 3..=11 {4470+ let decision =4471+ controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);4472+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4473+ assert_eq!(decision.target_kbps, 9_000);4474+ }4475+ let decision = controller_decide(&mut controller, &clean_feedback(12), 11);4476+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);4477+ assert_eq!(decision.target_kbps, 9_250);4478+ assert!(4479+ decision4480+ .reasons4481+ .contains(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY)4482+ );4483+ }4484+4485+ 4486+ fn stale_or_non_monotonic_feedback_cannot_drive_an_increase() {4487+ let mut controller =4488+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4489+ controller_decide(&mut controller, &clean_feedback(1), 0);4490+ let mut severe = clean_feedback(2);4491+ severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;4492+ controller_decide(&mut controller, &severe, 1);4493+ let stale = controller_decide(&mut controller, &clean_feedback(3), 4);4494+ assert_eq!(stale.state, AdaptiveBitrateStateV1::Hold);4495+ assert_eq!(stale.target_kbps, 9_000);4496+ assert!(4497+ stale4498+ .reasons4499+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4500+ );4501+ assert!(4502+ controller4503+ .decide(4504+ &clean_feedback(2),4505+ MediaV3TelemetrySnapshot::default(),4506+ Instant::now() + Duration::from_secs(4),4507+ )4508+ .is_err()4509+ );4510+4511+ let now = Instant::now();4512+ let mut rapid = ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4513+ rapid4514+ .decide(&clean_feedback(1), MediaV3TelemetrySnapshot::default(), now)4515+ .unwrap();4516+ let too_fast = rapid4517+ .decide(4518+ &clean_feedback(2),4519+ MediaV3TelemetrySnapshot::default(),4520+ now + Duration::from_millis(500),4521+ )4522+ .unwrap();4523+ assert_eq!(too_fast.state, AdaptiveBitrateStateV1::Hold);4524+ assert!(4525+ too_fast4526+ .reasons4527+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4528+ );4529+ }4530+4531+ 4532+ fn full_decode_queue_is_severe_even_at_capacity_two() {4533+ let mut controller =4534+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4535+ controller_decide(&mut controller, &clean_feedback(1), 0);4536+ let mut report = clean_feedback(2);4537+ report.decode_queue_capacity = 2;4538+ report.decode_queue_depth = 2;4539+ let decision = controller_decide(&mut controller, &report, 1);4540+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4541+ assert!(4542+ decision4543+ .reasons4544+ .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)4545+ );4546+ }4547+4548+ 4549+ fn decision_id_exhaustion_fails_instead_of_repeating() {4550+ let mut controller =4551+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4552+ controller.next_decision_id = u64::MAX;4553+ assert!(4554+ controller4555+ .decide(4556+ &clean_feedback(1),4557+ MediaV3TelemetrySnapshot::default(),4558+ Instant::now(),4559+ )4560+ .is_err()4561+ );4562+ assert_eq!(controller.next_decision_id, u64::MAX);4563+ }4564+4565+ 4566+ fn stale_feedback_applies_trusted_loss_and_send_failure_pressure_once() {4567+ for telemetry in [4568+ MediaV3TelemetrySnapshot {4569+ selected_path_lost_packets: 1,4570+ ..MediaV3TelemetrySnapshot::default()4571+ },4572+ MediaV3TelemetrySnapshot {4573+ send_failures: 1,4574+ ..MediaV3TelemetrySnapshot::default()4575+ },4576+ ] {4577+ let now = Instant::now();4578+ let mut controller =4579+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4580+ let decision = controller4581+ .decide(&clean_feedback(1), telemetry, now)4582+ .unwrap();4583+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4584+ assert_eq!(decision.target_kbps, 9_000);4585+ assert!(4586+ decision4587+ .reasons4588+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE)4589+ );4590+ assert!(4591+ decision4592+ .reasons4593+ .contains(AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION)4594+ );4595+4596+ let repeated = controller4597+ .decide(4598+ &clean_feedback(2),4599+ telemetry,4600+ now + Duration::from_millis(500),4601+ )4602+ .unwrap();4603+ assert_eq!(repeated.state, AdaptiveBitrateStateV1::Hold);4604+ assert_eq!(repeated.target_kbps, 9_000);4605+ }4606+ }4607+4608+ 4609+ fn stale_moderate_cancellation_is_retained_until_hysteresis_reduces() {4610+ let now = Instant::now();4611+ let telemetry = MediaV3TelemetrySnapshot {4612+ scheduler_cancellations: 1,4613+ ..MediaV3TelemetrySnapshot::default()4614+ };4615+ let mut controller =4616+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4617+ let first = controller4618+ .decide(&clean_feedback(1), telemetry, now)4619+ .unwrap();4620+ assert_eq!(first.state, AdaptiveBitrateStateV1::Hold);4621+ assert_eq!(controller.previous_telemetry.scheduler_cancellations, 0);4622+4623+ let second = controller4624+ .decide(4625+ &clean_feedback(2),4626+ telemetry,4627+ now + Duration::from_millis(500),4628+ )4629+ .unwrap();4630+ assert_eq!(second.state, AdaptiveBitrateStateV1::Decrease);4631+ assert_eq!(second.target_kbps, 9_500);4632+ assert_eq!(controller.previous_telemetry.scheduler_cancellations, 1);4633+ }4634+4635+ 4636+ fn clean_recovery_below_five_mbps_advances_by_one_quantum() {4637+ let mut controller =4638+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4639+ controller.target_kbps = 4_000;4640+ controller_decide(&mut controller, &clean_feedback(1), 0);4641+ controller_decide(&mut controller, &clean_feedback(2), 1);4642+ for report_id in 3..=11 {4643+ let decision =4644+ controller_decide(&mut controller, &clean_feedback(report_id), report_id - 1);4645+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4646+ }4647+ let decision = controller_decide(&mut controller, &clean_feedback(12), 11);4648+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Increase);4649+ assert_eq!(decision.target_kbps, 4_250);4650+ }4651+4652+ 4653+ fn recovery_requires_latency_samples_and_sequence_progress_but_pressure_still_reduces() {4654+ let mut controller =4655+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());4656+ controller.target_kbps = 9_000;4657+ controller_decide(&mut controller, &clean_feedback(1), 0);4658+ controller_decide(&mut controller, &clean_feedback(2), 1);4659+4660+ for report_id in 3..=14 {4661+ let mut report = clean_feedback(report_id);4662+ report.last_sequence = Some(2);4663+ let decision = controller_decide(&mut controller, &report, report_id - 1);4664+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4665+ assert_eq!(decision.target_kbps, 9_000);4666+ }4667+ assert_eq!(controller.clean_windows, 0);4668+4669+ for report_id in 15..=26 {4670+ let mut report = clean_feedback(report_id);4671+ report.decode_p95_ms = None;4672+ let decision = controller_decide(&mut controller, &report, report_id - 1);4673+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Hold);4674+ assert_eq!(decision.target_kbps, 9_000);4675+ }4676+ assert_eq!(controller.clean_windows, 0);4677+4678+ let mut pressure = clean_feedback(27);4679+ pressure.decode_p95_ms = None;4680+ pressure.decode_queue_capacity = 2;4681+ pressure.decode_queue_depth = 2;4682+ let decision = controller_decide(&mut controller, &pressure, 26);4683+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);4684+ assert_eq!(decision.target_kbps, 6_750);4685+ assert!(4686+ decision4687+ .reasons4688+ .contains(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG)4689+ );4690+ }4691+31954692 struct DropNotify(Option<tokio::sync::oneshot::Sender<()>>);31964693 31974694 impl Drop for DropNotify {@@ -4150,6 +5647,293 @@ mod tests {
41505647 assert!(wait.closed.await.is_err());41515648 }41525649 5650+ 5651+ fn feedback_attaches_only_to_exact_active_view_session() {5652+ let sessions = Arc::new(SessionRegistry::default());5653+ let nonce = [9; 16];5654+ assert!(sessions.claim_feedback(endpoint(1), nonce).is_err());5655+5656+ let no_view = sessions5657+ .claim(endpoint(1), nonce, InvitationGrants::GAMEPAD)5658+ .unwrap();5659+ assert!(sessions.claim_feedback(endpoint(1), nonce).is_err());5660+ drop(no_view);5661+5662+ let media = sessions5663+ .claim(endpoint(1), nonce, InvitationGrants::VIEW)5664+ .unwrap();5665+ assert!(sessions.claim_feedback(endpoint(2), nonce).is_err());5666+ assert!(sessions.claim_feedback(endpoint(1), [8; 16]).is_err());5667+ let feedback = sessions.claim_feedback(endpoint(1), nonce).unwrap();5668+ assert_eq!(feedback.session_id, media.session_id);5669+ assert!(feedback.encoder_control.is_none());5670+ assert!(sessions.claim_feedback(endpoint(1), nonce).is_err());5671+5672+ drop(media);5673+ assert!(5674+ sessions5675+ .claim(endpoint(2), nonce, InvitationGrants::VIEW)5676+ .is_err(),5677+ "feedback teardown must keep the draining session isolated"5678+ );5679+ drop(feedback);5680+ assert!(5681+ sessions5682+ .claim(endpoint(2), nonce, InvitationGrants::VIEW)5683+ .is_ok()5684+ );5685+ }5686+5687+ 5688+ fn adaptive_encoder_proposals_are_bound_to_the_exact_active_generation() {5689+ let sessions = Arc::new(SessionRegistry::default());5690+ let remote = endpoint(1);5691+ let media = sessions5692+ .claim(remote, [3; 16], InvitationGrants::VIEW)5693+ .unwrap();5694+ let harness = crate::source::EncoderControlTestHarness::new();5695+5696+ assert!(5697+ sessions5698+ .install_encoder_control(5699+ endpoint(2),5700+ media.session_id,5701+ Some(harness.control.clone())5702+ )5703+ .is_err()5704+ );5705+ sessions5706+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))5707+ .unwrap();5708+ let feedback = sessions.claim_feedback(remote, [3; 16]).unwrap();5709+ assert!(feedback.encoder_control.is_some());5710+ let proposal = sessions5711+ .propose_adaptive_encoder_update(remote, media.session_id, 8_000, true)5712+ .unwrap()5713+ .unwrap();5714+ assert_eq!(proposal.target_kbps, 8_000);5715+ assert!(proposal.force_keyframe_revision > Some(proposal.bitrate_revision));5716+5717+ let old_session_id = media.session_id;5718+ drop(media);5719+ assert!(5720+ sessions5721+ .propose_adaptive_encoder_update(remote, old_session_id, 7_000, false)5722+ .is_err(),5723+ "a draining generation must not issue another encoder proposal"5724+ );5725+ drop(feedback);5726+ }5727+5728+ 5729+ async fn adaptive_encoder_commit_requires_exact_readback_and_tracks_recovery_separately() {5730+ let sessions = Arc::new(SessionRegistry::default());5731+ let remote = endpoint(1);5732+ let media = sessions5733+ .claim(remote, [4; 16], InvitationGrants::VIEW)5734+ .unwrap();5735+ let harness = crate::source::EncoderControlTestHarness::new();5736+ sessions5737+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))5738+ .unwrap();5739+5740+ let proposal = sessions5741+ .propose_adaptive_encoder_update(remote, media.session_id, 8_000, true)5742+ .unwrap()5743+ .unwrap();5744+ harness.status.send_modify(|status| {5745+ status.applied_bitrate_revision = Some(proposal.bitrate_revision);5746+ status.applied_bitrate_kbps = Some(8_000);5747+ status.requested_force_keyframe_revision = proposal.force_keyframe_revision;5748+ status.acknowledged_force_keyframe_revision = proposal.force_keyframe_revision;5749+ });5750+ assert!(matches!(5751+ commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &proposal).await,5752+ AdaptiveEncoderCommit::Applied5753+ ));5754+ assert!(matches!(5755+ await_adaptive_recovery_keyframe(&sessions, remote, media.session_id, &proposal).await,5756+ Some(Ok(()))5757+ ));5758+5759+ let mismatch = sessions5760+ .propose_adaptive_encoder_update(remote, media.session_id, 7_000, false)5761+ .unwrap()5762+ .unwrap();5763+ harness.status.send_modify(|status| {5764+ status.applied_bitrate_revision = Some(mismatch.bitrate_revision);5765+ status.applied_bitrate_kbps = Some(7_250);5766+ });5767+ assert!(matches!(5768+ commit_adaptive_encoder_proposal(&sessions, remote, media.session_id, &mismatch).await,5769+ AdaptiveEncoderCommit::NotApplied(_)5770+ ));5771+5772+ let stale_generation = sessions5773+ .propose_adaptive_encoder_update(remote, media.session_id, 6_000, false)5774+ .unwrap()5775+ .unwrap();5776+ let session_id = media.session_id;5777+ drop(media);5778+ assert!(matches!(5779+ commit_adaptive_encoder_proposal(&sessions, remote, session_id, &stale_generation)5780+ .await,5781+ AdaptiveEncoderCommit::GenerationEnded5782+ ));5783+ }5784+5785+ 5786+ fn failed_adaptive_application_commits_observation_but_not_target() {5787+ let mut committed =5788+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());5789+ controller_decide(&mut committed, &clean_feedback(1), 0);5790+ let old_target = committed.target_kbps;5791+ let old_decision_id = committed.next_decision_id;5792+ let mut severe = clean_feedback(2);5793+ severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;5794+5795+ let mut candidate = committed.clone();5796+ let decision = controller_decide(&mut candidate, &severe, 1);5797+ assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);5798+ assert!(candidate.target_kbps < old_target);5799+ committed.commit_observation_from(&candidate);5800+5801+ assert_eq!(committed.target_kbps, old_target);5802+ assert_eq!(committed.last_report_id, Some(2));5803+ assert_eq!(committed.next_decision_id, old_decision_id + 1);5804+ assert!(5805+ committed5806+ .decide(5807+ &clean_feedback(3),5808+ MediaV3TelemetrySnapshot::default(),5809+ Instant::now() + Duration::from_secs(2)5810+ )5811+ .is_ok()5812+ );5813+ }5814+5815+ 5816+ fn adaptive_recovery_idr_is_only_requested_for_a_bitrate_change_during_resync() {5817+ let mut report = clean_feedback(1);5818+ let mut decision = AdaptiveBitrateDecisionV1 {5819+ decision_id: 1,5820+ report_id: 1,5821+ target_kbps: 8_000,5822+ floor_kbps: 1_000,5823+ ceiling_kbps: 12_000,5824+ state: AdaptiveBitrateStateV1::Decrease,5825+ reasons: AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,5826+ applied: false,5827+ };5828+ assert!(!adaptive_recovery_keyframe_required(&report, &decision));5829+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;5830+ assert!(adaptive_recovery_keyframe_required(&report, &decision));5831+ decision.state = AdaptiveBitrateStateV1::Hold;5832+ assert!(!adaptive_recovery_keyframe_required(&report, &decision));5833+ }5834+5835+ 5836+ async fn stalled_adaptive_apply_coalesces_feedback_without_losing_pressure() {5837+ use tokio::io::AsyncWriteExt as _;5838+5839+ let (sender, mut receiver) =5840+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());5841+ let (mut writer, reader) = tokio::io::duplex(512);5842+ let reader_task = tokio::spawn(forward_media_feedback_reports(reader, sender.clone()));5843+ let mut cursor = FeedbackIngressCursor::default();5844+5845+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &clean_feedback(1))5846+ .await5847+ .unwrap();5848+ receiver.changed().await.unwrap();5849+ let (first, next_cursor) = receiver5850+ .borrow_and_update()5851+ .report_since(cursor)5852+ .unwrap()5853+ .unwrap();5854+ assert_eq!(first.report_id, 1);5855+ cursor = next_cursor;5856+5857+ // The consumer is now notionally stalled on an encoder commit. Two5858+ // reports overwrite one watch notification, but their interval,5859+ // deltas, and resync pressure must remain cumulative.5860+ let mut pressured = clean_feedback(2);5861+ pressured.flags = MediaFeedbackFlags::RESYNC_ACTIVE;5862+ pressured.transport_dropped_delta = 2;5863+ pressured.decoder_dropped_delta = 1;5864+ pressured.decode_queue_depth = 3;5865+ pressured.transport_delivery_p95_ms = Some(180);5866+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &pressured)5867+ .await5868+ .unwrap();5869+5870+ let mut latest = clean_feedback(3);5871+ latest.frontend_dropped_delta = 4;5872+ latest.presenter_dropped_delta = 3;5873+ latest.frontend_queue_depth = 3;5874+ latest.decode_queue_depth = 1;5875+ latest.transport_delivery_p95_ms = Some(40);5876+ latest.decode_p95_ms = Some(9);5877+ sigil_protocol::write_media_feedback_report_v1(&mut writer, &latest)5878+ .await5879+ .unwrap();5880+ writer.shutdown().await.unwrap();5881+ reader_task.await.unwrap().unwrap();5882+5883+ receiver.changed().await.unwrap();5884+ let (aggregate, next_cursor) = receiver5885+ .borrow_and_update()5886+ .report_since(cursor)5887+ .unwrap()5888+ .unwrap();5889+ assert_eq!(aggregate.report_id, 3);5890+ assert_eq!(aggregate.interval_ms, 2_000);5891+ assert_eq!(aggregate.transport_dropped_delta, 2);5892+ assert_eq!(aggregate.frontend_dropped_delta, 4);5893+ assert_eq!(aggregate.decoder_dropped_delta, 1);5894+ assert_eq!(aggregate.presenter_dropped_delta, 3);5895+ assert!(aggregate.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE));5896+ assert_eq!(aggregate.last_sequence, latest.last_sequence);5897+ assert_eq!(aggregate.frontend_queue_depth, latest.frontend_queue_depth);5898+ assert_eq!(aggregate.decode_queue_depth, latest.decode_queue_depth);5899+ assert_eq!(5900+ aggregate.transport_delivery_p95_ms,5901+ latest.transport_delivery_p95_ms5902+ );5903+ assert_eq!(aggregate.decode_p95_ms, latest.decode_p95_ms);5904+ cursor = next_cursor;5905+ assert!(5906+ receiver.borrow().report_since(cursor).unwrap().is_none(),5907+ "one cumulative ingress generation must be processed only once"5908+ );5909+5910+ for report_id in 4..=9 {5911+ sender.send_modify(|ingress| ingress.observe(clean_feedback(report_id)));5912+ }5913+ receiver.changed().await.unwrap();5914+ let (clamped, _) = receiver5915+ .borrow_and_update()5916+ .report_since(cursor)5917+ .unwrap()5918+ .unwrap();5919+ assert_eq!(clamped.interval_ms, 5_000);5920+ let mut controller =5921+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());5922+ let decision = controller5923+ .decide(5924+ &clamped,5925+ MediaV3TelemetrySnapshot::default(),5926+ Instant::now(),5927+ )5928+ .unwrap();5929+ assert!(5930+ decision5931+ .reasons5932+ .contains(AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE),5933+ "a clamped aggregate must be stale rather than disguised as one fresh report"5934+ );5935+ }5936+41535937 41545938 fn pending_handshakes_are_bounded() {41555939 let sessions = SessionRegistry::default();@@ -188,6 +188,25 @@ impl EncoderControl {
188188 }189189 }190190 191+192+pub(crate) struct EncoderControlTestHarness {193+ pub control: EncoderControl,194+ pub status: watch::Sender<EncoderControlStatus>,195+ _desired: watch::Receiver<EncoderControlDesired>,196+}197+198+199+impl EncoderControlTestHarness {200+ pub(crate) fn new() -> Self {201+ let (control, desired, status) = EncoderControl::new();202+ Self {203+ control,204+ status,205+ _desired: desired,206+ }207+ }208+}209+191210 192211 pub struct EncodedFrame {193212 pub sequence: u64,@@ -0,0 +1,746 @@
1+//! Bounded receiver feedback and adaptive-bitrate decisions.2+//!3+//! The feedback ALPN uses one direction for receiver reports and the other for4+//! host decisions. Both records are fixed width so a peer cannot influence an5+//! allocation or leave a decoder waiting for a peer-declared payload length.6+7+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};8+9+use crate::{PROTOCOL_VERSION, ProtocolError, Result};10+11+const MEDIA_FEEDBACK_REPORT_V1_MAGIC: [u8; 4] = *b"SGR1";12+const ADAPTIVE_BITRATE_DECISION_V1_MAGIC: [u8; 4] = *b"SGD1";13+14+/// Size of one receiver feedback report in bytes.15+pub const MEDIA_FEEDBACK_REPORT_V1_LEN: usize = 64;16+const MEDIA_FEEDBACK_REPORT_V1_LEN_U16: u16 = MEDIA_FEEDBACK_REPORT_V1_LEN as u16;17+/// Size of one host adaptive-bitrate decision in bytes.18+pub const ADAPTIVE_BITRATE_DECISION_V1_LEN: usize = 40;19+const ADAPTIVE_BITRATE_DECISION_V1_LEN_U16: u16 = ADAPTIVE_BITRATE_DECISION_V1_LEN as u16;20+21+const MIN_FEEDBACK_INTERVAL_MS: u16 = 250;22+const MAX_FEEDBACK_INTERVAL_MS: u16 = 5_000;23+const MAX_FEEDBACK_QUEUE_CAPACITY: u8 = 16;24+const OPTIONAL_SEQUENCE_NONE: u64 = u64::MAX;25+const OPTIONAL_LATENCY_NONE: u16 = u16::MAX;26+const MAX_FEEDBACK_LATENCY_MS: u16 = 60_000;27+const MIN_ADAPTIVE_BITRATE_KBPS: u32 = 1_000;28+const MAX_ADAPTIVE_BITRATE_KBPS: u32 = 100_000;29+30+/// State flags reported by Portal.31+32+pub struct MediaFeedbackFlags(u8);33+34+impl MediaFeedbackFlags {35+ pub const NONE: Self = Self(0);36+ pub const RESYNC_ACTIVE: Self = Self(1 << 0);37+38+ const KNOWN: u8 = Self::RESYNC_ACTIVE.0;39+40+ pub const fn from_bits(bits: u8) -> Option<Self> {41+ if bits & !Self::KNOWN == 0 {42+ Some(Self(bits))43+ } else {44+ None45+ }46+ }47+48+ pub const fn bits(self) -> u8 {49+ self.050+ }51+52+ pub const fn contains(self, flag: Self) -> bool {53+ self.0 & flag.0 == flag.054+ }55+56+ pub const fn union(self, other: Self) -> Self {57+ Self(self.0 | other.0)58+ }59+}60+61+/// One bounded snapshot of receiver-side video pressure.62+63+pub struct MediaFeedbackReportV1 {64+ pub report_id: u64,65+ pub interval_ms: u16,66+ pub flags: MediaFeedbackFlags,67+ pub last_sequence: Option<u64>,68+ pub transport_dropped_delta: u32,69+ pub frontend_dropped_delta: u32,70+ pub decoder_dropped_delta: u32,71+ pub presenter_dropped_delta: u32,72+ pub frontend_queue_depth: u8,73+ pub frontend_queue_capacity: u8,74+ pub decode_queue_depth: u8,75+ pub decode_queue_capacity: u8,76+ pub presenter_queue_depth: u8,77+ pub presenter_queue_capacity: u8,78+ pub transport_delivery_p95_ms: Option<u16>,79+ pub decode_p95_ms: Option<u16>,80+ pub presentation_p95_ms: Option<u16>,81+}82+83+impl MediaFeedbackReportV1 {84+ pub fn validate(&self) -> Result<()> {85+ if self.report_id == 0 {86+ return Err(invalid_report("report ID must be nonzero"));87+ }88+ if !(MIN_FEEDBACK_INTERVAL_MS..=MAX_FEEDBACK_INTERVAL_MS).contains(&self.interval_ms) {89+ return Err(invalid_report("interval must be 250..=5000 milliseconds"));90+ }91+ if MediaFeedbackFlags::from_bits(self.flags.bits()).is_none() {92+ return Err(invalid_report("unsupported feedback flag bits"));93+ }94+ if self.last_sequence == Some(OPTIONAL_SEQUENCE_NONE) {95+ return Err(invalid_report(96+ "the all-ones last sequence is reserved for none",97+ ));98+ }99+ validate_queue(self.frontend_queue_depth, self.frontend_queue_capacity)?;100+ validate_queue(self.decode_queue_depth, self.decode_queue_capacity)?;101+ validate_queue(self.presenter_queue_depth, self.presenter_queue_capacity)?;102+ for latency in [103+ self.transport_delivery_p95_ms,104+ self.decode_p95_ms,105+ self.presentation_p95_ms,106+ ] {107+ if latency.is_some_and(|latency| latency > MAX_FEEDBACK_LATENCY_MS) {108+ return Err(invalid_report(109+ "p95 latency must be at most 60000 milliseconds",110+ ));111+ }112+ }113+ Ok(())114+ }115+116+ pub fn encode(&self) -> Result<[u8; MEDIA_FEEDBACK_REPORT_V1_LEN]> {117+ self.validate()?;118+ let mut wire = [0_u8; MEDIA_FEEDBACK_REPORT_V1_LEN];119+ wire[0..4].copy_from_slice(&MEDIA_FEEDBACK_REPORT_V1_MAGIC);120+ wire[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes());121+ wire[6..8].copy_from_slice(&MEDIA_FEEDBACK_REPORT_V1_LEN_U16.to_be_bytes());122+ wire[8..16].copy_from_slice(&self.report_id.to_be_bytes());123+ wire[16..18].copy_from_slice(&self.interval_ms.to_be_bytes());124+ wire[18] = self.flags.bits();125+ // Byte 19 is reserved and remains zero.126+ wire[20..28].copy_from_slice(127+ &self128+ .last_sequence129+ .unwrap_or(OPTIONAL_SEQUENCE_NONE)130+ .to_be_bytes(),131+ );132+ wire[28..32].copy_from_slice(&self.transport_dropped_delta.to_be_bytes());133+ wire[32..36].copy_from_slice(&self.frontend_dropped_delta.to_be_bytes());134+ wire[36..40].copy_from_slice(&self.decoder_dropped_delta.to_be_bytes());135+ wire[40..44].copy_from_slice(&self.presenter_dropped_delta.to_be_bytes());136+ wire[44] = self.frontend_queue_depth;137+ wire[45] = self.frontend_queue_capacity;138+ wire[46] = self.decode_queue_depth;139+ wire[47] = self.decode_queue_capacity;140+ wire[48] = self.presenter_queue_depth;141+ wire[49] = self.presenter_queue_capacity;142+ wire[50..52].copy_from_slice(143+ &self144+ .transport_delivery_p95_ms145+ .unwrap_or(OPTIONAL_LATENCY_NONE)146+ .to_be_bytes(),147+ );148+ wire[52..54].copy_from_slice(149+ &self150+ .decode_p95_ms151+ .unwrap_or(OPTIONAL_LATENCY_NONE)152+ .to_be_bytes(),153+ );154+ wire[54..56].copy_from_slice(155+ &self156+ .presentation_p95_ms157+ .unwrap_or(OPTIONAL_LATENCY_NONE)158+ .to_be_bytes(),159+ );160+ // Bytes 56..64 are reserved and remain zero.161+ Ok(wire)162+ }163+164+ pub fn decode(wire: &[u8; MEDIA_FEEDBACK_REPORT_V1_LEN]) -> Result<Self> {165+ let magic: [u8; 4] = wire[0..4].try_into().expect("fixed slice length");166+ if magic != MEDIA_FEEDBACK_REPORT_V1_MAGIC {167+ return Err(invalid_report("invalid report magic"));168+ }169+ validate_fixed_prefix(170+ wire,171+ MEDIA_FEEDBACK_REPORT_V1_LEN_U16,172+ "media feedback report",173+ )?;174+ if wire[19] != 0 || wire[56..64].iter().any(|byte| *byte != 0) {175+ return Err(invalid_report("reserved fields must be zero"));176+ }177+ let flags = MediaFeedbackFlags::from_bits(wire[18])178+ .ok_or_else(|| invalid_report("unsupported feedback flag bits"))?;179+ let last_sequence =180+ u64::from_be_bytes(wire[20..28].try_into().expect("fixed slice length"));181+ let report = Self {182+ report_id: u64::from_be_bytes(wire[8..16].try_into().expect("fixed slice length")),183+ interval_ms: u16::from_be_bytes(wire[16..18].try_into().expect("fixed slice length")),184+ flags,185+ last_sequence: (last_sequence != OPTIONAL_SEQUENCE_NONE).then_some(last_sequence),186+ transport_dropped_delta: u32::from_be_bytes(187+ wire[28..32].try_into().expect("fixed slice length"),188+ ),189+ frontend_dropped_delta: u32::from_be_bytes(190+ wire[32..36].try_into().expect("fixed slice length"),191+ ),192+ decoder_dropped_delta: u32::from_be_bytes(193+ wire[36..40].try_into().expect("fixed slice length"),194+ ),195+ presenter_dropped_delta: u32::from_be_bytes(196+ wire[40..44].try_into().expect("fixed slice length"),197+ ),198+ frontend_queue_depth: wire[44],199+ frontend_queue_capacity: wire[45],200+ decode_queue_depth: wire[46],201+ decode_queue_capacity: wire[47],202+ presenter_queue_depth: wire[48],203+ presenter_queue_capacity: wire[49],204+ transport_delivery_p95_ms: decode_optional_latency(wire, 50),205+ decode_p95_ms: decode_optional_latency(wire, 52),206+ presentation_p95_ms: decode_optional_latency(wire, 54),207+ };208+ report.validate()?;209+ Ok(report)210+ }211+}212+213+/// Direction selected by the host controller.214+215+216+pub enum AdaptiveBitrateStateV1 {217+ Hold = 0,218+ Decrease = 1,219+ Increase = 2,220+}221+222+impl TryFrom<u8> for AdaptiveBitrateStateV1 {223+ type Error = ProtocolError;224+225+ fn try_from(value: u8) -> Result<Self> {226+ match value {227+ 0 => Ok(Self::Hold),228+ 1 => Ok(Self::Decrease),229+ 2 => Ok(Self::Increase),230+ _ => Err(invalid_decision("unsupported controller state")),231+ }232+ }233+}234+235+/// Bounded reasons contributing to a bitrate decision.236+237+pub struct AdaptiveBitrateReasonFlagsV1(u16);238+239+impl AdaptiveBitrateReasonFlagsV1 {240+ pub const NONE: Self = Self(0);241+ pub const RTT_INFLATION: Self = Self(1 << 0);242+ pub const LOSS_OR_CANCELLATION: Self = Self(1 << 1);243+ pub const SENDER_BACKPRESSURE: Self = Self(1 << 2);244+ pub const RECEIVER_QUEUE: Self = Self(1 << 3);245+ pub const DECODE_BACKLOG: Self = Self(1 << 4);246+ pub const DELIVERY_LATENCY: Self = Self(1 << 5);247+ pub const CLEAN_RECOVERY: Self = Self(1 << 6);248+ pub const FEEDBACK_STALE: Self = Self(1 << 7);249+250+ const KNOWN: u16 = Self::RTT_INFLATION.0251+ | Self::LOSS_OR_CANCELLATION.0252+ | Self::SENDER_BACKPRESSURE.0253+ | Self::RECEIVER_QUEUE.0254+ | Self::DECODE_BACKLOG.0255+ | Self::DELIVERY_LATENCY.0256+ | Self::CLEAN_RECOVERY.0257+ | Self::FEEDBACK_STALE.0;258+259+ pub const fn from_bits(bits: u16) -> Option<Self> {260+ if bits & !Self::KNOWN == 0 {261+ Some(Self(bits))262+ } else {263+ None264+ }265+ }266+267+ pub const fn bits(self) -> u16 {268+ self.0269+ }270+271+ pub const fn contains(self, flag: Self) -> bool {272+ self.0 & flag.0 == flag.0273+ }274+275+ pub const fn union(self, other: Self) -> Self {276+ Self(self.0 | other.0)277+ }278+}279+280+/// One bounded host controller decision corresponding to a receiver report.281+282+pub struct AdaptiveBitrateDecisionV1 {283+ pub decision_id: u64,284+ pub report_id: u64,285+ pub target_kbps: u32,286+ pub floor_kbps: u32,287+ pub ceiling_kbps: u32,288+ pub state: AdaptiveBitrateStateV1,289+ pub reasons: AdaptiveBitrateReasonFlagsV1,290+ pub applied: bool,291+}292+293+impl AdaptiveBitrateDecisionV1 {294+ pub fn validate(&self) -> Result<()> {295+ if self.decision_id == 0 {296+ return Err(invalid_decision("decision ID must be nonzero"));297+ }298+ if self.report_id == 0 {299+ return Err(invalid_decision("report ID must be nonzero"));300+ }301+ for bitrate in [self.target_kbps, self.floor_kbps, self.ceiling_kbps] {302+ if !(MIN_ADAPTIVE_BITRATE_KBPS..=MAX_ADAPTIVE_BITRATE_KBPS).contains(&bitrate) {303+ return Err(invalid_decision(304+ "bitrates must be between 1000 and 100000 kilobits per second",305+ ));306+ }307+ }308+ if self.floor_kbps > self.target_kbps || self.target_kbps > self.ceiling_kbps {309+ return Err(invalid_decision(310+ "bitrate floor must not exceed target and target must not exceed ceiling",311+ ));312+ }313+ if AdaptiveBitrateReasonFlagsV1::from_bits(self.reasons.bits()).is_none() {314+ return Err(invalid_decision("unsupported decision reason bits"));315+ }316+ Ok(())317+ }318+319+ pub fn encode(&self) -> Result<[u8; ADAPTIVE_BITRATE_DECISION_V1_LEN]> {320+ self.validate()?;321+ let mut wire = [0_u8; ADAPTIVE_BITRATE_DECISION_V1_LEN];322+ wire[0..4].copy_from_slice(&ADAPTIVE_BITRATE_DECISION_V1_MAGIC);323+ wire[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes());324+ wire[6..8].copy_from_slice(&ADAPTIVE_BITRATE_DECISION_V1_LEN_U16.to_be_bytes());325+ wire[8..16].copy_from_slice(&self.decision_id.to_be_bytes());326+ wire[16..24].copy_from_slice(&self.report_id.to_be_bytes());327+ wire[24..28].copy_from_slice(&self.target_kbps.to_be_bytes());328+ wire[28..32].copy_from_slice(&self.floor_kbps.to_be_bytes());329+ wire[32..36].copy_from_slice(&self.ceiling_kbps.to_be_bytes());330+ wire[36] = self.state as u8;331+ wire[37] = u8::from(self.applied);332+ wire[38..40].copy_from_slice(&self.reasons.bits().to_be_bytes());333+ Ok(wire)334+ }335+336+ pub fn decode(wire: &[u8; ADAPTIVE_BITRATE_DECISION_V1_LEN]) -> Result<Self> {337+ let magic: [u8; 4] = wire[0..4].try_into().expect("fixed slice length");338+ if magic != ADAPTIVE_BITRATE_DECISION_V1_MAGIC {339+ return Err(invalid_decision("invalid decision magic"));340+ }341+ validate_fixed_prefix(342+ wire,343+ ADAPTIVE_BITRATE_DECISION_V1_LEN_U16,344+ "adaptive bitrate decision",345+ )?;346+ if wire[37] > 1 {347+ return Err(invalid_decision("applied must be encoded as zero or one"));348+ }349+ let reason_bits = u16::from_be_bytes(wire[38..40].try_into().expect("fixed slice length"));350+ let decision = Self {351+ decision_id: u64::from_be_bytes(wire[8..16].try_into().expect("fixed slice length")),352+ report_id: u64::from_be_bytes(wire[16..24].try_into().expect("fixed slice length")),353+ target_kbps: u32::from_be_bytes(wire[24..28].try_into().expect("fixed slice length")),354+ floor_kbps: u32::from_be_bytes(wire[28..32].try_into().expect("fixed slice length")),355+ ceiling_kbps: u32::from_be_bytes(wire[32..36].try_into().expect("fixed slice length")),356+ state: AdaptiveBitrateStateV1::try_from(wire[36])?,357+ reasons: AdaptiveBitrateReasonFlagsV1::from_bits(reason_bits)358+ .ok_or_else(|| invalid_decision("unsupported decision reason bits"))?,359+ applied: wire[37] == 1,360+ };361+ decision.validate()?;362+ Ok(decision)363+ }364+}365+366+/// Read one fixed-width report. Clean EOF before the next record returns `None`.367+pub async fn read_media_feedback_report_v1<R>(368+ reader: &mut R,369+) -> Result<Option<MediaFeedbackReportV1>>370+where371+ R: AsyncRead + Unpin,372+{373+ let mut wire = [0_u8; MEDIA_FEEDBACK_REPORT_V1_LEN];374+ if reader.read(&mut wire[..1]).await? == 0 {375+ return Ok(None);376+ }377+ reader.read_exact(&mut wire[1..]).await?;378+ MediaFeedbackReportV1::decode(&wire).map(Some)379+}380+381+/// Validate and write one fixed-width report.382+pub async fn write_media_feedback_report_v1<W>(383+ writer: &mut W,384+ report: &MediaFeedbackReportV1,385+) -> Result<()>386+where387+ W: AsyncWrite + Unpin,388+{389+ writer.write_all(&report.encode()?).await?;390+ writer.flush().await?;391+ Ok(())392+}393+394+/// Read one fixed-width decision. Clean EOF before the next record returns `None`.395+pub async fn read_adaptive_bitrate_decision_v1<R>(396+ reader: &mut R,397+) -> Result<Option<AdaptiveBitrateDecisionV1>>398+where399+ R: AsyncRead + Unpin,400+{401+ let mut wire = [0_u8; ADAPTIVE_BITRATE_DECISION_V1_LEN];402+ if reader.read(&mut wire[..1]).await? == 0 {403+ return Ok(None);404+ }405+ reader.read_exact(&mut wire[1..]).await?;406+ AdaptiveBitrateDecisionV1::decode(&wire).map(Some)407+}408+409+/// Validate and write one fixed-width decision.410+pub async fn write_adaptive_bitrate_decision_v1<W>(411+ writer: &mut W,412+ decision: &AdaptiveBitrateDecisionV1,413+) -> Result<()>414+where415+ W: AsyncWrite + Unpin,416+{417+ writer.write_all(&decision.encode()?).await?;418+ writer.flush().await?;419+ Ok(())420+}421+422+fn validate_queue(depth: u8, capacity: u8) -> Result<()> {423+ if !(1..=MAX_FEEDBACK_QUEUE_CAPACITY).contains(&capacity) {424+ return Err(invalid_report("queue capacity must be 1..=16"));425+ }426+ if depth > capacity {427+ return Err(invalid_report("queue depth must not exceed capacity"));428+ }429+ Ok(())430+}431+432+fn decode_optional_latency<const N: usize>(wire: &[u8; N], offset: usize) -> Option<u16> {433+ let value = u16::from_be_bytes(434+ wire[offset..offset + 2]435+ .try_into()436+ .expect("fixed slice length"),437+ );438+ (value != OPTIONAL_LATENCY_NONE).then_some(value)439+}440+441+fn validate_fixed_prefix<const N: usize>(442+ wire: &[u8; N],443+ expected_len: u16,444+ message_type: &'static str,445+) -> Result<()> {446+ let version = u16::from_be_bytes(wire[4..6].try_into().expect("fixed slice length"));447+ if version != PROTOCOL_VERSION {448+ return Err(ProtocolError::UnsupportedVersion {449+ expected: PROTOCOL_VERSION,450+ actual: version,451+ });452+ }453+ let actual_len = u16::from_be_bytes(wire[6..8].try_into().expect("fixed slice length"));454+ if actual_len != expected_len {455+ return Err(ProtocolError::InvalidMessage {456+ message_type,457+ reason: "fixed record length does not match the protocol",458+ });459+ }460+ Ok(())461+}462+463+fn invalid_report(reason: &'static str) -> ProtocolError {464+ ProtocolError::InvalidMessage {465+ message_type: "media feedback report",466+ reason,467+ }468+}469+470+fn invalid_decision(reason: &'static str) -> ProtocolError {471+ ProtocolError::InvalidMessage {472+ message_type: "adaptive bitrate decision",473+ reason,474+ }475+}476+477+478+mod tests {479+ use std::io::ErrorKind;480+481+ use tokio::io::{AsyncWriteExt, duplex};482+483+ use super::*;484+485+ fn report() -> MediaFeedbackReportV1 {486+ MediaFeedbackReportV1 {487+ report_id: 0x0102_0304_0506_0708,488+ interval_ms: 1_000,489+ flags: MediaFeedbackFlags::RESYNC_ACTIVE,490+ last_sequence: Some(0x1112_1314_1516_1718),491+ transport_dropped_delta: 0x0102_0304,492+ frontend_dropped_delta: 0x1112_1314,493+ decoder_dropped_delta: 0x2122_2324,494+ presenter_dropped_delta: 0x3132_3334,495+ frontend_queue_depth: 1,496+ frontend_queue_capacity: 4,497+ decode_queue_depth: 2,498+ decode_queue_capacity: 2,499+ presenter_queue_depth: 1,500+ presenter_queue_capacity: 2,501+ transport_delivery_p95_ms: Some(17),502+ decode_p95_ms: Some(33),503+ presentation_p95_ms: None,504+ }505+ }506+507+ fn decision() -> AdaptiveBitrateDecisionV1 {508+ AdaptiveBitrateDecisionV1 {509+ decision_id: 0x0102_0304_0506_0708,510+ report_id: 0x1112_1314_1516_1718,511+ target_kbps: 12_000,512+ floor_kbps: 4_000,513+ ceiling_kbps: 20_000,514+ state: AdaptiveBitrateStateV1::Decrease,515+ reasons: AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION516+ .union(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG),517+ applied: true,518+ }519+ }520+521+ 522+ fn feedback_report_golden_vector() {523+ assert_eq!(524+ report().encode().unwrap(),525+ [526+ 0x53, 0x47, 0x52, 0x31, // SGR1527+ 0x00, 0x01, 0x00, 0x40, // version, fixed length528+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // report ID529+ 0x03, 0xe8, 0x01, 0x00, // interval, flags, reserved530+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, // last sequence531+ 0x01, 0x02, 0x03, 0x04, // transport drops532+ 0x11, 0x12, 0x13, 0x14, // frontend drops533+ 0x21, 0x22, 0x23, 0x24, // decoder drops534+ 0x31, 0x32, 0x33, 0x34, // presenter drops535+ 0x01, 0x04, 0x02, 0x02, 0x01, 0x02, // queue pairs536+ 0x00, 0x11, 0x00, 0x21, 0xff, 0xff, // p95 latencies537+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved538+ ]539+ );540+ }541+542+ 543+ fn adaptive_bitrate_decision_golden_vector() {544+ assert_eq!(545+ decision().encode().unwrap(),546+ [547+ 0x53, 0x47, 0x44, 0x31, // SGD1548+ 0x00, 0x01, 0x00, 0x28, // version, fixed length549+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // decision ID550+ 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, // report ID551+ 0x00, 0x00, 0x2e, 0xe0, // target: 12000552+ 0x00, 0x00, 0x0f, 0xa0, // floor: 4000553+ 0x00, 0x00, 0x4e, 0x20, // ceiling: 20000554+ 0x01, 0x01, 0x00, 0x12, // decrease, applied, reasons555+ ]556+ );557+ }558+559+ 560+ fn records_round_trip() {561+ let report = report();562+ assert_eq!(563+ MediaFeedbackReportV1::decode(&report.encode().unwrap()).unwrap(),564+ report565+ );566+ let decision = decision();567+ assert_eq!(568+ AdaptiveBitrateDecisionV1::decode(&decision.encode().unwrap()).unwrap(),569+ decision570+ );571+ }572+573+ 574+ fn feedback_report_rejects_out_of_bounds_fields() {575+ let mut value = report();576+ value.report_id = 0;577+ assert!(value.validate().is_err());578+ value = report();579+ value.interval_ms = 249;580+ assert!(value.validate().is_err());581+ value = report();582+ value.interval_ms = 5_001;583+ assert!(value.validate().is_err());584+ value = report();585+ value.last_sequence = Some(u64::MAX);586+ assert!(value.validate().is_err());587+ value = report();588+ value.frontend_queue_capacity = 0;589+ assert!(value.validate().is_err());590+ value = report();591+ value.decode_queue_capacity = 17;592+ assert!(value.validate().is_err());593+ value = report();594+ value.presenter_queue_depth = 3;595+ assert!(value.validate().is_err());596+ value = report();597+ value.decode_p95_ms = Some(60_001);598+ assert!(value.validate().is_err());599+ }600+601+ 602+ fn feedback_report_rejects_malformed_wire_fields() {603+ let valid = report().encode().unwrap();604+ for (offset, value) in [605+ (0, 0), // magic606+ (5, 2), // version607+ (7, 63), // length608+ (18, 2), // flags609+ (19, 1), // reserved610+ (63, 1), // trailing reserved611+ ] {612+ let mut malformed = valid;613+ malformed[offset] = value;614+ assert!(615+ MediaFeedbackReportV1::decode(&malformed).is_err(),616+ "offset {offset}"617+ );618+ }619+620+ let mut invalid_latency = valid;621+ invalid_latency[52..54].copy_from_slice(&60_001_u16.to_be_bytes());622+ assert!(MediaFeedbackReportV1::decode(&invalid_latency).is_err());623+ }624+625+ 626+ fn adaptive_bitrate_decision_rejects_out_of_bounds_fields() {627+ let mut value = decision();628+ value.decision_id = 0;629+ assert!(value.validate().is_err());630+ value = decision();631+ value.report_id = 0;632+ assert!(value.validate().is_err());633+ value = decision();634+ value.floor_kbps = 999;635+ assert!(value.validate().is_err());636+ value = decision();637+ value.ceiling_kbps = 100_001;638+ assert!(value.validate().is_err());639+ value = decision();640+ value.target_kbps = value.floor_kbps - 1;641+ assert!(value.validate().is_err());642+ value = decision();643+ value.target_kbps = value.ceiling_kbps + 1;644+ assert!(value.validate().is_err());645+ }646+647+ 648+ fn adaptive_bitrate_decision_rejects_malformed_wire_fields() {649+ let valid = decision().encode().unwrap();650+ for (offset, value) in [651+ (0, 0), // magic652+ (5, 2), // version653+ (7, 39), // length654+ (36, 3), // state655+ (37, 2), // applied656+ (38, 1), // unknown high reason bit657+ ] {658+ let mut malformed = valid;659+ malformed[offset] = value;660+ assert!(661+ AdaptiveBitrateDecisionV1::decode(&malformed).is_err(),662+ "offset {offset}"663+ );664+ }665+ }666+667+ 668+ async fn async_report_records_round_trip_and_clean_eof() {669+ let first = report();670+ let mut second = report();671+ second.report_id += 1;672+ let (mut writer, mut reader) = duplex(256);673+ write_media_feedback_report_v1(&mut writer, &first)674+ .await675+ .unwrap();676+ write_media_feedback_report_v1(&mut writer, &second)677+ .await678+ .unwrap();679+ writer.shutdown().await.unwrap();680+681+ assert_eq!(682+ read_media_feedback_report_v1(&mut reader).await.unwrap(),683+ Some(first)684+ );685+ assert_eq!(686+ read_media_feedback_report_v1(&mut reader).await.unwrap(),687+ Some(second)688+ );689+ assert_eq!(690+ read_media_feedback_report_v1(&mut reader).await.unwrap(),691+ None692+ );693+ }694+695+ 696+ async fn async_decision_records_round_trip_and_clean_eof() {697+ let expected = decision();698+ let (mut writer, mut reader) = duplex(128);699+ write_adaptive_bitrate_decision_v1(&mut writer, &expected)700+ .await701+ .unwrap();702+ writer.shutdown().await.unwrap();703+704+ assert_eq!(705+ read_adaptive_bitrate_decision_v1(&mut reader)706+ .await707+ .unwrap(),708+ Some(expected)709+ );710+ assert_eq!(711+ read_adaptive_bitrate_decision_v1(&mut reader)712+ .await713+ .unwrap(),714+ None715+ );716+ }717+718+ 719+ async fn truncated_fixed_records_are_io_errors() {720+ let (mut report_writer, mut report_reader) = duplex(64);721+ report_writer722+ .write_all(&report().encode().unwrap()[..63])723+ .await724+ .unwrap();725+ report_writer.shutdown().await.unwrap();726+ let error = read_media_feedback_report_v1(&mut report_reader)727+ .await728+ .unwrap_err();729+ assert!(730+ matches!(error, ProtocolError::Io(error) if error.kind() == ErrorKind::UnexpectedEof)731+ );732+733+ let (mut decision_writer, mut decision_reader) = duplex(40);734+ decision_writer735+ .write_all(&decision().encode().unwrap()[..39])736+ .await737+ .unwrap();738+ decision_writer.shutdown().await.unwrap();739+ let error = read_adaptive_bitrate_decision_v1(&mut decision_reader)740+ .await741+ .unwrap_err();742+ assert!(743+ matches!(error, ProtocolError::Io(error) if error.kind() == ErrorKind::UnexpectedEof)744+ );745+ }746+}@@ -7,6 +7,7 @@
77 88 mod audio;99 mod error;10+mod feedback;1011 mod framing;1112 mod handshake;1213 mod input;@@ -16,6 +17,12 @@ mod media_v3;
1617 1718 pub use audio::{AUDIO_HEADER_LEN, AudioCodec, AudioFlags, AudioPacket, AudioPacketHeader};1819 pub use error::{ProtocolError, Result};20+pub use feedback::{21+ ADAPTIVE_BITRATE_DECISION_V1_LEN, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1,22+ AdaptiveBitrateStateV1, MEDIA_FEEDBACK_REPORT_V1_LEN, MediaFeedbackFlags,23+ MediaFeedbackReportV1, read_adaptive_bitrate_decision_v1, read_media_feedback_report_v1,24+ write_adaptive_bitrate_decision_v1, write_media_feedback_report_v1,25+};1926 pub use handshake::{2027 Capability, ClientHello, HostHello, MAX_POINTER_SURFACE_HEIGHT, MAX_POINTER_SURFACE_WIDTH,2128 MIN_POINTER_SURFACE_HEIGHT, MIN_POINTER_SURFACE_WIDTH, PointerSurfaceDimensions,@@ -55,6 +62,8 @@ pub const MEDIA_ALPN_V2: &[u8] = b"sigil/media/2";
5562 ///5663 /// This is a Sigil protocol and is not IETF MoQ Transport compatible.5764 pub const MEDIA_ALPN_V3: &[u8] = b"sigil/media/3";65+/// ALPN for bounded receiver telemetry and host bitrate decisions.66+pub const MEDIA_FEEDBACK_ALPN_V1: &[u8] = b"sigil/media-feedback/1";5867 /// ALPN for the v1 latency-independent input stream.5968 pub const INPUT_ALPN_V1: &[u8] = b"sigil/input/1";6069 /// ALPN for the v1 session-control stream.@@ -112,6 +121,7 @@ mod tests {
112121 assert_eq!(MEDIA_ALPN_V1, b"sigil/media/1");113122 assert_eq!(MEDIA_ALPN_V2, b"sigil/media/2");114123 assert_eq!(MEDIA_ALPN_V3, b"sigil/media/3");124+ assert_eq!(MEDIA_FEEDBACK_ALPN_V1, b"sigil/media-feedback/1");115125 assert_eq!(INPUT_ALPN_V1, b"sigil/input/1");116126 assert_eq!(CONTROL_ALPN_V1, b"sigil/control/1");117127 assert_eq!(AUDIO_ALPN_V1, b"sigil/audio/1");@@ -0,0 +1,260 @@
1+export const ADAPTIVE_FEEDBACK_INTERVAL_MS = 1000;2+export const ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS = 250;3+export const ADAPTIVE_FEEDBACK_INTERVAL_MAX_MS = 5000;4+export const ADAPTIVE_QUEUE_DEPTH_MAX = 16;5+export const ADAPTIVE_COUNTER_DELTA_MAX = 0xffff_ffff;6+export const ADAPTIVE_LATENCY_MS_MAX = 60_000;7+8+function boundedInteger(value, maximum, name) {9+ if (!Number.isSafeInteger(value) || value < 0) {10+ throw new TypeError(`${name} must be a non-negative safe integer`);11+ }12+ return Math.min(value, maximum);13+}14+15+function boundedLatency(value, name) {16+ if (value === null || value === undefined) return null;17+ if (!Number.isFinite(value) || value < 0) {18+ throw new TypeError(`${name} must be finite and non-negative`);19+ }20+ return Math.min(value, ADAPTIVE_LATENCY_MS_MAX);21+}22+23+function boundedQueue(depth, capacity, name) {24+ const safeCapacity = boundedInteger(capacity, ADAPTIVE_QUEUE_DEPTH_MAX, `${name} capacity`);25+ if (safeCapacity < 1) throw new RangeError(`${name} capacity must be positive`);26+ return {27+ depth: Math.min(boundedInteger(depth, ADAPTIVE_QUEUE_DEPTH_MAX, `${name} depth`), safeCapacity),28+ capacity: safeCapacity,29+ };30+}31+32+/**33+ * Strictly normalize the small, bounded snapshot that crosses the Tauri34+ * command boundary. Cumulative counters never cross the wire; the publisher35+ * converts them to saturating interval deltas.36+ */37+export function normalizeAdaptiveFeedbackSnapshot(snapshot) {38+ if (!snapshot || typeof snapshot !== 'object') throw new TypeError('feedback snapshot is required');39+ const frontend = boundedQueue(40+ snapshot.frontendQueueDepth,41+ snapshot.frontendQueueCapacity,42+ 'frontend queue',43+ );44+ const decoder = boundedQueue(45+ snapshot.decoderQueueDepth,46+ snapshot.decoderQueueCapacity,47+ 'decoder queue',48+ );49+ const presenter = boundedQueue(50+ snapshot.presenterQueueDepth,51+ snapshot.presenterQueueCapacity,52+ 'presenter queue',53+ );54+ if (typeof snapshot.resyncActive !== 'boolean') {55+ throw new TypeError('resyncActive must be a boolean');56+ }57+ return {58+ last_sequence: snapshot.lastSequence === null || snapshot.lastSequence === undefined59+ ? null60+ : boundedInteger(snapshot.lastSequence, Number.MAX_SAFE_INTEGER, 'last sequence'),61+ frontend_queue_depth: frontend.depth,62+ frontend_queue_capacity: frontend.capacity,63+ decode_queue_depth: decoder.depth,64+ decode_queue_capacity: decoder.capacity,65+ presenter_queue_depth: presenter.depth,66+ presenter_queue_capacity: presenter.capacity,67+ transport_dropped_total: boundedInteger(68+ snapshot.transportDroppedTotal,69+ Number.MAX_SAFE_INTEGER,70+ 'transport dropped total',71+ ),72+ frontend_dropped_total: boundedInteger(73+ snapshot.frontendDroppedTotal,74+ Number.MAX_SAFE_INTEGER,75+ 'frontend dropped total',76+ ),77+ decoder_dropped_total: boundedInteger(78+ snapshot.decoderDroppedTotal,79+ Number.MAX_SAFE_INTEGER,80+ 'decoder dropped total',81+ ),82+ presenter_dropped_total: boundedInteger(83+ snapshot.presenterDroppedTotal,84+ Number.MAX_SAFE_INTEGER,85+ 'presenter dropped total',86+ ),87+ transport_delivery_p95_ms: boundedLatency(88+ snapshot.transportDeliveryP95Ms,89+ 'transport delivery p95',90+ ),91+ decode_p95_ms: boundedLatency(snapshot.decodeLatencyP95Ms, 'decode p95'),92+ presentation_p95_ms: boundedLatency(93+ snapshot.presentationLatencyP95Ms,94+ 'presentation p95',95+ ),96+ resync_active: snapshot.resyncActive,97+ };98+}99+100+function saturatingDelta(current, previous) {101+ if (current < previous) return 0;102+ return Math.min(current - previous, ADAPTIVE_COUNTER_DELTA_MAX);103+}104+105+function counterBaseline(snapshot) {106+ return {107+ transport: snapshot.transport_dropped_total,108+ frontend: snapshot.frontend_dropped_total,109+ decoder: snapshot.decoder_dropped_total,110+ presenter: snapshot.presenter_dropped_total,111+ };112+}113+114+function intervalReport(snapshot, baseline, intervalMs) {115+ return {116+ interval_ms: intervalMs,117+ last_sequence: snapshot.last_sequence,118+ frontend_queue_depth: snapshot.frontend_queue_depth,119+ frontend_queue_capacity: snapshot.frontend_queue_capacity,120+ decode_queue_depth: snapshot.decode_queue_depth,121+ decode_queue_capacity: snapshot.decode_queue_capacity,122+ presenter_queue_depth: snapshot.presenter_queue_depth,123+ presenter_queue_capacity: snapshot.presenter_queue_capacity,124+ transport_dropped_delta: saturatingDelta(snapshot.transport_dropped_total, baseline.transport),125+ frontend_dropped_delta: saturatingDelta(snapshot.frontend_dropped_total, baseline.frontend),126+ decoder_dropped_delta: saturatingDelta(snapshot.decoder_dropped_total, baseline.decoder),127+ presenter_dropped_delta: saturatingDelta(snapshot.presenter_dropped_total, baseline.presenter),128+ transport_delivery_p95_ms: snapshot.transport_delivery_p95_ms,129+ decode_p95_ms: snapshot.decode_p95_ms,130+ presentation_p95_ms: snapshot.presentation_p95_ms,131+ resync_active: snapshot.resync_active,132+ };133+}134+135+/** Generation-scoped, at-most-1 Hz, single-flight adaptive feedback sender. */136+export class AdaptiveFeedbackPublisher {137+ #invoke;138+ #now;139+ #generation = null;140+ #available = false;141+ #inFlight = false;142+ #lastStartedAt = Number.NEGATIVE_INFINITY;143+ #baselineAt = null;144+ #baseline = { transport: 0, frontend: 0, decoder: 0, presenter: 0 };145+ #latest = null;146+147+ constructor({ invokeCommand, now = () => performance.now() }) {148+ if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');149+ if (typeof now !== 'function') throw new TypeError('now must be a function');150+ this.#invoke = invokeCommand;151+ this.#now = now;152+ }153+154+ start(generation, available) {155+ if (!Number.isSafeInteger(generation) || generation < 1) {156+ throw new TypeError('feedback generation must be a positive safe integer');157+ }158+ if (typeof available !== 'boolean') throw new TypeError('feedback availability must be boolean');159+ this.#generation = generation;160+ this.#available = available;161+ this.#inFlight = false;162+ this.#lastStartedAt = Number.NEGATIVE_INFINITY;163+ this.#baselineAt = null;164+ this.#baseline = { transport: 0, frontend: 0, decoder: 0, presenter: 0 };165+ this.#latest = null;166+ }167+168+ stop() {169+ this.#generation = null;170+ this.#available = false;171+ this.#inFlight = false;172+ this.#baselineAt = null;173+ this.#latest = null;174+ }175+176+ get available() { return this.#available; }177+ get inFlight() { return this.#inFlight; }178+179+ publish(rawSnapshot) {180+ if (!this.#available || this.#generation === null) return false;181+ const snapshot = normalizeAdaptiveFeedbackSnapshot(rawSnapshot);182+ this.#latest = snapshot;183+ const now = this.#now();184+ if (!Number.isFinite(now)) throw new TypeError('feedback clock must be finite');185+ if (this.#baselineAt === null) {186+ // Counters can advance while the native connect command is still187+ // returning. Establish the first baseline after the generation is live188+ // so pre-generation pressure is never mislabeled as a short interval.189+ this.#baseline = counterBaseline(snapshot);190+ this.#baselineAt = now;191+ return false;192+ }193+ const elapsed = now - this.#baselineAt;194+ if (!Number.isFinite(elapsed) || elapsed < 0) {195+ throw new TypeError('feedback interval must be finite and non-negative');196+ }197+ if (this.#inFlight || now - this.#lastStartedAt < ADAPTIVE_FEEDBACK_INTERVAL_MS) return false;198+ if (elapsed < ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS) return false;199+200+ const generation = this.#generation;201+ const intervalMs = Math.min(202+ Math.max(Math.round(elapsed), ADAPTIVE_FEEDBACK_INTERVAL_MIN_MS),203+ ADAPTIVE_FEEDBACK_INTERVAL_MAX_MS,204+ );205+ const report = intervalReport(snapshot, this.#baseline, intervalMs);206+ this.#inFlight = true;207+ let invocation;208+ try {209+ invocation = this.#invoke('iroh_client_send_media_feedback', { generation, report });210+ } catch (error) {211+ this.#inFlight = false;212+ console.warn('adaptive feedback failed:', error);213+ return false;214+ }215+ this.#lastStartedAt = now;216+ Promise.resolve(invocation)217+ .then((accepted) => {218+ if (generation === this.#generation && accepted === true) {219+ this.#baseline = counterBaseline(snapshot);220+ this.#baselineAt = now;221+ }222+ })223+ .catch((error) => {224+ if (generation === this.#generation) console.warn('adaptive feedback failed:', error);225+ })226+ .finally(() => {227+ if (generation === this.#generation) this.#inFlight = false;228+ });229+ return true;230+ }231+}232+233+export function normalizeAdaptiveDecisionEnvelope(payload, generation) {234+ if (!payload || typeof payload !== 'object') throw new TypeError('adaptive decision is required');235+ if (!Number.isSafeInteger(generation) || generation < 1 || payload.generation !== generation) {236+ return null;237+ }238+ const decision = payload.decision;239+ if (!decision || typeof decision !== 'object') throw new TypeError('adaptive decision body is required');240+ return {241+ decision_id: boundedInteger(decision.decision_id, Number.MAX_SAFE_INTEGER, 'decision ID'),242+ report_id: boundedInteger(decision.report_id, Number.MAX_SAFE_INTEGER, 'report ID'),243+ target_kbps: boundedInteger(decision.target_kbps, 100_000, 'target bitrate'),244+ floor_kbps: boundedInteger(decision.floor_kbps, 100_000, 'bitrate floor'),245+ ceiling_kbps: boundedInteger(decision.ceiling_kbps, 100_000, 'bitrate ceiling'),246+ state: ['hold', 'decrease', 'increase'].includes(decision.state) ? decision.state : 'unknown',247+ reasons: Array.isArray(decision.reasons)248+ ? decision.reasons.filter((reason) => typeof reason === 'string').slice(0, 8)249+ : [],250+ applied: decision.applied === true,251+ };252+}253+254+export function formatAdaptiveDecision(decision, available) {255+ if (!available) return 'unavailable';256+ if (!decision) return 'awaiting report · advisory only';257+ const reasons = decision.reasons.length ? decision.reasons.join(', ') : 'no pressure signals';258+ const disposition = decision.applied ? 'host reports applied' : 'advisory only (not applied)';259+ return `${decision.target_kbps} kbps · ${decision.state} · ${reasons} · ${disposition}`;260+}@@ -0,0 +1,242 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+import {4+ ADAPTIVE_COUNTER_DELTA_MAX,5+ AdaptiveFeedbackPublisher,6+ formatAdaptiveDecision,7+ normalizeAdaptiveDecisionEnvelope,8+ normalizeAdaptiveFeedbackSnapshot,9+} from './adaptive-feedback.mjs';10+11+function snapshot(overrides = {}) {12+ return {13+ frontendQueueDepth: 1,14+ frontendQueueCapacity: 4,15+ decoderQueueDepth: 1,16+ decoderQueueCapacity: 2,17+ presenterQueueDepth: 1,18+ presenterQueueCapacity: 2,19+ transportDroppedTotal: 3,20+ frontendDroppedTotal: 4,21+ decoderDroppedTotal: 5,22+ presenterDroppedTotal: 6,23+ transportDeliveryP95Ms: 18.5,24+ decodeLatencyP95Ms: 4.5,25+ presentationLatencyP95Ms: 24.5,26+ resyncActive: false,27+ lastSequence: 99,28+ ...overrides,29+ };30+}31+32+function flushPromises() {33+ return new Promise((resolve) => setImmediate(resolve));34+}35+36+test('feedback snapshots reject unsafe values and clamp wire bounds', () => {37+ assert.throws(() => normalizeAdaptiveFeedbackSnapshot(snapshot({ decoderQueueDepth: NaN })), /safe integer/);38+ assert.throws(() => normalizeAdaptiveFeedbackSnapshot(snapshot({ resyncActive: 1 })), /boolean/);39+ const value = normalizeAdaptiveFeedbackSnapshot(snapshot({40+ decoderQueueDepth: 99,41+ transportDeliveryP95Ms: 1e9,42+ }));43+ assert.equal(value.decode_queue_depth, 2);44+ assert.equal(value.transport_delivery_p95_ms, 60_000);45+});46+47+test('publisher is generation-scoped, one hertz, single-flight, and reports deltas', async () => {48+ let now = 0;49+ const calls = [];50+ const completions = [];51+ const publisher = new AdaptiveFeedbackPublisher({52+ now: () => now,53+ invokeCommand: (command, args) => {54+ calls.push({ command, args });55+ return new Promise((resolve) => completions.push(resolve));56+ },57+ });58+ publisher.start(7, true);59+ assert.equal(publisher.publish(snapshot()), false);60+ now = 1000;61+ assert.equal(publisher.publish(snapshot()), true);62+ assert.equal(publisher.publish(snapshot({ transportDroppedTotal: 10 })), false);63+ now = 2000;64+ assert.equal(publisher.publish(snapshot({ transportDroppedTotal: 11 })), false);65+ assert.equal(calls.length, 1);66+ completions.shift()(true);67+ await flushPromises();68+ assert.equal(publisher.publish(snapshot({ transportDroppedTotal: 12 })), true);69+ assert.equal(calls[1].args.generation, 7);70+ assert.equal(calls[1].args.report.transport_dropped_delta, 9);71+ assert.equal(calls[1].args.report.frontend_dropped_delta, 0);72+});73+74+test('counter deltas saturate and counter resets do not underflow', async () => {75+ let now = 0;76+ const calls = [];77+ const publisher = new AdaptiveFeedbackPublisher({78+ now: () => now,79+ invokeCommand: async (_command, args) => {80+ calls.push(args.report);81+ return true;82+ },83+ });84+ publisher.start(1, true);85+ publisher.publish(snapshot({86+ transportDroppedTotal: 0,87+ frontendDroppedTotal: 0,88+ decoderDroppedTotal: 0,89+ presenterDroppedTotal: 0,90+ }));91+ now = 1000;92+ publisher.publish(snapshot({ transportDroppedTotal: ADAPTIVE_COUNTER_DELTA_MAX + 10 }));93+ await flushPromises();94+ now = 2000;95+ publisher.publish(snapshot({ transportDroppedTotal: 1 }));96+ assert.equal(calls[0].transport_dropped_delta, ADAPTIVE_COUNTER_DELTA_MAX);97+ assert.equal(calls[1].transport_dropped_delta, 0);98+});99+100+test('stale completion cannot unlock or alter a newer generation', async () => {101+ let resolveOld;102+ let now = 0;103+ const calls = [];104+ const publisher = new AdaptiveFeedbackPublisher({105+ now: () => now,106+ invokeCommand: (_command, args) => {107+ calls.push(args.generation);108+ if (args.generation === 1) return new Promise((resolve) => { resolveOld = resolve; });109+ return Promise.resolve(true);110+ },111+ });112+ publisher.start(1, true);113+ publisher.publish(snapshot());114+ now = 1000;115+ publisher.publish(snapshot());116+ publisher.start(2, true);117+ publisher.publish(snapshot());118+ now = 2000;119+ publisher.publish(snapshot());120+ resolveOld();121+ await flushPromises();122+ assert.deepEqual(calls, [1, 2]);123+});124+125+test('false and rejected sends retain cumulative loss until a report is accepted', async () => {126+ let now = 0;127+ const reports = [];128+ const outcomes = [129+ () => false,130+ () => Promise.reject(new Error('closed')),131+ () => true,132+ () => true,133+ ];134+ const originalWarn = console.warn;135+ console.warn = () => {};136+ try {137+ const publisher = new AdaptiveFeedbackPublisher({138+ now: () => now,139+ invokeCommand: (_command, args) => {140+ reports.push(args.report);141+ return outcomes.shift()();142+ },143+ });144+ publisher.start(1, true);145+ publisher.publish(snapshot({146+ transportDroppedTotal: 0,147+ frontendDroppedTotal: 0,148+ decoderDroppedTotal: 0,149+ presenterDroppedTotal: 0,150+ }));151+ now = 1000;152+ publisher.publish(snapshot({ transportDroppedTotal: 3 }));153+ await flushPromises();154+ now = 2000;155+ publisher.publish(snapshot({ transportDroppedTotal: 7 }));156+ await flushPromises();157+ now = 3000;158+ publisher.publish(snapshot({ transportDroppedTotal: 9 }));159+ await flushPromises();160+ now = 4000;161+ publisher.publish(snapshot({ transportDroppedTotal: 10 }));162+ assert.deepEqual(163+ reports.map((report) => report.transport_dropped_delta),164+ [3, 7, 9, 1],165+ );166+ assert.deepEqual(167+ reports.map((report) => report.interval_ms),168+ [1000, 2000, 3000, 1000],169+ );170+ } finally {171+ console.warn = originalWarn;172+ }173+});174+175+test('a synchronous invoke throw clears single-flight state and permits retry', async () => {176+ let attempts = 0;177+ let now = 0;178+ const originalWarn = console.warn;179+ console.warn = () => {};180+ try {181+ const publisher = new AdaptiveFeedbackPublisher({182+ now: () => now,183+ invokeCommand: () => {184+ attempts++;185+ if (attempts === 1) throw new Error('synchronous bridge failure');186+ return Promise.resolve(true);187+ },188+ });189+ publisher.start(1, true);190+ publisher.publish(snapshot());191+ now = 1000;192+ assert.equal(publisher.publish(snapshot()), false);193+ assert.equal(publisher.inFlight, false);194+ assert.equal(publisher.publish(snapshot()), true);195+ await flushPromises();196+ assert.equal(publisher.inFlight, false);197+ assert.equal(attempts, 2);198+ } finally {199+ console.warn = originalWarn;200+ }201+});202+203+test('publisher measures and bounds the actual baseline interval', async () => {204+ let now = 0;205+ const reports = [];206+ const publisher = new AdaptiveFeedbackPublisher({207+ now: () => now,208+ invokeCommand: async (_command, args) => {209+ reports.push(args.report);210+ return true;211+ },212+ });213+ publisher.start(1, true);214+ publisher.publish(snapshot());215+ now = 249;216+ assert.equal(publisher.publish(snapshot()), false);217+ now = 1_275;218+ assert.equal(publisher.publish(snapshot()), true);219+ await flushPromises();220+ now = 8_000;221+ assert.equal(publisher.publish(snapshot({ transportDroppedTotal: 7 })), true);222+ assert.deepEqual(reports.map((report) => report.interval_ms), [1_275, 5_000]);223+});224+225+test('decision diagnostics reject stale generations and say advisory not applied', () => {226+ assert.equal(normalizeAdaptiveDecisionEnvelope({ generation: 8, decision: {} }, 7), null);227+ const decision = normalizeAdaptiveDecisionEnvelope({228+ generation: 7,229+ decision: {230+ decision_id: 4,231+ report_id: 3,232+ target_kbps: 8000,233+ floor_kbps: 4000,234+ ceiling_kbps: 20000,235+ state: 'hold',236+ reasons: ['clean-recovery'],237+ applied: false,238+ },239+ }, 7);240+ assert.match(formatAdaptiveDecision(decision, true), /advisory only \(not applied\)/);241+ assert.equal(formatAdaptiveDecision(null, false), 'unavailable');242+});@@ -200,6 +200,8 @@
200200 <div class="info-row"><span class="info-label">transport</span><span class="info-value" id="stream-transport">—</span></div>201201 <div class="info-row"><span class="info-label">path</span><span class="info-value" id="stream-path">—</span></div>202202 <div class="info-row"><span class="info-label">RTT</span><span class="info-value" id="stream-rtt">— ms</span></div>203+ <div class="info-row metric-stack"><span class="info-label">adaptive feedback</span><span class="info-value" id="stream-adaptive-feedback">unavailable</span></div>204+ <div class="info-row metric-stack"><span class="info-label">adaptive decision</span><span class="info-value" id="stream-adaptive-decision">unavailable</span></div>203205 <div class="info-row"><span class="info-label">control</span><span class="info-value" id="stream-control">—</span></div>204206 <div class="info-row"><span class="info-label">audio</span><span class="info-value" id="stream-audio-state">unavailable</span></div>205207 <div class="info-row"><span class="info-label">audio webview received</span><span class="info-value" id="stream-audio-webview-received-packets">0 packets</span></div>@@ -92,6 +92,11 @@ import {
9292 DECODER_RECOVERY_REASONS,9393 DecoderRecoveryState,9494 } from './decoder-recovery.mjs';95+import {96+ AdaptiveFeedbackPublisher,97+ formatAdaptiveDecision,98+ normalizeAdaptiveDecisionEnvelope,99+} from './adaptive-feedback.mjs';95100 import {96101 formatInvitationExpiry,97102 grantLabel,@@ -229,6 +234,10 @@ function handlePointerPositionFeedback(message, session) {
229234 230235 const invoke = (...args) => window.__TAURI__.core.invoke(...args);231236 const listen = (...args) => window.__TAURI__.event.listen(...args);237+const adaptiveFeedbackPublisher = new AdaptiveFeedbackPublisher({ invokeCommand: invoke });238+let adaptiveFeedbackAvailable = false;239+let adaptiveFeedbackError = null;240+let adaptiveDecision = null;232241 233242 function updatePanelVisibility() {234243 const streamSection = document.getElementById('stream-section');@@ -762,6 +771,10 @@ async function connectHost() {
762771 if (activeFrameSession) activeFrameSession.closing = true;763772 activeFrameSession = null;764773 activeFrameGeneration = null;774+ adaptiveFeedbackPublisher.stop();775+ adaptiveFeedbackAvailable = false;776+ adaptiveFeedbackError = null;777+ adaptiveDecision = null;765778 resetStreamTelemetry();766779 pointerSurfaceDimensions = null;767780 remotePointerPosition = null;@@ -818,6 +831,10 @@ async function connectHost() {
818831 : null;819832 if (result.connected) {820833 activateConnectedFrameSession(frameSession, result.media_generation);834+ adaptiveFeedbackAvailable = result.adaptive_feedback_available === true;835+ adaptiveFeedbackError = typeof result.adaptive_feedback_error === 'string'836+ ? result.adaptive_feedback_error : null;837+ adaptiveFeedbackPublisher.start(result.media_generation, adaptiveFeedbackAvailable);821838 connected = true;822839 disconnecting = false;823840 inputCapabilities = {@@ -918,6 +935,9 @@ async function connectHost() {
918935 if (frameSession) frameSession.closing = true;919936 if (activeFrameSession === frameSession) activeFrameSession = null;920937 activeFrameGeneration = null;938+ adaptiveFeedbackPublisher.stop();939+ adaptiveFeedbackAvailable = false;940+ adaptiveDecision = null;921941 if (rustConnectionCommitted) {922942 // Rust commits its active-connection guard before returning. A client-side923943 // validation failure must close that exact committed attempt directly;@@ -946,6 +966,10 @@ async function disconnect() {
946966 if (activeFrameSession) activeFrameSession.closing = true;947967 activeFrameSession = null;948968 activeFrameGeneration = null;969+ adaptiveFeedbackPublisher.stop();970+ adaptiveFeedbackAvailable = false;971+ adaptiveFeedbackError = null;972+ adaptiveDecision = null;949973 controlTransitionGeneration += 1;950974 controlTransitionInProgress = false;951975 controllerActivationGate.reset();@@ -1082,6 +1106,7 @@ const MAX_DECODE_TIMINGS = 8;
10821106 let streamPathMode = 'unknown';10831107 let streamTransportMode = 'unknown';10841108 let streamRttMs = null;1109+let lastMediaSequence = null;10851110 const MAX_DECODE_QUEUE_SIZE = 2;10861111 10871112 function requestDecoderKeyframe(reason) {@@ -1239,6 +1264,8 @@ function resetStreamTelemetry() {
12391264 streamPathMode = 'unknown';12401265 streamTransportMode = 'unknown';12411266 streamRttMs = null;1267+ lastMediaSequence = null;1268+ adaptiveDecision = null;12421269 }12431270 12441271 function enqueueVideoChunk(chunk, receivedAt, codecLabel, mediaPtsMicros = null) {@@ -1393,6 +1420,33 @@ function updateStreamStats() {
13931420 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)13941421 ? `${streamRttMs.toFixed(1)} ms`13951422 : '— ms';1423+ document.getElementById('stream-adaptive-feedback').textContent = adaptiveFeedbackAvailable1424+ ? 'authenticated · 1 Hz bounded reports'1425+ : adaptiveFeedbackError || 'unavailable';1426+ document.getElementById('stream-adaptive-decision').textContent = formatAdaptiveDecision(1427+ adaptiveDecision,1428+ adaptiveFeedbackAvailable,1429+ );1430+1431+ adaptiveFeedbackPublisher.publish({1432+ lastSequence: lastMediaSequence,1433+ frontendQueueDepth: frontendQueueStats?.depth ?? 0,1434+ frontendQueueCapacity: frontendQueueStats?.capacity ?? 4,1435+ decoderQueueDepth: videoDecoder?.decodeQueueSize ?? 0,1436+ decoderQueueCapacity: MAX_DECODE_QUEUE_SIZE,1437+ presenterQueueDepth: framePresenter.depth,1438+ presenterQueueCapacity: 2,1439+ transportDroppedTotal: transportDroppedFrames,1440+ frontendDroppedTotal: frontendDroppedFrames,1441+ decoderDroppedTotal: droppedFrames,1442+ presenterDroppedTotal: presentationDroppedFrames,1443+ // Portal has no clock-synchronized capture-to-delivery measurement yet.1444+ // IPC send duration is a different boundary and must not be mislabeled.1445+ transportDeliveryP95Ms: null,1446+ decodeLatencyP95Ms: decodePercentiles.p95,1447+ presentationLatencyP95Ms: presentPercentiles.p95,1448+ resyncActive: decoderRecovery.recovering || frontendResyncStats?.active === true,1449+ });13961450 }13971451 13981452 setInterval(() => {@@ -1823,6 +1877,8 @@ listen('frame-stats', (event) => {
18231877 streamRttMs = Number.isFinite(event.payload.path_rtt_ms)18241878 ? event.payload.path_rtt_ms18251879 : streamRttMs;1880+ lastMediaSequence = Number.isSafeInteger(event.payload.sequence) && event.payload.sequence >= 01881+ ? event.payload.sequence : lastMediaSequence;18261882 lastTransportFps = Number.isFinite(event.payload.transport_receive_fps)18271883 ? event.payload.transport_receive_fps18281884 : Number.isFinite(event.payload.transport_fps)@@ -1836,6 +1892,27 @@ listen('frame-stats', (event) => {
18361892 updateStreamStats();18371893 });18381894 1895+listen('adaptive-bitrate-decision', (event) => {1896+ try {1897+ const decision = normalizeAdaptiveDecisionEnvelope(event.payload, activeFrameGeneration);1898+ if (decision === null) return;1899+ adaptiveDecision = decision;1900+ updateStreamStats();1901+ } catch (error) {1902+ console.warn('invalid adaptive bitrate diagnostic:', error);1903+ }1904+});1905+1906+listen('adaptive-feedback-state', (event) => {1907+ if (!isCurrentFrameGeneration(event.payload?.generation, activeFrameGeneration)) return;1908+ if (event.payload?.available !== false) return;1909+ adaptiveFeedbackAvailable = false;1910+ adaptiveFeedbackError = typeof event.payload.error === 'string'1911+ ? event.payload.error : 'feedback stream closed';1912+ adaptiveFeedbackPublisher.stop();1913+ updateStreamStats();1914+});1915+18391916 listen('frame-error', (event) => {18401917 const session = activeFrameSession;18411918 if (!isActiveFrameSession(session, activeFrameSession)) return;@@ -1,25 +1,29 @@
11 use super::auth::derive_iroh_secret_from_key;22 use super::enrollment::{connection_enrollment, mark_invitation_redeemed};33 use super::state::{4- AUDIO_DELIVERY_CAPACITY, AppState, AudioDeliveryState, CLIENT_INPUT_QUEUE_CAPACITY, FRAME_ALPN,5- INPUT_ALPN, development_direct_node_available,4+ AUDIO_DELIVERY_CAPACITY, AccumulatedMediaFeedback, AppState, AudioDeliveryState,5+ CLIENT_INPUT_QUEUE_CAPACITY, FRAME_ALPN, INPUT_ALPN, MediaFeedbackSender,6+ development_direct_node_available,67 };78 use base64::Engine;89 use iroh::{Endpoint, SecretKey, endpoint::presets};910 use iroh_moq::{Moq, MoqSession};1011 use moq_net::{BroadcastConsumer, GroupConsumer, Track, TrackConsumer};1112 use openh264::{formats::YUVSource, nal_units};12-use serde::Serialize;13+use serde::{Deserialize, Serialize};1314 use sigil_protocol::{14- AUDIO_ALPN_V1, AudioFlags, AudioPacket, AudioPacketHeader, CONTROL_ALPN_V1, Capability,15- ClientHello, FrameFlags, INPUT_ALPN_V1, InputEvent, InvitationGrants, KeyframeRequestReasonV3,15+ AUDIO_ALPN_V1, AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,16+ AudioFlags, AudioPacket, AudioPacketHeader, CONTROL_ALPN_V1, Capability, ClientHello,17+ FrameFlags, INPUT_ALPN_V1, InputEvent, InvitationGrants, KeyframeRequestReasonV3,1618 MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MAX_MEDIA_OBJECT_ID_V3,1719 MAX_MEDIA_PAYLOAD_LEN, MAX_VIDEO_DIMENSION, MAX_VIDEO_PIXELS, MEDIA_ALPN_V1, MEDIA_ALPN_V2,18- MEDIA_ALPN_V3, MOQ_VIDEO_H264_TRACK, MediaCodec, MediaControlRequestV3, MediaFrame,19- MediaObjectV3, PointerPosition, PointerSurfaceDimensions, ProtocolError,20- RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN, decode_media_frame_object,21- media_moq_broadcast_name, read_host_hello, read_input_ack, read_media_frame, read_media_object,22- read_media_object_v3, write_client_hello, write_input_event, write_media_control_request_v3,20+ MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, MOQ_VIDEO_H264_TRACK, MediaCodec, MediaControlRequestV3,21+ MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame, MediaObjectV3, PointerPosition,22+ PointerSurfaceDimensions, ProtocolError, RELATIVE_POINTER_DELTA_MAX,23+ RELATIVE_POINTER_DELTA_MIN, decode_media_frame_object, media_moq_broadcast_name,24+ read_adaptive_bitrate_decision_v1, read_host_hello, read_input_ack, read_media_frame,25+ read_media_object, read_media_object_v3, write_client_hello, write_input_event,26+ write_media_control_request_v3, write_media_feedback_report_v1,2327 };2428 use std::collections::{BTreeMap, VecDeque};2529 use std::io::Cursor;@@ -91,6 +95,9 @@ const CLIENT_FRAME_CHANNEL_CAPACITY: usize = 4;
9195 // AudioWorklet owns a separate fixed ring and never feeds back into transport.9296 const CLIENT_FRAME_STATS_INTERVAL: Duration = Duration::from_millis(250);9397 const CLIENT_ENDPOINT_CLOSE_TIMEOUT: Duration = Duration::from_secs(2);98+const CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);99+const CLIENT_MEDIA_FEEDBACK_IO_TIMEOUT: Duration = Duration::from_secs(5);100+const CLIENT_ADAPTIVE_DECISION_DELIVERY_INTERVAL: Duration = Duration::from_secs(1);94101 const CLIENT_FRAME_RATE_WINDOW: Duration = Duration::from_secs(1);95102 const CLIENT_FRAME_TIMING_WINDOW: Duration = Duration::from_secs(5);96103 // Host configuration permits at most 240 fps. Leave a little headroom for@@ -413,6 +420,34 @@ fn take_generation_owned<T>(slot: &mut Option<(u64, T)>, expected_generation: u6
413420 }414421 }415422 423+fn take_generation_owned_triple<T, U>(424+ slot: &mut Option<(u64, T, U)>,425+ expected_generation: u64,426+) -> Option<(T, U)> {427+ if slot428+ .as_ref()429+ .is_some_and(|(generation, _, _)| *generation == expected_generation)430+ {431+ slot.take().map(|(_, value, companion)| (value, companion))432+ } else {433+ None434+ }435+}436+437+async fn retire_media_feedback_generation(app: &AppHandle, generation: u64) -> bool {438+ let state = app.state::<AppState>();439+ let connection = {440+ let _connection_serial = state.client_connection_serial.lock().await;441+ let mut slot = state.media_feedback.lock().await;442+ take_generation_owned_triple(&mut slot, generation).map(|(connection, _)| connection)443+ };444+ let Some(connection) = connection else {445+ return false;446+ };447+ connection.close(0_u32.into(), b"adaptive feedback ended");448+ true449+}450+416451 async fn retire_upstream_moq_generation(417452 app: &AppHandle,418453 media_generation: u64,@@ -440,6 +475,11 @@ async fn retire_upstream_moq_generation(
440475 let mut delivery = state.frame_delivery.lock().await;441476 let _ = take_generation_owned(&mut delivery, media_generation);442477 }478+ let feedback_connection = {479+ let mut feedback = state.media_feedback.lock().await;480+ take_generation_owned_triple(&mut feedback, media_generation)481+ .map(|(connection, _)| connection)482+ };443483 *state.input_send.lock().await = None;444484 445485 let audio_connection = if let Some(audio_generation) = audio_generation {@@ -460,6 +500,9 @@ async fn retire_upstream_moq_generation(
460500 461501 let endpoint = state.client_endpoint.lock().await.take();462502 media_connection.close(0_u32.into(), b"upstream MoQ media ended");503+ if let Some(feedback_connection) = feedback_connection {504+ feedback_connection.close(0_u32.into(), b"upstream MoQ media ended");505+ }463506 if let Some(audio_connection) = audio_connection {464507 audio_connection.close(0_u32.into(), b"upstream MoQ media ended");465508 }@@ -733,9 +776,49 @@ pub struct ConnectResult {
733776 pub audio_generation: Option<u64>,734777 pub audio_error: Option<String>,735778 pub media_generation: u64,779+ pub adaptive_feedback_available: bool,780+ pub adaptive_feedback_error: Option<String>,736781 pub error: Option<String>,737782 }738783 784+785+pub struct ClientMediaFeedbackReport {786+ pub interval_ms: u16,787+ pub last_sequence: Option<u64>,788+ pub transport_dropped_delta: u32,789+ pub frontend_dropped_delta: u32,790+ pub decoder_dropped_delta: u32,791+ pub presenter_dropped_delta: u32,792+ pub frontend_queue_depth: u8,793+ pub frontend_queue_capacity: u8,794+ pub decode_queue_depth: u8,795+ pub decode_queue_capacity: u8,796+ pub presenter_queue_depth: u8,797+ pub presenter_queue_capacity: u8,798+ pub transport_delivery_p95_ms: Option<f64>,799+ pub decode_p95_ms: Option<f64>,800+ pub presentation_p95_ms: Option<f64>,801+ pub resync_active: bool,802+}803+804+805+struct AdaptiveBitrateDecisionPayload {806+ generation: u64,807+ decision: AdaptiveBitrateDecisionDiagnostic,808+}809+810+811+struct AdaptiveBitrateDecisionDiagnostic {812+ decision_id: u64,813+ report_id: u64,814+ target_kbps: u32,815+ floor_kbps: u32,816+ ceiling_kbps: u32,817+ state: &'static str,818+ reasons: Vec<&'static str>,819+ applied: bool,820+}821+739822 struct NegotiatedV1Stream {740823 session_id: u64,741824 capabilities: Vec<Capability>,@@ -761,6 +844,10 @@ impl MediaTransport {
761844 Self::GroupedObjectsV3 => "grouped-v3",762845 }763846 }847+848+ const fn supports_adaptive_feedback(self) -> bool {849+ matches!(self, Self::UpstreamMoq | Self::GroupedObjectsV3)850+ }764851 }765852 766853 @@ -1826,6 +1913,249 @@ fn connect_error_is_unsupported_alpn(error: &iroh::endpoint::ConnectError) -> bo
18261913 }18271914 }18281915 1916+async fn open_negotiated_feedback_stream(1917+ endpoint: &Endpoint,1918+ address: &iroh::EndpointAddr,1919+ nonce: [u8; 16],1920+ media_session_id: u64,1921+) -> Result<1922+ Option<(1923+ iroh::endpoint::Connection,1924+ iroh::endpoint::SendStream,1925+ iroh::endpoint::RecvStream,1926+ )>,1927+ String,1928+> {1929+ let connection = match endpoint1930+ .connect(address.clone(), MEDIA_FEEDBACK_ALPN_V1)1931+ .await1932+ {1933+ Ok(connection) => connection,1934+ Err(error) if connect_error_is_unsupported_alpn(&error) => return Ok(None),1935+ Err(error) => {1936+ return Err(format!(1937+ "Failed to connect adaptive feedback stream: {error}"1938+ ));1939+ }1940+ };1941+ let (mut send, mut recv) = connection1942+ .open_bi()1943+ .await1944+ .map_err(|error| format!("Failed to open adaptive feedback handshake: {error}"))?;1945+ let negotiation = negotiate_v1(1946+ &mut send,1947+ &mut recv,1948+ nonce,1949+ vec![Capability::VideoH264],1950+ Some(Capability::VideoH264),1951+ "adaptive feedback",1952+ None,1953+ )1954+ .await?;1955+ if negotiation.session_id != media_session_id {1956+ return Err("Host returned mismatched media and adaptive feedback sessions".to_string());1957+ }1958+ Ok(Some((connection, send, recv)))1959+}1960+1961+fn adaptive_bitrate_state_name(state: AdaptiveBitrateStateV1) -> &'static str {1962+ match state {1963+ AdaptiveBitrateStateV1::Hold => "hold",1964+ AdaptiveBitrateStateV1::Decrease => "decrease",1965+ AdaptiveBitrateStateV1::Increase => "increase",1966+ }1967+}1968+1969+fn adaptive_bitrate_reason_names(reasons: AdaptiveBitrateReasonFlagsV1) -> Vec<&'static str> {1970+ [1971+ (AdaptiveBitrateReasonFlagsV1::RTT_INFLATION, "rtt-inflation"),1972+ (1973+ AdaptiveBitrateReasonFlagsV1::LOSS_OR_CANCELLATION,1974+ "loss-or-cancellation",1975+ ),1976+ (1977+ AdaptiveBitrateReasonFlagsV1::SENDER_BACKPRESSURE,1978+ "sender-backpressure",1979+ ),1980+ (1981+ AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,1982+ "receiver-queue",1983+ ),1984+ (1985+ AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG,1986+ "decode-backlog",1987+ ),1988+ (1989+ AdaptiveBitrateReasonFlagsV1::DELIVERY_LATENCY,1990+ "delivery-latency",1991+ ),1992+ (1993+ AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY,1994+ "clean-recovery",1995+ ),1996+ (1997+ AdaptiveBitrateReasonFlagsV1::FEEDBACK_STALE,1998+ "feedback-stale",1999+ ),2000+ ]2001+ .into_iter()2002+ .filter_map(|(flag, name)| reasons.contains(flag).then_some(name))2003+ .collect()2004+}2005+2006+fn adaptive_bitrate_decision_diagnostic(2007+ decision: AdaptiveBitrateDecisionV1,2008+) -> AdaptiveBitrateDecisionDiagnostic {2009+ AdaptiveBitrateDecisionDiagnostic {2010+ decision_id: decision.decision_id,2011+ report_id: decision.report_id,2012+ target_kbps: decision.target_kbps,2013+ floor_kbps: decision.floor_kbps,2014+ ceiling_kbps: decision.ceiling_kbps,2015+ state: adaptive_bitrate_state_name(decision.state),2016+ reasons: adaptive_bitrate_reason_names(decision.reasons),2017+ applied: decision.applied,2018+ }2019+}2020+2021+2022+struct AdaptiveDecisionSequence {2023+ last_decision_id: Option<u64>,2024+ last_report_id: Option<u64>,2025+}2026+2027+impl AdaptiveDecisionSequence {2028+ fn accept(&mut self, decision: &AdaptiveBitrateDecisionV1) -> Result<(), String> {2029+ if self2030+ .last_decision_id2031+ .is_some_and(|previous| decision.decision_id <= previous)2032+ {2033+ return Err(format!(2034+ "adaptive decision ID did not increase: previous={:?}, current={}",2035+ self.last_decision_id, decision.decision_id2036+ ));2037+ }2038+ if self2039+ .last_report_id2040+ .is_some_and(|previous| decision.report_id <= previous)2041+ {2042+ return Err(format!(2043+ "adaptive decision report ID did not increase: previous={:?}, current={}",2044+ self.last_report_id, decision.report_id2045+ ));2046+ }2047+ self.last_decision_id = Some(decision.decision_id);2048+ self.last_report_id = Some(decision.report_id);2049+ Ok(())2050+ }2051+}2052+2053+async fn emit_paced_adaptive_decisions<F>(2054+ generation: u64,2055+ mut decisions: tokio::sync::watch::Receiver<Option<AdaptiveBitrateDecisionV1>>,2056+ delivery_interval: Duration,2057+ mut emit: F,2058+) -> Result<(), String>2059+where2060+ F: FnMut(AdaptiveBitrateDecisionPayload) -> Result<(), String>,2061+{2062+ let mut delivery = tokio::time::interval(delivery_interval);2063+ delivery.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);2064+ loop {2065+ decisions2066+ .changed()2067+ .await2068+ .map_err(|_| "adaptive decision receiver closed".to_string())?;2069+ // The first tick is immediate. Later ticks pace the webview boundary;2070+ // any host burst replaces the watch value while this task waits.2071+ delivery.tick().await;2072+ let Some(decision) = *decisions.borrow_and_update() else {2073+ continue;2074+ };2075+ emit(AdaptiveBitrateDecisionPayload {2076+ generation,2077+ decision: adaptive_bitrate_decision_diagnostic(decision),2078+ })?;2079+ }2080+}2081+2082+async fn run_media_feedback_session(2083+ app: AppHandle,2084+ generation: u64,2085+ mut send: iroh::endpoint::SendStream,2086+ mut recv: iroh::endpoint::RecvStream,2087+ mut reports: tokio::sync::watch::Receiver<Option<AccumulatedMediaFeedback>>,2088+) {2089+ let (decision_tx, decision_rx) = tokio::sync::watch::channel(None);2090+ let writer = async {2091+ let mut last_written = None;2092+ loop {2093+ reports2094+ .changed()2095+ .await2096+ .map_err(|_| "adaptive feedback sender closed".to_string())?;2097+ let accumulated = *reports.borrow_and_update();2098+ let Some(accumulated) = accumulated else {2099+ continue;2100+ };2101+ let report = accumulated.report_since(last_written);2102+ tokio::time::timeout(2103+ CLIENT_MEDIA_FEEDBACK_IO_TIMEOUT,2104+ write_media_feedback_report_v1(&mut send, &report),2105+ )2106+ .await2107+ .map_err(|_| "adaptive feedback write timed out".to_string())?2108+ .map_err(|error| format!("adaptive feedback write failed: {error}"))?;2109+ last_written = Some(accumulated);2110+ }2111+ 2112+ Ok::<(), String>(())2113+ };2114+ let reader = async {2115+ let mut sequence = AdaptiveDecisionSequence::default();2116+ loop {2117+ let decision = read_adaptive_bitrate_decision_v1(&mut recv)2118+ .await2119+ .map_err(|error| format!("adaptive decision read failed: {error}"))?2120+ .ok_or_else(|| "adaptive decision stream closed".to_string())?;2121+ sequence.accept(&decision)?;2122+ decision_tx.send_replace(Some(decision));2123+ }2124+ 2125+ Ok::<(), String>(())2126+ };2127+ let decision_app = app.clone();2128+ let decision_emitter = emit_paced_adaptive_decisions(2129+ generation,2130+ decision_rx,2131+ CLIENT_ADAPTIVE_DECISION_DELIVERY_INTERVAL,2132+ move |payload| {2133+ decision_app2134+ .emit("adaptive-bitrate-decision", payload)2135+ .map_err(|error| format!("adaptive decision event delivery failed: {error}"))2136+ },2137+ );2138+ let terminal = tokio::select! {2139+ terminal = writer => terminal,2140+ terminal = reader => terminal,2141+ terminal = decision_emitter => terminal,2142+ };2143+ if let Err(error) = terminal {2144+ eprintln!("[client] {error}");2145+ if let Err(emit_error) = app.emit(2146+ "adaptive-feedback-state",2147+ serde_json::json!({2148+ "generation": generation,2149+ "available": false,2150+ "error": error,2151+ }),2152+ ) {2153+ eprintln!("[client] adaptive feedback terminal event delivery failed: {emit_error}");2154+ }2155+ }2156+ retire_media_feedback_generation(&app, generation).await;2157+}2158+18292159 async fn open_legacy_negotiated_media_stream(18302160 endpoint: &Endpoint,18312161 address: &iroh::EndpointAddr,@@ -2651,6 +2981,39 @@ pub async fn iroh_client_connect(
26512981 mark_invitation_redeemed(&app, expected_invitation)?;26522982 }26532983 2984+ // Feedback is a v3 sidecar for both the preferred upstream-MoQ transport2985+ // and the grouped-v3 compatibility path. Unsupported ALPN is normal2986+ // compatibility with older Sigil hosts; all other failures remain visible2987+ // diagnostics but never downgrade the authenticated media session.2988+ let (adaptive_feedback_stream, adaptive_feedback_error) = if media_transport2989+ .supports_adaptive_feedback()2990+ {2991+ match tokio::time::timeout(2992+ CLIENT_MEDIA_FEEDBACK_CONNECT_TIMEOUT,2993+ open_negotiated_feedback_stream(2994+ &endpoint,2995+ &addr,2996+ handshake_nonce,2997+ media_session_id.ok_or_else(|| "Media v3 omitted its session ID".to_string())?,2998+ ),2999+ )3000+ .await3001+ {3002+ Ok(Ok(stream)) => (stream, None),3003+ Ok(Err(error)) => {3004+ eprintln!("[client] adaptive feedback unavailable: {error}");3005+ (None, Some(error))3006+ }3007+ Err(_) => {3008+ let error = "Adaptive feedback negotiation timed out".to_string();3009+ eprintln!("[client] adaptive feedback unavailable: {error}");3010+ (None, Some(error))3011+ }3012+ }3013+ } else {3014+ (None, None)3015+ };3016+26543017 let (input_send, input_recv, input_capabilities) = if use_v1 {26553018 let [26563019 visibility_capabilities,@@ -2781,7 +3144,7 @@ pub async fn iroh_client_connect(
27813144 app.clone(),27823145 &endpoint,27833146 AudioStartRequest {2784- address: addr,3147+ address: addr.clone(),27853148 handshake_nonce,27863149 media_session_id,27873150 audio_supported,@@ -2815,6 +3178,22 @@ pub async fn iroh_client_connect(
28153178 *ce = Some(endpoint.clone());28163179 }28173180 *state.media_connection.lock().await = Some((media_generation, frame_conn.clone()));3181+ let adaptive_feedback_available = adaptive_feedback_stream.is_some();3182+ if let Some((connection, send, recv)) = adaptive_feedback_stream {3183+ let (feedback_tx, feedback_rx) = tokio::sync::watch::channel(None);3184+ let feedback_sender: MediaFeedbackSender = feedback_tx;3185+ *state.media_feedback.lock().await =3186+ Some((media_generation, connection, feedback_sender.clone()));3187+ tokio::spawn(run_media_feedback_session(3188+ app.clone(),3189+ media_generation,3190+ send,3191+ recv,3192+ feedback_rx,3193+ ));3194+ } else {3195+ *state.media_feedback.lock().await = None;3196+ }28183197 let media_control_requests = if let Some(control_stream) = media_control_stream {28193198 let (control_tx, control_rx) = tokio::sync::mpsc::channel(1);28203199 *state.media_control.lock().await = Some((media_generation, control_tx.clone()));@@ -3488,6 +3867,8 @@ pub async fn iroh_client_connect(
34883867 audio_generation: connected_audio_generation,34893868 audio_error,34903869 media_generation,3870+ adaptive_feedback_available,3871+ adaptive_feedback_error,34913872 error: None,34923873 };34933874 connect_guard.commit();@@ -3793,6 +4174,9 @@ mod tests {
37934174 37944175 fn upstream_moq_transport_is_distinct_from_legacy_compatibility() {37954176 assert_eq!(MediaTransport::UpstreamMoq.diagnostic_name(), "iroh-moq");4177+ assert!(MediaTransport::UpstreamMoq.supports_adaptive_feedback());4178+ assert!(MediaTransport::GroupedObjectsV3.supports_adaptive_feedback());4179+ assert!(!MediaTransport::IndependentObjectsV2.supports_adaptive_feedback());37964180 assert_ne!(37974181 MediaTransport::UpstreamMoq,37984182 MediaTransport::GroupedObjectsV3@@ -4309,6 +4693,175 @@ mod tests {
43094693 assert!(next_media_generation(&exhausted).is_err());43104694 }43114695 4696+ fn client_feedback() -> ClientMediaFeedbackReport {4697+ ClientMediaFeedbackReport {4698+ interval_ms: 1_000,4699+ last_sequence: Some(99),4700+ transport_dropped_delta: 1,4701+ frontend_dropped_delta: 2,4702+ decoder_dropped_delta: 3,4703+ presenter_dropped_delta: 4,4704+ frontend_queue_depth: 1,4705+ frontend_queue_capacity: 4,4706+ decode_queue_depth: 1,4707+ decode_queue_capacity: 2,4708+ presenter_queue_depth: 0,4709+ presenter_queue_capacity: 2,4710+ transport_delivery_p95_ms: Some(17.4),4711+ decode_p95_ms: Some(4.6),4712+ presentation_p95_ms: None,4713+ resync_active: true,4714+ }4715+ }4716+4717+ 4718+ fn adaptive_feedback_conversion_is_bounded_and_protocol_valid() {4719+ let report = media_feedback_report(7, client_feedback()).unwrap();4720+ assert_eq!(report.report_id, 7);4721+ assert_eq!(report.interval_ms, 1_000);4722+ assert_eq!(report.last_sequence, Some(99));4723+ assert_eq!(report.transport_delivery_p95_ms, Some(17));4724+ assert_eq!(report.decode_p95_ms, Some(5));4725+ assert!(report.flags.contains(MediaFeedbackFlags::RESYNC_ACTIVE));4726+ report.validate().unwrap();4727+4728+ let mut invalid = client_feedback();4729+ invalid.decode_queue_depth = 3;4730+ assert!(media_feedback_report(8, invalid).is_err());4731+ let mut invalid_interval = client_feedback();4732+ invalid_interval.interval_ms = 249;4733+ assert!(media_feedback_report(9, invalid_interval).is_err());4734+ assert!(feedback_latency_ms(Some(f64::INFINITY), "decode").is_err());4735+ assert!(feedback_latency_ms(Some(60_001.0), "decode").is_err());4736+ }4737+4738+ 4739+ fn feedback_report_ids_are_nonzero_monotonic_and_checked() {4740+ let counter = AtomicU64::new(0);4741+ assert_eq!(next_feedback_report_id(&counter).unwrap(), 1);4742+ assert_eq!(next_feedback_report_id(&counter).unwrap(), 2);4743+ assert!(next_feedback_report_id(&AtomicU64::new(u64::MAX)).is_err());4744+ }4745+4746+ 4747+ async fn adaptive_feedback_watch_coalesces_latest_state_without_losing_pressure() {4748+ let (sender, mut receiver) = tokio::sync::watch::channel(None);4749+ let first = media_feedback_report(1, client_feedback()).unwrap();4750+ sender.send_replace(Some(AccumulatedMediaFeedback::new(first)));4751+ receiver.changed().await.unwrap();4752+ let stalled_write = receiver.borrow_and_update().unwrap();4753+4754+ let mut latest = client_feedback();4755+ latest.interval_ms = 1_250;4756+ latest.transport_dropped_delta = 9;4757+ let second = media_feedback_report(2, latest).unwrap();4758+ sender.send_modify(|pending| pending.as_mut().unwrap().merge(second));4759+ let mut newest = client_feedback();4760+ newest.interval_ms = 1_500;4761+ newest.transport_dropped_delta = 11;4762+ newest.frontend_queue_depth = 3;4763+ let third = media_feedback_report(3, newest).unwrap();4764+ sender.send_modify(|pending| pending.as_mut().unwrap().merge(third));4765+4766+ assert_eq!(stalled_write.report_since(None), first);4767+ receiver.changed().await.unwrap();4768+ let coalesced = receiver4769+ .borrow_and_update()4770+ .unwrap()4771+ .report_since(Some(stalled_write));4772+ assert_eq!(coalesced.report_id, 3);4773+ assert_eq!(coalesced.interval_ms, 2_750);4774+ assert_eq!(coalesced.transport_dropped_delta, 20);4775+ assert_eq!(coalesced.frontend_queue_depth, 3);4776+ }4777+4778+ 4779+ fn adaptive_decision_diagnostics_are_explicitly_shadow_state() {4780+ let diagnostic = adaptive_bitrate_decision_diagnostic(adaptive_decision(2, 1));4781+ assert_eq!(diagnostic.state, "decrease");4782+ assert_eq!(diagnostic.reasons, vec!["receiver-queue", "decode-backlog"]);4783+ assert!(!diagnostic.applied);4784+ }4785+4786+ fn adaptive_decision(decision_id: u64, report_id: u64) -> AdaptiveBitrateDecisionV1 {4787+ AdaptiveBitrateDecisionV1 {4788+ decision_id,4789+ report_id,4790+ target_kbps: 8_000,4791+ floor_kbps: 4_000,4792+ ceiling_kbps: 20_000,4793+ state: AdaptiveBitrateStateV1::Decrease,4794+ reasons: AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE4795+ .union(AdaptiveBitrateReasonFlagsV1::DECODE_BACKLOG),4796+ applied: false,4797+ }4798+ }4799+4800+ 4801+ fn adaptive_decision_sequence_rejects_duplicate_or_regressing_ids() {4802+ let mut sequence = AdaptiveDecisionSequence::default();4803+ sequence.accept(&adaptive_decision(1, 10)).unwrap();4804+ sequence.accept(&adaptive_decision(3, 12)).unwrap();4805+ assert!(sequence.accept(&adaptive_decision(3, 13)).is_err());4806+4807+ let mut report_regression = AdaptiveDecisionSequence::default();4808+ report_regression.accept(&adaptive_decision(1, 10)).unwrap();4809+ assert!(report_regression.accept(&adaptive_decision(2, 10)).is_err());4810+ assert!(report_regression.accept(&adaptive_decision(3, 9)).is_err());4811+ }4812+4813+ 4814+ async fn adaptive_decision_delivery_is_paced_latest_value_and_emit_failure_is_terminal() {4815+ let (sender, receiver) = tokio::sync::watch::channel(None);4816+ let (delivered_tx, mut delivered_rx) = tokio::sync::mpsc::channel(2);4817+ let delivery = tokio::spawn(emit_paced_adaptive_decisions(4818+ 7,4819+ receiver,4820+ Duration::from_millis(20),4821+ move |payload| {4822+ delivered_tx4823+ .try_send(payload)4824+ .map_err(|error| format!("test delivery failed: {error}"))4825+ },4826+ ));4827+ sender.send_replace(Some(adaptive_decision(1, 1)));4828+ sender.send_replace(Some(adaptive_decision(2, 2)));4829+ let first = tokio::time::timeout(Duration::from_millis(100), delivered_rx.recv())4830+ .await4831+ .unwrap()4832+ .unwrap();4833+ assert_eq!(first.generation, 7);4834+ assert_eq!(first.decision.decision_id, 2);4835+4836+ sender.send_replace(Some(adaptive_decision(3, 3)));4837+ assert!(4838+ tokio::time::timeout(Duration::from_millis(5), delivered_rx.recv())4839+ .await4840+ .is_err()4841+ );4842+ let second = tokio::time::timeout(Duration::from_millis(100), delivered_rx.recv())4843+ .await4844+ .unwrap()4845+ .unwrap();4846+ assert_eq!(second.decision.decision_id, 3);4847+ delivery.abort();4848+4849+ let (sender, receiver) = tokio::sync::watch::channel(None);4850+ let terminal = tokio::spawn(emit_paced_adaptive_decisions(4851+ 7,4852+ receiver,4853+ Duration::from_millis(1),4854+ |_payload| Err("webview channel closed".to_string()),4855+ ));4856+ sender.send_replace(Some(adaptive_decision(4, 4)));4857+ let error = tokio::time::timeout(Duration::from_millis(100), terminal)4858+ .await4859+ .unwrap()4860+ .unwrap()4861+ .unwrap_err();4862+ assert_eq!(error, "webview channel closed");4863+ }4864+43124865 43134866 fn audio_reorder_window_is_bounded_and_marks_skipped_packets() {43144867 let mut reorder = AudioReorderBuffer::default();@@ -4401,6 +4954,18 @@ mod tests {
44014954 assert_eq!(slot, Some((12, "replacement")));44024955 assert_eq!(take_generation_owned(&mut slot, 12), Some("replacement"));44034956 assert_eq!(slot, None);4957+4958+ let mut feedback = Some((22, "replacement connection", "replacement sender"));4959+ assert_eq!(take_generation_owned_triple(&mut feedback, 21), None);4960+ assert_eq!(4961+ feedback,4962+ Some((22, "replacement connection", "replacement sender"))4963+ );4964+ assert_eq!(4965+ take_generation_owned_triple(&mut feedback, 22),4966+ Some(("replacement connection", "replacement sender"))4967+ );4968+ assert_eq!(feedback, None);44044969 }44054970 44064971 @@ -5015,6 +5580,88 @@ pub async fn iroh_client_request_keyframe(
50155580 }50165581 }50175582 5583+fn feedback_latency_ms(value: Option<f64>, name: &str) -> Result<Option<u16>, String> {5584+ value5585+ .map(|value| {5586+ if !value.is_finite() || !(0.0..=60_000.0).contains(&value) {5587+ return Err(format!("{name} must be finite and between 0 and 60000 ms"));5588+ }5589+ Ok(value.round() as u16)5590+ })5591+ .transpose()5592+}5593+5594+fn next_feedback_report_id(counter: &AtomicU64) -> Result<u64, String> {5595+ counter5596+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {5597+ current.checked_add(1)5598+ })5599+ .map(|previous| previous + 1)5600+ .map_err(|_| "Adaptive feedback report ID overflowed".to_string())5601+}5602+5603+fn media_feedback_report(5604+ report_id: u64,5605+ input: ClientMediaFeedbackReport,5606+) -> Result<MediaFeedbackReportV1, String> {5607+ let flags = if input.resync_active {5608+ MediaFeedbackFlags::RESYNC_ACTIVE5609+ } else {5610+ MediaFeedbackFlags::NONE5611+ };5612+ let report = MediaFeedbackReportV1 {5613+ report_id,5614+ interval_ms: input.interval_ms,5615+ flags,5616+ last_sequence: input.last_sequence,5617+ transport_dropped_delta: input.transport_dropped_delta,5618+ frontend_dropped_delta: input.frontend_dropped_delta,5619+ decoder_dropped_delta: input.decoder_dropped_delta,5620+ presenter_dropped_delta: input.presenter_dropped_delta,5621+ frontend_queue_depth: input.frontend_queue_depth,5622+ frontend_queue_capacity: input.frontend_queue_capacity,5623+ decode_queue_depth: input.decode_queue_depth,5624+ decode_queue_capacity: input.decode_queue_capacity,5625+ presenter_queue_depth: input.presenter_queue_depth,5626+ presenter_queue_capacity: input.presenter_queue_capacity,5627+ transport_delivery_p95_ms: feedback_latency_ms(5628+ input.transport_delivery_p95_ms,5629+ "delivery p95",5630+ )?,5631+ decode_p95_ms: feedback_latency_ms(input.decode_p95_ms, "decode p95")?,5632+ presentation_p95_ms: feedback_latency_ms(input.presentation_p95_ms, "presentation p95")?,5633+ };5634+ report5635+ .validate()5636+ .map_err(|error| format!("Invalid adaptive feedback report: {error}"))?;5637+ Ok(report)5638+}5639+5640+5641+pub async fn iroh_client_send_media_feedback(5642+ state: State<'_, AppState>,5643+ generation: u64,5644+ report: ClientMediaFeedbackReport,5645+) -> Result<bool, String> {5646+ let feedback = state.media_feedback.lock().await;5647+ let Some((current_generation, _connection, sender)) = feedback.as_ref() else {5648+ return Ok(false);5649+ };5650+ if *current_generation != generation {5651+ return Ok(false);5652+ }5653+ if sender.receiver_count() == 0 {5654+ return Err("Adaptive feedback channel closed".to_string());5655+ }5656+ let report_id = next_feedback_report_id(&state.media_feedback_report_id)?;5657+ let report = media_feedback_report(report_id, report)?;5658+ sender.send_modify(|pending| match pending {5659+ Some(pending) => pending.merge(report),5660+ None => *pending = Some(AccumulatedMediaFeedback::new(report)),5661+ });5662+ Ok(true)5663+}5664+50185665 50195666 pub async fn iroh_client_ack_frame(50205667 state: State<'_, AppState>,@@ -5112,6 +5759,9 @@ pub async fn iroh_client_disconnect(state: State<'_, AppState>) -> Result<bool,
51125759 next_audio_generation(&state.audio_connection_generation)?;51135760 lock_audio_deliveries(&state.audio_deliveries).clear();51145761 *state.media_control.lock().await = None;5762+ if let Some((_generation, connection, _sender)) = state.media_feedback.lock().await.take() {5763+ connection.close(0_u32.into(), b"client disconnected");5764+ }51155765 // Do not rely on endpoint shutdown alone to retire the session. The frame51165766 // reader and diagnostics task both own connection clones, and a surviving51175767 // media connection keeps the host's encoder and one-client lease alive.@@ -1,5 +1,5 @@
11 use serde::Serialize;2-use sigil_protocol::KeyframeRequestReasonV3;2+use sigil_protocol::{KeyframeRequestReasonV3, MediaFeedbackReportV1};33 use std::collections::BTreeSet;44 use std::ffi::OsString;55 use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};@@ -335,6 +335,8 @@ pub struct AppState {
335335 pub client_endpoint: TokioMutex<Option<iroh::Endpoint>>,336336 pub media_connection: TokioMutex<Option<(u64, iroh::endpoint::Connection)>>,337337 pub media_control: TokioMutex<Option<(u64, MediaControlRequestSender)>>,338+ pub media_feedback: TokioMutex<Option<(u64, iroh::endpoint::Connection, MediaFeedbackSender)>>,339+ pub media_feedback_report_id: Arc<AtomicU64>,338340 pub frame_delivery: TokioMutex<Option<(u64, Arc<AtomicUsize>)>>,339341 pub client_media_generation: Arc<AtomicU64>,340342 pub audio_deliveries: Arc<StdMutex<AudioDeliveryState>>,@@ -348,6 +350,90 @@ pub struct AppState {
348350 349351 pub type MediaControlRequestSender =350352 tokio::sync::mpsc::Sender<(KeyframeRequestReasonV3, Option<u64>)>;353+pub type MediaFeedbackSender = tokio::sync::watch::Sender<Option<AccumulatedMediaFeedback>>;354+355+const MAX_MEDIA_FEEDBACK_INTERVAL_MS: u64 = 5_000;356+357+/// Constant-size latest receiver state plus cumulative interval pressure.358+///359+/// A watch channel deliberately coalesces updates while its consumer is360+/// blocked. Keeping cumulative counters here lets the writer recover the full361+/// delta since its last successful write instead of losing the reports that362+/// were replaced in the meantime.363+364+pub struct AccumulatedMediaFeedback {365+ latest: MediaFeedbackReportV1,366+ interval_ms_total: u64,367+ transport_dropped_total: u64,368+ frontend_dropped_total: u64,369+ decoder_dropped_total: u64,370+ presenter_dropped_total: u64,371+}372+373+impl AccumulatedMediaFeedback {374+ pub fn new(report: MediaFeedbackReportV1) -> Self {375+ Self {376+ latest: report,377+ interval_ms_total: u64::from(report.interval_ms),378+ transport_dropped_total: u64::from(report.transport_dropped_delta),379+ frontend_dropped_total: u64::from(report.frontend_dropped_delta),380+ decoder_dropped_total: u64::from(report.decoder_dropped_delta),381+ presenter_dropped_total: u64::from(report.presenter_dropped_delta),382+ }383+ }384+385+ pub fn merge(&mut self, report: MediaFeedbackReportV1) {386+ self.latest = report;387+ self.interval_ms_total = self388+ .interval_ms_total389+ .saturating_add(u64::from(report.interval_ms));390+ self.transport_dropped_total = self391+ .transport_dropped_total392+ .saturating_add(u64::from(report.transport_dropped_delta));393+ self.frontend_dropped_total = self394+ .frontend_dropped_total395+ .saturating_add(u64::from(report.frontend_dropped_delta));396+ self.decoder_dropped_total = self397+ .decoder_dropped_total398+ .saturating_add(u64::from(report.decoder_dropped_delta));399+ self.presenter_dropped_total = self400+ .presenter_dropped_total401+ .saturating_add(u64::from(report.presenter_dropped_delta));402+ }403+404+ pub fn report_since(self, previous: Option<Self>) -> MediaFeedbackReportV1 {405+ let previous = previous.unwrap_or(Self {406+ latest: self.latest,407+ interval_ms_total: 0,408+ transport_dropped_total: 0,409+ frontend_dropped_total: 0,410+ decoder_dropped_total: 0,411+ presenter_dropped_total: 0,412+ });413+ let mut report = self.latest;414+ report.interval_ms = self415+ .interval_ms_total416+ .saturating_sub(previous.interval_ms_total)417+ .min(MAX_MEDIA_FEEDBACK_INTERVAL_MS) as u16;418+ report.transport_dropped_delta = cumulative_delta(419+ self.transport_dropped_total,420+ previous.transport_dropped_total,421+ );422+ report.frontend_dropped_delta =423+ cumulative_delta(self.frontend_dropped_total, previous.frontend_dropped_total);424+ report.decoder_dropped_delta =425+ cumulative_delta(self.decoder_dropped_total, previous.decoder_dropped_total);426+ report.presenter_dropped_delta = cumulative_delta(427+ self.presenter_dropped_total,428+ previous.presenter_dropped_total,429+ );430+ report431+ }432+}433+434+fn cumulative_delta(current: u64, previous: u64) -> u32 {435+ current.saturating_sub(previous).min(u64::from(u32::MAX)) as u32436+}351437 352438 impl Default for AppState {353439 fn default() -> Self {@@ -371,6 +457,8 @@ impl AppState {
371457 client_endpoint: TokioMutex::new(None),372458 media_connection: TokioMutex::new(None),373459 media_control: TokioMutex::new(None),460+ media_feedback: TokioMutex::new(None),461+ media_feedback_report_id: Arc::new(AtomicU64::new(0)),374462 frame_delivery: TokioMutex::new(None),375463 client_media_generation: Arc::new(AtomicU64::new(0)),376464 audio_deliveries: Arc::new(StdMutex::new(AudioDeliveryState::default())),@@ -650,6 +738,7 @@ mod tests {
650738 assert!(state.client_endpoint.try_lock().unwrap().is_none());651739 assert!(state.media_connection.try_lock().unwrap().is_none());652740 assert!(state.media_control.try_lock().unwrap().is_none());741+ assert!(state.media_feedback.try_lock().unwrap().is_none());653742 assert!(state.audio_connection.try_lock().unwrap().is_none());654743 }655744 @@ -96,6 +96,7 @@ fn main() {
9696 commands::network::iroh_client_disconnect,9797 commands::network::iroh_client_ack_frame,9898 commands::network::iroh_client_request_keyframe,99+ commands::network::iroh_client_send_media_feedback,99100 commands::network::iroh_client_ack_audio,100101 commands::network::iroh_client_stop_audio,101102 commands::network::iroh_client_send_input,