fix(streaming): keep static Gamescope sessions alive
- commit
- 596140420f0557ae177c13dafc1d2457fbd695b0
- parent
- 99bfba92d6731e8ff7075d3229bfd6b3652bf43f
- parent
- fb210b4c1202be94f91f7c8fb1ff2462db4fef1d
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | crates/ | 572 | ++++++++++++++++++++++++++++++- |
| M | crates/ | 79 | ++++- |
@@ -1,7 +1,8 @@
11 use std::{22 collections::BTreeMap,3+ fs,34 fs::OpenOptions,4- io::Read,5+ io::{Read, Write},56 path::{Path, PathBuf},67 time::{Duration, Instant, SystemTime, UNIX_EPOCH},78 };@@ -12,14 +13,17 @@ use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, endpoint::presets};
1213 use iroh_moq::{Moq, MoqSession};1314 use moq_net::{BroadcastConsumer, GroupConsumer, TrackConsumer};1415 use sigil_protocol::{16+ AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,1517 CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,1618 GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputEvent,1719 InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN, MAX_MEDIA_GROUP_BYTES_V3,18- MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MediaCodec,19- MediaControlRequestV3, MediaFrame, MediaObjectV3, PointerSurfaceDimensions, ProtocolError,20- SignedInvitation, decode_media_frame_object, media_moq_broadcast_name, read_host_hello,21- read_input_ack, read_media_frame, read_media_object, read_media_object_v3, write_client_hello,22- write_input_event, write_media_control_request_v3,20+ MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,21+ MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,22+ MediaObjectV3, PointerSurfaceDimensions, ProtocolError, SignedInvitation,23+ decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,24+ read_host_hello, read_input_ack, read_media_frame, read_media_object, read_media_object_v3,25+ write_client_hello, write_input_event, write_media_control_request_v3,26+ write_media_feedback_report_v1,2327 };2428 2529 @@ -99,6 +103,21 @@ struct Args {
99103 /// complete left-click. Intended for libinput/Gamescope-backed proof.100104 101105 pointer_smoke: bool,106+ /// Prove adaptive-resolution liveness across a controlled Gamescope stall.107+ /// GATE_DIR coordinates the external SIGSTOP/SIGCONT harness.108+ 109+110+111+112+113+114+115+116+117+118+119+ )]120+ resolution_stall_smoke: Option<PathBuf>,102121 }103122 104123 @@ -143,6 +162,11 @@ const KEYFRAME_SMOKE_REQUEST_AFTER_FRAMES: u32 = 3;
143162 const KEYFRAME_SMOKE_MINIMUM_FRAMES: u32 = KEYFRAME_SMOKE_REQUEST_AFTER_FRAMES + 1;144163 const SLOW_CONSUMER_MINIMUM_FRAMES: u32 = 2;145164 const SLOW_CONSUMER_RECOVERY_TIMEOUT: Duration = Duration::from_secs(2);165+const RESOLUTION_STALL_FEEDBACK_INTERVAL: Duration = Duration::from_secs(1);166+const RESOLUTION_STALL_FRESH_REPORTS: u64 = 10;167+const RESOLUTION_STALL_FEEDBACK_TIMEOUT: Duration = Duration::from_secs(5);168+const RESOLUTION_STALL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(2);169+const RESOLUTION_STALL_GATE_POLL_INTERVAL: Duration = Duration::from_millis(50);146170 const MAX_INVITATION_FILE_BYTES: u64 = (MAX_INVITATION_TOKEN_LEN + 2) as u64;147171 148172 @@ -976,6 +1000,414 @@ impl From<MediaObjectV3> for AcceptedMedia {
9761000 }9771001 }9781002 1003+1004+struct ResolutionStallEvidence {1005+ native_dimensions: (u16, u16),1006+ reduced_dimensions: (u16, u16),1007+ stall_sequence: u64,1008+ fresh_duration: Duration,1009+ stall_input_ack_micros: u64,1010+ resume_input_ack_micros: u64,1011+ resume_media_micros: u64,1012+ resume_sequence_advance: u64,1013+}1014+1015+struct ResolutionStallGate {1016+ ready: PathBuf,1017+ stalled: PathBuf,1018+ resume_ready: PathBuf,1019+ resumed: PathBuf,1020+}1021+1022+impl ResolutionStallGate {1023+ fn new(directory: &Path) -> Result<Self> {1024+ ensure!(1025+ directory.is_dir(),1026+ "resolution stall gate is not a directory"1027+ );1028+ let gate = Self {1029+ ready: directory.join("ready"),1030+ stalled: directory.join("stalled"),1031+ resume_ready: directory.join("resume-ready"),1032+ resumed: directory.join("resumed"),1033+ };1034+ for marker in [1035+ &gate.ready,1036+ &gate.stalled,1037+ &gate.resume_ready,1038+ &gate.resumed,1039+ ] {1040+ ensure!(1041+ !marker.exists(),1042+ "resolution stall gate marker already exists: {}",1043+ marker.display()1044+ );1045+ }1046+ Ok(gate)1047+ }1048+}1049+1050+fn resolution_stall_feedback(1051+ report_id: u64,1052+ flags: MediaFeedbackFlags,1053+ last_sequence: u64,1054+) -> MediaFeedbackReportV1 {1055+ MediaFeedbackReportV1 {1056+ report_id,1057+ interval_ms: 1_000,1058+ flags,1059+ last_sequence: Some(last_sequence),1060+ transport_dropped_delta: 0,1061+ frontend_dropped_delta: 0,1062+ decoder_dropped_delta: 0,1063+ presenter_dropped_delta: 0,1064+ frontend_queue_depth: 0,1065+ frontend_queue_capacity: 4,1066+ decode_queue_depth: 0,1067+ decode_queue_capacity: 4,1068+ presenter_queue_depth: 0,1069+ presenter_queue_capacity: 2,1070+ transport_delivery_p95_ms: Some(10),1071+ decode_p95_ms: Some(3),1072+ presentation_p95_ms: Some(5),1073+ }1074+}1075+1076+fn resolution_stall_reduced_dimensions(native: (u16, u16)) -> Result<(u16, u16)> {1077+ ensure!(1078+ native.0.is_multiple_of(4) && native.1.is_multiple_of(4),1079+ "native dimensions must be divisible by four for an exact three-quarter tier"1080+ );1081+ let reduced = (native.0 / 4 * 3, native.1 / 4 * 3);1082+ ensure!(1083+ reduced.0 >= 64 && reduced.1 >= 64,1084+ "three-quarter dimensions are below the H.264 minimum"1085+ );1086+ ensure!(1087+ reduced.0.is_multiple_of(2) && reduced.1.is_multiple_of(2),1088+ "three-quarter H.264 dimensions must be even"1089+ );1090+ ensure!(1091+ u32::from(native.0) * u32::from(reduced.1) == u32::from(native.1) * u32::from(reduced.0),1092+ "three-quarter dimensions must preserve the native aspect ratio"1093+ );1094+ Ok(reduced)1095+}1096+1097+async fn exchange_resolution_stall_feedback(1098+ send: &mut iroh::endpoint::SendStream,1099+ recv: &mut iroh::endpoint::RecvStream,1100+ report: &MediaFeedbackReportV1,1101+) -> Result<AdaptiveBitrateDecisionV1> {1102+ tokio::time::timeout(1103+ RESOLUTION_STALL_FEEDBACK_TIMEOUT,1104+ write_media_feedback_report_v1(send, report),1105+ )1106+ .await1107+ .context("timed out writing resolution-stall feedback")??;1108+ let decision = tokio::time::timeout(1109+ RESOLUTION_STALL_FEEDBACK_TIMEOUT,1110+ read_adaptive_bitrate_decision_v1(recv),1111+ )1112+ .await1113+ .context("timed out waiting for resolution-stall feedback decision")??1114+ .context("host closed the resolution-stall feedback stream")?;1115+ ensure!(1116+ decision.report_id == report.report_id,1117+ "resolution-stall decision report ID {} does not match report {}",1118+ decision.report_id,1119+ report.report_id1120+ );1121+ Ok(decision)1122+}1123+1124+async fn next_resolution_stall_moq_frame(receiver: &mut MoqProbeReceiver) -> Result<AcceptedMedia> {1125+ loop {1126+ match receiver.next().await? {1127+ Some(MoqProbeOutcome::Frame {1128+ frame,1129+ group_sequence,1130+ ..1131+ }) => {1132+ return Ok(AcceptedMedia {1133+ flags: frame.header.flags,1134+ width: frame.header.width,1135+ height: frame.header.height,1136+ sequence: frame.header.sequence,1137+ capture_timestamp_us: frame.header.capture_timestamp_us,1138+ payload_len: frame.payload.len(),1139+ v3_group_id: Some(group_sequence),1140+ });1141+ }1142+ Some(MoqProbeOutcome::Dropped) => {}1143+ None => bail!("host closed upstream MoQ during resolution-stall smoke"),1144+ }1145+ }1146+}1147+1148+fn create_resolution_stall_marker(path: &Path, contents: &str) -> Result<()> {1149+ let mut options = OpenOptions::new();1150+ options.write(true).create_new(true);1151+ 1152+ options.mode(0o600);1153+ let mut marker = options1154+ .open(path)1155+ .with_context(|| format!("creating resolution stall marker {}", path.display()))?;1156+ marker1157+ .write_all(contents.as_bytes())1158+ .with_context(|| format!("writing resolution stall marker {}", path.display()))?;1159+ marker1160+ .sync_all()1161+ .with_context(|| format!("syncing resolution stall marker {}", path.display()))?;1162+ Ok(())1163+}1164+1165+async fn wait_for_resolution_stall_marker(path: &Path, timeout: Duration) -> Result<()> {1166+ tokio::time::timeout(timeout, async {1167+ loop {1168+ match fs::metadata(path) {1169+ Ok(metadata) => {1170+ ensure!(1171+ metadata.is_file(),1172+ "resolution stall gate marker is not a file: {}",1173+ path.display()1174+ );1175+ return Ok(());1176+ }1177+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {1178+ tokio::time::sleep(RESOLUTION_STALL_GATE_POLL_INTERVAL).await;1179+ }1180+ Err(error) => {1181+ return Err(error).with_context(|| {1182+ format!("reading resolution stall gate marker {}", path.display())1183+ });1184+ }1185+ }1186+ }1187+ })1188+ .await1189+ .with_context(|| {1190+ format!(1191+ "timed out waiting for resolution stall marker {}",1192+ path.display()1193+ )1194+ })?1195+}1196+1197+async fn send_resolution_stall_input_probe(1198+ send: &mut iroh::endpoint::SendStream,1199+ recv: &mut iroh::endpoint::RecvStream,1200+ expected_ack: &mut u64,1201+) -> Result<u64> {1202+ let started = Instant::now();1203+ write_input_event(send, &InputEvent::Probe)1204+ .await1205+ .context("writing input probe during resolution stall")?;1206+ *expected_ack = expected_ack.saturating_add(1);1207+ tokio::time::timeout(1208+ RESOLUTION_STALL_RECOVERY_TIMEOUT,1209+ read_expected_input_ack(recv, 2, *expected_ack),1210+ )1211+ .await1212+ .context("input acknowledgement exceeded the resolution-stall bound")??;1213+ Ok(u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX))1214+}1215+1216+1217+async fn run_resolution_stall_smoke(1218+ gate_directory: &Path,1219+ receiver: &mut MoqProbeReceiver,1220+ feedback_send: &mut iroh::endpoint::SendStream,1221+ feedback_recv: &mut iroh::endpoint::RecvStream,1222+ input_send: &mut iroh::endpoint::SendStream,1223+ input_recv: &mut iroh::endpoint::RecvStream,1224+ expected_ack: &mut u64,1225+ expected_native: Option<(u16, u16)>,1226+ timeout: Duration,1227+) -> Result<ResolutionStallEvidence> {1228+ let gate = ResolutionStallGate::new(gate_directory)?;1229+ let initial = tokio::time::timeout(timeout, next_resolution_stall_moq_frame(receiver))1230+ .await1231+ .context("timed out waiting for initial resolution-stall media")??;1232+ ensure!(1233+ initial.flags.contains(FrameFlags::KEYFRAME)1234+ && initial.flags.contains(FrameFlags::CODEC_CONFIG),1235+ "resolution-stall smoke did not begin at a configured IDR"1236+ );1237+ let native_dimensions = (initial.width, initial.height);1238+ if let Some(expected) = expected_native {1239+ ensure!(1240+ native_dimensions == expected,1241+ "expected {}x{} native media but received {}x{}",1242+ expected.0,1243+ expected.1,1244+ native_dimensions.0,1245+ native_dimensions.11246+ );1247+ }1248+ let reduced_dimensions = resolution_stall_reduced_dimensions(native_dimensions)?;1249+1250+ let mut report_id = 1_u64;1251+ let seed = resolution_stall_feedback(report_id, MediaFeedbackFlags::NONE, initial.sequence);1252+ let _ = exchange_resolution_stall_feedback(feedback_send, feedback_recv, &seed).await?;1253+ tokio::time::sleep(RESOLUTION_STALL_FEEDBACK_INTERVAL).await;1254+1255+ report_id += 1;1256+ let pressure = resolution_stall_feedback(1257+ report_id,1258+ MediaFeedbackFlags::RESYNC_ACTIVE,1259+ initial.sequence,1260+ );1261+ let pressure_decision =1262+ exchange_resolution_stall_feedback(feedback_send, feedback_recv, &pressure).await?;1263+ ensure!(1264+ pressure_decision1265+ .reasons1266+ .contains(AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE),1267+ "resolution-stall pressure report did not register receiver pressure"1268+ );1269+1270+ let reduced = tokio::time::timeout(timeout, async {1271+ loop {1272+ let frame = next_resolution_stall_moq_frame(receiver).await?;1273+ let dimensions = (frame.width, frame.height);1274+ ensure!(1275+ dimensions == native_dimensions || dimensions == reduced_dimensions,1276+ "resolution-stall media changed to unexpected dimensions {}x{}",1277+ dimensions.0,1278+ dimensions.11279+ );1280+ if dimensions == reduced_dimensions1281+ && frame.flags.contains(FrameFlags::KEYFRAME)1282+ && frame.flags.contains(FrameFlags::CODEC_CONFIG)1283+ {1284+ break Ok::<_, anyhow::Error>(frame);1285+ }1286+ }1287+ })1288+ .await1289+ .context("timed out waiting for pressure-triggered reduced configured IDR")??;1290+ ensure!(1291+ reduced.sequence > initial.sequence,1292+ "reduced configured IDR did not advance media sequence"1293+ );1294+1295+ create_resolution_stall_marker(1296+ &gate.ready,1297+ &format!(1298+ "native={}x{}\nreduced={}x{}\nsequence={}\n",1299+ native_dimensions.0,1300+ native_dimensions.1,1301+ reduced_dimensions.0,1302+ reduced_dimensions.1,1303+ reduced.sequence1304+ ),1305+ )?;1306+ println!("resolution_stall_ready=1");1307+ println!("resolution_stall_ready_sequence={}", reduced.sequence);1308+ std::io::stdout()1309+ .flush()1310+ .context("flushing resolution-stall readiness marker")?;1311+ wait_for_resolution_stall_marker(&gate.stalled, timeout).await?;1312+1313+ report_id += 1;1314+ let reseed = resolution_stall_feedback(report_id, MediaFeedbackFlags::NONE, reduced.sequence);1315+ let _ = exchange_resolution_stall_feedback(feedback_send, feedback_recv, &reseed).await?;1316+ let mut last_feedback_at = Instant::now();1317+ let mut fresh_started = None;1318+ let mut stall_input_ack_micros = None;1319+ for index in 0..RESOLUTION_STALL_FRESH_REPORTS {1320+ tokio::time::sleep_until(tokio::time::Instant::from_std(1321+ last_feedback_at + RESOLUTION_STALL_FEEDBACK_INTERVAL,1322+ ))1323+ .await;1324+ report_id += 1;1325+ let report =1326+ resolution_stall_feedback(report_id, MediaFeedbackFlags::NONE, reduced.sequence);1327+ let sent_at = Instant::now();1328+ fresh_started.get_or_insert(sent_at);1329+ let decision =1330+ exchange_resolution_stall_feedback(feedback_send, feedback_recv, &report).await?;1331+ ensure!(1332+ decision.state != AdaptiveBitrateStateV1::Increase,1333+ "no-progress feedback unexpectedly increased adaptive bitrate"1334+ );1335+ ensure!(1336+ !decision1337+ .reasons1338+ .contains(AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY),1339+ "no-progress feedback was incorrectly classified as clean recovery"1340+ );1341+ last_feedback_at = sent_at;1342+ if index + 1 == RESOLUTION_STALL_FRESH_REPORTS / 2 {1343+ stall_input_ack_micros = Some(1344+ send_resolution_stall_input_probe(input_send, input_recv, expected_ack).await?,1345+ );1346+ }1347+ }1348+ let fresh_duration = fresh_started1349+ .context("resolution-stall fresh feedback interval did not start")?1350+ .elapsed();1351+ ensure!(1352+ fresh_duration > Duration::from_secs(8),1353+ "resolution-stall fresh feedback lasted only {}ms",1354+ fresh_duration.as_millis()1355+ );1356+1357+ create_resolution_stall_marker(1358+ &gate.resume_ready,1359+ &format!(1360+ "fresh_reports={RESOLUTION_STALL_FRESH_REPORTS}\nfresh_duration_ms={}\n",1361+ fresh_duration.as_millis()1362+ ),1363+ )?;1364+ println!("resolution_stall_resume_ready=1");1365+ std::io::stdout()1366+ .flush()1367+ .context("flushing resolution-stall resume marker")?;1368+ wait_for_resolution_stall_marker(&gate.resumed, timeout).await?;1369+1370+ let resume_input_ack_micros =1371+ send_resolution_stall_input_probe(input_send, input_recv, expected_ack).await?;1372+ let media_resumed_at = Instant::now();1373+ let resumed = tokio::time::timeout(1374+ RESOLUTION_STALL_RECOVERY_TIMEOUT,1375+ next_resolution_stall_moq_frame(receiver),1376+ )1377+ .await1378+ .context("media did not resume within the resolution-stall bound")??;1379+ ensure!(1380+ (resumed.width, resumed.height) == reduced_dimensions,1381+ "media resumed at {}x{} instead of reduced {}x{}",1382+ resumed.width,1383+ resumed.height,1384+ reduced_dimensions.0,1385+ reduced_dimensions.11386+ );1387+ let resume_sequence_advance = resumed1388+ .sequence1389+ .checked_sub(reduced.sequence)1390+ .context("resumed media sequence did not advance")?;1391+ ensure!(1392+ resume_sequence_advance > 0,1393+ "resumed media sequence did not advance"1394+ );1395+ let resume_media_micros =1396+ u64::try_from(media_resumed_at.elapsed().as_micros()).unwrap_or(u64::MAX);1397+1398+ Ok(ResolutionStallEvidence {1399+ native_dimensions,1400+ reduced_dimensions,1401+ stall_sequence: reduced.sequence,1402+ fresh_duration,1403+ stall_input_ack_micros: stall_input_ack_micros1404+ .context("resolution-stall input acknowledgement was not measured")?,1405+ resume_input_ack_micros,1406+ resume_media_micros,1407+ resume_sequence_advance,1408+ })1409+}1410+9791411 9801412 async fn main() -> Result<()> {9811413 let args = Args::parse();@@ -1193,6 +1625,102 @@ async fn main() -> Result<()> {
11931625 read_expected_input_ack(&mut input_recv, args.timeout_seconds, expected_ack).await?;11941626 }11951627 1628+ if let Some(gate_directory) = args.resolution_stall_smoke.as_deref() {1629+ let feedback_connection = endpoint1630+ .connect(address.clone(), MEDIA_FEEDBACK_ALPN_V1)1631+ .await1632+ .context("connecting media feedback protocol")?;1633+ let (mut feedback_send, mut feedback_recv) = feedback_connection1634+ .open_bi()1635+ .await1636+ .context("opening media feedback stream")?;1637+ let feedback_negotiation = negotiate(1638+ &mut feedback_send,1639+ &mut feedback_recv,1640+ nonce,1641+ vec![Capability::VideoH264],1642+ Capability::VideoH264,1643+ "media feedback",1644+ None,1645+ )1646+ .await?;1647+ ensure!(1648+ session_id == feedback_negotiation.session_id,1649+ "media and feedback session IDs differ"1650+ );1651+1652+ let evidence = run_resolution_stall_smoke(1653+ gate_directory,1654+ moq_receiver1655+ .as_mut()1656+ .expect("resolution-stall smoke requires upstream MoQ"),1657+ &mut feedback_send,1658+ &mut feedback_recv,1659+ &mut input_send,1660+ &mut input_recv,1661+ &mut expected_ack,1662+ args.expect_size,1663+ Duration::from_secs(args.timeout_seconds),1664+ )1665+ .await?;1666+1667+ feedback_send1668+ .finish()1669+ .context("finishing media feedback stream")?;1670+ input_send.finish().context("finishing input stream")?;1671+ if let Some(mut media_send) = media_send {1672+ media_send1673+ .finish()1674+ .context("finishing media request stream")?;1675+ }1676+ feedback_connection.close(0_u32.into(), b"probe complete");1677+ input_connection.close(0_u32.into(), b"probe complete");1678+ moq_receiver1679+ .as_ref()1680+ .expect("resolution-stall smoke requires upstream MoQ")1681+ .connection()1682+ .close(0_u32.into(), b"probe complete");1683+ media_connection.close(0_u32.into(), b"probe complete");1684+ tokio::time::sleep(Duration::from_millis(50)).await;1685+ endpoint.close().await;1686+1687+ println!("probe=ok");1688+ println!("session_id={session_id}");1689+ println!("resolution_stall_smoke=ok");1690+ println!("resolution_stall_session_id={session_id}");1691+ println!(1692+ "resolution_stall_native_dimensions={}x{}",1693+ evidence.native_dimensions.0, evidence.native_dimensions.11694+ );1695+ println!(1696+ "resolution_stall_reduced_dimensions={}x{}",1697+ evidence.reduced_dimensions.0, evidence.reduced_dimensions.11698+ );1699+ println!("resolution_stall_sequence={}", evidence.stall_sequence);1700+ println!("resolution_stall_fresh_reports={RESOLUTION_STALL_FRESH_REPORTS}");1701+ println!(1702+ "resolution_stall_fresh_duration_ms={}",1703+ evidence.fresh_duration.as_millis()1704+ );1705+ println!(1706+ "resolution_stall_input_ack_micros={}",1707+ evidence.stall_input_ack_micros1708+ );1709+ println!(1710+ "resolution_resume_input_ack_micros={}",1711+ evidence.resume_input_ack_micros1712+ );1713+ println!(1714+ "resolution_resume_media_micros={}",1715+ evidence.resume_media_micros1716+ );1717+ println!(1718+ "resolution_resume_sequence_advance={}",1719+ evidence.resume_sequence_advance1720+ );1721+ return Ok(());1722+ }1723+11961724 let started = Instant::now();11971725 let mut received = 0_u32;11981726 let mut bytes = 0_u64;@@ -2152,6 +2680,38 @@ async fn read_expected_input_ack(
21522680 mod tests {21532681 use super::*;21542682 2683+ 2684+ fn resolution_stall_feedback_is_complete_and_keeps_the_observed_sequence() {2685+ let report = resolution_stall_feedback(7, MediaFeedbackFlags::NONE, 42);2686+ report.validate().unwrap();2687+ assert_eq!(report.report_id, 7);2688+ assert_eq!(report.interval_ms, 1_000);2689+ assert_eq!(report.last_sequence, Some(42));2690+ assert_eq!(report.frontend_queue_depth, 0);2691+ assert_eq!(report.decode_queue_depth, 0);2692+ assert_eq!(report.presenter_queue_depth, 0);2693+ assert_eq!(report.transport_delivery_p95_ms, Some(10));2694+ assert_eq!(report.decode_p95_ms, Some(3));2695+ assert_eq!(report.presentation_p95_ms, Some(5));2696+2697+ let later = resolution_stall_feedback(8, MediaFeedbackFlags::NONE, 42);2698+ assert_eq!(later.last_sequence, report.last_sequence);2699+ }2700+2701+ 2702+ fn resolution_stall_reduced_dimensions_are_exact_even_and_same_aspect() {2703+ assert_eq!(2704+ resolution_stall_reduced_dimensions((2_560, 1_600)).unwrap(),2705+ (1_920, 1_200)2706+ );2707+ assert_eq!(2708+ resolution_stall_reduced_dimensions((1_280, 800)).unwrap(),2709+ (960, 600)2710+ );2711+ assert!(resolution_stall_reduced_dimensions((1_278, 800)).is_err());2712+ assert!(resolution_stall_reduced_dimensions((80, 64)).is_err());2713+ }2714+21552715 21562716 fn sequence_checks_reject_duplicates_regressions_and_overflow() {21572717 assert_eq!(sequence_gap(41, 42).unwrap(), 0);@@ -271,7 +271,14 @@ impl MotionResolutionPolicy {
271271 });272272 self.update_motion(motion_active, observed_at);273273 }274- self.update_feedback(evaluation.pressure_severity, evaluation.fresh);274+ let resolution_severity = if evaluation.pressure_severity == FeedbackSeverity::Clean275+ && !evaluation.clean_recovery_evidence276+ {277+ FeedbackSeverity::InsufficientEvidence278+ } else {279+ evaluation.pressure_severity280+ };281+ self.update_feedback(resolution_severity, evaluation.fresh);275282 self.evaluate_target(observed_at)276283 }277284 @@ -523,6 +530,7 @@ struct ShadowBitrateController {
523530 struct AdaptiveBitrateEvaluation {524531 decision: AdaptiveBitrateDecisionV1,525532 pressure_severity: FeedbackSeverity,533+ clean_recovery_evidence: bool,526534 fresh: bool,527535 }528536 @@ -649,6 +657,7 @@ impl ShadowBitrateController {
649657 Ok(AdaptiveBitrateEvaluation {650658 decision,651659 pressure_severity,660+ clean_recovery_evidence: severity == FeedbackSeverity::Clean,652661 fresh: !stale,653662 })654663 }@@ -5366,6 +5375,74 @@ mod tests {
53665375 assert!(!held.changed);53675376 }53685377 5378+ 5379+ fn damage_driven_no_progress_cannot_restore_native_resolution() {5380+ let now = Instant::now();5381+ let telemetry = MediaV3TelemetrySnapshot::default();5382+ let mut bitrate = ShadowBitrateController::new(12_000, telemetry);5383+ let mut resolution = MotionResolutionPolicy::new(native_dimensions()).unwrap();5384+5385+ let mut seed = clean_feedback(1);5386+ seed.last_sequence = Some(100);5387+ let seed_evaluation = bitrate.evaluate(&seed, telemetry, now).unwrap();5388+ resolution.observe_window(&seed, &seed_evaluation, now);5389+5390+ let mut pressure = clean_feedback(2);5391+ pressure.last_sequence = Some(110);5392+ pressure.flags = MediaFeedbackFlags::RESYNC_ACTIVE;5393+ let pressure_evaluation = bitrate5394+ .evaluate(&pressure, telemetry, now + Duration::from_secs(1))5395+ .unwrap();5396+ let downshift = resolution.observe_window(5397+ &pressure,5398+ &pressure_evaluation,5399+ now + Duration::from_secs(1),5400+ );5401+ assert_eq!(downshift.tier, ResolutionTier::Reduced);5402+ assert!(downshift.changed);5403+5404+ for second in 2..=4 {5405+ let mut no_progress = clean_feedback(second + 1);5406+ no_progress.last_sequence = Some(110);5407+ let evaluation = bitrate5408+ .evaluate(&no_progress, telemetry, now + Duration::from_secs(second))5409+ .unwrap();5410+ assert_eq!(evaluation.pressure_severity, FeedbackSeverity::Clean);5411+ assert!(!evaluation.clean_recovery_evidence);5412+ let decision = resolution.observe_window(5413+ &no_progress,5414+ &evaluation,5415+ now + Duration::from_secs(second),5416+ );5417+ assert_eq!(decision.tier, ResolutionTier::Reduced);5418+ assert_eq!(decision.trigger, ResolutionTrigger::Pressure);5419+ assert!(!decision.changed);5420+ assert_eq!(resolution.clean_windows, 0);5421+ }5422+5423+ for (report_id, second, sequence) in [(6, 5, 111), (7, 6, 112), (8, 7, 113)] {5424+ let mut low_motion = clean_feedback(report_id);5425+ low_motion.last_sequence = Some(sequence);5426+ let evaluation = bitrate5427+ .evaluate(&low_motion, telemetry, now + Duration::from_secs(second))5428+ .unwrap();5429+ assert!(evaluation.clean_recovery_evidence);5430+ let decision = resolution.observe_window(5431+ &low_motion,5432+ &evaluation,5433+ now + Duration::from_secs(second),5434+ );5435+ if report_id < 8 {5436+ assert_eq!(decision.tier, ResolutionTier::Reduced);5437+ assert!(!decision.changed);5438+ } else {5439+ assert_eq!(decision.tier, ResolutionTier::Native);5440+ assert_eq!(decision.trigger, ResolutionTrigger::Recovery);5441+ assert!(decision.changed);5442+ }5443+ }5444+ }5445+53695446 53705447 fn shadow_controller_immediately_decreases_for_severe_feedback_and_stays_bounded() {53715448 let mut controller =