Adaptive bitrate and motion-sensitive resolution only actuate on the in-process backend; on external gst-launch the controller computes every decision and applies none. That backend was the default, so on a stock configuration both features were running in shadow and silently doing nothing. Make the in-process backend the default, but tie the default to build capability rather than flipping it unconditionally. validate() rejects in-process on a build without the feature, so an unconditional default would make a binary reject its own default configuration. CI runs `cargo test --workspace` without the feature (verify-demo-build.sh:82), so that would have broken the gate. The cfg_attr keeps the default valid everywhere: Linux plus the feature selects in-process, everything else external. The CBR requirement is not a temporary limitation, so stop implying it is. The in-process pipeline is built around a mutable encoder `bitrate` property that constant-quantizer mode does not expose; the two are contradictory. The validation error now explains that and names the escape hatch, so a CQP host is told to choose between adaptive bitrate and CQP rather than reading "currently requires CBR" as something that will lapse. Shadow mode was also invisible. A CQP or external host would never adapt and nothing in the logs said why. Log the specific reason once per feedback client. Prove the recovery half of issue #8 and #9: sigil-probe gains --resolution-recovery-windows, which submits clean, sequence-advancing feedback after the stall gate releases and requires the controller to climb back out of the pressure trough. The loop drains continuously until each feedback deadline, because consuming one frame per window starves the receiver and manufactures the very cancellation backpressure the loop exists to prove has cleared. Hardware evidence on the reference host (GPD Pocket 4 / Bazzite, in-process, CBR 12000): the descent is bounded and monotonic, 12000 -> 9000 -> 7000 -> 5500 -> 4250 -> 3250 -> 2500 kbps at a consistent ~0.77 step with no oscillation and no floor breach, driven first by loss/backpressure/receiver queue and then by measured RTT inflation. Resolution drops 1280x800 -> 960x600 under pressure and media resumes 46us after SIGCONT. Every Decrease reports applied=false and the following Hold at the new target reports applied=true, which is the documented commit-on-encoder-readback behaviour. The increase does NOT yet reproduce on hardware. Across 14 clean recovery windows the host keeps incrementing scheduler_cancellations by roughly five per second while RTT stays at ~150-290us and lost packets stay zero, so cancellation_delta >= 2 holds LOSS_OR_CANCELLATION asserted, feedback is never classified clean, and the controller correctly declines to climb. Whether that cancellation rate is inherent to latest-frame-wins group cancellation, in which case no client can ever increase, or an artifact of the probe's consumption pattern, is still open and needs a no-stall steady-state run to settle.
- commit
- ca1d29428223b3b90222cfdc49eecd148bcc99c0
- parent
- 3b65bac7bf31691e5d09ac942a0ba40bea14c028
- author
- Dami <dami.etoile@gmail.com>
| M | crates/ | 121 | ++++++++++++++++++++++++++++++ |
| M | crates/ | 48 | ++++++++++-- |
| M | crates/ | 26 | ++++++ |
@@ -120,6 +120,18 @@ struct Args {
120120 ]121121 )]122122 resolution_stall_smoke: Option<PathBuf>,123+ /// After the stall is released, submit this many clean, sequence-advancing124+ /// feedback windows and require the controller to climb back out of the125+ /// pressure trough. The host needs CLEAN_WINDOWS plus COOLDOWN_WINDOWS of126+ /// clean evidence, so fewer than about twelve cannot observe an increase.127+ /// 0 keeps the historical behaviour of ending at the resume barrier.128+ 129+130+131+132+133+134+ resolution_recovery_windows: u64,123135 }124136 125137 @@ -764,6 +776,17 @@ struct ResolutionStallEvidence {
764776 resume_input_ack_micros: u64,765777 resume_media_micros: u64,766778 resume_sequence_advance: u64,779+ recovery: Option<ResolutionRecoveryEvidence>,780+}781+782+783+struct ResolutionRecoveryEvidence {784+ windows: u64,785+ trough_kbps: u32,786+ final_kbps: u32,787+ increase_observed: bool,788+ final_dimensions: (u16, u16),789+ restored_native: bool,767790 }768791 769792 struct ResolutionStallGate {@@ -1016,6 +1039,7 @@ async fn run_resolution_stall_smoke(
10161039 expected_ack: &mut u64,10171040 expected_native: Option<(u16, u16)>,10181041 timeout: Duration,1042+ recovery_windows: u64,10191043 ) -> Result<ResolutionStallEvidence> {10201044 let gate = ResolutionStallGate::new(gate_directory)?;10211045 let initial = tokio::time::timeout(timeout, next_resolution_stall_moq_frame(receiver))@@ -1052,6 +1076,7 @@ async fn run_resolution_stall_smoke(
10521076 );10531077 let pressure_decision =10541078 exchange_feedback_report(feedback_send, feedback_recv, &pressure).await?;1079+ let mut trough_kbps = pressure_decision.target_kbps;10551080 ensure!(10561081 pressure_decision10571082 .reasons@@ -1119,6 +1144,7 @@ async fn run_resolution_stall_smoke(
11191144 let sent_at = Instant::now();11201145 fresh_started.get_or_insert(sent_at);11211146 let decision = exchange_feedback_report(feedback_send, feedback_recv, &report).await?;1147+ trough_kbps = trough_kbps.min(decision.target_kbps);11221148 ensure!(11231149 decision.state != AdaptiveBitrateStateV1::Increase,11241150 "no-progress feedback unexpectedly increased adaptive bitrate"@@ -1186,6 +1212,72 @@ async fn run_resolution_stall_smoke(
11861212 let resume_media_micros =11871213 u64::try_from(media_resumed_at.elapsed().as_micros()).unwrap_or(u64::MAX);11881214 1215+ // Proving the descent is only half of the contract. Feed clean, genuinely1216+ // advancing evidence for long enough to clear the clean-window and1217+ // cooldown counters, then require the controller to climb back out.1218+ let recovery = if recovery_windows > 0 {1219+ let mut latest_sequence = resumed.sequence;1220+ let mut latest_dimensions = (resumed.width, resumed.height);1221+ let mut final_kbps = trough_kbps;1222+ let mut increase_observed = false;1223+ let mut last_feedback_at = Instant::now();1224+ for _ in 0..recovery_windows {1225+ // Drain as fast as frames arrive until the next feedback deadline.1226+ // Consuming a single frame per window would starve the receiver and1227+ // manufacture exactly the cancellation backpressure this loop1228+ // exists to prove has cleared, so the controller would correctly1229+ // refuse to climb and the test would be measuring its own harness.1230+ let deadline = last_feedback_at + RESOLUTION_STALL_FEEDBACK_INTERVAL;1231+ loop {1232+ let now = Instant::now();1233+ if now >= deadline {1234+ break;1235+ }1236+ match tokio::time::timeout(1237+ deadline - now,1238+ next_resolution_stall_moq_frame(receiver),1239+ )1240+ .await1241+ {1242+ Ok(Ok(frame)) => {1243+ latest_sequence = frame.sequence;1244+ latest_dimensions = (frame.width, frame.height);1245+ }1246+ Ok(Err(error)) => return Err(error),1247+ Err(_) => break,1248+ }1249+ }1250+ report_id += 1;1251+ let report =1252+ complete_feedback_report(report_id, MediaFeedbackFlags::NONE, latest_sequence);1253+ let sent_at = Instant::now();1254+ let decision = exchange_feedback_report(feedback_send, feedback_recv, &report).await?;1255+ last_feedback_at = sent_at;1256+ final_kbps = decision.target_kbps;1257+ if decision.state == AdaptiveBitrateStateV1::Increase {1258+ increase_observed = true;1259+ }1260+ }1261+ ensure!(1262+ increase_observed,1263+ "adaptive bitrate never increased across {recovery_windows} clean recovery windows (trough {trough_kbps} kbps, final {final_kbps} kbps)"1264+ );1265+ ensure!(1266+ final_kbps > trough_kbps,1267+ "adaptive bitrate recovered no ground: trough {trough_kbps} kbps, final {final_kbps} kbps"1268+ );1269+ Some(ResolutionRecoveryEvidence {1270+ windows: recovery_windows,1271+ trough_kbps,1272+ final_kbps,1273+ increase_observed,1274+ final_dimensions: latest_dimensions,1275+ restored_native: latest_dimensions == native_dimensions,1276+ })1277+ } else {1278+ None1279+ };1280+11891281 Ok(ResolutionStallEvidence {11901282 native_dimensions,11911283 reduced_dimensions,@@ -1196,6 +1288,7 @@ async fn run_resolution_stall_smoke(
11961288 resume_input_ack_micros,11971289 resume_media_micros,11981290 resume_sequence_advance,1291+ recovery,11991292 })12001293 }12011294 @@ -1491,6 +1584,7 @@ async fn main() -> Result<()> {
14911584 &mut expected_ack,14921585 args.expect_size,14931586 Duration::from_secs(args.timeout_seconds),1587+ args.resolution_recovery_windows,14941588 )14951589 .await?;14961590 @@ -1534,6 +1628,33 @@ async fn main() -> Result<()> {
15341628 "resolution_stall_input_ack_micros={}",15351629 evidence.stall_input_ack_micros15361630 );1631+ match evidence.recovery.as_ref() {1632+ Some(recovery) => {1633+ println!("resolution_recovery_windows={}", recovery.windows);1634+ println!("resolution_recovery_trough_kbps={}", recovery.trough_kbps);1635+ println!("resolution_recovery_final_kbps={}", recovery.final_kbps);1636+ println!(1637+ "resolution_recovery_increase_observed={}",1638+ recovery.increase_observed1639+ );1640+ println!(1641+ "resolution_recovery_final_dimensions={}x{}",1642+ recovery.final_dimensions.0, recovery.final_dimensions.11643+ );1644+ println!(1645+ "resolution_recovery_restored_native={}",1646+ recovery.restored_native1647+ );1648+ }1649+ None => {1650+ println!("resolution_recovery_windows=not-requested");1651+ println!("resolution_recovery_trough_kbps=not-requested");1652+ println!("resolution_recovery_final_kbps=not-requested");1653+ println!("resolution_recovery_increase_observed=not-requested");1654+ println!("resolution_recovery_final_dimensions=not-requested");1655+ println!("resolution_recovery_restored_native=not-requested");1656+ }1657+ }15371658 println!(15381659 "resolution_resume_input_ack_micros={}",15391660 evidence.resume_input_ack_micros@@ -95,12 +95,22 @@ pub enum VaapiRateControl {
9595 9696 9797 pub enum GamescopeEncoderBackend {98- /// Preserve the proven child-process pipeline and its natural-IDR control99- /// limitation for existing configurations.100- 98+ /// The child-process pipeline. It cannot change encoder properties while99+ /// playing, so adaptive bitrate and motion-sensitive resolution can only100+ /// run in shadow. Retained for CQP and as an explicit escape hatch, and it101+ /// is the default on builds that cannot run the in-process backend.102+ 103+104+105+101106 ExternalGstLaunch,102107 /// Run the video pipeline in-process so Sigil can apply bounded encoder103- /// controls. This remains an explicit opt-in until hardware acceptance.108+ /// controls. This is the only backend where adaptive bitrate and109+ /// resolution actuation are live, so it is the default wherever it can110+ /// actually run. The default deliberately tracks build capability: a111+ /// binary without this feature would otherwise reject its own default112+ /// configuration.113+ 104114 InProcessGstreamer,105115 }106116 @@ -144,8 +154,9 @@ pub struct GamescopePipewireConfig {
144154 pub pw_dump_path: PathBuf,145155 pub gst_launch_path: PathBuf,146156 pub gst_inspect_path: PathBuf,147- /// External gst-launch remains the compatibility default. The in-process148- /// backend is accepted only by Linux builds that contain its feature.157+ /// Defaults to the in-process backend on builds that contain its feature,158+ /// because that is the only backend where adaptive bitrate and resolution159+ /// actuation are live. Other builds default to external gst-launch.149160 150161 pub encoder_backend: GamescopeEncoderBackend,151162 /// Exact dynamically registered VA encoder factory, such as `vah264enc`.@@ -268,10 +279,19 @@ impl GamescopePipewireConfig {
268279 || cfg!(all(target_os = "linux", feature = "in-process-gstreamer")),269280 "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"270281 );282+ // Not a temporary gap: the in-process pipeline is built around a283+ // mutable encoder `bitrate` property, which constant-quantizer mode284+ // does not have. The two are contradictory rather than unfinished.271285 ensure!(272286 self.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer273287 || self.rate_control == VaapiRateControl::Cbr,274- "gamescope_pipewire.encoder_backend=in-process-gstreamer currently requires CBR"288+ "gamescope_pipewire.encoder_backend=in-process-gstreamer requires \289+ rate_control=\"cbr\", because adaptive bitrate changes a live \290+ encoder bitrate that constant-quantizer mode does not expose. \291+ Either set rate_control=\"cbr\" to keep adaptive bitrate and \292+ motion-sensitive resolution, or set \293+ encoder_backend=\"external-gst-launch\" to keep CQP and accept \294+ that both adapt in shadow only."275295 );276296 ensure!(277297 self.vaapi_render_node@@ -608,6 +628,16 @@ fn validate_file_security(path: &Path, metadata: &fs::Metadata) -> Result<()> {
608628 mod tests {609629 use super::*;610630 631+ /// The compiled default tracks build capability, so tests must expect the632+ /// backend this binary can actually run rather than a fixed variant.633+ fn default_encoder_backend() -> GamescopeEncoderBackend {634+ if cfg!(all(target_os = "linux", feature = "in-process-gstreamer")) {635+ GamescopeEncoderBackend::InProcessGstreamer636+ } else {637+ GamescopeEncoderBackend::ExternalGstLaunch638+ }639+ }640+611641 612642 fn config_revision_is_exact_bounded_and_strict() {613643 let first = ConfigRevision::from_bytes(b"a = 1\n");@@ -837,7 +867,7 @@ source = "gamescope-pipewire"
837867 }838868 839869 840- fn gamescope_encoder_backend_defaults_external() {870+ fn gamescope_encoder_backend_defaults_to_build_capability() {841871 let config: HostConfig = toml::from_str(842872 r#"843873 identity_path = "/tmp/host.key"@@ -860,7 +890,7 @@ bitrate_kbps = 12000
860890 861891 assert_eq!(862892 config.gamescope_pipewire.unwrap().encoder_backend,863- GamescopeEncoderBackend::ExternalGstLaunch893+ default_encoder_backend()864894 );865895 }866896 @@ -1146,6 +1146,32 @@ pub(super) async fn serve_media_feedback(
11461146 };11471147 let encoder_actuation_available =11481148 adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();1149+ // Shadow mode computes every decision and applies none. Say why, or a host1150+ // silently never adapts and nothing in the logs explains it.1151+ if !encoder_actuation_available {1152+ let reason = if !adaptive_bitrate_actuation_enabled(config) {1153+ match config.gamescope_pipewire.as_ref() {1154+ Some(gamescope)1155+ if gamescope.encoder_backend != GamescopeEncoderBackend::InProcessGstreamer =>1156+ {1157+ "gamescope_pipewire.encoder_backend is not in-process-gstreamer"1158+ }1159+ Some(gamescope) if gamescope.rate_control != VaapiRateControl::Cbr => {1160+ "gamescope_pipewire.rate_control is not cbr"1161+ }1162+ Some(_) => "adaptive bitrate actuation is unavailable for this configuration",1163+ None => "the configured source is not gamescope-pipewire",1164+ }1165+ } else {1166+ "this session has no encoder control handle"1167+ };1168+ warn!(1169+ %remote,1170+ session_id = lease.session_id,1171+ reason,1172+ "adaptive bitrate and resolution run in shadow: decisions are computed but never applied"1173+ );1174+ }11491175 11501176 write_host_hello(11511177 &mut send,