cat / goq
clone

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

Merge pull request #139 from FelineStateMachine/fix/portal-control-v2-input-ack

FelineStateMachine committed ad308aa
fix: control v2 disconnected instantly, then shadow mode detached the viewer
commit
ad308aae214cc4e7b93bc76a75273bec36a6d3f9
parent
17d02c28dbf3e1862286c4af3e9a2976f3134e62
parent
5c7eb5555b402f200513aae5bbf9d49eb5a2b1ea
author
FelineStateMachine <dami.etoile@gmail.com>
2 files changed, +76 -6
Mcrates/sigil-host/src/server/adaptive.rs61+++++++++++++++++++++++++++++-
Msrc-tauri/src/media/input_delivery.rs21++++++++--
M crates/sigil-host/src/server/adaptive.rs +59 -2
@@ -599,6 +599,10 @@ pub(super) struct GenerationAdaptiveCoordinator {
599599 state: Mutex<GenerationAggregateState>,600600 changed: tokio::sync::Notify,601601 recovering_viewers: Arc<AtomicUsize>,602+ // Detaching is an action. In shadow mode the controller computes a lower603+ // bitrate and never applies it, so a pressured viewer cannot recover and604+ // would be detached for failing to do something it was never allowed to do.605+ encoder_actuation_available: bool,602606 }603607 604608 impl std::fmt::Debug for GenerationAdaptiveCoordinator {
@@ -657,6 +661,8 @@ impl GenerationAdaptiveCoordinator {
657661 recovering_viewers: Arc<AtomicUsize>,658662 ) -> Result<(Arc<Self>, tokio::task::JoinHandle<Result<()>>)> {659663 let ceiling_kbps = adaptive_bitrate_ceiling_kbps(&config)?;664+ let encoder_actuation_available =665+ encoder_actuation_available(&config, encoder_control.as_ref());660666 let coordinator = Arc::new(Self {661667 generation_id,662668 ceiling_kbps,
@@ -667,6 +673,7 @@ impl GenerationAdaptiveCoordinator {
667673 }),668674 changed: tokio::sync::Notify::new(),669675 recovering_viewers,676+ encoder_actuation_available,670677 });671678 let task_coordinator = Arc::clone(&coordinator);672679 let task = tokio::spawn(async move {
@@ -849,7 +856,16 @@ impl GenerationAdaptiveCoordinator {
849856 match contributor.recovery.observe(&evaluation, now) {850857 RecoveryMutation::RequestRecovery => request_recovery = true,851858 RecoveryMutation::Detach => {852- contributor.detach.send_replace(true);859+ if self.encoder_actuation_available {860+ contributor.detach.send_replace(true);861+ } else {862+ warn!(863+ generation_id = self.generation_id,864+ "viewer stayed below the bitrate floor, but shadow mode never applied \865+ a reduction; keeping it attached instead of detaching it for failing \866+ to recover"867+ );868+ }853869 }854870 RecoveryMutation::None | RecoveryMutation::Recovered => {}855871 }
@@ -1913,7 +1929,7 @@ pub(super) async fn serve_media_feedback(
19131929 }19141930 };19151931 let encoder_actuation_available =1916- adaptive_bitrate_actuation_enabled(config) && lease.encoder_control.is_some();1932+ encoder_actuation_available(config, lease.encoder_control.as_ref());19171933 // Shadow mode computes every decision and applies none. Say why, or a host19181934 // silently never adapts and nothing in the logs explains it.19191935 if !encoder_actuation_available {
@@ -2504,6 +2520,17 @@ fn adaptive_bitrate_ceiling_kbps(config: &HostConfig) -> Result<u32> {
25042520 Ok(ceiling)25052521 }25062522 2523+/// Whether adaptive decisions can actually be applied to the encoder. When this2524+/// is false the controller runs in shadow: it computes every decision and2525+/// applies none, so a pressured viewer has no path back and must not be2526+/// detached for failing to take it.2527+fn encoder_actuation_available(2528+ config: &HostConfig,2529+ encoder_control: Option<&EncoderControl>,2530+) -> bool {2531+ adaptive_bitrate_actuation_enabled(config) && encoder_control.is_some()2532+}2533+25072534 fn adaptive_bitrate_actuation_enabled(config: &HostConfig) -> bool {25082535 config.source == VideoSource::GamescopePipewire25092536 && config.gamescope_pipewire.as_ref().is_some_and(|gamescope| {
@@ -2754,6 +2781,36 @@ mod tests {
27542781 assert_eq!(evaluation.pressure_severity, FeedbackSeverity::Clean);27552782 }27562783 2784+ // Regression: the shipped activation guide writes2785+ // encoder_backend = "external-gst-launch", which puts adaptive in shadow.2786+ // Detaching is an action, so shadow mode must not reach for it: the viewer2787+ // is below floor precisely because no reduction was ever applied.2788+ #[test]2789+ fn shadow_mode_never_gains_permission_to_detach_a_viewer() {2790+ let external = gamescope_feedback_test_config(VaapiRateControl::Cbr);2791+ assert_eq!(2792+ external2793+ .gamescope_pipewire2794+ .as_ref()2795+ .unwrap()2796+ .encoder_backend,2797+ GamescopeEncoderBackend::ExternalGstLaunch,2798+ "the activation guide's backend is the one that selects shadow mode"2799+ );2800+ assert!(!adaptive_bitrate_actuation_enabled(&external));2801+ assert!(!encoder_actuation_available(&external, None));2802+2803+ let mut in_process = external.clone();2804+ in_process2805+ .gamescope_pipewire2806+ .as_mut()2807+ .unwrap()2808+ .encoder_backend = GamescopeEncoderBackend::InProcessGstreamer;2809+ assert!(adaptive_bitrate_actuation_enabled(&in_process));2810+ // Still shadow without a live encoder handle: nothing to actuate.2811+ assert!(!encoder_actuation_available(&in_process, None));2812+ }2813+27572814 #[test]27582815 fn persistent_floor_breach_retries_then_detaches_without_holding_survivors_down() {27592816 let now = Instant::now();
M src-tauri/src/media/input_delivery.rs +17 -4
@@ -547,6 +547,12 @@ pub(crate) async fn start_focused_input_v2(state: &AppState) -> Result<InputAvai
547547 context.grants,548548 )549549 .await?;550+ // The v1 path records this at negotiation time. Without the same call here551+ // the ack diagnostics stay "not negotiated" while the input stream really552+ // did negotiate InputAck, so the host's first acknowledgement is rejected553+ // as unsolicited and control v2 tears the session down.554+ lock_network_diagnostics(&context.network_diagnostics)555+ .set_input_ack_negotiated(availability.input_ack);550556 let (tx, rx) = tokio::sync::mpsc::channel::<InputEvent>(CLIENT_INPUT_QUEUE_CAPACITY);551557 *state.input_send.lock().await = Some(tx);552558 *state.input_connection.lock().await = Some((context.native_generation, connection));
@@ -762,16 +768,23 @@ pub(crate) async fn run_input_feedback_v2(
762768 || response.slot != ControllerSlot::ZERO763769 || response.focus_generation != focus_generation764770 {771+ eprintln!(772+ "[client] input v2 feedback binding mismatch: got session={} slot={:?} focus={}, expected session={} slot=0 focus={}",773+ response.session_id,774+ response.slot,775+ response.focus_generation,776+ session_id,777+ focus_generation778+ );765779 break PointerFeedbackTerminalReason::Malformed;766780 }767- if observe_input_ack_if_negotiated(781+ if let Err(error) = observe_input_ack_if_negotiated(768782 &feedback_diagnostics,769783 input_ack_enabled,770784 response.ack.sequence,771785 Instant::now(),772- )773- .is_err()774- {786+ ) {787+ eprintln!("[client] input v2 acknowledgement rejected: {error}");775788 break PointerFeedbackTerminalReason::Malformed;776789 }777790 if pointer_feedback_enabled