From ec4920666021648d0eac24d822892fee1b32919d Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:44:49 +1000 Subject: [PATCH] fix(F4): clear real_sink.name when the adopted sink departs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex audit of the F1-F6 sweep flagged this. The fallback path in `try_capture_real_sink` adopts the first non-processed Audio/Sink when no real sink is known; if that sink later disconnects (USB DAC pulled, Bluetooth peer drops), `on_global_remove`'s `sinks_by_name.retain` was clearing `s.real_sink.node_id` but leaving `s.real_sink.name` set to the departed name. Symptoms: - `apply_pending_routes` then logs "target sink not yet on registry" for every bypass route and queues forever — `name` no longer resolves through `sinks_by_name`. - `adopt_new_real_sink` from the metadata listener would normally rescue this, but WP only re-fires `default.audio.sink` on actual changes; if the user's default is unchanged in WP's view (because the departed sink wasn't WP's pick), no event arrives. The retain-callback now clears both `name` and `node_id` when the removed node's name matches `real_sink.name`. The F4 fallback will then pick a replacement from the next non-processed Audio/Sink the registry surfaces, or a fresh metadata event will set a specific choice — either path recovers cleanly. Codex's other finding (theoretical duplicate-link creation in multi-channel apply_pending_routes when the link listener lags behind the drain timer within a single tick) is real-but-unlikely: the listener fires within microseconds on the same event loop; drain ticks are 50 ms apart, so the listener always catches up before the next drain. The unchanged-target gap-mitigation from `5c769a1` also reduces exposure — most ReevaluateAll passes don't hit the create loop at all now. Filed as a "watch but don't fix" note inline in the routing follow-up memory. 190 tests pass; clippy clean. --- crates/headroom-core/src/pw/registry.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/headroom-core/src/pw/registry.rs b/crates/headroom-core/src/pw/registry.rs index c271c60..c4f5904 100644 --- a/crates/headroom-core/src/pw/registry.rs +++ b/crates/headroom-core/src/pw/registry.rs @@ -1485,7 +1485,20 @@ impl RoutingState { if id == node_id { tracing::debug!(node_id, name, "real sink removed from registry"); let mut s = self.daemon.lock(); - if s.real_sink.node_id == Some(node_id) { + // Clear BOTH name and node_id when the departing + // sink is our preferred real sink. Just nulling + // node_id (the previous behaviour) left the name + // pinned to a sink that no longer exists, so + // `apply_pending_routes` would queue every bypass + // route forever against a stale target name. The + // F4 fallback or a fresh `default.audio.sink` event + // can then pick a replacement. + if s.real_sink.name.as_deref() == Some(name.as_str()) { + s.real_sink.name = None; + s.real_sink.node_id = None; + } else if s.real_sink.node_id == Some(node_id) { + // Defensive: id matched but name didn't (sinks + // shouldn't double-register). Null the id. s.real_sink.node_id = None; } false