feat(host): recover desktop audio fanout after sink replacement

This commit is contained in:
2026-08-21 17:00:30 -04:00
parent 5a65f50c4b
commit d09ee9b02f
5 changed files with 632 additions and 67 deletions
+155 -3
View File
@@ -321,12 +321,16 @@ mod tests {
}
fn sink_serial(name: &str) -> Result<u64> {
sink_global_identity(name).map(|(_, serial)| serial)
}
fn sink_global_identity(name: &str) -> Result<(u32, 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
let object = objects
.as_array()
.context("pw-dump root was not an array")?
.iter()
@@ -336,8 +340,14 @@ mod tests {
.and_then(serde_json::Value::as_str)
== Some(name)
})
.and_then(object_serial)
.context("capture sink had no object.serial")
.context("capture sink was absent from pw-dump")?;
let id = object
.get("id")
.and_then(value_u64)
.and_then(|id| u32::try_from(id).ok())
.context("capture sink had no usable global id")?;
let serial = object_serial(object).context("capture sink had no object.serial")?;
Ok((id, serial))
}
/// Subprocess half of the SIGKILL gate. The outer test kills this process,
@@ -637,4 +647,146 @@ mod tests {
"the owned fan-out link survived its output stream"
);
}
/// Phase-6 row 7: remove the live connection-owned sink from the server,
/// then prove the owner recreates it with a new serial and the fan-out
/// controller drops both stale proxies before returning both replacement
/// channel links to ACTIVE.
#[tokio::test]
#[ignore = "live: destroys and recreates the shared PipeWire capture sink; run alone with --test-threads=1"]
async fn live_capture_sink_replacement_relinks_every_channel() {
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
let sink_name = crate::repair::plan::sink_name_for(std::process::id());
let fixture_name = format!("pixelpass_phase6_replacement_{}", std::process::id());
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
let (health, _) = health::channel();
let plan = CapturePlan::start(&opts, health.clone())
.await
.expect("start desktop-excluding replacement fixture");
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 replacement audio fixture");
let initial_deadline = Instant::now() + Duration::from_secs(5);
let old_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() < initial_deadline => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
_ => panic!("initial sink never reached two ACTIVE links"),
}
};
let old_link_serials: Vec<u64> = old_links
.iter()
.map(|link| object_serial(link).expect("old link object.serial"))
.collect();
let (old_global_id, old_sink_serial) =
sink_global_identity(&sink_name).expect("read initial sink identity");
let destroy = Command::new("pw-cli")
.args(["destroy", &old_global_id.to_string()])
.status()
.expect("run pw-cli destroy for the owned sink");
assert!(
destroy.success(),
"pw-cli refused to destroy the owned sink"
);
let replacement_deadline = Instant::now() + Duration::from_secs(5);
let (new_sink_serial, new_links) = loop {
let identity = sink_global_identity(&sink_name);
let links = native_links(&fixture_name, &sink_name);
if let (Ok((_, serial)), Ok(links)) = (identity, links)
&& serial != old_sink_serial
&& links.len() == 2
&& links.iter().all(|link| {
link.pointer("/info/state")
.and_then(serde_json::Value::as_str)
== Some("active")
})
{
break (serial, links);
}
if Instant::now() >= replacement_deadline {
panic!(
"replacement sink never returned every channel link to ACTIVE; identity={:?}, links={:?}, health={:?}",
sink_global_identity(&sink_name),
native_links(&fixture_name, &sink_name).map(|links| {
links
.iter()
.map(|link| {
(
object_serial(link),
link.pointer("/info/state")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
)
})
.collect::<Vec<_>>()
}),
health.fault(),
);
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
assert_ne!(new_sink_serial, old_sink_serial);
let new_link_serials: Vec<u64> = new_links
.iter()
.map(|link| object_serial(link).expect("replacement link object.serial"))
.collect();
assert!(
old_link_serials
.iter()
.all(|serial| !object_serial_is_live(*serial).unwrap_or(true)),
"a stale old-sink link proxy remained live"
);
assert!(
new_link_serials
.iter()
.all(|serial| !old_link_serials.contains(serial)),
"replacement must bind fresh non-lingering link objects"
);
fixture
.kill()
.await
.expect("stop replacement audio fixture");
plan.shutdown().await;
let residue_deadline = Instant::now() + Duration::from_secs(2);
while pulse_source_exists(&format!("{sink_name}.monitor"))
&& Instant::now() < residue_deadline
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
assert!(health.fault().is_none());
}
}