//! Pure Phase-6 fan-out planning. //! //! This module converts one coherent taint decision into exact per-port link //! 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. 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, ECHO_CANCEL_GROUP_PREFIX, ExclusionCtx, Reason, StickyState, evaluate, }; use crate::common::output::AudioExclusionEvent; use tokio::sync::mpsc; 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: 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)] pub(super) enum PlanIssue { CaptureSinkMissing, CaptureSinkNotASink, MissingOutputPorts, MissingCapturePorts, UnidentifiedChannel, AmbiguousCaptureChannel, DuplicateOutputChannel, IncompatibleChannelLayout, ExclusiveCapturePort, LinkCreationFailed, } impl PlanIssue { pub(super) fn code(self) -> &'static str { match self { Self::CaptureSinkMissing => "capture-sink-missing", Self::CaptureSinkNotASink => "capture-sink-not-a-sink", Self::MissingOutputPorts => "missing-output-ports", Self::MissingCapturePorts => "missing-capture-ports", Self::UnidentifiedChannel => "unidentified-channel", Self::AmbiguousCaptureChannel => "ambiguous-capture-channel", Self::DuplicateOutputChannel => "duplicate-output-channel", Self::IncompatibleChannelLayout => "incompatible-channel-layout", Self::ExclusiveCapturePort => "exclusive-capture-port", Self::LinkCreationFailed => "link-creation-failed", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum StreamPlan { Excluded { reason: Reason }, Unsupported { issue: PlanIssue }, Capture { links: BTreeSet }, } /// 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, ) -> BTreeMap; } /// 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, ); /// 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)] 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, foreign_aec_groups: BTreeSet, status_tx: Option>, } impl FanoutController { #[cfg(test)] pub(super) fn new(capture_sink: Serial, aec: AecConfig) -> Self { Self::build(capture_sink, aec, None) } /// Construct the production controller with a non-blocking status path. /// The PipeWire callback only enqueues owned records; JSON serialization /// and stdout I/O happen on a Tokio task outside the graph loop. pub(super) fn with_status_sender( capture_sink: Serial, aec: AecConfig, status_tx: mpsc::UnboundedSender, ) -> Self { Self::build(capture_sink, aec, Some(status_tx)) } fn build( capture_sink: Serial, aec: AecConfig, status_tx: Option>, ) -> Self { Self { capture_sink, validator: AecValidator::new(aec, AEC_VALIDATION_TIMEOUT_MILLIS), sticky: StickyState::default(), states: BTreeMap::new(), foreign_aec_groups: BTreeSet::new(), status_tx, } } fn emit_status(&self, event: AudioExclusionEvent) { if let Some(status_tx) = &self.status_tx { // A closed receiver means the machine front-end no longer accepts // notices. Capture ownership remains safe and should not be // poisoned merely because its best-effort stdout consumer ended. let _ = status_tx.send(event); } } #[cfg(test)] fn states(&self) -> &BTreeMap { &self.states } } impl MutationProjectionSink for FanoutController { fn on_projection( &mut self, projection: &Projection, _kind: EventKind, now_us: u64, links: &mut dyn LinkMutation, ) { let prior_aec_state = self.validator.state(); self.validator .observe(&projection.snapshot, projection.graph_ready, now_us / 1_000); let aec_state = self.validator.state(); let aec_status = if prior_aec_state != aec_state { self.validator .configured_module_id() .and_then(|module_index| match aec_state { AecState::Failed => Some(AudioExclusionEvent::AecFailed { module_index }), AecState::Revoked => Some(AudioExclusionEvent::AecRevoked { module_index }), _ => None, }) } else { None }; let configured_aec = self.validator.configured_module_id(); let foreign_aec_groups: BTreeSet = projection .snapshot .nodes() .filter_map(|node| { let group = node.props.link_group.as_ref()?; (group.starts_with(ECHO_CANCEL_GROUP_PREFIX) && configured_aec .is_none_or(|module| node.props.pulse_module_id != Some(module))) .then(|| group.clone()) }) .collect(); let new_foreign_groups: Vec = foreign_aec_groups .difference(&self.foreign_aec_groups) .cloned() .collect(); 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 = 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 = 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 = 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(); let mut stream_statuses = Vec::new(); 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" ); stream_statuses.push(AudioExclusionEvent::StreamUnsupported { stream_serial: serial.0, reason: issue.code(), }); } StreamCaptureState::Captured => tracing::info!( serial = serial.0, "audio fan-out: every required link is active" ), _ => {} } } self.states = next_states; self.foreign_aec_groups = foreign_aec_groups; // `reconcile` above has already dropped every link forbidden by an AEC // transition or a newly unsafe graph. Only report after that mutation // boundary, so `aec_revoked` can never race ahead of link revocation. if let Some(event) = aec_status { self.emit_status(event); } for link_group in new_foreign_groups { self.emit_status(AudioExclusionEvent::ForeignAecWarning { link_group }); } for event in stream_statuses { 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. /// /// The caller must pass `decisions` produced from this exact `snapshot`. The /// mutation edge upholds that condition structurally by evaluating and planning /// inline in one observer callback; keeping this function pure makes the port /// matrix independently falsifiable. pub(super) fn plan( snapshot: &GraphSnapshot, decisions: &Decisions, capture_sink: Serial, ) -> BTreeMap { let sink = snapshot.node(capture_sink); let sink_issue = match sink { None => Some(PlanIssue::CaptureSinkMissing), Some(node) if node.role != MediaRole::Sink => Some(PlanIssue::CaptureSinkNotASink), Some(_) => None, }; decisions .candidates .iter() .map(|(&serial, decision)| { let stream_plan = if let Some(reason) = decision.reason() { StreamPlan::Excluded { reason } } else if let Some(issue) = sink_issue { StreamPlan::Unsupported { issue } } else { let node = snapshot .node(serial) .expect("a decision from this snapshot names a live node"); match pair_ports( 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 }, } }; (serial, stream_plan) }) .collect() } fn pair_ports<'a>( candidate_ports: impl Iterator, capture_ports: impl Iterator, output_node: GlobalId, output_node_serial: Serial, input_node: GlobalId, input_node_serial: Serial, ) -> Result, PlanIssue> { let outputs: Vec<&PortSnapshot> = candidate_ports .filter(|port| port.direction == PortDirection::Out && !port.monitor) .collect(); if outputs.is_empty() { return Err(PlanIssue::MissingOutputPorts); } let inputs: Vec<&PortSnapshot> = capture_ports .filter(|port| port.direction == PortDirection::In) .collect(); if inputs.is_empty() { return Err(PlanIssue::MissingCapturePorts); } if inputs.iter().any(|port| port.exclusive) { return Err(PlanIssue::ExclusiveCapturePort); } 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).is_some() { return Err(PlanIssue::AmbiguousCaptureChannel); } } let mut output_channels = BTreeSet::new(); let mut links = BTreeSet::new(); for output in outputs { let channel = output .channel .as_deref() .ok_or(PlanIssue::UnidentifiedChannel)?; if !output_channels.insert(channel) { return Err(PlanIssue::DuplicateOutputChannel); } if channel == "MONO" { for &input in inputs_by_channel.values() { links.insert(DesiredLink { 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) = inputs_by_channel.get(channel) else { return Err(PlanIssue::IncompatibleChannelLayout); }; links.insert(DesiredLink { 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) } #[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, NodeProps, PortDirection}; use crate::host::taint::{ExclusionCtx, StickyState, evaluate}; fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) { let snapshot = graph.build(); let (decisions, _) = evaluate( &snapshot, &ExclusionCtx { pipewire_pulse_pid: Some(PULSE_PID), graph_ready: true, ..ExclusionCtx::default() }, &StickyState::default(), ); (snapshot, decisions) } fn stereo_graph() -> (Graph, Serial, Serial, [GlobalId; 4]) { let mut graph = Graph::new(); let app = graph.app_node("music", MediaRole::StreamOutput, 42); let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43); let app_fl = graph.port_on_channel(app, PortDirection::Out, false, Some("FL")); let app_fr = graph.port_on_channel(app, PortDirection::Out, false, Some("FR")); let sink_fl = graph.port_on_channel(sink, PortDirection::In, false, Some("FL")); let sink_fr = graph.port_on_channel(sink, PortDirection::In, false, Some("FR")); ( graph, app.serial, sink.serial, [app_fl, app_fr, sink_fl, sink_fr], ) } 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, creates: usize, drops: usize, next_state: BTreeMap, } impl LinkMutation for FakeLinks { fn reconcile( &mut self, snapshot: &GraphSnapshot, desired: &BTreeSet, ) -> BTreeMap { let current: BTreeSet = desired .iter() .copied() .filter(|link| link.is_current(snapshot)) .collect(); self.drops += self.held.difference(¤t).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); } fn controller_with_status( capture_sink: Serial, aec: AecConfig, ) -> ( FanoutController, mpsc::UnboundedReceiver, ) { let (status_tx, status_rx) = mpsc::unbounded_channel(); ( FanoutController::with_status_sender(capture_sink, aec, status_tx), status_rx, ) } #[test] fn stereo_ports_pair_by_channel_not_enumeration_order() { let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph(); let (snapshot, decisions) = decisions(&graph); assert_eq!( plan(&snapshot, &decisions, sink).get(&app), Some(&StreamPlan::Capture { links: BTreeSet::from([ DesiredLink { output: endpoint(&snapshot, app, app_fl), input: endpoint(&snapshot, sink, sink_fl), }, DesiredLink { output: endpoint(&snapshot, app, app_fr), input: endpoint(&snapshot, sink, sink_fr), }, ]), }) ); } #[test] fn mono_fans_to_both_stereo_inputs() { let mut graph = Graph::new(); let app = graph.app_node("mono", MediaRole::StreamOutput, 42); let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43); let mono = graph.port_on_channel(app, PortDirection::Out, false, Some("MONO")); let left = graph.port_on_channel(sink, PortDirection::In, false, Some("FL")); let right = graph.port_on_channel(sink, PortDirection::In, false, Some("FR")); let (snapshot, decisions) = decisions(&graph); let StreamPlan::Capture { links } = plan(&snapshot, &decisions, sink.serial)[&app.serial].clone() else { panic!("mono stream must be plannable"); }; assert_eq!(links.len(), 2); assert_eq!( links .iter() .map(|link| link.output.port_id) .collect::>(), BTreeSet::from([mono]) ); assert_eq!( links .iter() .map(|link| link.input.port_id) .collect::>(), BTreeSet::from([left, right]) ); } #[test] fn unknown_or_incompatible_layout_is_unsupported_not_guessed() { let (mut graph, app, sink, _) = stereo_graph(); let surround = graph.app_node("surround", MediaRole::StreamOutput, 44); graph.port_on_channel(surround, PortDirection::Out, false, Some("FC")); let unknown = graph.app_node("unknown", MediaRole::StreamOutput, 45); graph.port_on_channel(unknown, PortDirection::Out, false, None); let (snapshot, decisions) = decisions(&graph); let plans = plan(&snapshot, &decisions, sink); assert!(matches!(plans[&app], StreamPlan::Capture { .. })); assert_eq!( plans[&surround.serial], StreamPlan::Unsupported { issue: PlanIssue::IncompatibleChannelLayout } ); assert_eq!( plans[&unknown.serial], StreamPlan::Unsupported { issue: PlanIssue::UnidentifiedChannel } ); } #[test] fn excluded_candidate_never_produces_a_link_plan() { let (mut graph, app, sink, _) = stereo_graph(); let call = graph.peerspeak_node("call", 7); graph.port_on_channel(call, PortDirection::Out, false, Some("FL")); let (snapshot, decisions) = decisions(&graph); let plans = plan(&snapshot, &decisions, sink); assert!(matches!(plans[&app], StreamPlan::Capture { .. })); assert_eq!( plans[&call.serial], StreamPlan::Excluded { reason: Reason::PeerspeakOwned } ); } #[test] fn replacing_the_capture_sink_changes_every_target_port() { 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 (snapshot, decisions) = decisions(&graph); let old = plan(&snapshot, &decisions, old_sink); let new = plan(&snapshot, &decisions, new_sink.serial); let StreamPlan::Capture { links: old } = &old[&app] else { panic!("old sink plan"); }; let StreamPlan::Capture { links: new } = &new[&app] else { panic!("new sink plan"); }; assert_ne!(old, new); assert_eq!( new.iter() .map(|link| link.input.port_id) .collect::>(), BTreeSet::from([new_fl, new_fr]) ); } #[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::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(); 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 aec_validation_deadline_emits_the_failed_cause_once() { let (graph, app, sink, _) = stereo_graph(); let mut links = FakeLinks::default(); let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::PulseModule(999)); drive(&mut controller, &graph, &mut links); assert!(matches!( controller.states()[&app], StreamCaptureState::Gated { aec: AecState::Validating } )); controller.on_projection( &projection(&graph), EventKind::Tick, (AEC_VALIDATION_TIMEOUT_MILLIS + 2) * 1_000, &mut links, ); assert_eq!( status_rx.try_recv(), Ok(AudioExclusionEvent::AecFailed { module_index: 999 }) ); assert!(matches!( controller.states()[&app], StreamCaptureState::Gated { aec: AecState::Failed } )); controller.on_projection( &projection(&graph), EventKind::Tick, (AEC_VALIDATION_TIMEOUT_MILLIS + 3) * 1_000, &mut links, ); assert_eq!( status_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty), "a sticky failed state must not repeat the event on every tick" ); } #[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, mut status_rx) = controller_with_status(sink, AecConfig::Off); drive(&mut controller, &graph, &mut links); assert_eq!( controller.states()[&app], StreamCaptureState::Unsupported { issue: PlanIssue::LinkCreationFailed } ); assert_eq!(links.creates, 2); assert_eq!( status_rx.try_recv(), Ok(AudioExclusionEvent::StreamUnsupported { stream_serial: app.0, reason: "link-creation-failed", }) ); drive(&mut controller, &graph, &mut links); assert_eq!( links.creates, 2, "an identical failed intent must not hot-loop" ); assert_eq!( status_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty), "an unchanged unsupported stream must not repeat its event" ); } #[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, mut status_rx) = controller_with_status(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 } ); assert_eq!( status_rx.try_recv(), Ok(AudioExclusionEvent::AecRevoked { module_index: 999 }) ); } #[test] fn second_echo_cancel_group_emits_one_foreign_warning() { use crate::host::taint::fixture::pulse_module; let (mut graph, _app, sink, _) = stereo_graph(); let own_client = graph.client_of_module(999); let mut own_props = pulse_module(own_client, 999, PULSE_PID); own_props.link_group = Some("echo-cancel-owned-13".to_string()); graph.node("owned-aec-playback", MediaRole::StreamOutput, own_props); let foreign_client = graph.client_of_module(1_000); let mut foreign_props = pulse_module(foreign_client, 1_000, PULSE_PID); foreign_props.link_group = Some("echo-cancel-foreign-14".to_string()); graph.node( "foreign-aec-playback", MediaRole::StreamOutput, foreign_props.clone(), ); graph.node("foreign-aec-capture", MediaRole::StreamInput, foreign_props); let mut links = FakeLinks::default(); let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::PulseModule(999)); drive(&mut controller, &graph, &mut links); assert_eq!( status_rx.try_recv(), Ok(AudioExclusionEvent::ForeignAecWarning { link_group: "echo-cancel-foreign-14".to_string(), }) ); assert_eq!( status_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty), "all legs in one foreign group collapse to one warning" ); drive(&mut controller, &graph, &mut links); assert_eq!( status_rx.try_recv(), Err(mpsc::error::TryRecvError::Empty), "a still-live foreign group must not warn on every graph event" ); } #[test] fn unindexed_native_echo_cancel_group_is_still_foreign() { let (mut graph, _app, sink, _) = stereo_graph(); graph.group_node( "native-aec-playback", MediaRole::StreamOutput, "echo-cancel-native-1", 77, ); let mut links = FakeLinks::default(); let (mut controller, mut status_rx) = controller_with_status(sink, AecConfig::Off); drive(&mut controller, &graph, &mut links); assert_eq!( status_rx.try_recv(), Ok(AudioExclusionEvent::ForeignAecWarning { link_group: "echo-cancel-native-1".to_string(), }) ); } }