feat(host): fan out desktop audio through owned links

This commit is contained in:
2026-08-21 16:12:52 -04:00
parent 781defcd84
commit 98cde2c19b
4 changed files with 1171 additions and 84 deletions
+516 -34
View File
@@ -1,29 +1,61 @@
//! Pure Phase-6 fan-out planning.
//!
//! This module converts one coherent taint decision into exact per-port link
//! specifications. It deliberately cannot create PipeWire objects: the
//! mutation edge will consume this plan on the observer thread, re-evaluate in
//! that same callback, then retain the resulting non-lingering link proxies.
//! specifications and owns the pure state around the mutation edge. PipeWire
//! object creation remains behind [`LinkMutation`], implemented by the registry
//! adapter on its main-loop thread. That split keeps policy and the link-manager
//! matrix deterministic while making it impossible for Tokio to address a
//! recyclable global id directly.
//!
//! Port identity is `audio.channel`, never registry enumeration order or a
//! recyclable global id. An unknown or incompatible layout makes that stream
//! unsupported; it never produces a guessed partial capture.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use super::aec::{AecConfig, AecState, AecValidator};
use super::observer::{EventKind, Millis, Projection};
use super::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, PortDirection, PortSnapshot, Serial,
};
use super::taint::{Decisions, Reason};
use super::taint::{Decisions, ExclusionCtx, Reason, StickyState, evaluate};
const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
/// One endpoint identity carried from a coherent graph snapshot to the
/// immediate mutation edge. IDs select the server objects; serials prove the
/// IDs still name those same objects. Neither may be omitted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct DesiredEndpoint {
pub(super) node_id: GlobalId,
pub(super) node_serial: Serial,
pub(super) port_id: GlobalId,
pub(super) port_serial: Serial,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct DesiredLink {
pub(super) output_node: GlobalId,
pub(super) output_port: GlobalId,
pub(super) input_node: GlobalId,
pub(super) input_port: GlobalId,
pub(super) output: DesiredEndpoint,
pub(super) input: DesiredEndpoint,
}
impl DesiredLink {
/// Revalidate every recyclable id against its never-recycled serial.
/// Called by the production mutator immediately before `create_object`.
pub(super) fn is_current(self, snapshot: &GraphSnapshot) -> bool {
endpoint_is_current(snapshot, self.output) && endpoint_is_current(snapshot, self.input)
}
}
fn endpoint_is_current(snapshot: &GraphSnapshot, endpoint: DesiredEndpoint) -> bool {
snapshot
.node(endpoint.node_serial)
.is_some_and(|node| node.id == endpoint.node_id)
&& snapshot.ports().any(|port| {
port.serial == endpoint.port_serial
&& port.id == endpoint.port_id
&& port.node == endpoint.node_id
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -37,6 +69,7 @@ pub(super) enum PlanIssue {
DuplicateOutputChannel,
IncompatibleChannelLayout,
ExclusiveCapturePort,
LinkCreationFailed,
}
impl PlanIssue {
@@ -51,6 +84,7 @@ impl PlanIssue {
Self::DuplicateOutputChannel => "duplicate-output-channel",
Self::IncompatibleChannelLayout => "incompatible-channel-layout",
Self::ExclusiveCapturePort => "exclusive-capture-port",
Self::LinkCreationFailed => "link-creation-failed",
}
}
}
@@ -62,6 +96,165 @@ pub(super) enum StreamPlan {
Capture { links: BTreeSet<DesiredLink> },
}
/// State of one retained link proxy as observed from its bound PipeWire Link.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ManagedLinkState {
Pending,
Active,
Failed,
}
/// The narrow mutation capability available to the Phase-6 controller. The
/// adapter owns the PipeWire implementation and all proxies. Tests inject a
/// ledger with no PipeWire dependency.
pub(super) trait LinkMutation {
/// Replace the owned set with `desired`, dropping obsolete proxies first.
/// Implementations must revalidate [`DesiredLink::is_current`] against
/// this exact snapshot immediately before each creation.
fn reconcile(
&mut self,
snapshot: &GraphSnapshot,
desired: &BTreeSet<DesiredLink>,
) -> BTreeMap<DesiredLink, ManagedLinkState>;
}
/// A projection consumer that is explicitly allowed to mutate the graph.
/// Kept separate from the read-only observer sink trait so the Phase-5 audit
/// still has no mutation capability in its type signature.
pub(super) trait MutationProjectionSink: Send {
fn on_projection(
&mut self,
projection: &Projection,
kind: EventKind,
now_us: u64,
links: &mut dyn LinkMutation,
);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum StreamCaptureState {
Excluded { reason: Reason },
Unsupported { issue: PlanIssue },
Gated { aec: AecState },
Linking,
Captured,
}
/// Phases 2, 4, and the pure port planner folded into one per-event controller.
/// It never retains a global id between callbacks: only sticky serial identity
/// and the capture sink's serial survive a recompute.
pub(super) struct FanoutController {
capture_sink: Serial,
validator: AecValidator,
sticky: StickyState,
states: BTreeMap<Serial, StreamCaptureState>,
}
impl FanoutController {
pub(super) fn new(capture_sink: Serial, aec: AecConfig) -> Self {
Self {
capture_sink,
validator: AecValidator::new(aec, AEC_VALIDATION_TIMEOUT_MILLIS),
sticky: StickyState::default(),
states: BTreeMap::new(),
}
}
#[cfg(test)]
fn states(&self) -> &BTreeMap<Serial, StreamCaptureState> {
&self.states
}
}
impl MutationProjectionSink for FanoutController {
fn on_projection(
&mut self,
projection: &Projection,
_kind: EventKind,
now_us: u64,
links: &mut dyn LinkMutation,
) {
self.validator
.observe(&projection.snapshot, projection.graph_ready, now_us / 1_000);
let aec_state = self.validator.state();
let ctx = ExclusionCtx {
aec_module_id: self.validator.validated_module_id(),
pipewire_pulse_pid: projection.pipewire_pulse_pid,
pixelpass_owned: BTreeSet::from([self.capture_sink]),
graph_ready: projection.graph_ready,
};
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
self.sticky = sticky;
let plans = plan(&projection.snapshot, &decisions, self.capture_sink);
let desired: BTreeSet<DesiredLink> = if self.validator.fan_out_permitted() {
plans
.values()
.filter_map(|plan| match plan {
StreamPlan::Capture { links } => Some(links.iter().copied()),
_ => None,
})
.flatten()
.collect()
} else {
BTreeSet::new()
};
let managed = links.reconcile(&projection.snapshot, &desired);
let next_states: BTreeMap<Serial, StreamCaptureState> = plans
.into_iter()
.map(|(serial, plan)| {
let state = match plan {
StreamPlan::Excluded { reason } => StreamCaptureState::Excluded { reason },
StreamPlan::Unsupported { issue } => StreamCaptureState::Unsupported { issue },
StreamPlan::Capture { .. } if !self.validator.fan_out_permitted() => {
StreamCaptureState::Gated { aec: aec_state }
}
StreamPlan::Capture { links: planned } => {
let link_states: Vec<ManagedLinkState> = planned
.iter()
.filter_map(|link| managed.get(link).copied())
.collect();
if link_states.len() != planned.len()
|| link_states.contains(&ManagedLinkState::Failed)
{
StreamCaptureState::Unsupported {
issue: PlanIssue::LinkCreationFailed,
}
} else if link_states
.iter()
.all(|state| *state == ManagedLinkState::Active)
{
StreamCaptureState::Captured
} else {
StreamCaptureState::Linking
}
}
};
(serial, state)
})
.collect();
for (&serial, state) in &next_states {
if self.states.get(&serial) == Some(state) {
continue;
}
match state {
StreamCaptureState::Unsupported { issue } => tracing::warn!(
serial = serial.0,
reason = issue.code(),
"audio fan-out: stream unsupported"
),
StreamCaptureState::Captured => tracing::info!(
serial = serial.0,
"audio fan-out: every required link is active"
),
_ => {}
}
}
self.states = next_states;
}
}
/// Plan every candidate in the decision universe against one capture sink.
///
/// The caller must pass `decisions` produced from this exact `snapshot`. The
@@ -96,7 +289,9 @@ pub(super) fn plan(
snapshot.ports_of(node.id),
snapshot.ports_of(sink.expect("validated above").id),
node.id,
node.serial,
sink.expect("validated above").id,
sink.expect("validated above").serial,
) {
Ok(links) => StreamPlan::Capture { links },
Err(issue) => StreamPlan::Unsupported { issue },
@@ -111,7 +306,9 @@ fn pair_ports<'a>(
candidate_ports: impl Iterator<Item = &'a PortSnapshot>,
capture_ports: impl Iterator<Item = &'a PortSnapshot>,
output_node: GlobalId,
output_node_serial: Serial,
input_node: GlobalId,
input_node_serial: Serial,
) -> Result<BTreeSet<DesiredLink>, PlanIssue> {
let outputs: Vec<&PortSnapshot> = candidate_ports
.filter(|port| port.direction == PortDirection::Out && !port.monitor)
@@ -129,13 +326,13 @@ fn pair_ports<'a>(
return Err(PlanIssue::ExclusiveCapturePort);
}
let mut inputs_by_channel: BTreeMap<&str, GlobalId> = BTreeMap::new();
let mut inputs_by_channel: BTreeMap<&str, &PortSnapshot> = BTreeMap::new();
for port in &inputs {
let channel = port
.channel
.as_deref()
.ok_or(PlanIssue::UnidentifiedChannel)?;
if inputs_by_channel.insert(channel, port.id).is_some() {
if inputs_by_channel.insert(channel, port).is_some() {
return Err(PlanIssue::AmbiguousCaptureChannel);
}
}
@@ -151,24 +348,40 @@ fn pair_ports<'a>(
return Err(PlanIssue::DuplicateOutputChannel);
}
if channel == "MONO" {
for &input_port in inputs_by_channel.values() {
for &input in inputs_by_channel.values() {
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
output: DesiredEndpoint {
node_id: output_node,
node_serial: output_node_serial,
port_id: output.id,
port_serial: output.serial,
},
input: DesiredEndpoint {
node_id: input_node,
node_serial: input_node_serial,
port_id: input.id,
port_serial: input.serial,
},
});
}
continue;
}
let Some(&input_port) = inputs_by_channel.get(channel) else {
let Some(&input) = inputs_by_channel.get(channel) else {
return Err(PlanIssue::IncompatibleChannelLayout);
};
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
output: DesiredEndpoint {
node_id: output_node,
node_serial: output_node_serial,
port_id: output.id,
port_serial: output.serial,
},
input: DesiredEndpoint {
node_id: input_node,
node_serial: input_node_serial,
port_id: input.id,
port_serial: input.serial,
},
});
}
Ok(links)
@@ -177,8 +390,9 @@ fn pair_ports<'a>(
#[cfg(test)]
mod tests {
use super::*;
use crate::host::observer::Readiness;
use crate::host::taint::fixture::{Graph, PULSE_PID};
use crate::host::taint::snapshot::{MediaRole, PortDirection};
use crate::host::taint::snapshot::{MediaRole, NodeProps, PortDirection};
use crate::host::taint::{ExclusionCtx, StickyState, evaluate};
fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) {
@@ -211,6 +425,77 @@ mod tests {
)
}
fn endpoint(
snapshot: &GraphSnapshot,
node_serial: Serial,
port_id: GlobalId,
) -> DesiredEndpoint {
let node = snapshot.node(node_serial).expect("fixture node");
let port = snapshot
.ports()
.find(|port| port.id == port_id && port.node == node.id)
.expect("fixture port");
DesiredEndpoint {
node_id: node.id,
node_serial,
port_id,
port_serial: port.serial,
}
}
fn projection(graph: &Graph) -> Projection {
projection_from(graph.build())
}
fn projection_from(snapshot: GraphSnapshot) -> Projection {
Projection {
snapshot,
pipewire_pulse_pid: Some(PULSE_PID),
graph_ready: true,
readiness: Readiness::Complete,
}
}
#[derive(Default)]
struct FakeLinks {
held: BTreeSet<DesiredLink>,
creates: usize,
drops: usize,
next_state: BTreeMap<DesiredLink, ManagedLinkState>,
}
impl LinkMutation for FakeLinks {
fn reconcile(
&mut self,
snapshot: &GraphSnapshot,
desired: &BTreeSet<DesiredLink>,
) -> BTreeMap<DesiredLink, ManagedLinkState> {
let current: BTreeSet<DesiredLink> = desired
.iter()
.copied()
.filter(|link| link.is_current(snapshot))
.collect();
self.drops += self.held.difference(&current).count();
self.creates += current.difference(&self.held).count();
self.held = current.clone();
current
.into_iter()
.map(|link| {
let state = self
.next_state
.get(&link)
.copied()
.unwrap_or(ManagedLinkState::Active);
(link, state)
})
.collect()
}
}
fn drive(controller: &mut FanoutController, graph: &Graph, links: &mut FakeLinks) {
controller.on_projection(&projection(graph), EventKind::Graph, 1_000, links);
}
#[test]
fn stereo_ports_pair_by_channel_not_enumeration_order() {
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
@@ -220,16 +505,12 @@ mod tests {
Some(&StreamPlan::Capture {
links: BTreeSet::from([
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fl,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fl,
output: endpoint(&snapshot, app, app_fl),
input: endpoint(&snapshot, sink, sink_fl),
},
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fr,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fr,
output: endpoint(&snapshot, app, app_fr),
input: endpoint(&snapshot, sink, sink_fr),
},
]),
})
@@ -254,14 +535,14 @@ mod tests {
assert_eq!(
links
.iter()
.map(|link| link.output_port)
.map(|link| link.output.port_id)
.collect::<BTreeSet<_>>(),
BTreeSet::from([mono])
);
assert_eq!(
links
.iter()
.map(|link| link.input_port)
.map(|link| link.input.port_id)
.collect::<BTreeSet<_>>(),
BTreeSet::from([left, right])
);
@@ -325,9 +606,210 @@ mod tests {
assert_ne!(old, new);
assert_eq!(
new.iter()
.map(|link| link.input_port)
.map(|link| link.input.port_id)
.collect::<BTreeSet<_>>(),
BTreeSet::from([new_fl, new_fr])
);
}
#[test]
fn serial_revalidation_rejects_a_recycled_node_id_before_mutation() {
let (mut graph, app, sink, _) = stereo_graph();
let (snapshot, decisions) = decisions(&graph);
let StreamPlan::Capture { links } = &plan(&snapshot, &decisions, sink)[&app] else {
panic!("fixture must plan links");
};
assert!(links.iter().all(|link| link.is_current(&snapshot)));
let old_app_id = snapshot.node(app).expect("old app").id;
let reborn = graph.node_with_id(
"reborn",
MediaRole::StreamOutput,
old_app_id,
NodeProps::default(),
);
graph.port_on_channel(reborn, PortDirection::Out, false, Some("FL"));
graph.port_on_channel(reborn, PortDirection::Out, false, Some("FR"));
let recycled = graph.build_without(&[crate::host::taint::fixture::NodeRef {
serial: app,
id: old_app_id,
}]);
assert!(
links.iter().all(|link| !link.is_current(&recycled)),
"the same global id with a new serial must not be addressable"
);
}
#[test]
fn duplicate_projection_is_idempotent_and_all_links_must_activate() {
let (graph, app, sink, _) = stereo_graph();
let first_projection = projection(&graph);
let decisions = evaluate(
&first_projection.snapshot,
&ExclusionCtx {
graph_ready: true,
pipewire_pulse_pid: Some(PULSE_PID),
..ExclusionCtx::default()
},
&StickyState::default(),
)
.0;
let StreamPlan::Capture { links: planned } =
&plan(&first_projection.snapshot, &decisions, sink)[&app]
else {
panic!("fixture must plan links");
};
let pending = *planned.iter().next().expect("stereo link");
let mut links = FakeLinks::default();
links.next_state.insert(pending, ManagedLinkState::Pending);
let mut controller = FanoutController::new(sink, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert_eq!(links.creates, 2);
assert_eq!(
controller.states()[&app],
StreamCaptureState::Linking,
"one pending channel prevents a captured verdict"
);
drive(&mut controller, &graph, &mut links);
assert_eq!(
links.creates, 2,
"the same node must not get a second link set"
);
links.next_state.clear();
drive(&mut controller, &graph, &mut links);
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
}
#[test]
fn newly_unsafe_ancestry_drops_owned_links_before_any_replacement() {
let (mut graph, app, sink, _) = stereo_graph();
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(sink, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert_eq!(links.held.len(), 2);
let call = graph.peerspeak_node("call", 7);
let app_id = graph.build().node(app).expect("app remains live").id;
graph.link(
call,
crate::host::taint::fixture::NodeRef {
serial: app,
id: app_id,
},
);
drive(&mut controller, &graph, &mut links);
assert!(links.held.is_empty());
assert_eq!(links.drops, 2);
assert!(matches!(
controller.states()[&app],
StreamCaptureState::Excluded { .. }
));
}
#[test]
fn eligible_late_arrival_is_positively_captured() {
let mut graph = Graph::new();
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
graph.port_on_channel(sink, PortDirection::In, false, Some("FR"));
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(sink.serial, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert!(links.held.is_empty());
let late = graph.app_node("late", MediaRole::StreamOutput, 44);
graph.port_on_channel(late, PortDirection::Out, false, Some("FL"));
graph.port_on_channel(late, PortDirection::Out, false, Some("FR"));
drive(&mut controller, &graph, &mut links);
assert_eq!(links.creates, 2);
assert_eq!(
controller.states()[&late.serial],
StreamCaptureState::Captured
);
}
#[test]
fn configured_but_unvalidated_aec_gates_every_mutation() {
let (graph, app, sink, _) = stereo_graph();
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(sink, AecConfig::PulseModule(999));
drive(&mut controller, &graph, &mut links);
assert_eq!(links.creates, 0);
assert!(matches!(
controller.states()[&app],
StreamCaptureState::Gated {
aec: AecState::Validating
}
));
}
#[test]
fn link_creation_failure_is_sticky_for_the_same_intent() {
let (graph, app, sink, _) = stereo_graph();
let first_projection = projection(&graph);
let decisions = evaluate(
&first_projection.snapshot,
&ExclusionCtx {
graph_ready: true,
pipewire_pulse_pid: Some(PULSE_PID),
..ExclusionCtx::default()
},
&StickyState::default(),
)
.0;
let StreamPlan::Capture { links: planned } =
&plan(&first_projection.snapshot, &decisions, sink)[&app]
else {
panic!("fixture must plan links");
};
let mut links = FakeLinks::default();
links.next_state.insert(
*planned.iter().next().expect("stereo link"),
ManagedLinkState::Failed,
);
let mut controller = FanoutController::new(sink, AecConfig::Off);
drive(&mut controller, &graph, &mut links);
assert_eq!(
controller.states()[&app],
StreamCaptureState::Unsupported {
issue: PlanIssue::LinkCreationFailed
}
);
assert_eq!(links.creates, 2);
drive(&mut controller, &graph, &mut links);
assert_eq!(
links.creates, 2,
"an identical failed intent must not hot-loop"
);
}
#[test]
fn aec_revocation_drops_previously_active_fanout() {
let (mut graph, app, sink, _) = stereo_graph();
let aec = graph.module_node("aec-playback", MediaRole::StreamOutput, 999);
graph.port_on_channel(aec, PortDirection::Out, false, Some("FL"));
let mut links = FakeLinks::default();
let mut controller = FanoutController::new(sink, AecConfig::PulseModule(999));
drive(&mut controller, &graph, &mut links);
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
assert_eq!(links.held.len(), 2);
controller.on_projection(
&projection_from(graph.build_without(&[aec])),
EventKind::Graph,
2_000,
&mut links,
);
assert!(links.held.is_empty());
assert_eq!(links.drops, 2);
assert_eq!(
controller.states()[&app],
StreamCaptureState::Gated {
aec: AecState::Revoked
}
);
}
}