test(host): qualify Phase 6 AEC leak path
This commit is contained in:
+680
-1
@@ -199,10 +199,14 @@ impl CapturePlan {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::Quality;
|
||||
use crate::host::aec::AecConfig;
|
||||
use crate::host::fanout::TestFanoutPolicy;
|
||||
use crate::host::observer::{Projection, Readiness};
|
||||
use crate::host::taint::fixture::Graph;
|
||||
use nix::sys::signal::Signal;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::f64::consts::PI;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -313,6 +317,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct Phase6MeasurementBackend {
|
||||
aec: AecConfig,
|
||||
policy: TestFanoutPolicy,
|
||||
}
|
||||
|
||||
impl CapturePlanBackend for Phase6MeasurementBackend {
|
||||
async fn resolve_default_monitor(&mut self) -> Result<DefaultMonitor> {
|
||||
panic!("the Phase-6 measurement must use the hidden DesktopExcluding selector")
|
||||
}
|
||||
|
||||
async fn start_routing(
|
||||
&mut self,
|
||||
_opts: &HostOpts,
|
||||
_health: health::Reporter,
|
||||
) -> Result<Routing> {
|
||||
panic!("the Phase-6 measurement must not construct legacy Routing")
|
||||
}
|
||||
|
||||
async fn start_bare_capture_sink(
|
||||
&mut self,
|
||||
health: health::Reporter,
|
||||
) -> Result<BareCaptureSink> {
|
||||
BareCaptureSink::start_for_phase6_measurement(health, self.aec, self.policy).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_desktop_failure_is_closed(failure: anyhow::Error, expected_cause: &str) {
|
||||
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
|
||||
assert_eq!(
|
||||
@@ -935,4 +965,653 @@ mod tests {
|
||||
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
|
||||
assert!(health.fault().is_none());
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct LeakArm {
|
||||
label: &'static str,
|
||||
play_remote_tone: bool,
|
||||
policy: TestFanoutPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LeakMeasurement {
|
||||
label: &'static str,
|
||||
desktop_dbfs: f64,
|
||||
remote_dbfs: f64,
|
||||
}
|
||||
|
||||
struct PulseModuleGuard {
|
||||
id: Option<u64>,
|
||||
sink_name: String,
|
||||
source_name: String,
|
||||
}
|
||||
|
||||
impl PulseModuleGuard {
|
||||
fn load(label: &str) -> Result<Self> {
|
||||
let suffix = format!("{}_{}", std::process::id(), label);
|
||||
let sink_name = format!("pixelpass_phase6_aec_{suffix}_sink");
|
||||
let source_name = format!("pixelpass_phase6_aec_{suffix}_source");
|
||||
let output = Command::new("pactl")
|
||||
.args([
|
||||
"load-module",
|
||||
"module-echo-cancel",
|
||||
"aec_method=webrtc",
|
||||
&format!("sink_name={sink_name}"),
|
||||
&format!("source_name={source_name}"),
|
||||
])
|
||||
.output()
|
||||
.context("load the controlled Phase-6 echo-cancel module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl load-module module-echo-cancel failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let id = String::from_utf8(output.stdout)
|
||||
.context("echo-cancel module id was not UTF-8")?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.context("echo-cancel module id was not a bare u64")?;
|
||||
Ok(Self {
|
||||
id: Some(id),
|
||||
sink_name,
|
||||
source_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn id(&self) -> u64 {
|
||||
self.id.expect("live Pulse module guard")
|
||||
}
|
||||
|
||||
fn unload(&mut self) -> Result<()> {
|
||||
let Some(id) = self.id else {
|
||||
return Ok(());
|
||||
};
|
||||
let output = Command::new("pactl")
|
||||
.args(["unload-module", &id.to_string()])
|
||||
.output()
|
||||
.context("unload the exact Phase-6 echo-cancel module")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl unload-module {id} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
self.id = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PulseModuleGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.unload() {
|
||||
eprintln!("Phase-6 module cleanup failed: {error:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContainedProcess {
|
||||
child: Option<Child>,
|
||||
pid: u32,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl ContainedProcess {
|
||||
fn spawn(command: &mut Command, label: &'static str) -> Result<Self> {
|
||||
let child = crate::common::contained::spawn(command)
|
||||
.with_context(|| format!("spawn contained {label}"))?;
|
||||
let pid = child.id();
|
||||
Ok(Self {
|
||||
child: Some(child),
|
||||
pid,
|
||||
label,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_running(&mut self) -> Result<()> {
|
||||
let status = self
|
||||
.child
|
||||
.as_mut()
|
||||
.context("process was already stopped")?
|
||||
.try_wait()
|
||||
.with_context(|| format!("inspect {}", self.label))?;
|
||||
if let Some(status) = status {
|
||||
let stderr = read_child_stderr(
|
||||
self.child
|
||||
.as_mut()
|
||||
.expect("exited process stayed available for stderr"),
|
||||
);
|
||||
self.child.take();
|
||||
bail!("{} exited early with {status}: {stderr}", self.label);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<()> {
|
||||
let Some(mut child) = self.child.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let _ = crate::common::contained::signal_group(self.pid, Signal::SIGTERM);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let status = loop {
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.with_context(|| format!("wait for {}", self.label))?
|
||||
{
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = crate::common::contained::signal_group(self.pid, Signal::SIGKILL);
|
||||
break child
|
||||
.wait()
|
||||
.with_context(|| format!("reap {} after SIGKILL", self.label))?;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
let stderr = read_child_stderr(&mut child);
|
||||
if !stderr.trim().is_empty() {
|
||||
bail!("{} stopped with {status} and stderr: {stderr}", self.label);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ContainedProcess {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = crate::common::contained::signal_group(self.pid, Signal::SIGKILL);
|
||||
let _ = child.wait();
|
||||
let stderr = read_child_stderr(&mut child);
|
||||
if !stderr.trim().is_empty() {
|
||||
eprintln!("{} cleanup stderr: {stderr}", self.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_child_stderr(child: &mut Child) -> String {
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = child.stderr.take()
|
||||
&& let Err(error) = pipe.read_to_string(&mut stderr)
|
||||
{
|
||||
return format!("<failed to read stderr: {error}>");
|
||||
}
|
||||
stderr.trim().to_string()
|
||||
}
|
||||
|
||||
struct RawCaptureFile(PathBuf);
|
||||
|
||||
impl RawCaptureFile {
|
||||
fn new(label: &str) -> Self {
|
||||
Self(std::env::temp_dir().join(format!(
|
||||
"pixelpass-phase6-{}-{label}.s16le",
|
||||
std::process::id()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RawCaptureFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn pw_dump_objects(context: &str) -> Result<Vec<serde_json::Value>> {
|
||||
let output = Command::new("pw-dump")
|
||||
.output()
|
||||
.with_context(|| format!("run pw-dump for {context}"))?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pw-dump failed for {context}: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout)
|
||||
.with_context(|| format!("parse pw-dump JSON for {context}"))
|
||||
}
|
||||
|
||||
fn aec_playback_identity(module_id: u64) -> Result<(u64, String)> {
|
||||
let objects = pw_dump_objects("the Phase-6 AEC playback identity")?;
|
||||
// The role is deliberately filtered before the module identity. A
|
||||
// module-echo-cancel index labels four nodes; only its playback stream
|
||||
// is the hazardous fan-out candidate.
|
||||
let matches: Vec<(u64, String)> = objects
|
||||
.iter()
|
||||
.filter(|object| {
|
||||
object.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("PipeWire:Interface:Node")
|
||||
})
|
||||
.filter(|object| {
|
||||
object
|
||||
.pointer("/info/props/media.class")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("Stream/Output/Audio")
|
||||
})
|
||||
.filter(|object| {
|
||||
object
|
||||
.pointer("/info/props/pulse.module.id")
|
||||
.and_then(value_u64)
|
||||
== Some(module_id)
|
||||
})
|
||||
.filter_map(|object| {
|
||||
Some((
|
||||
object.get("id").and_then(value_u64)?,
|
||||
object
|
||||
.pointer("/info/props/node.name")
|
||||
.and_then(serde_json::Value::as_str)?
|
||||
.to_string(),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[identity] => Ok(identity.clone()),
|
||||
[] => bail!("module {module_id} has no Stream/Output/Audio playback node"),
|
||||
_ => bail!("module {module_id} has multiple Stream/Output/Audio nodes: {matches:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn native_links_from_node_id(
|
||||
output_id: u64,
|
||||
sink_name: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let objects = pw_dump_objects("the exact AEC playback links")?;
|
||||
let sink_id = objects
|
||||
.iter()
|
||||
.find(|object| {
|
||||
object.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("PipeWire:Interface:Node")
|
||||
&& object
|
||||
.pointer("/info/props/node.name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(sink_name)
|
||||
})
|
||||
.and_then(|object| object.get("id"))
|
||||
.and_then(value_u64)
|
||||
.context("link destination sink was absent from pw-dump")?;
|
||||
Ok(objects
|
||||
.into_iter()
|
||||
.filter(|object| {
|
||||
object.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("PipeWire:Interface:Link")
|
||||
&& object
|
||||
.pointer("/info/props/link.output.node")
|
||||
.and_then(value_u64)
|
||||
== Some(output_id)
|
||||
&& object
|
||||
.pointer("/info/props/link.input.node")
|
||||
.and_then(value_u64)
|
||||
== Some(sink_id)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn every_link_active(links: &[serde_json::Value]) -> bool {
|
||||
links.iter().all(|link| {
|
||||
link.pointer("/info/state")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("active")
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_named_links(output: &str, sink: &str, expected: usize) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Ok(links) = native_links(output, sink)
|
||||
&& links.len() == expected
|
||||
&& every_link_active(&links)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out waiting for {expected} ACTIVE links from {output} to {sink}: {:?}",
|
||||
native_links(output, sink)
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_id_links(output_id: u64, sink: &str, expected: usize) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Ok(links) = native_links_from_node_id(output_id, sink)
|
||||
&& links.len() == expected
|
||||
&& every_link_active(&links)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out waiting for {expected} ACTIVE links from node {output_id} to {sink}: {:?}",
|
||||
native_links_from_node_id(output_id, sink)
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_id_links_present(output_id: u64, sink: &str, expected: usize) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Ok(links) = native_links_from_node_id(output_id, sink)
|
||||
&& links.len() == expected
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out waiting for {expected} links from node {output_id} to {sink}: {:?}",
|
||||
native_links_from_node_id(output_id, sink)
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_aec_playback(module_id: u64) -> Result<(u64, String)> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match aec_playback_identity(module_id) {
|
||||
Ok(identity) => return Ok(identity),
|
||||
Err(_) if Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_sink_name() -> Result<String> {
|
||||
let output = Command::new("pactl")
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.context("read the default sink for the Phase-6 graph proof")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl get-default-sink failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let name = String::from_utf8(output.stdout)
|
||||
.context("default sink name was not UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
bail!("pactl get-default-sink returned an empty name");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn start_tone(
|
||||
frequency: u32,
|
||||
node_name: &str,
|
||||
sink: Option<&str>,
|
||||
owned: bool,
|
||||
) -> Result<ContainedProcess> {
|
||||
let mut command = Command::new("gst-launch-1.0");
|
||||
command.args([
|
||||
"-q",
|
||||
"audiotestsrc",
|
||||
"is-live=true",
|
||||
"wave=sine",
|
||||
&format!("freq={frequency}"),
|
||||
"volume=0.02",
|
||||
"!",
|
||||
"audioconvert",
|
||||
"!",
|
||||
"audioresample",
|
||||
"!",
|
||||
"audio/x-raw,rate=48000,channels=2",
|
||||
"!",
|
||||
"pulsesink",
|
||||
"sync=true",
|
||||
]);
|
||||
if let Some(sink) = sink {
|
||||
command.arg(format!("device={sink}"));
|
||||
}
|
||||
let pulse_props = if owned {
|
||||
format!("node.name={node_name} peerspeak.owned=1")
|
||||
} else {
|
||||
format!("node.name={node_name}")
|
||||
};
|
||||
command
|
||||
.env("PULSE_PROP", pulse_props)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped());
|
||||
ContainedProcess::spawn(&mut command, "Phase-6 tone generator")
|
||||
}
|
||||
|
||||
async fn record_capture(monitor_name: &str, label: &str) -> Result<Vec<u8>> {
|
||||
let raw = RawCaptureFile::new(label);
|
||||
let output_file =
|
||||
std::fs::File::create(&raw.0).with_context(|| format!("create {}", raw.0.display()))?;
|
||||
let mut command = Command::new("parec");
|
||||
command
|
||||
.args([
|
||||
"--record",
|
||||
"--raw",
|
||||
&format!("--device={monitor_name}"),
|
||||
"--rate=48000",
|
||||
"--format=s16le",
|
||||
"--channels=2",
|
||||
"--client-name=pixelpass-phase6-measurement",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(output_file))
|
||||
.stderr(Stdio::piped());
|
||||
let mut recorder = ContainedProcess::spawn(&mut command, "Phase-6 parec recorder")?;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
recorder.ensure_running()?;
|
||||
recorder.stop()?;
|
||||
std::fs::read(&raw.0).with_context(|| format!("read {}", raw.0.display()))
|
||||
}
|
||||
|
||||
fn tone_dbfs(raw: &[u8], frequency: f64) -> Result<f64> {
|
||||
const SAMPLE_RATE: f64 = 48_000.0;
|
||||
const CHANNELS: usize = 2;
|
||||
let frames = raw.len() / (std::mem::size_of::<i16>() * CHANNELS);
|
||||
if frames < SAMPLE_RATE as usize {
|
||||
bail!("capture was too short: {frames} stereo frames");
|
||||
}
|
||||
// Drop startup transients, then use a Hann-windowed exact-frequency
|
||||
// projection. This is a gross-leak qualification, not the Phase-9
|
||||
// PN/MLS intelligibility rig.
|
||||
let skip = SAMPLE_RATE as usize / 2;
|
||||
let count = frames - skip;
|
||||
let mut real = 0.0;
|
||||
let mut imag = 0.0;
|
||||
let mut window_sum = 0.0;
|
||||
for index in 0..count {
|
||||
let frame = skip + index;
|
||||
let offset = frame * 4;
|
||||
let left = i16::from_le_bytes([raw[offset], raw[offset + 1]]) as f64;
|
||||
let right = i16::from_le_bytes([raw[offset + 2], raw[offset + 3]]) as f64;
|
||||
let sample = (left + right) / 2.0;
|
||||
let window = 0.5 - 0.5 * (2.0 * PI * index as f64 / (count - 1) as f64).cos();
|
||||
let phase = 2.0 * PI * frequency * index as f64 / SAMPLE_RATE;
|
||||
real += sample * window * phase.cos();
|
||||
imag -= sample * window * phase.sin();
|
||||
window_sum += window;
|
||||
}
|
||||
let amplitude = 2.0 * real.hypot(imag) / window_sum / i16::MAX as f64;
|
||||
Ok(20.0 * amplitude.max(1.0e-12).log10())
|
||||
}
|
||||
|
||||
fn any_echo_cancel_module_loaded() -> Result<bool> {
|
||||
let output = Command::new("pactl")
|
||||
.args(["list", "short", "modules"])
|
||||
.output()
|
||||
.context("inspect pre-existing Pulse modules")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"pactl list short modules failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).contains("module-echo-cancel"))
|
||||
}
|
||||
|
||||
async fn run_leak_arm(arm: LeakArm) -> Result<LeakMeasurement> {
|
||||
let mut aec = PulseModuleGuard::load(arm.label)?;
|
||||
let module_id = aec.id();
|
||||
let (aec_playback_id, aec_playback_name) = wait_for_aec_playback(module_id).await?;
|
||||
let capture_sink_name = crate::repair::plan::sink_name_for(std::process::id());
|
||||
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
|
||||
let (health, _) = health::channel();
|
||||
let mut backend = Phase6MeasurementBackend {
|
||||
aec: AecConfig::PulseModule(module_id),
|
||||
policy: arm.policy,
|
||||
};
|
||||
let plan = CapturePlan::start_with_backend(&opts, health.clone(), &mut backend)
|
||||
.await
|
||||
.with_context(|| format!("start the {} production-path capture plan", arm.label))?;
|
||||
let capture_sink = match &plan {
|
||||
CapturePlan::DesktopExcluding { capture_sink } => capture_sink,
|
||||
_ => bail!("{} arm did not select DesktopExcluding", arm.label),
|
||||
};
|
||||
let monitor_name = capture_sink.monitor_name().to_string();
|
||||
let default_sink = default_sink_name()?;
|
||||
let desktop_name = format!("pixelpass_phase6_{}_440", arm.label);
|
||||
let remote_name = format!("peerspeak_owned_phase6_{}_1500", arm.label);
|
||||
let mut desktop = start_tone(440, &desktop_name, None, false)?;
|
||||
let mut remote = if arm.play_remote_tone {
|
||||
Some(start_tone(1_500, &remote_name, Some(&aec.sink_name), true)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
wait_for_named_links(&desktop_name, &capture_sink_name, 2).await?;
|
||||
wait_for_named_links(&desktop_name, &default_sink, 2).await?;
|
||||
if let Some(remote) = remote.as_mut() {
|
||||
wait_for_named_links(&remote_name, &aec.sink_name, 2).await?;
|
||||
wait_for_named_links(&remote_name, &capture_sink_name, 0).await?;
|
||||
remote.ensure_running()?;
|
||||
}
|
||||
// This is the load-bearing policy observation: the same exact module
|
||||
// playback node has zero capture links in control/guarded and two only
|
||||
// in the deliberately-naive test arm. Its normal speaker link remains
|
||||
// live in all three arms.
|
||||
if arm.play_remote_tone {
|
||||
wait_for_id_links(aec_playback_id, &default_sink, 2).await?;
|
||||
} else {
|
||||
// With no 1500 Hz input the module is correctly idle: the two
|
||||
// passive speaker links exist but PipeWire reports them paused.
|
||||
wait_for_id_links_present(aec_playback_id, &default_sink, 2).await?;
|
||||
}
|
||||
let expected_aec_links = if arm.policy == TestFanoutPolicy::IncludeConfiguredAec {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
wait_for_id_links(aec_playback_id, &capture_sink_name, expected_aec_links).await?;
|
||||
desktop.ensure_running()?;
|
||||
|
||||
let raw = record_capture(&monitor_name, arm.label).await?;
|
||||
let measurement = LeakMeasurement {
|
||||
label: arm.label,
|
||||
desktop_dbfs: tone_dbfs(&raw, 440.0)?,
|
||||
remote_dbfs: tone_dbfs(&raw, 1_500.0)?,
|
||||
};
|
||||
|
||||
if let Some(remote) = remote.as_mut() {
|
||||
remote.stop()?;
|
||||
}
|
||||
desktop.stop()?;
|
||||
plan.shutdown().await;
|
||||
aec.unload()?;
|
||||
let residue_deadline = Instant::now() + Duration::from_secs(3);
|
||||
while (pulse_source_exists(&monitor_name)
|
||||
|| pulse_source_exists(&aec.source_name)
|
||||
|| Command::new("pactl")
|
||||
.args(["get-sink-volume", &aec.sink_name])
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success()))
|
||||
&& Instant::now() < residue_deadline
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
if pulse_source_exists(&monitor_name)
|
||||
|| pulse_source_exists(&aec.source_name)
|
||||
|| Command::new("pactl")
|
||||
.args(["get-sink-volume", &aec.sink_name])
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
{
|
||||
bail!("{} arm left a Pulse source or sink behind", arm.label);
|
||||
}
|
||||
if let Some(fault) = health.fault() {
|
||||
bail!("{} arm poisoned audio health: {fault}", arm.label);
|
||||
}
|
||||
eprintln!(
|
||||
"Phase-6 {}: 440 Hz {:.2} dBFS, 1500 Hz {:.2} dBFS; AEC playback node {} ({})",
|
||||
measurement.label,
|
||||
measurement.desktop_dbfs,
|
||||
measurement.remote_dbfs,
|
||||
aec_playback_name,
|
||||
aec_playback_id,
|
||||
);
|
||||
Ok(measurement)
|
||||
}
|
||||
|
||||
/// Final Phase-6 qualification: record the real hidden selector → native
|
||||
/// sink → fan-out observer/link-manager → Pulse monitor source path. The
|
||||
/// naive arm changes only the test-compiled AEC-identity decision, making
|
||||
/// it a positive control for a gross remote-call leak.
|
||||
#[tokio::test]
|
||||
#[ignore = "live: plays low-level tones and mutates the shared audio graph; run alone with --test-threads=1"]
|
||||
async fn live_phase6_three_arm_aec_leak_measurement() -> Result<()> {
|
||||
if any_echo_cancel_module_loaded()? {
|
||||
bail!("refusing the controlled measurement while another module-echo-cancel is live");
|
||||
}
|
||||
let control = run_leak_arm(LeakArm {
|
||||
label: "control",
|
||||
play_remote_tone: false,
|
||||
policy: TestFanoutPolicy::Safe,
|
||||
})
|
||||
.await?;
|
||||
let guarded = run_leak_arm(LeakArm {
|
||||
label: "guarded",
|
||||
play_remote_tone: true,
|
||||
policy: TestFanoutPolicy::Safe,
|
||||
})
|
||||
.await?;
|
||||
let naive = run_leak_arm(LeakArm {
|
||||
label: "naive",
|
||||
play_remote_tone: true,
|
||||
policy: TestFanoutPolicy::IncludeConfiguredAec,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Thresholds are declared before interpreting this run. They qualify
|
||||
// gross steady-state leakage only; Phase 9 replaces tones with PN/MLS
|
||||
// material for intelligibility and field variance.
|
||||
const DESKTOP_PRESENT_DBFS: f64 = -65.0;
|
||||
const GUARDED_DESKTOP_DRIFT_DB: f64 = 6.0;
|
||||
const GUARDED_FLOOR_TOLERANCE_DB: f64 = 3.0;
|
||||
const POSITIVE_CONTROL_MARGIN_DB: f64 = 18.0;
|
||||
const NAIVE_TONE_BALANCE_DB: f64 = 10.0;
|
||||
assert!(control.desktop_dbfs > DESKTOP_PRESENT_DBFS, "{control:?}");
|
||||
assert!(guarded.desktop_dbfs > DESKTOP_PRESENT_DBFS, "{guarded:?}");
|
||||
assert!(naive.desktop_dbfs > DESKTOP_PRESENT_DBFS, "{naive:?}");
|
||||
assert!(
|
||||
(guarded.desktop_dbfs - control.desktop_dbfs).abs() <= GUARDED_DESKTOP_DRIFT_DB,
|
||||
"guarded desktop level drifted from control: control={control:?}, guarded={guarded:?}"
|
||||
);
|
||||
assert!(
|
||||
guarded.remote_dbfs <= control.remote_dbfs + GUARDED_FLOOR_TOLERANCE_DB,
|
||||
"guarded 1500 Hz energy rose above the control floor: control={control:?}, guarded={guarded:?}"
|
||||
);
|
||||
assert!(
|
||||
naive.remote_dbfs >= control.remote_dbfs + POSITIVE_CONTROL_MARGIN_DB
|
||||
&& naive.remote_dbfs >= guarded.remote_dbfs + POSITIVE_CONTROL_MARGIN_DB,
|
||||
"the naive positive control did not expose a gross 1500 Hz leak: control={control:?}, guarded={guarded:?}, naive={naive:?}"
|
||||
);
|
||||
assert!(
|
||||
(naive.remote_dbfs - naive.desktop_dbfs).abs() <= NAIVE_TONE_BALANCE_DB,
|
||||
"the naive arm did not capture both tones at comparable levels: {naive:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::aec::{AecConfig, AecState, AecValidator};
|
||||
use super::observer::{EventKind, Millis, Projection};
|
||||
#[cfg(test)]
|
||||
use super::taint::Eligibility;
|
||||
use super::taint::snapshot::{
|
||||
GlobalId, GraphSnapshot, MediaRole, PortDirection, PortSnapshot, Serial,
|
||||
};
|
||||
@@ -181,6 +183,33 @@ pub(super) struct FanoutController {
|
||||
states: BTreeMap<Serial, StreamCaptureState>,
|
||||
foreign_aec_groups: BTreeSet<String>,
|
||||
status_tx: Option<mpsc::UnboundedSender<AudioExclusionEvent>>,
|
||||
#[cfg(test)]
|
||||
test_policy: TestFanoutPolicy,
|
||||
}
|
||||
|
||||
/// Test-only control for the Phase-6 leak measurement. The shipping binary
|
||||
/// has no representation of the deliberately unsafe policy: this field,
|
||||
/// constructor, and rewrite are all removed before production type checking.
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum TestFanoutPolicy {
|
||||
#[default]
|
||||
Safe,
|
||||
IncludeConfiguredAec,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestFanoutPolicy {
|
||||
fn apply(self, mut decisions: Decisions) -> Decisions {
|
||||
if self == Self::IncludeConfiguredAec {
|
||||
for decision in decisions.candidates.values_mut() {
|
||||
if decision.reason() == Some(Reason::AecIdentity) {
|
||||
decision.eligibility = Eligibility::Eligible;
|
||||
}
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
}
|
||||
|
||||
impl FanoutController {
|
||||
@@ -189,6 +218,18 @@ impl FanoutController {
|
||||
Self::build(capture_sink, aec, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn with_test_policy(
|
||||
capture_sink: Serial,
|
||||
aec: AecConfig,
|
||||
status_tx: mpsc::UnboundedSender<AudioExclusionEvent>,
|
||||
test_policy: TestFanoutPolicy,
|
||||
) -> Self {
|
||||
let mut controller = Self::build(capture_sink, aec, Some(status_tx));
|
||||
controller.test_policy = test_policy;
|
||||
controller
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -212,6 +253,8 @@ impl FanoutController {
|
||||
states: BTreeMap::new(),
|
||||
foreign_aec_groups: BTreeSet::new(),
|
||||
status_tx,
|
||||
#[cfg(test)]
|
||||
test_policy: TestFanoutPolicy::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +319,8 @@ impl MutationProjectionSink for FanoutController {
|
||||
graph_ready: projection.graph_ready,
|
||||
};
|
||||
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
|
||||
#[cfg(test)]
|
||||
let decisions = self.test_policy.apply(decisions);
|
||||
self.sticky = sticky;
|
||||
let plans = plan(&projection.snapshot, &decisions, self.capture_sink);
|
||||
|
||||
@@ -1289,6 +1334,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase6_naive_control_rewrites_only_the_configured_aec_candidate() {
|
||||
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"));
|
||||
graph.port_on_channel(aec, PortDirection::Out, false, Some("FR"));
|
||||
|
||||
let mut safe_links = FakeLinks::default();
|
||||
let (status_tx, _status_rx) = mpsc::unbounded_channel();
|
||||
let mut safe = FanoutController::with_test_policy(
|
||||
sink,
|
||||
AecConfig::PulseModule(999),
|
||||
status_tx,
|
||||
TestFanoutPolicy::Safe,
|
||||
);
|
||||
drive(&mut safe, &graph, &mut safe_links);
|
||||
assert_eq!(safe_links.held.len(), 2);
|
||||
assert_eq!(safe.states()[&app], StreamCaptureState::Captured);
|
||||
assert_eq!(
|
||||
safe.states()[&aec.serial],
|
||||
StreamCaptureState::Excluded {
|
||||
reason: Reason::AecIdentity,
|
||||
}
|
||||
);
|
||||
|
||||
let mut naive_links = FakeLinks::default();
|
||||
let (status_tx, _status_rx) = mpsc::unbounded_channel();
|
||||
let mut naive = FanoutController::with_test_policy(
|
||||
sink,
|
||||
AecConfig::PulseModule(999),
|
||||
status_tx,
|
||||
TestFanoutPolicy::IncludeConfiguredAec,
|
||||
);
|
||||
drive(&mut naive, &graph, &mut naive_links);
|
||||
assert_eq!(naive_links.held.len(), 4);
|
||||
assert_eq!(naive.states()[&app], StreamCaptureState::Captured);
|
||||
assert_eq!(naive.states()[&aec.serial], StreamCaptureState::Captured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_echo_cancel_group_emits_one_foreign_warning() {
|
||||
use crate::host::taint::fixture::pulse_module;
|
||||
|
||||
+38
-6
@@ -9,7 +9,9 @@
|
||||
|
||||
use super::aec::AecConfig;
|
||||
use super::audio::parse_object_serial;
|
||||
use super::fanout::FanoutController;
|
||||
#[cfg(test)]
|
||||
use super::fanout::TestFanoutPolicy;
|
||||
use super::fanout::{FanoutController, MutationProjectionSink};
|
||||
use super::health;
|
||||
use super::observer::adapter::{FanoutControl, RegistryObserverHandle};
|
||||
use super::observer::{Projection, Readiness};
|
||||
@@ -159,6 +161,40 @@ pub(super) struct BareCaptureSink {
|
||||
|
||||
impl BareCaptureSink {
|
||||
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
|
||||
Self::start_with_controller(health, |capture_sink, status_tx| {
|
||||
Box::new(FanoutController::with_status_sender(
|
||||
capture_sink,
|
||||
AecConfig::Off,
|
||||
status_tx,
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn start_for_phase6_measurement(
|
||||
health: health::Reporter,
|
||||
aec: AecConfig,
|
||||
test_policy: TestFanoutPolicy,
|
||||
) -> Result<Self> {
|
||||
Self::start_with_controller(health, move |capture_sink, status_tx| {
|
||||
Box::new(FanoutController::with_test_policy(
|
||||
capture_sink,
|
||||
aec,
|
||||
status_tx,
|
||||
test_policy,
|
||||
))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_with_controller<F>(health: health::Reporter, controller: F) -> Result<Self>
|
||||
where
|
||||
F: FnOnce(
|
||||
Serial,
|
||||
mpsc::UnboundedSender<AudioExclusionEvent>,
|
||||
) -> Box<dyn MutationProjectionSink>,
|
||||
{
|
||||
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
||||
let (graph_owner, _event_rx, identity_rx) =
|
||||
AudioGraphOwner::start(None, spec, health.clone())
|
||||
@@ -168,11 +204,7 @@ impl BareCaptureSink {
|
||||
let (status_tx, status_rx) = mpsc::unbounded_channel();
|
||||
let status_forwarder = tokio::spawn(forward_audio_exclusion_status(status_rx));
|
||||
let fanout = match RegistryObserverHandle::spawn_with_mutation_sink(
|
||||
Box::new(FanoutController::with_status_sender(
|
||||
Serial(identity.serial),
|
||||
AecConfig::Off,
|
||||
status_tx,
|
||||
)),
|
||||
controller(Serial(identity.serial), status_tx),
|
||||
health.clone(),
|
||||
)
|
||||
.context("failed to start the desktop-excluding fan-out observer")
|
||||
|
||||
Reference in New Issue
Block a user