feat(host): recover desktop audio fanout after sink replacement
This commit is contained in:
+1
-1
@@ -92,7 +92,7 @@ impl Routing {
|
|||||||
// by this actor connection, not a module owned by pipewire-pulse. The
|
// by this actor connection, not a module owned by pipewire-pulse. The
|
||||||
// actor also absorbs the per-app router so every sink-owning mode has one
|
// actor also absorbs the per-app router so every sink-owning mode has one
|
||||||
// graph lifetime and one readiness handshake.
|
// graph lifetime and one readiness handshake.
|
||||||
let (graph_owner, mut event_rx) = AudioGraphOwner::start(
|
let (graph_owner, mut event_rx, _identity_rx) = AudioGraphOwner::start(
|
||||||
opts.app.clone(),
|
opts.app.clone(),
|
||||||
CaptureSinkSpec::for_pid(pid),
|
CaptureSinkSpec::for_pid(pid),
|
||||||
health.clone(),
|
health.clone(),
|
||||||
|
|||||||
+155
-3
@@ -321,12 +321,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sink_serial(name: &str) -> Result<u64> {
|
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")
|
let output = Command::new("pw-dump")
|
||||||
.output()
|
.output()
|
||||||
.context("run pw-dump for the Phase-6 sink identity")?;
|
.context("run pw-dump for the Phase-6 sink identity")?;
|
||||||
let objects: serde_json::Value =
|
let objects: serde_json::Value =
|
||||||
serde_json::from_slice(&output.stdout).context("parse pw-dump JSON")?;
|
serde_json::from_slice(&output.stdout).context("parse pw-dump JSON")?;
|
||||||
objects
|
let object = objects
|
||||||
.as_array()
|
.as_array()
|
||||||
.context("pw-dump root was not an array")?
|
.context("pw-dump root was not an array")?
|
||||||
.iter()
|
.iter()
|
||||||
@@ -336,8 +340,14 @@ mod tests {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
== Some(name)
|
== Some(name)
|
||||||
})
|
})
|
||||||
.and_then(object_serial)
|
.context("capture sink was absent from pw-dump")?;
|
||||||
.context("capture sink had no object.serial")
|
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,
|
/// 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"
|
"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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,19 @@ pub(super) trait MutationProjectionSink: Send {
|
|||||||
now_us: u64,
|
now_us: u64,
|
||||||
links: &mut dyn LinkMutation,
|
links: &mut dyn LinkMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Retarget a running controller to the exact serial of a recreated
|
||||||
|
/// connection-owned capture sink, then reconcile immediately against the
|
||||||
|
/// observer's current coherent projection. The adapter invokes this only
|
||||||
|
/// on its PipeWire loop thread, keeping old-link revocation and new-link
|
||||||
|
/// creation ordered with ordinary registry observations.
|
||||||
|
fn replace_capture_sink(
|
||||||
|
&mut self,
|
||||||
|
capture_sink: Serial,
|
||||||
|
projection: &Projection,
|
||||||
|
now_us: u64,
|
||||||
|
links: &mut dyn LinkMutation,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -339,6 +352,24 @@ impl MutationProjectionSink for FanoutController {
|
|||||||
self.emit_status(event);
|
self.emit_status(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_capture_sink(
|
||||||
|
&mut self,
|
||||||
|
capture_sink: Serial,
|
||||||
|
projection: &Projection,
|
||||||
|
now_us: u64,
|
||||||
|
links: &mut dyn LinkMutation,
|
||||||
|
) {
|
||||||
|
if self.capture_sink == capture_sink {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.capture_sink = capture_sink;
|
||||||
|
// This is an explicit owner control message rather than a model event,
|
||||||
|
// so it must not be coalesced behind the next registry callback. The
|
||||||
|
// normal projection path is still the single place that evaluates,
|
||||||
|
// plans, revokes stale proxies, and determines all-links-ACTIVE.
|
||||||
|
self.on_projection(projection, EventKind::Graph, now_us, links);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plan every candidate in the decision universe against one capture sink.
|
/// Plan every candidate in the decision universe against one capture sink.
|
||||||
@@ -712,6 +743,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacing_the_capture_sink_drops_stale_links_and_recaptures() {
|
||||||
|
let (mut graph, app, old_sink, _) = stereo_graph();
|
||||||
|
let new_sink = graph.native_virtual_node("pixelpass_capture_new", MediaRole::Sink, 46);
|
||||||
|
let new_fl = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FL"));
|
||||||
|
let new_fr = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FR"));
|
||||||
|
let projection = projection(&graph);
|
||||||
|
let mut links = FakeLinks::default();
|
||||||
|
let mut controller = FanoutController::new(old_sink, AecConfig::Off);
|
||||||
|
|
||||||
|
controller.on_projection(&projection, EventKind::Graph, 1_000, &mut links);
|
||||||
|
assert_eq!(links.creates, 2);
|
||||||
|
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
|
||||||
|
|
||||||
|
controller.replace_capture_sink(new_sink.serial, &projection, 2_000, &mut links);
|
||||||
|
|
||||||
|
assert_eq!(links.drops, 2, "every old-sink proxy must be dropped");
|
||||||
|
assert_eq!(links.creates, 4, "both replacement links must be created");
|
||||||
|
assert_eq!(controller.states()[&app], StreamCaptureState::Captured);
|
||||||
|
assert_eq!(
|
||||||
|
links
|
||||||
|
.held
|
||||||
|
.iter()
|
||||||
|
.map(|link| link.input.port_id)
|
||||||
|
.collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([new_fl, new_fr]),
|
||||||
|
"only replacement-sink ports may remain held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serial_revalidation_rejects_a_recycled_node_id_before_mutation() {
|
fn serial_revalidation_rejects_a_recycled_node_id_before_mutation() {
|
||||||
let (mut graph, app, sink, _) = stereo_graph();
|
let (mut graph, app, sink, _) = stereo_graph();
|
||||||
|
|||||||
+336
-52
@@ -12,7 +12,7 @@ use super::audio::parse_object_serial;
|
|||||||
use super::fanout::FanoutController;
|
use super::fanout::FanoutController;
|
||||||
use super::health;
|
use super::health;
|
||||||
use super::observer::Readiness;
|
use super::observer::Readiness;
|
||||||
use super::observer::adapter::RegistryObserverHandle;
|
use super::observer::adapter::{FanoutControl, RegistryObserverHandle};
|
||||||
use super::owned_thread::OwnedThread;
|
use super::owned_thread::OwnedThread;
|
||||||
use super::taint::snapshot::{GlobalId, Serial};
|
use super::taint::snapshot::{GlobalId, Serial};
|
||||||
use crate::common::output::AudioExclusionEvent;
|
use crate::common::output::AudioExclusionEvent;
|
||||||
@@ -123,6 +123,9 @@ enum Cmd {
|
|||||||
/// the main loop. Dropping the connection removes the native non-lingering
|
/// the main loop. Dropping the connection removes the native non-lingering
|
||||||
/// sink only after those restoration writes have reached the server.
|
/// sink only after those restoration writes have reached the server.
|
||||||
Stop,
|
Stop,
|
||||||
|
/// Recreate an unexpectedly removed connection-owned sink on this same
|
||||||
|
/// PipeWire connection. Only the actor's own callbacks enqueue this.
|
||||||
|
RecreateSink,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tokio-side owner of the PipeWire actor thread.
|
/// Tokio-side owner of the PipeWire actor thread.
|
||||||
@@ -143,9 +146,11 @@ pub(super) struct AudioGraphOwner {
|
|||||||
/// connection that retains non-lingering native PipeWire links; legacy
|
/// connection that retains non-lingering native PipeWire links; legacy
|
||||||
/// `Routing` remains a separate type in `host::audio`.
|
/// `Routing` remains a separate type in `host::audio`.
|
||||||
pub(super) struct BareCaptureSink {
|
pub(super) struct BareCaptureSink {
|
||||||
// Drop order is load-bearing: close the fan-out observer connection (and
|
// Shutdown order is load-bearing: stop identity forwarding, close the
|
||||||
// therefore every non-lingering link), then detach the status task, before
|
// fan-out observer connection (and therefore every non-lingering link),
|
||||||
// the sink-owning connection.
|
// then drain status before closing the sink-owning connection.
|
||||||
|
replacement_stop: Option<oneshot::Sender<()>>,
|
||||||
|
replacement_forwarder: Option<tokio::task::JoinHandle<()>>,
|
||||||
fanout: Option<RegistryObserverHandle>,
|
fanout: Option<RegistryObserverHandle>,
|
||||||
status_forwarder: Option<tokio::task::JoinHandle<()>>,
|
status_forwarder: Option<tokio::task::JoinHandle<()>>,
|
||||||
graph_owner: Option<AudioGraphOwner>,
|
graph_owner: Option<AudioGraphOwner>,
|
||||||
@@ -155,9 +160,10 @@ pub(super) struct BareCaptureSink {
|
|||||||
impl BareCaptureSink {
|
impl BareCaptureSink {
|
||||||
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
|
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
|
||||||
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
||||||
let (graph_owner, _event_rx) = AudioGraphOwner::start(None, spec, health.clone())
|
let (graph_owner, _event_rx, identity_rx) =
|
||||||
.await
|
AudioGraphOwner::start(None, spec, health.clone())
|
||||||
.context("failed to start the bare connection-owned capture sink")?;
|
.await
|
||||||
|
.context("failed to start the bare connection-owned capture sink")?;
|
||||||
let identity = graph_owner.identity().clone();
|
let identity = graph_owner.identity().clone();
|
||||||
let (status_tx, status_rx) = mpsc::unbounded_channel();
|
let (status_tx, status_rx) = mpsc::unbounded_channel();
|
||||||
let status_forwarder = tokio::spawn(forward_audio_exclusion_status(status_rx));
|
let status_forwarder = tokio::spawn(forward_audio_exclusion_status(status_rx));
|
||||||
@@ -167,7 +173,7 @@ impl BareCaptureSink {
|
|||||||
AecConfig::Off,
|
AecConfig::Off,
|
||||||
status_tx,
|
status_tx,
|
||||||
)),
|
)),
|
||||||
health,
|
health.clone(),
|
||||||
)
|
)
|
||||||
.context("failed to start the desktop-excluding fan-out observer")
|
.context("failed to start the desktop-excluding fan-out observer")
|
||||||
{
|
{
|
||||||
@@ -184,7 +190,17 @@ impl BareCaptureSink {
|
|||||||
graph_owner.shutdown().await;
|
graph_owner.shutdown().await;
|
||||||
return Err(error).context("desktop-excluding fan-out did not become ready");
|
return Err(error).context("desktop-excluding fan-out did not become ready");
|
||||||
}
|
}
|
||||||
|
let (replacement_stop, replacement_stop_rx) = oneshot::channel();
|
||||||
|
let replacement_forwarder = tokio::spawn(forward_capture_sink_replacements(
|
||||||
|
identity_rx,
|
||||||
|
fanout.fanout_control(),
|
||||||
|
identity.serial,
|
||||||
|
replacement_stop_rx,
|
||||||
|
health,
|
||||||
|
));
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
replacement_stop: Some(replacement_stop),
|
||||||
|
replacement_forwarder: Some(replacement_forwarder),
|
||||||
fanout: Some(fanout),
|
fanout: Some(fanout),
|
||||||
status_forwarder: Some(status_forwarder),
|
status_forwarder: Some(status_forwarder),
|
||||||
graph_owner: Some(graph_owner),
|
graph_owner: Some(graph_owner),
|
||||||
@@ -204,6 +220,14 @@ impl BareCaptureSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn shutdown(mut self) {
|
pub(super) async fn shutdown(mut self) {
|
||||||
|
if let Some(stop) = self.replacement_stop.take() {
|
||||||
|
let _ = stop.send(());
|
||||||
|
}
|
||||||
|
if let Some(replacement_forwarder) = self.replacement_forwarder.take()
|
||||||
|
&& let Err(error) = replacement_forwarder.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "audio fan-out: replacement forwarder task failed");
|
||||||
|
}
|
||||||
// Joining the observer closes its PipeWire connection and drops every
|
// Joining the observer closes its PipeWire connection and drops every
|
||||||
// retained non-lingering link before the capture sink can disappear.
|
// retained non-lingering link before the capture sink can disappear.
|
||||||
if let Some(fanout) = self.fanout.take() {
|
if let Some(fanout) = self.fanout.take() {
|
||||||
@@ -222,6 +246,39 @@ impl BareCaptureSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn forward_capture_sink_replacements(
|
||||||
|
mut identity_rx: mpsc::UnboundedReceiver<SinkIdentity>,
|
||||||
|
fanout: FanoutControl,
|
||||||
|
mut current_serial: u64,
|
||||||
|
mut stop_rx: oneshot::Receiver<()>,
|
||||||
|
health: health::Reporter,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = &mut stop_rx => break,
|
||||||
|
identity = identity_rx.recv() => {
|
||||||
|
let Some(identity) = identity else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if identity.serial == current_serial {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current_serial = identity.serial;
|
||||||
|
tracing::info!(
|
||||||
|
global_id = identity.global_id,
|
||||||
|
serial = identity.serial,
|
||||||
|
"audio fan-out: retargeting to recreated capture sink"
|
||||||
|
);
|
||||||
|
if !fanout.replace_capture_sink(Serial(identity.serial)) {
|
||||||
|
health.poison("audio fan-out observer rejected capture-sink replacement");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn forward_audio_exclusion_status(
|
async fn forward_audio_exclusion_status(
|
||||||
mut status_rx: mpsc::UnboundedReceiver<AudioExclusionEvent>,
|
mut status_rx: mpsc::UnboundedReceiver<AudioExclusionEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -263,9 +320,14 @@ impl AudioGraphOwner {
|
|||||||
filter_name: Option<String>,
|
filter_name: Option<String>,
|
||||||
spec: CaptureSinkSpec,
|
spec: CaptureSinkSpec,
|
||||||
health: health::Reporter,
|
health: health::Reporter,
|
||||||
) -> Result<(Self, mpsc::UnboundedReceiver<GraphEvent>)> {
|
) -> Result<(
|
||||||
|
Self,
|
||||||
|
mpsc::UnboundedReceiver<GraphEvent>,
|
||||||
|
mpsc::UnboundedReceiver<SinkIdentity>,
|
||||||
|
)> {
|
||||||
let (cmd_tx, cmd_rx) = pw::channel::channel::<Cmd>();
|
let (cmd_tx, cmd_rx) = pw::channel::channel::<Cmd>();
|
||||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<GraphEvent>();
|
let (event_tx, event_rx) = mpsc::unbounded_channel::<GraphEvent>();
|
||||||
|
let (identity_tx, identity_rx) = mpsc::unbounded_channel::<SinkIdentity>();
|
||||||
let (ready_tx, ready_rx) = oneshot::channel::<SinkIdentity>();
|
let (ready_tx, ready_rx) = oneshot::channel::<SinkIdentity>();
|
||||||
let phase = Arc::new(AtomicU8::new(GRAPH_STARTING));
|
let phase = Arc::new(AtomicU8::new(GRAPH_STARTING));
|
||||||
let phase_for_thread = Arc::clone(&phase);
|
let phase_for_thread = Arc::clone(&phase);
|
||||||
@@ -273,6 +335,7 @@ impl AudioGraphOwner {
|
|||||||
let shutdown_for_thread = Arc::clone(&shutdown_observed);
|
let shutdown_for_thread = Arc::clone(&shutdown_observed);
|
||||||
let health_for_thread = health.clone();
|
let health_for_thread = health.clone();
|
||||||
let spec_for_thread = spec.clone();
|
let spec_for_thread = spec.clone();
|
||||||
|
let cmd_tx_for_thread = cmd_tx.clone();
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("pixelpass-audio-graph".to_string())
|
.name("pixelpass-audio-graph".to_string())
|
||||||
@@ -282,7 +345,9 @@ impl AudioGraphOwner {
|
|||||||
filter_name,
|
filter_name,
|
||||||
spec_for_thread,
|
spec_for_thread,
|
||||||
cmd_rx,
|
cmd_rx,
|
||||||
|
cmd_tx_for_thread,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
identity_tx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
Arc::clone(&phase_for_thread),
|
Arc::clone(&phase_for_thread),
|
||||||
Arc::clone(&shutdown_for_thread),
|
Arc::clone(&shutdown_for_thread),
|
||||||
@@ -350,7 +415,7 @@ impl AudioGraphOwner {
|
|||||||
monitor = %identity.monitor_name,
|
monitor = %identity.monitor_name,
|
||||||
"audio graph: connection-owned capture sink ready"
|
"audio graph: connection-owned capture sink ready"
|
||||||
);
|
);
|
||||||
Ok((owner, event_rx))
|
Ok((owner, event_rx, identity_rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn identity(&self) -> &SinkIdentity {
|
pub(super) fn identity(&self) -> &SinkIdentity {
|
||||||
@@ -503,7 +568,10 @@ struct ActorState {
|
|||||||
filter_lower: Option<String>,
|
filter_lower: Option<String>,
|
||||||
readiness: NativeReadiness,
|
readiness: NativeReadiness,
|
||||||
ready_tx: Option<oneshot::Sender<SinkIdentity>>,
|
ready_tx: Option<oneshot::Sender<SinkIdentity>>,
|
||||||
|
identity_tx: Option<mpsc::UnboundedSender<SinkIdentity>>,
|
||||||
identity: Option<SinkIdentity>,
|
identity: Option<SinkIdentity>,
|
||||||
|
replacement_pending: bool,
|
||||||
|
replacement_prior_serial: Option<u64>,
|
||||||
epoch: u64,
|
epoch: u64,
|
||||||
pending: BTreeMap<u32, ObservedNode>,
|
pending: BTreeMap<u32, ObservedNode>,
|
||||||
routed: BTreeMap<u32, RouteRecord>,
|
routed: BTreeMap<u32, RouteRecord>,
|
||||||
@@ -525,6 +593,10 @@ impl ActorState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.identity = Some(identity.clone());
|
self.identity = Some(identity.clone());
|
||||||
|
self.replacement_pending = false;
|
||||||
|
if let Some(tx) = &self.identity_tx {
|
||||||
|
let _ = tx.send(identity.clone());
|
||||||
|
}
|
||||||
if let Some(tx) = self.ready_tx.take() {
|
if let Some(tx) = self.ready_tx.take() {
|
||||||
let _ = tx.send(identity);
|
let _ = tx.send(identity);
|
||||||
}
|
}
|
||||||
@@ -573,7 +645,21 @@ impl ActorState {
|
|||||||
{
|
{
|
||||||
let _ = tx.send(GraphEvent::LastRoutedStreamGone);
|
let _ = tx.send(GraphEvent::LastRoutedStreamGone);
|
||||||
}
|
}
|
||||||
self.readiness.bound_global_id == Some(id)
|
self.identity
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|identity| identity.global_id == id)
|
||||||
|
|| self.readiness.bound_global_id == Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_sink_replacement(&mut self) -> bool {
|
||||||
|
if self.closing || self.replacement_pending {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.replacement_prior_serial = self.identity.take().map(|identity| identity.serial);
|
||||||
|
self.readiness.bound_global_id = None;
|
||||||
|
self.readiness.core_synced = false;
|
||||||
|
self.replacement_pending = true;
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_metadata(
|
fn update_metadata(
|
||||||
@@ -669,6 +755,69 @@ fn try_flush(state: &Rc<RefCell<ActorState>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move routes that still point at the removed owned serial directly to the
|
||||||
|
/// replacement serial. A route changed by another actor during the gap is left
|
||||||
|
/// alone, preserving the same compare-before-restore ownership rule used at
|
||||||
|
/// shutdown. Bare Phase-6 capture has no routed records, but keeping the graph
|
||||||
|
/// actor internally coherent avoids making sink recovery mode-dependent.
|
||||||
|
fn retarget_routed_after_replacement(state: &Rc<RefCell<ActorState>>) {
|
||||||
|
let (metadata, actions, replacement_target) = {
|
||||||
|
let mut state = state.borrow_mut();
|
||||||
|
let Some(prior_serial) = state.replacement_prior_serial else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(identity) = state.identity.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let replacement_target = identity.serial.to_string();
|
||||||
|
let prior_target = prior_serial.to_string();
|
||||||
|
let Some(metadata) = state.default_metadata.clone() else {
|
||||||
|
if state.routed.is_empty() {
|
||||||
|
state.replacement_prior_serial = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let actions: Vec<u32> = state
|
||||||
|
.routed
|
||||||
|
.values()
|
||||||
|
.filter(|record| {
|
||||||
|
observation_is_current(&state.readiness.globals, &record.observed)
|
||||||
|
&& state
|
||||||
|
.current_targets
|
||||||
|
.get(&record.observed.global_id)
|
||||||
|
.and_then(|target| target.value.as_deref())
|
||||||
|
== Some(prior_target.as_str())
|
||||||
|
})
|
||||||
|
.map(|record| record.observed.global_id)
|
||||||
|
.collect();
|
||||||
|
for id in &actions {
|
||||||
|
state.current_targets.insert(
|
||||||
|
*id,
|
||||||
|
MetadataValue {
|
||||||
|
type_: Some("Spa:Id".to_string()),
|
||||||
|
value: Some(replacement_target.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.replacement_prior_serial = None;
|
||||||
|
(metadata, actions, replacement_target)
|
||||||
|
};
|
||||||
|
|
||||||
|
for id in actions {
|
||||||
|
metadata.metadata.set_property(
|
||||||
|
id,
|
||||||
|
"target.object",
|
||||||
|
Some("Spa:Id"),
|
||||||
|
Some(&replacement_target),
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
node_id = id,
|
||||||
|
sink_serial = %replacement_target,
|
||||||
|
"audio graph: stream retargeted to replacement sink"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn restoration_for(
|
fn restoration_for(
|
||||||
record: &RouteRecord,
|
record: &RouteRecord,
|
||||||
current: Option<&MetadataValue>,
|
current: Option<&MetadataValue>,
|
||||||
@@ -707,12 +856,83 @@ fn prepare_quiesce(
|
|||||||
(metadata, actions)
|
(metadata, actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_sink_replacement(
|
||||||
|
state: &Rc<RefCell<ActorState>>,
|
||||||
|
cmd_tx: &pw::channel::Sender<Cmd>,
|
||||||
|
health: &health::Reporter,
|
||||||
|
main_loop: &pw::main_loop::MainLoopRc,
|
||||||
|
) {
|
||||||
|
if !state.borrow_mut().begin_sink_replacement() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if cmd_tx.send(Cmd::RecreateSink).is_err() {
|
||||||
|
health.poison("audio graph could not enqueue capture-sink recreation");
|
||||||
|
main_loop.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn create_owned_sink(
|
||||||
|
core: &pw::core::CoreRc,
|
||||||
|
spec: &CaptureSinkSpec,
|
||||||
|
state: &Rc<RefCell<ActorState>>,
|
||||||
|
cmd_tx: &pw::channel::Sender<Cmd>,
|
||||||
|
health: &health::Reporter,
|
||||||
|
main_loop: &pw::main_loop::MainLoopRc,
|
||||||
|
shutdown_observed: &Arc<AtomicBool>,
|
||||||
|
) -> Result<OwnedSink> {
|
||||||
|
let sink = core
|
||||||
|
.create_object::<pw::node::Node>("adapter", &spec.properties())
|
||||||
|
.context("could not create the connection-owned capture sink")?;
|
||||||
|
let state_for_bound = Rc::clone(state);
|
||||||
|
let state_for_proxy_remove = Rc::clone(state);
|
||||||
|
let cmd_for_proxy_remove = cmd_tx.clone();
|
||||||
|
let main_loop_for_proxy_remove = main_loop.clone();
|
||||||
|
let health_for_proxy_remove = health.clone();
|
||||||
|
let shutdown_for_proxy_remove = Arc::clone(shutdown_observed);
|
||||||
|
let main_loop_for_proxy_error = main_loop.clone();
|
||||||
|
let health_for_proxy_error = health.clone();
|
||||||
|
let shutdown_for_proxy_error = Arc::clone(shutdown_observed);
|
||||||
|
let listener = sink
|
||||||
|
.upcast_ref()
|
||||||
|
.add_listener_local()
|
||||||
|
.bound(move |global_id| {
|
||||||
|
state_for_bound.borrow_mut().observe_bound(global_id);
|
||||||
|
})
|
||||||
|
.removed(move || {
|
||||||
|
if !shutdown_for_proxy_remove.load(Ordering::Acquire) {
|
||||||
|
request_sink_replacement(
|
||||||
|
&state_for_proxy_remove,
|
||||||
|
&cmd_for_proxy_remove,
|
||||||
|
&health_for_proxy_remove,
|
||||||
|
&main_loop_for_proxy_remove,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.error(move |seq, res, message| {
|
||||||
|
tracing::warn!(seq, result = res, %message, "audio graph: capture-sink proxy error");
|
||||||
|
if !shutdown_for_proxy_error.load(Ordering::Acquire) {
|
||||||
|
health_for_proxy_error.poison(format!(
|
||||||
|
"connection-owned capture-sink proxy error {res}: {message}"
|
||||||
|
));
|
||||||
|
main_loop_for_proxy_error.quit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
Ok(OwnedSink {
|
||||||
|
_listener: listener,
|
||||||
|
_proxy: sink,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_graph(
|
fn run_graph(
|
||||||
filter_name: Option<String>,
|
filter_name: Option<String>,
|
||||||
spec: CaptureSinkSpec,
|
spec: CaptureSinkSpec,
|
||||||
cmd_rx: pw::channel::Receiver<Cmd>,
|
cmd_rx: pw::channel::Receiver<Cmd>,
|
||||||
|
cmd_tx: pw::channel::Sender<Cmd>,
|
||||||
event_tx: mpsc::UnboundedSender<GraphEvent>,
|
event_tx: mpsc::UnboundedSender<GraphEvent>,
|
||||||
|
identity_tx: mpsc::UnboundedSender<SinkIdentity>,
|
||||||
ready_tx: oneshot::Sender<SinkIdentity>,
|
ready_tx: oneshot::Sender<SinkIdentity>,
|
||||||
phase: Arc<AtomicU8>,
|
phase: Arc<AtomicU8>,
|
||||||
shutdown_observed: Arc<AtomicBool>,
|
shutdown_observed: Arc<AtomicBool>,
|
||||||
@@ -732,7 +952,10 @@ fn run_graph(
|
|||||||
filter_lower: filter_name.map(|name| name.to_ascii_lowercase()),
|
filter_lower: filter_name.map(|name| name.to_ascii_lowercase()),
|
||||||
readiness: NativeReadiness::default(),
|
readiness: NativeReadiness::default(),
|
||||||
ready_tx: Some(ready_tx),
|
ready_tx: Some(ready_tx),
|
||||||
|
identity_tx: Some(identity_tx),
|
||||||
identity: None,
|
identity: None,
|
||||||
|
replacement_pending: false,
|
||||||
|
replacement_prior_serial: None,
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
pending: BTreeMap::new(),
|
pending: BTreeMap::new(),
|
||||||
routed: BTreeMap::new(),
|
routed: BTreeMap::new(),
|
||||||
@@ -744,10 +967,15 @@ fn run_graph(
|
|||||||
stop_sync: None,
|
stop_sync: None,
|
||||||
closing: false,
|
closing: false,
|
||||||
}));
|
}));
|
||||||
|
let owned_sink = Rc::new(RefCell::new(None::<OwnedSink>));
|
||||||
|
|
||||||
let main_loop_for_cmd = main_loop.clone();
|
let main_loop_for_cmd = main_loop.clone();
|
||||||
let core_for_cmd = core.clone();
|
let core_for_cmd = core.clone();
|
||||||
let state_for_cmd = Rc::clone(&state);
|
let state_for_cmd = Rc::clone(&state);
|
||||||
|
let sink_for_cmd = Rc::clone(&owned_sink);
|
||||||
|
let spec_for_cmd = spec.clone();
|
||||||
|
let cmd_tx_for_cmd = cmd_tx.clone();
|
||||||
|
let health_for_cmd = health.clone();
|
||||||
let shutdown_for_cmd = Arc::clone(&shutdown_observed);
|
let shutdown_for_cmd = Arc::clone(&shutdown_observed);
|
||||||
let _cmd_receiver = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
let _cmd_receiver = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
||||||
Cmd::Quiesce { ack } => {
|
Cmd::Quiesce { ack } => {
|
||||||
@@ -796,6 +1024,41 @@ fn run_graph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Cmd::RecreateSink => {
|
||||||
|
if shutdown_for_cmd.load(Ordering::Acquire) || state_for_cmd.borrow().closing {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The old proxy is already server-removed. Drop its callbacks
|
||||||
|
// before installing the next generation so no stale listener can
|
||||||
|
// affect the replacement's identity handshake.
|
||||||
|
drop(sink_for_cmd.borrow_mut().take());
|
||||||
|
match create_owned_sink(
|
||||||
|
&core_for_cmd,
|
||||||
|
&spec_for_cmd,
|
||||||
|
&state_for_cmd,
|
||||||
|
&cmd_tx_for_cmd,
|
||||||
|
&health_for_cmd,
|
||||||
|
&main_loop_for_cmd,
|
||||||
|
&shutdown_for_cmd,
|
||||||
|
) {
|
||||||
|
Ok(sink) => {
|
||||||
|
*sink_for_cmd.borrow_mut() = Some(sink);
|
||||||
|
match core_for_cmd.sync(0) {
|
||||||
|
Ok(seq) => state_for_cmd.borrow_mut().initial_sync = Some(seq),
|
||||||
|
Err(error) => {
|
||||||
|
health_for_cmd.poison(format!(
|
||||||
|
"capture-sink replacement core.sync failed: {error}"
|
||||||
|
));
|
||||||
|
main_loop_for_cmd.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
health_for_cmd.poison(format!("capture-sink recreation failed: {error:#}"));
|
||||||
|
main_loop_for_cmd.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let state_for_done = Rc::clone(&state);
|
let state_for_done = Rc::clone(&state);
|
||||||
@@ -834,6 +1097,7 @@ fn run_graph(
|
|||||||
// Existing matching streams may have been observed before the
|
// Existing matching streams may have been observed before the
|
||||||
// initial round-trip. Readiness becoming true is itself the
|
// initial round-trip. Readiness becoming true is itself the
|
||||||
// event that makes those observations routable.
|
// event that makes those observations routable.
|
||||||
|
retarget_routed_after_replacement(&state_for_done);
|
||||||
try_flush(&state_for_done);
|
try_flush(&state_for_done);
|
||||||
}
|
}
|
||||||
if stop_completed {
|
if stop_completed {
|
||||||
@@ -855,6 +1119,7 @@ fn run_graph(
|
|||||||
let state_for_remove = Rc::clone(&state);
|
let state_for_remove = Rc::clone(&state);
|
||||||
let main_loop_for_remove = main_loop.clone();
|
let main_loop_for_remove = main_loop.clone();
|
||||||
let health_for_remove = health.clone();
|
let health_for_remove = health.clone();
|
||||||
|
let cmd_tx_for_remove = cmd_tx.clone();
|
||||||
let shutdown_for_remove = Arc::clone(&shutdown_observed);
|
let shutdown_for_remove = Arc::clone(&shutdown_observed);
|
||||||
let _registry_listener = registry
|
let _registry_listener = registry
|
||||||
.add_listener_local()
|
.add_listener_local()
|
||||||
@@ -927,48 +1192,25 @@ fn run_graph(
|
|||||||
.global_remove(move |id| {
|
.global_remove(move |id| {
|
||||||
let own_sink_removed = state_for_remove.borrow_mut().remove_global(id);
|
let own_sink_removed = state_for_remove.borrow_mut().remove_global(id);
|
||||||
if own_sink_removed && !shutdown_for_remove.load(Ordering::Acquire) {
|
if own_sink_removed && !shutdown_for_remove.load(Ordering::Acquire) {
|
||||||
health_for_remove.poison("connection-owned capture sink disappeared unexpectedly");
|
request_sink_replacement(
|
||||||
main_loop_for_remove.quit();
|
&state_for_remove,
|
||||||
|
&cmd_tx_for_remove,
|
||||||
|
&health_for_remove,
|
||||||
|
&main_loop_for_remove,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.register();
|
.register();
|
||||||
|
|
||||||
let sink = core
|
*owned_sink.borrow_mut() = Some(create_owned_sink(
|
||||||
.create_object::<pw::node::Node>("adapter", &spec.properties())
|
&core,
|
||||||
.context("could not create the connection-owned capture sink")?;
|
&spec,
|
||||||
let state_for_bound = Rc::clone(&state);
|
&state,
|
||||||
let main_loop_for_proxy_remove = main_loop.clone();
|
&cmd_tx,
|
||||||
let health_for_proxy_remove = health.clone();
|
&health,
|
||||||
let shutdown_for_proxy_remove = Arc::clone(&shutdown_observed);
|
&main_loop,
|
||||||
let main_loop_for_proxy_error = main_loop.clone();
|
&shutdown_observed,
|
||||||
let health_for_proxy_error = health.clone();
|
)?);
|
||||||
let shutdown_for_proxy_error = Arc::clone(&shutdown_observed);
|
|
||||||
let listener = sink
|
|
||||||
.upcast_ref()
|
|
||||||
.add_listener_local()
|
|
||||||
.bound(move |global_id| {
|
|
||||||
state_for_bound.borrow_mut().observe_bound(global_id);
|
|
||||||
})
|
|
||||||
.removed(move || {
|
|
||||||
if !shutdown_for_proxy_remove.load(Ordering::Acquire) {
|
|
||||||
health_for_proxy_remove.poison("connection-owned capture-sink proxy was removed");
|
|
||||||
main_loop_for_proxy_remove.quit();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.error(move |seq, res, message| {
|
|
||||||
tracing::warn!(seq, result = res, %message, "audio graph: capture-sink proxy error");
|
|
||||||
if !shutdown_for_proxy_error.load(Ordering::Acquire) {
|
|
||||||
health_for_proxy_error.poison(format!(
|
|
||||||
"connection-owned capture-sink proxy error {res}: {message}"
|
|
||||||
));
|
|
||||||
main_loop_for_proxy_error.quit();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
let _owned_sink = OwnedSink {
|
|
||||||
_listener: listener,
|
|
||||||
_proxy: sink,
|
|
||||||
};
|
|
||||||
|
|
||||||
let initial_sync = core
|
let initial_sync = core
|
||||||
.sync(0)
|
.sync(0)
|
||||||
@@ -1508,9 +1750,10 @@ mod tests {
|
|||||||
"live fixture name is already present"
|
"live fixture name is already present"
|
||||||
);
|
);
|
||||||
let (health, _) = health::channel();
|
let (health, _) = health::channel();
|
||||||
let (mut owner, _events) = AudioGraphOwner::start(None, spec.clone(), health.clone())
|
let (mut owner, _events, _identities) =
|
||||||
.await
|
AudioGraphOwner::start(None, spec.clone(), health.clone())
|
||||||
.expect("connection-owned sink starts");
|
.await
|
||||||
|
.expect("connection-owned sink starts");
|
||||||
assert_ne!(owner.identity().global_id, u32::MAX);
|
assert_ne!(owner.identity().global_id, u32::MAX);
|
||||||
assert!(owner.identity().serial > 0);
|
assert!(owner.identity().serial > 0);
|
||||||
assert!(pulse_source_exists(spec.monitor_name()).await);
|
assert!(pulse_source_exists(spec.monitor_name()).await);
|
||||||
@@ -1532,4 +1775,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(health.fault().is_none());
|
assert!(health.fault().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live: destroys and recreates a real connection-owned PipeWire sink; serialize live audio tests"]
|
||||||
|
async fn live_removed_sink_is_recreated_with_a_fresh_identity() {
|
||||||
|
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
||||||
|
assert!(!pulse_source_exists(spec.monitor_name()).await);
|
||||||
|
let (health, _) = health::channel();
|
||||||
|
let (owner, _events, mut identities) =
|
||||||
|
AudioGraphOwner::start(None, spec.clone(), health.clone())
|
||||||
|
.await
|
||||||
|
.expect("connection-owned sink starts");
|
||||||
|
let initial = owner.identity().clone();
|
||||||
|
let announced_initial = tokio::time::timeout(Duration::from_secs(1), identities.recv())
|
||||||
|
.await
|
||||||
|
.expect("initial identity announcement timed out")
|
||||||
|
.expect("identity channel closed");
|
||||||
|
assert_eq!(announced_initial, initial);
|
||||||
|
|
||||||
|
let destroy = tokio::process::Command::new("pw-cli")
|
||||||
|
.args(["destroy", &initial.global_id.to_string()])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.expect("run pw-cli destroy");
|
||||||
|
assert!(destroy.success());
|
||||||
|
let replacement = tokio::time::timeout(Duration::from_secs(5), identities.recv())
|
||||||
|
.await
|
||||||
|
.expect("replacement identity announcement timed out")
|
||||||
|
.expect("identity channel closed during replacement");
|
||||||
|
assert_ne!(replacement.serial, initial.serial);
|
||||||
|
assert_eq!(replacement.name, initial.name);
|
||||||
|
assert_eq!(replacement.monitor_name, initial.monitor_name);
|
||||||
|
assert!(pulse_source_exists(spec.monitor_name()).await);
|
||||||
|
assert!(health.fault().is_none());
|
||||||
|
|
||||||
|
assert!(owner.shutdown().await);
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
while pulse_source_exists(spec.monitor_name()).await && Instant::now() < deadline {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
assert!(!pulse_source_exists(spec.monitor_name()).await);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ use std::time::{Duration, Instant};
|
|||||||
const READINESS_TIMEOUT_MILLIS: u64 = 2_000;
|
const READINESS_TIMEOUT_MILLIS: u64 = 2_000;
|
||||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
fn recoverable_core_error(result: i32) -> bool {
|
||||||
|
// A global can disappear after registry enumeration but before (or while)
|
||||||
|
// this observer's bound proxy finishes an operation. PipeWire reports that
|
||||||
|
// ordinary churn as asynchronous -ENOENT on the Core. The model's removal
|
||||||
|
// event and serial revalidation still fail closed, so terminating the
|
||||||
|
// mutation owner here would make sink recreation impossible without
|
||||||
|
// adding safety. Other Core failures remain fatal.
|
||||||
|
result == -(nix::errno::Errno::ENOENT as i32)
|
||||||
|
}
|
||||||
|
|
||||||
/// A consumer that sees **every** projection, one per applied registry event,
|
/// A consumer that sees **every** projection, one per applied registry event,
|
||||||
/// on the observer thread.
|
/// on the observer thread.
|
||||||
///
|
///
|
||||||
@@ -53,10 +63,31 @@ pub trait ProjectionSink: Send {
|
|||||||
/// Tokio-side access to the observer's most recent coherent projection.
|
/// Tokio-side access to the observer's most recent coherent projection.
|
||||||
pub struct RegistryObserverHandle {
|
pub struct RegistryObserverHandle {
|
||||||
latest: Arc<Mutex<Option<Projection>>>,
|
latest: Arc<Mutex<Option<Projection>>>,
|
||||||
shutdown_tx: pw::channel::Sender<()>,
|
command_tx: pw::channel::Sender<ObserverCommand>,
|
||||||
thread: Option<JoinHandle<()>>,
|
thread: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ObserverCommand {
|
||||||
|
ReplaceCaptureSink(Serial),
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloneable Tokio-side capability for the one mutation-controller command
|
||||||
|
/// needed during capture-sink recreation. The serial is consumed on the
|
||||||
|
/// observer's PipeWire thread; callers never receive or reuse a global id.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(in crate::host) struct FanoutControl {
|
||||||
|
command_tx: pw::channel::Sender<ObserverCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FanoutControl {
|
||||||
|
pub(in crate::host) fn replace_capture_sink(&self, capture_sink: Serial) -> bool {
|
||||||
|
self.command_tx
|
||||||
|
.send(ObserverCommand::ReplaceCaptureSink(capture_sink))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RegistryObserverHandle {
|
impl RegistryObserverHandle {
|
||||||
/// Spawn the read-only PipeWire registry observer.
|
/// Spawn the read-only PipeWire registry observer.
|
||||||
pub fn spawn() -> Result<Self> {
|
pub fn spawn() -> Result<Self> {
|
||||||
@@ -86,11 +117,11 @@ impl RegistryObserverHandle {
|
|||||||
let mutation_health = consumer.mutation_health();
|
let mutation_health = consumer.mutation_health();
|
||||||
let latest = Arc::new(Mutex::new(None));
|
let latest = Arc::new(Mutex::new(None));
|
||||||
let latest_for_thread = Arc::clone(&latest);
|
let latest_for_thread = Arc::clone(&latest);
|
||||||
let (shutdown_tx, shutdown_rx) = pw::channel::channel::<()>();
|
let (command_tx, command_rx) = pw::channel::channel::<ObserverCommand>();
|
||||||
let thread = std::thread::Builder::new()
|
let thread = std::thread::Builder::new()
|
||||||
.name("pixelpass-pw-observer".to_string())
|
.name("pixelpass-pw-observer".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = run_observer(latest_for_thread, shutdown_rx, consumer) {
|
if let Err(e) = run_observer(latest_for_thread, command_rx, consumer) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"registry observer: libpipewire thread exited with error: {e:#}"
|
"registry observer: libpipewire thread exited with error: {e:#}"
|
||||||
);
|
);
|
||||||
@@ -103,7 +134,7 @@ impl RegistryObserverHandle {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
latest,
|
latest,
|
||||||
shutdown_tx,
|
command_tx,
|
||||||
thread: Some(thread),
|
thread: Some(thread),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,6 +147,12 @@ impl RegistryObserverHandle {
|
|||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(in crate::host) fn fanout_control(&self) -> FanoutControl {
|
||||||
|
FanoutControl {
|
||||||
|
command_tx: self.command_tx.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ObserverConsumer {
|
enum ObserverConsumer {
|
||||||
@@ -137,7 +174,7 @@ impl ObserverConsumer {
|
|||||||
|
|
||||||
impl Drop for RegistryObserverHandle {
|
impl Drop for RegistryObserverHandle {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.shutdown_tx.send(());
|
let _ = self.command_tx.send(ObserverCommand::Shutdown);
|
||||||
if let Some(thread) = self.thread.take()
|
if let Some(thread) = self.thread.take()
|
||||||
&& let Err(e) = thread.join()
|
&& let Err(e) = thread.join()
|
||||||
{
|
{
|
||||||
@@ -424,6 +461,14 @@ impl ObserverState {
|
|||||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection);
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replace_capture_sink(&mut self, capture_sink: Serial) {
|
||||||
|
let projection = self.model.project();
|
||||||
|
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
|
||||||
|
if let ObserverConsumer::Mutating { sink, .. } = &mut self.consumer {
|
||||||
|
sink.replace_capture_sink(capture_sink, &projection, now_us, &mut self.link_mutation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record the global's id and apply its add event as one step, so the
|
/// Record the global's id and apply its add event as one step, so the
|
||||||
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
|
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
|
||||||
/// index. Recording only on *applied* adds (never on unknown object types
|
/// index. Recording only on *applied* adds (never on unknown object types
|
||||||
@@ -489,7 +534,7 @@ impl ObserverState {
|
|||||||
|
|
||||||
fn run_observer(
|
fn run_observer(
|
||||||
latest: Arc<Mutex<Option<Projection>>>,
|
latest: Arc<Mutex<Option<Projection>>>,
|
||||||
shutdown_rx: pw::channel::Receiver<()>,
|
command_rx: pw::channel::Receiver<ObserverCommand>,
|
||||||
consumer: ObserverConsumer,
|
consumer: ObserverConsumer,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
@@ -512,10 +557,18 @@ fn run_observer(
|
|||||||
|
|
||||||
let shutdown_observed = Rc::new(Cell::new(false));
|
let shutdown_observed = Rc::new(Cell::new(false));
|
||||||
let shutdown_for_receiver = Rc::clone(&shutdown_observed);
|
let shutdown_for_receiver = Rc::clone(&shutdown_observed);
|
||||||
let main_loop_for_shutdown = main_loop.clone();
|
let main_loop_for_command = main_loop.clone();
|
||||||
let _shutdown_receiver = shutdown_rx.attach(main_loop.loop_(), move |()| {
|
let state_for_command = Rc::clone(&state);
|
||||||
shutdown_for_receiver.set(true);
|
let _command_receiver = command_rx.attach(main_loop.loop_(), move |command| match command {
|
||||||
main_loop_for_shutdown.quit();
|
ObserverCommand::ReplaceCaptureSink(capture_sink) => {
|
||||||
|
state_for_command
|
||||||
|
.borrow_mut()
|
||||||
|
.replace_capture_sink(capture_sink);
|
||||||
|
}
|
||||||
|
ObserverCommand::Shutdown => {
|
||||||
|
shutdown_for_receiver.set(true);
|
||||||
|
main_loop_for_command.quit();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let pending_sync = Rc::new(Cell::new(None));
|
let pending_sync = Rc::new(Cell::new(None));
|
||||||
@@ -539,7 +592,15 @@ fn run_observer(
|
|||||||
%message,
|
%message,
|
||||||
"registry observer: PipeWire core error"
|
"registry observer: PipeWire core error"
|
||||||
);
|
);
|
||||||
if let Some(health) = &mutation_health_for_error {
|
if recoverable_core_error(res) {
|
||||||
|
tracing::info!(
|
||||||
|
id,
|
||||||
|
seq,
|
||||||
|
result = res,
|
||||||
|
%message,
|
||||||
|
"registry observer: stale resource disappeared during graph churn"
|
||||||
|
);
|
||||||
|
} else if let Some(health) = &mutation_health_for_error {
|
||||||
health.poison(format!(
|
health.poison(format!(
|
||||||
"audio fan-out PipeWire core error {res}: {message}"
|
"audio fan-out PipeWire core error {res}: {message}"
|
||||||
));
|
));
|
||||||
@@ -992,6 +1053,13 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_stale_resource_core_errors_are_recoverable() {
|
||||||
|
assert!(recoverable_core_error(-(nix::errno::Errno::ENOENT as i32)));
|
||||||
|
assert!(!recoverable_core_error(-(nix::errno::Errno::EPIPE as i32)));
|
||||||
|
assert!(!recoverable_core_error(0));
|
||||||
|
}
|
||||||
|
|
||||||
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
||||||
/// [`truthy`] spelling is wrong for it.
|
/// [`truthy`] spelling is wrong for it.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user