feat(host): fan out desktop audio through owned links
This commit is contained in:
+516
-34
@@ -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(¤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);
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user