feat(host): fan out desktop audio through owned links
This commit is contained in:
+346
-32
@@ -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 7–8.
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user