feat(host): recover desktop audio fanout after sink replacement
This commit is contained in:
+155
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user