diff --git a/crates/headroom-core/src/pw/registry.rs b/crates/headroom-core/src/pw/registry.rs index 6f81acb..13c0790 100644 --- a/crates/headroom-core/src/pw/registry.rs +++ b/crates/headroom-core/src/pw/registry.rs @@ -194,6 +194,11 @@ pub struct RoutingState { /// keyed by source stream node id. Kept alive so the links /// persist; dropped on stream removal or route change. managed_route_links: HashMap>, + /// Window-based limit for `default.audio.sink` re-assertions + /// so a hostile WP policy can't pull us into a hot loop. + /// `(window_start, attempts_in_window)`. See + /// [`Self::reassert_default_processed`]. + default_reassertion: Option<(std::time::Instant, u32)>, } /// Per-stream Layer A bundle: the tap (audio path), the controller @@ -259,6 +264,7 @@ impl RoutingState { outbound_links_by_node: HashMap::new(), pending_routes: HashMap::new(), managed_route_links: HashMap::new(), + default_reassertion: None, } } @@ -521,10 +527,22 @@ impl RoutingState { self.filter_playback_id = Some(global.id); // If a real sink is already known, pin the filter to it // immediately. Common at boot when the filter playback global - // arrives after we've adopted the prior default. + // arrives after we've adopted the prior default. Both writing + // target.object (the cheap hint) AND enqueuing through 4k's + // explicit-link path matters here — without the explicit + // enforcement, WirePlumber also fans the filter's output back + // into `headroom-processed:playback`, creating a tight + // feedback loop (filter output → processed sink → filter + // capture → filter output). let target = self.daemon.lock().real_sink.name.clone(); if let Some(name) = target { - self.write_stream_target(global.id, &name, "headroom-filter.playback"); + self.write_stream_target(global.id, &name, FILTER_PLAYBACK_NODE_NAME); + self.enqueue_route( + global.id, + name, + FILTER_PLAYBACK_NODE_NAME.to_owned(), + Route::Bypass, + ); } } @@ -1111,6 +1129,40 @@ impl RoutingState { self.adopt_new_real_sink(name); } + /// Re-assert `default.audio.sink = headroom-processed` with a + /// per-second attempt cap. WirePlumber's session policy will + /// often immediately rewrite our value back to the user's + /// stored preference; this method fights back so apps that + /// resolve to "the default sink" land in the processor. + /// + /// We tolerate up to `MAX_PER_WINDOW` rewrites per + /// `WINDOW` — enough to outlast WP's typical 1-2 follow-up + /// writes and (in adversarial cases) ride out a brief metadata + /// storm — then back off for the rest of the window. Explicit + /// 4k links continue to enforce routing for managed streams + /// regardless of which side wins the default. + fn reassert_default_processed(&mut self) { + const WINDOW: std::time::Duration = std::time::Duration::from_secs(1); + const MAX_PER_WINDOW: u32 = 10; + let now = std::time::Instant::now(); + match &mut self.default_reassertion { + Some((started, n)) if now.duration_since(*started) < WINDOW => { + if *n >= MAX_PER_WINDOW { + tracing::debug!( + attempts = *n, + "default.audio.sink re-assertion budget exhausted for this window" + ); + return; + } + *n += 1; + } + _ => { + self.default_reassertion = Some((now, 1)); + } + } + self.write_default_audio_sink(PROCESSED_SINK_NAME); + } + /// Update `preferred_real_sink` and retarget every bypass-routed /// stream + the filter playback + re-assert headroom-processed as /// default. @@ -1118,7 +1170,12 @@ impl RoutingState { let (bypass_targets, resolved_node_id) = { let mut s = self.daemon.lock(); let Some(targets) = s.apply_real_sink_change(&new_sink_name) else { - return; // Idempotent no-op. + // Real sink unchanged but WP just wrote default away + // from headroom-processed. Re-assert (rate-limited) + // and return — no bypass-retarget work needed. + drop(s); + self.reassert_default_processed(); + return; }; // If we already know this sink by name from the registry, // populate node_id in the same pass; otherwise it'll @@ -1153,12 +1210,19 @@ impl RoutingState { } // Retarget the filter playback so processed audio follows the - // new speaker. node.dont-move / NODE_DONT_RECONNECT prevent - // WirePlumber from deciding for the filter, but explicit - // target.object writes are an operator-level override and are - // honoured. + // new speaker. Same dual-write as the bypass streams above: + // target.object as a hint, explicit-link enqueue as the + // source of truth — otherwise filter.playback ends up + // dual-linked (real sink + processed:playback, which is a + // feedback loop into its own input). if let Some(playback_id) = self.filter_playback_id { self.write_stream_target(playback_id, &new_sink_name, FILTER_PLAYBACK_NODE_NAME); + self.enqueue_route( + playback_id, + new_sink_name.clone(), + FILTER_PLAYBACK_NODE_NAME.to_owned(), + Route::Bypass, + ); } else { tracing::debug!( "filter playback id not yet captured; will be pinned on its registry arrival" @@ -1167,8 +1231,10 @@ impl RoutingState { // Re-assert headroom-processed as the system default so new // streams keep landing in the processor. This will emit a - // property event we ignore (PROCESSED_SINK_NAME branch above). - self.write_default_audio_sink(PROCESSED_SINK_NAME); + // property event we ignore (PROCESSED_SINK_NAME branch + // above). Rate-limited so a WP that insists on its own + // default doesn't pull us into an unbounded loop. + self.reassert_default_processed(); // Tell IPC subscribers a real sink switch happened. let event = Event::new(