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
+346 -32
View File
@@ -9,9 +9,9 @@
//! - [`CapturePlan::DesktopExcluding`] contains only [`BareCaptureSink`], which
//! has no module ledger or loopback constructor.
//!
//! Phase 6 will add retained native PipeWire links to the last variant. Until
//! then it intentionally captures silence through a real, connection-owned
//! sink reached by the hidden phase-0d CLI trigger.
//! Phase 6 feeds the last variant through retained, non-lingering native
//! PipeWire links. The selector remains the hidden phase-0d trigger until the
//! public capability and cross-repository protocol land in phases 78.
use anyhow::{Context, Result, bail};
use tokio::process::Command;
@@ -148,8 +148,15 @@ impl CapturePlan {
mod tests {
use super::*;
use crate::cli::Quality;
use nix::sys::signal::Signal;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc as std_mpsc;
use std::time::{Duration, Instant};
const PHASE6_SIGKILL_HELPER: &str = "PIXELPASS_PHASE6_SIGKILL_HELPER";
const PHASE6_READY: &str = "PIXELPASS_PHASE6_READY=";
fn opts(
app: bool,
strict_audio: bool,
@@ -224,7 +231,13 @@ mod tests {
.is_ok_and(|output| output.status.success())
}
fn incoming_links(sink_name: &str) -> Result<Vec<serde_json::Value>> {
fn value_u64(value: &serde_json::Value) -> Option<u64> {
value
.as_u64()
.or_else(|| value.as_str().and_then(|value| value.parse::<u64>().ok()))
}
fn native_links(output_name: &str, sink_name: &str) -> Result<Vec<serde_json::Value>> {
let output = std::process::Command::new("pw-dump")
.output()
.context("run pw-dump for the phase-0d graph assertion")?;
@@ -239,19 +252,22 @@ mod tests {
let objects = objects
.as_array()
.context("pw-dump root was not an array")?;
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(serde_json::Value::as_u64)
.context("bare capture sink was absent from pw-dump")?;
let node_id = |name: &str| {
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(name)
})
.and_then(|object| object.get("id"))
.and_then(value_u64)
};
let output_id = node_id(output_name).context("fixture output was absent from pw-dump")?;
let sink_id = node_id(sink_name).context("bare capture sink was absent from pw-dump")?;
Ok(objects
.iter()
@@ -261,24 +277,259 @@ mod tests {
{
return false;
}
let Some(input_node) = object.pointer("/info/props/link.input.node") else {
let Some(input_node) = object
.pointer("/info/props/link.input.node")
.and_then(value_u64)
else {
return false;
};
input_node.as_u64().or_else(|| {
input_node
.as_str()
.and_then(|value| value.parse::<u64>().ok())
}) == Some(sink_id)
let Some(output_node) = object
.pointer("/info/props/link.output.node")
.and_then(value_u64)
else {
return false;
};
input_node == sink_id && output_node == output_id
})
.cloned()
.collect())
}
/// Phase-0d graph assertion: the hidden mode reaches the real native sink,
/// but neither a Pulse default-monitor module nor any native link feeds it.
fn object_serial(object: &serde_json::Value) -> Option<u64> {
object
.pointer("/info/props/object.serial")
.and_then(value_u64)
}
fn object_serial_is_live(serial: u64) -> Result<bool> {
let output = Command::new("pw-dump")
.output()
.context("run pw-dump for the Phase-6 SIGKILL gate")?;
if !output.status.success() {
bail!(
"pw-dump failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let objects: serde_json::Value =
serde_json::from_slice(&output.stdout).context("parse pw-dump JSON")?;
Ok(objects
.as_array()
.context("pw-dump root was not an array")?
.iter()
.any(|object| object_serial(object) == Some(serial)))
}
fn sink_serial(name: &str) -> Result<u64> {
let output = Command::new("pw-dump")
.output()
.context("run pw-dump for the Phase-6 sink identity")?;
let objects: serde_json::Value =
serde_json::from_slice(&output.stdout).context("parse pw-dump JSON")?;
objects
.as_array()
.context("pw-dump root was not an array")?
.iter()
.find(|object| {
object
.pointer("/info/props/node.name")
.and_then(serde_json::Value::as_str)
== Some(name)
})
.and_then(object_serial)
.context("capture sink had no object.serial")
}
/// Subprocess half of the SIGKILL gate. The outer test kills this process,
/// deliberately skipping every Rust destructor. Its GStreamer child uses
/// the contained spawn path so parent death removes the fixture stream too.
#[tokio::test]
async fn phase6_sigkill_helper() {
if std::env::var_os(PHASE6_SIGKILL_HELPER).is_none() {
return;
}
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
let sink_name = crate::repair::plan::sink_name_for(std::process::id());
let (health, _) = health::channel();
let _plan = CapturePlan::start(&opts, health)
.await
.expect("start Phase-6 SIGKILL helper plan");
let fixture_name = format!("pixelpass_phase6_sigkill_{}", std::process::id());
let mut command = Command::new("gst-launch-1.0");
command
.args([
"-q",
"audiotestsrc",
"is-live=true",
"volume=0",
"!",
"audioconvert",
"!",
"audio/x-raw,channels=2",
"!",
"pulsesink",
])
.env("PULSE_PROP", format!("node.name={fixture_name}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit());
// This helper is deliberately SIGKILLed by the outer test, so neither it nor this
// retained child can reach a normal wait. `contained::spawn` gives the fixture a
// parent-death signal; the outer test proves that path removes the fixture and links.
#[allow(
clippy::zombie_processes,
reason = "the SIGKILL cleanup gate intentionally bypasses normal child waiting"
)]
let _fixture = crate::common::contained::spawn(&mut command)
.expect("start contained Phase-6 SIGKILL fixture");
let deadline = Instant::now() + Duration::from_secs(5);
let links = loop {
match native_links(&fixture_name, &sink_name) {
Ok(links)
if links.len() == 2
&& links.iter().all(|link| {
link.pointer("/info/state")
.and_then(serde_json::Value::as_str)
== Some("active")
}) =>
{
break links;
}
_ if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
_ => panic!("SIGKILL fixture never reached two ACTIVE links"),
}
};
let sink_serial = sink_serial(&sink_name).expect("read capture sink serial");
let link_serials: Vec<u64> = links
.iter()
.map(|link| object_serial(link).expect("owned link object.serial"))
.collect();
println!(
"{PHASE6_READY}{sink_name} {sink_serial} {},{}",
link_serials[0], link_serials[1]
);
std::io::stdout().flush().expect("flush Phase-6 readiness");
std::future::pending::<()>().await;
}
struct Phase6Host {
child: Option<Child>,
pid: u32,
}
impl Drop for Phase6Host {
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();
}
}
}
#[test]
#[ignore = "live: SIGKILLs a graph-mutating helper; run alone with --test-threads=1"]
fn live_sigkill_removes_capture_sink_and_fanout_links() {
let executable = std::env::current_exe().expect("locate test executable");
let mut command = Command::new(executable);
command
.args([
"--exact",
"host::audio_plan::tests::phase6_sigkill_helper",
"--nocapture",
"--test-threads=1",
])
.env(PHASE6_SIGKILL_HELPER, "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let child =
crate::common::contained::spawn(&mut command).expect("spawn contained Phase-6 helper");
let pid = child.id();
let mut host = Phase6Host {
child: Some(child),
pid,
};
let stdout = host
.child
.as_mut()
.expect("helper stayed owned")
.stdout
.take()
.expect("helper stdout was piped");
let (line_tx, line_rx) = std_mpsc::sync_channel(1);
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if line.contains(PHASE6_READY) {
let _ = line_tx.send(line);
return;
}
}
});
let line = line_rx
.recv_timeout(Duration::from_secs(10))
.expect("Phase-6 helper did not report readiness");
let marker = line
.split_once(PHASE6_READY)
.map(|(_, marker)| marker)
.expect("malformed Phase-6 readiness marker");
let mut fields = marker.split_whitespace();
let sink_name = fields
.next()
.expect("readiness omitted sink name")
.to_string();
let sink_serial: u64 = fields
.next()
.expect("readiness omitted sink serial")
.parse()
.expect("sink serial was not numeric");
let link_serials: Vec<u64> = fields
.next()
.expect("readiness omitted link serials")
.split(',')
.map(|serial| serial.parse().expect("link serial was not numeric"))
.collect();
assert_eq!(link_serials.len(), 2);
crate::common::contained::signal_group(pid, Signal::SIGKILL)
.expect("SIGKILL the Phase-6 helper");
host.child
.as_mut()
.expect("helper stayed owned")
.wait()
.expect("reap the Phase-6 helper");
host.child.take();
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline
&& (pulse_source_exists(&format!("{sink_name}.monitor"))
|| object_serial_is_live(sink_serial).unwrap_or(true)
|| link_serials
.iter()
.any(|serial| object_serial_is_live(*serial).unwrap_or(true)))
{
std::thread::sleep(Duration::from_millis(20));
}
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
assert!(!object_serial_is_live(sink_serial).expect("inspect sink residue"));
for serial in link_serials {
assert!(
!object_serial_is_live(serial).expect("inspect link residue"),
"fan-out link serial {serial} survived its owning process"
);
}
}
/// First Phase-6 live mutation gate: the hidden production path links a
/// late eligible stream into the native sink without constructing the
/// forbidden Pulse default-monitor loopback. Removing the stream revokes
/// the link; shutting down removes the sink and every retained proxy.
#[tokio::test]
#[ignore = "live: mutates the shared PipeWire graph; run alone with --test-threads=1"]
async fn live_desktop_excluding_sink_has_no_legacy_feed() {
async fn live_desktop_excluding_fans_out_and_cleans_up_native_links() {
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
let sink_name = crate::repair::plan::sink_name_for(std::process::id());
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
@@ -308,12 +559,67 @@ mod tests {
feeding_modules.is_empty(),
"DesktopExcluding must not construct a Pulse loopback into its sink: {feeding_modules:?}"
);
assert!(
incoming_links(&sink_name)
.expect("inspect incoming native links")
.is_empty(),
"phase 0d must leave the bare sink unfed until phase 6 owns native links"
);
let fixture_name = format!("pixelpass_phase6_fixture_{}", std::process::id());
let mut fixture = tokio::process::Command::new("gst-launch-1.0");
fixture
.args([
"-q",
"audiotestsrc",
"is-live=true",
"volume=0",
"!",
"audioconvert",
"!",
"audio/x-raw,channels=2",
"!",
"pulsesink",
])
.env("PULSE_PROP", format!("node.name={fixture_name}"))
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut fixture = fixture
.spawn()
.expect("start the late eligible audio fixture");
let deadline = Instant::now() + Duration::from_secs(5);
let owned_links = loop {
match native_links(&fixture_name, &sink_name) {
Ok(links)
if links.len() == 2
&& links.iter().all(|link| {
link.pointer("/info/state")
.and_then(serde_json::Value::as_str)
== Some("active")
}) =>
{
break Some(links);
}
Ok(_) | Err(_) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
_ => break None,
}
};
fixture
.kill()
.await
.expect("stop the late eligible fixture");
let deadline = Instant::now() + Duration::from_secs(2);
let link_was_revoked = loop {
if native_links(&fixture_name, &sink_name)
.map(|links| links.is_empty())
.unwrap_or(true)
{
break true;
}
if Instant::now() >= deadline {
break false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
plan.shutdown().await;
let deadline = Instant::now() + Duration::from_secs(2);
@@ -322,5 +628,13 @@ mod tests {
}
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
assert!(health.fault().is_none());
assert!(
owned_links.is_some(),
"the late eligible stereo stream never reached two ACTIVE native fan-out links"
);
assert!(
link_was_revoked,
"the owned fan-out link survived its output stream"
);
}
}