cat / goq
clone

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

fix(host): shadow-mode adaptive no longer detaches viewers it cannot help

Dami committed 5c7eb55
With the input-ack disconnect fixed, the session survived long enough to reach
the next failure: the host detached the viewer after ~25 seconds with
reason="adaptive_recovery_detach".

`RecoveryMutation::Detach` applied unconditionally. In shadow mode the
controller computes a lower bitrate and never applies it, so a viewer under
sustained pressure cannot recover -- the only remedy is switched off -- and the
recovery state machine escalates to detach on schedule. Shadow mode disabled the
remedy and kept the punishment.

This is the documented default, not an exotic configuration.
`docs/sigil-host-activation.md` step 5 writes
`encoder_backend = "external-gst-launch"`, which is exactly what selects shadow
mode, so a host provisioned by following the guide detaches any viewer that
experiences sustained pressure. On the reference host that is 2560x1600@60 at
12000 kbps to a Wi-Fi client.

Detaching is an action, so it is now gated on actuation being available, and
says so when it declines. The duplicated actuation predicate becomes one
`encoder_actuation_available` helper used by both the coordinator and the
feedback session.
commit
5c7eb5555b402f200513aae5bbf9d49eb5a2b1ea
parent
4f7f481f717d125bb9fca4835f5c3649c1f930b2
author
Dami <dami.etoile@gmail.com>
1 file changed, +59 -2
Mcrates/sigil-host/src/server/adaptive.rs61+++++++++++++++++++++++++++++-
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();