fix(F4): clear real_sink.name when the adopted sink departs

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.
This commit is contained in:
atagen 2026-05-21 19:44:49 +10:00
parent 5c769a1226
commit ec49206660

View file

@ -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