feat(host): recover desktop audio fanout after sink replacement

This commit is contained in:
2026-08-21 17:00:30 -04:00
parent 5a65f50c4b
commit d09ee9b02f
5 changed files with 632 additions and 67 deletions
+61
View File
@@ -133,6 +133,19 @@ pub(super) trait MutationProjectionSink: Send {
now_us: u64,
links: &mut dyn LinkMutation,
);
/// Retarget a running controller to the exact serial of a recreated
/// connection-owned capture sink, then reconcile immediately against the
/// observer's current coherent projection. The adapter invokes this only
/// on its PipeWire loop thread, keeping old-link revocation and new-link
/// creation ordered with ordinary registry observations.
fn replace_capture_sink(
&mut self,
capture_sink: Serial,
projection: &Projection,
now_us: u64,
links: &mut dyn LinkMutation,
);
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -339,6 +352,24 @@ impl MutationProjectionSink for FanoutController {
self.emit_status(event);
}
}
fn replace_capture_sink(
&mut self,
capture_sink: Serial,
projection: &Projection,
now_us: u64,
links: &mut dyn LinkMutation,
) {
if self.capture_sink == capture_sink {
return;
}
self.capture_sink = capture_sink;
// This is an explicit owner control message rather than a model event,
// so it must not be coalesced behind the next registry callback. The
// normal projection path is still the single place that evaluates,
// plans, revokes stale proxies, and determines all-links-ACTIVE.
self.on_projection(projection, EventKind::Graph, now_us, links);
}
}
/// Plan every candidate in the decision universe against one capture sink.
@@ -712,6 +743,36 @@ mod tests {
);
}
#[test]
fn replacing_the_capture_sink_drops_stale_links_and_recaptures() {
let (mut graph, app, old_sink, _) = stereo_graph();
let new_sink = graph.native_virtual_node("pixelpass_capture_new", MediaRole::Sink, 46);
let new_fl = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FL"));
let new_fr = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FR"));
let projection = projection(&graph);
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(old_sink, AecConfig::Off);
controller.on_projection(&projection, EventKind::Graph, 1_000, &mut links);
assert_eq!(links.creates, 2);
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
controller.replace_capture_sink(new_sink.serial, &projection, 2_000, &mut links);
assert_eq!(links.drops, 2, "every old-sink proxy must be dropped");
assert_eq!(links.creates, 4, "both replacement links must be created");
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
assert_eq!(
links
.held
.iter()
.map(|link| link.input.port_id)
.collect::<BTreeSet<_>>(),
BTreeSet::from([new_fl, new_fr]),
"only replacement-sink ports may remain held"
);
}
#[test]
fn serial_revalidation_rejects_a_recycled_node_id_before_mutation() {
let (mut graph, app, sink, _) = stereo_graph();