1536 lines
57 KiB
Rust
1536 lines
57 KiB
Rust
//! Connection-owned PipeWire graph actor.
|
|
//!
|
|
//! The actor owns one non-lingering native capture sink plus the existing
|
|
//! per-application `target.object` router. The sink is therefore scoped to the
|
|
//! actor's PipeWire connection: closing or losing that connection removes it,
|
|
//! including after SIGKILL. Tokio never receives a bare PipeWire global id to
|
|
//! mutate; matching, serial validation, and metadata writes stay ordered on the
|
|
//! PipeWire main-loop thread.
|
|
|
|
use super::aec::AecConfig;
|
|
use super::audio::parse_object_serial;
|
|
use super::fanout::FanoutController;
|
|
use super::health;
|
|
use super::observer::Readiness;
|
|
use super::observer::adapter::RegistryObserverHandle;
|
|
use super::owned_thread::OwnedThread;
|
|
use super::taint::snapshot::{GlobalId, Serial};
|
|
use crate::common::output::AudioExclusionEvent;
|
|
use crate::repair::plan as repair_plan;
|
|
use anyhow::{Context, Result, bail};
|
|
use pipewire::proxy::ProxyT;
|
|
use pipewire::{self as pw, types::ObjectType};
|
|
use std::cell::RefCell;
|
|
use std::collections::BTreeMap;
|
|
use std::io;
|
|
use std::process::Stdio;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
/// A running main loop normally stops in a few milliseconds. Two seconds is
|
|
/// deliberately generous while still bounding host teardown.
|
|
const GRAPH_RUNNING_STOP_BUDGET: Duration = Duration::from_secs(2);
|
|
/// A thread stuck inside PipeWire initialisation cannot process `Stop`. Keep a
|
|
/// separate, longer policy deadline for that different failure mode.
|
|
const GRAPH_STARTING_STOP_BUDGET: Duration = Duration::from_secs(5);
|
|
const GRAPH_NATIVE_READY_BUDGET: Duration = Duration::from_secs(5);
|
|
const GRAPH_QUIESCE_BUDGET: Duration = Duration::from_secs(2);
|
|
/// Warm measurements on this host are single-digit milliseconds. This wider
|
|
/// bound covers a lagging pipewire-pulse bridge without borrowing the actor's
|
|
/// thread or turning a missing Pulse namespace into an unbounded wait.
|
|
const PULSE_MONITOR_READY_BUDGET: Duration = Duration::from_secs(3);
|
|
const PULSE_PROBE_BUDGET: Duration = Duration::from_millis(500);
|
|
const PULSE_PROBE_INTERVAL: Duration = Duration::from_millis(20);
|
|
/// The observer has its own sticky 2 s readiness deadline. This outer budget
|
|
/// includes thread startup and lets us turn either a timeout or a missing
|
|
/// capture-sink observation into a failed `DesktopExcluding` construction.
|
|
const FANOUT_READY_BUDGET: Duration = Duration::from_secs(3);
|
|
|
|
const GRAPH_STARTING: u8 = 0;
|
|
const GRAPH_RUNNING: u8 = 1;
|
|
const GRAPH_EXITED: u8 = 2;
|
|
|
|
/// Exact native capture-sink shape used by S4.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(super) struct CaptureSinkSpec {
|
|
name: String,
|
|
monitor_name: String,
|
|
}
|
|
|
|
impl CaptureSinkSpec {
|
|
pub(super) fn for_pid(pid: u32) -> Self {
|
|
let name = repair_plan::sink_name_for(pid);
|
|
let monitor_name = format!("{name}.monitor");
|
|
Self { name, monitor_name }
|
|
}
|
|
|
|
pub(super) fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
pub(super) fn monitor_name(&self) -> &str {
|
|
&self.monitor_name
|
|
}
|
|
|
|
fn properties(&self) -> pw::properties::PropertiesBox {
|
|
let mut props = pw::properties::properties! {
|
|
"factory.name" => "support.null-audio-sink",
|
|
"media.class" => "Audio/Sink",
|
|
"audio.channels" => "2",
|
|
"audio.position" => "[FL,FR]",
|
|
"node.virtual" => "true",
|
|
"monitor.channel-volumes" => "true",
|
|
// Load-bearing: the remote object must die with this connection.
|
|
"object.linger" => "false"
|
|
};
|
|
props.insert("node.name", self.name.as_str());
|
|
props
|
|
}
|
|
}
|
|
|
|
/// Identity established from the created proxy's bound id and that exact
|
|
/// registry global's serial. `node.name` is intentionally not an identity
|
|
/// input: PipeWire accepts duplicate names and Pulse selects the older one.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(super) struct SinkIdentity {
|
|
pub(super) name: String,
|
|
pub(super) monitor_name: String,
|
|
pub(super) global_id: u32,
|
|
pub(super) serial: u64,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) enum GraphEvent {
|
|
FirstRoutedStream,
|
|
LastRoutedStreamGone,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(super) enum QuiesceOutcome {
|
|
Confirmed,
|
|
Unconfirmed,
|
|
}
|
|
|
|
enum Cmd {
|
|
/// Stop producing route events, restore routes still owned by PixelPass,
|
|
/// and acknowledge the writes with a core round-trip. The sink remains
|
|
/// alive so Tokio can unload every dependent Pulse loopback first.
|
|
Quiesce { ack: oneshot::Sender<bool> },
|
|
/// Restore any still-owned routes, wait for a core round-trip, then quit
|
|
/// the main loop. Dropping the connection removes the native non-lingering
|
|
/// sink only after those restoration writes have reached the server.
|
|
Stop,
|
|
}
|
|
|
|
/// Tokio-side owner of the PipeWire actor thread.
|
|
pub(super) struct AudioGraphOwner {
|
|
cmd_tx: pw::channel::Sender<Cmd>,
|
|
thread: OwnedThread,
|
|
phase: Arc<AtomicU8>,
|
|
identity: SinkIdentity,
|
|
}
|
|
|
|
/// A connection-owned capture sink with no Pulse-module ledger and no API for
|
|
/// constructing the legacy default-monitor loopback.
|
|
///
|
|
/// This is the load-bearing type boundary for the phase-0d
|
|
/// `DesktopExcluding` plan: owning this value proves the sink exists, but the
|
|
/// only operations available are reading its monitor name and shutting down
|
|
/// its graph connections. Phase 6 feeds it through a separate observer
|
|
/// connection that retains non-lingering native PipeWire links; legacy
|
|
/// `Routing` remains a separate type in `host::audio`.
|
|
pub(super) struct BareCaptureSink {
|
|
// Drop order is load-bearing: close the fan-out observer connection (and
|
|
// therefore every non-lingering link), then detach the status task, before
|
|
// the sink-owning connection.
|
|
fanout: Option<RegistryObserverHandle>,
|
|
status_forwarder: Option<tokio::task::JoinHandle<()>>,
|
|
graph_owner: Option<AudioGraphOwner>,
|
|
monitor_name: String,
|
|
}
|
|
|
|
impl BareCaptureSink {
|
|
pub(super) async fn start(health: health::Reporter) -> Result<Self> {
|
|
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
|
let (graph_owner, _event_rx) = AudioGraphOwner::start(None, spec, health.clone())
|
|
.await
|
|
.context("failed to start the bare connection-owned capture sink")?;
|
|
let identity = graph_owner.identity().clone();
|
|
let (status_tx, status_rx) = mpsc::unbounded_channel();
|
|
let status_forwarder = tokio::spawn(forward_audio_exclusion_status(status_rx));
|
|
let fanout = match RegistryObserverHandle::spawn_with_mutation_sink(
|
|
Box::new(FanoutController::with_status_sender(
|
|
Serial(identity.serial),
|
|
AecConfig::Off,
|
|
status_tx,
|
|
)),
|
|
health,
|
|
)
|
|
.context("failed to start the desktop-excluding fan-out observer")
|
|
{
|
|
Ok(fanout) => fanout,
|
|
Err(error) => {
|
|
let _ = status_forwarder.await;
|
|
graph_owner.shutdown().await;
|
|
return Err(error);
|
|
}
|
|
};
|
|
if let Err(error) = wait_for_fanout_ready(&fanout, &identity).await {
|
|
drop(fanout);
|
|
let _ = status_forwarder.await;
|
|
graph_owner.shutdown().await;
|
|
return Err(error).context("desktop-excluding fan-out did not become ready");
|
|
}
|
|
Ok(Self {
|
|
fanout: Some(fanout),
|
|
status_forwarder: Some(status_forwarder),
|
|
graph_owner: Some(graph_owner),
|
|
monitor_name: identity.monitor_name,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn sink_name(&self) -> &str {
|
|
self.monitor_name
|
|
.strip_suffix(".monitor")
|
|
.expect("capture-sink monitor names always end in .monitor")
|
|
}
|
|
|
|
pub(super) fn monitor_name(&self) -> &str {
|
|
&self.monitor_name
|
|
}
|
|
|
|
pub(super) async fn shutdown(mut self) {
|
|
// Joining the observer closes its PipeWire connection and drops every
|
|
// retained non-lingering link before the capture sink can disappear.
|
|
if let Some(fanout) = self.fanout.take() {
|
|
drop(fanout);
|
|
}
|
|
if let Some(status_forwarder) = self.status_forwarder.take()
|
|
&& let Err(error) = status_forwarder.await
|
|
{
|
|
tracing::warn!(%error, "audio fan-out: status forwarder task failed");
|
|
}
|
|
if let Some(graph_owner) = self.graph_owner.take()
|
|
&& !graph_owner.shutdown().await
|
|
{
|
|
tracing::warn!("audio capture: bare AudioGraphOwner shutdown was not confirmed");
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn forward_audio_exclusion_status(
|
|
mut status_rx: mpsc::UnboundedReceiver<AudioExclusionEvent>,
|
|
) {
|
|
while let Some(event) = status_rx.recv().await {
|
|
event.emit();
|
|
}
|
|
}
|
|
|
|
async fn wait_for_fanout_ready(
|
|
observer: &RegistryObserverHandle,
|
|
identity: &SinkIdentity,
|
|
) -> Result<()> {
|
|
let deadline = Instant::now() + FANOUT_READY_BUDGET;
|
|
loop {
|
|
if let Some(projection) = observer.latest() {
|
|
if projection.readiness == Readiness::TimedOut {
|
|
bail!("registry observer reached its sticky readiness timeout");
|
|
}
|
|
let sink_is_current = projection
|
|
.snapshot
|
|
.node(Serial(identity.serial))
|
|
.is_some_and(|node| node.id == GlobalId(identity.global_id));
|
|
if projection.graph_ready && sink_is_current {
|
|
return Ok(());
|
|
}
|
|
}
|
|
if Instant::now() >= deadline {
|
|
bail!(
|
|
"capture sink serial {} was not present in a graph-ready projection within {FANOUT_READY_BUDGET:?}",
|
|
identity.serial
|
|
);
|
|
}
|
|
tokio::time::sleep(PULSE_PROBE_INTERVAL).await;
|
|
}
|
|
}
|
|
|
|
impl AudioGraphOwner {
|
|
pub(super) async fn start(
|
|
filter_name: Option<String>,
|
|
spec: CaptureSinkSpec,
|
|
health: health::Reporter,
|
|
) -> Result<(Self, mpsc::UnboundedReceiver<GraphEvent>)> {
|
|
let (cmd_tx, cmd_rx) = pw::channel::channel::<Cmd>();
|
|
let (event_tx, event_rx) = mpsc::unbounded_channel::<GraphEvent>();
|
|
let (ready_tx, ready_rx) = oneshot::channel::<SinkIdentity>();
|
|
let phase = Arc::new(AtomicU8::new(GRAPH_STARTING));
|
|
let phase_for_thread = Arc::clone(&phase);
|
|
let shutdown_observed = Arc::new(AtomicBool::new(false));
|
|
let shutdown_for_thread = Arc::clone(&shutdown_observed);
|
|
let health_for_thread = health.clone();
|
|
let spec_for_thread = spec.clone();
|
|
|
|
let thread = std::thread::Builder::new()
|
|
.name("pixelpass-audio-graph".to_string())
|
|
.spawn(move || {
|
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
run_graph(
|
|
filter_name,
|
|
spec_for_thread,
|
|
cmd_rx,
|
|
event_tx,
|
|
ready_tx,
|
|
Arc::clone(&phase_for_thread),
|
|
Arc::clone(&shutdown_for_thread),
|
|
health_for_thread.clone(),
|
|
)
|
|
}));
|
|
phase_for_thread.store(GRAPH_EXITED, Ordering::Release);
|
|
match result {
|
|
Ok(result) => report_graph_exit(
|
|
&health_for_thread,
|
|
shutdown_for_thread.load(Ordering::Acquire),
|
|
result,
|
|
),
|
|
Err(_) => {
|
|
health_for_thread.poison("AudioGraphOwner thread panicked");
|
|
}
|
|
}
|
|
})
|
|
.context("failed to spawn AudioGraphOwner thread")?;
|
|
|
|
// Construct the owner before the first await. Cancellation therefore
|
|
// keeps the OS handle owned and sends Stop from Drop.
|
|
let mut owner = Self {
|
|
cmd_tx,
|
|
thread: OwnedThread::new("AudioGraphOwner thread", thread, health.clone()),
|
|
phase,
|
|
// Replaced after the native handshake; this value is never exposed.
|
|
identity: SinkIdentity {
|
|
name: spec.name.clone(),
|
|
monitor_name: spec.monitor_name.clone(),
|
|
global_id: u32::MAX,
|
|
serial: 0,
|
|
},
|
|
};
|
|
|
|
let identity = match tokio::time::timeout(GRAPH_NATIVE_READY_BUDGET, ready_rx).await {
|
|
Ok(Ok(identity)) => identity,
|
|
Ok(Err(_)) => {
|
|
owner.shutdown().await;
|
|
bail!("AudioGraphOwner exited before the native sink became ready");
|
|
}
|
|
Err(_) => {
|
|
owner.shutdown().await;
|
|
bail!(
|
|
"AudioGraphOwner did not establish the native sink within {GRAPH_NATIVE_READY_BUDGET:?}"
|
|
);
|
|
}
|
|
};
|
|
owner.identity = identity.clone();
|
|
|
|
// A PipeWire core round-trip proves only this client's namespace. The
|
|
// GStreamer `pulsesrc` path uses pipewire-pulse, so readiness requires a
|
|
// separate Pulse lookup from Tokio, never a blocking subprocess on the
|
|
// actor thread.
|
|
let pulse_ready = wait_for_pulse_monitor(&identity.monitor_name).await;
|
|
if let Err(error) = pulse_ready {
|
|
owner.shutdown().await;
|
|
return Err(error).context("native capture sink was not exported to Pulse");
|
|
}
|
|
|
|
tracing::info!(
|
|
global_id = identity.global_id,
|
|
serial = identity.serial,
|
|
sink = %identity.name,
|
|
monitor = %identity.monitor_name,
|
|
"audio graph: connection-owned capture sink ready"
|
|
);
|
|
Ok((owner, event_rx))
|
|
}
|
|
|
|
pub(super) fn identity(&self) -> &SinkIdentity {
|
|
&self.identity
|
|
}
|
|
|
|
pub(super) async fn quiesce(&mut self) -> QuiesceOutcome {
|
|
let (ack_tx, ack_rx) = oneshot::channel();
|
|
if self.cmd_tx.send(Cmd::Quiesce { ack: ack_tx }).is_err() {
|
|
tracing::warn!("audio graph: actor exited before routes could be quiesced");
|
|
return QuiesceOutcome::Unconfirmed;
|
|
}
|
|
match tokio::time::timeout(GRAPH_QUIESCE_BUDGET, ack_rx).await {
|
|
Ok(Ok(true)) => QuiesceOutcome::Confirmed,
|
|
Ok(Ok(false) | Err(_)) => {
|
|
tracing::warn!("audio graph: route restoration was not acknowledged");
|
|
QuiesceOutcome::Unconfirmed
|
|
}
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
"audio graph: route restoration was not acknowledged within {GRAPH_QUIESCE_BUDGET:?}"
|
|
);
|
|
QuiesceOutcome::Unconfirmed
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn shutdown(mut self) -> bool {
|
|
let _ = self.cmd_tx.send(Cmd::Stop);
|
|
let budget = graph_shutdown_budget(self.phase.load(Ordering::Acquire));
|
|
self.thread.join_within(budget).await
|
|
}
|
|
}
|
|
|
|
impl Drop for AudioGraphOwner {
|
|
fn drop(&mut self) {
|
|
// If async construction or shutdown is cancelled, wake the MainLoop
|
|
// before OwnedThread poisons/quarantines the still-owned handle.
|
|
let _ = self.cmd_tx.send(Cmd::Stop);
|
|
}
|
|
}
|
|
|
|
fn graph_shutdown_budget(phase: u8) -> Duration {
|
|
if phase == GRAPH_STARTING {
|
|
GRAPH_STARTING_STOP_BUDGET
|
|
} else {
|
|
GRAPH_RUNNING_STOP_BUDGET
|
|
}
|
|
}
|
|
|
|
fn report_graph_exit(health: &health::Reporter, shutdown_observed: bool, result: Result<()>) {
|
|
match result {
|
|
Ok(()) if shutdown_observed => {}
|
|
Ok(()) => {
|
|
health.poison("AudioGraphOwner exited without a Stop command");
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!("audio graph: actor thread exited with error: {error:#}");
|
|
health.poison(format!("AudioGraphOwner failed: {error:#}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_pulse_monitor(monitor_name: &str) -> Result<Duration> {
|
|
let started = Instant::now();
|
|
let deadline = started + PULSE_MONITOR_READY_BUDGET;
|
|
|
|
loop {
|
|
let mut command = tokio::process::Command::new("pactl");
|
|
command
|
|
.args(["get-source-volume", monitor_name])
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
let last_error = match tokio::time::timeout(PULSE_PROBE_BUDGET, command.output()).await {
|
|
Ok(Ok(output)) if output.status.success() => return Ok(started.elapsed()),
|
|
Ok(Ok(output)) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
|
Ok(Err(error)) if error.kind() == io::ErrorKind::NotFound => {
|
|
return Err(error).context("failed to run `pactl get-source-volume`");
|
|
}
|
|
Ok(Err(error)) => error.to_string(),
|
|
Err(_) => format!("pactl probe exceeded {PULSE_PROBE_BUDGET:?}"),
|
|
};
|
|
|
|
if Instant::now() >= deadline {
|
|
bail!(
|
|
"Pulse monitor {monitor_name:?} did not become ready within {PULSE_MONITOR_READY_BUDGET:?}: {last_error}"
|
|
);
|
|
}
|
|
tokio::time::sleep(PULSE_PROBE_INTERVAL).await;
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct ObservedNode {
|
|
global_id: u32,
|
|
serial: u64,
|
|
/// Diagnostic only. Serial equality is the identity gate; epochs do not
|
|
/// invalidate an observation merely because unrelated graph traffic moved.
|
|
epoch: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
struct MetadataValue {
|
|
type_: Option<String>,
|
|
value: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RouteRecord {
|
|
observed: ObservedNode,
|
|
prior_target: MetadataValue,
|
|
}
|
|
|
|
struct BoundMetadata {
|
|
// Listener first: its Drop unhooks callbacks that reference the proxy.
|
|
_listener: pw::metadata::MetadataListener,
|
|
metadata: pw::metadata::Metadata,
|
|
}
|
|
|
|
struct OwnedSink {
|
|
// Listener first for the same teardown-order invariant as BoundMetadata.
|
|
_listener: pw::proxy::ProxyListener,
|
|
_proxy: pw::node::Node,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct NativeReadiness {
|
|
bound_global_id: Option<u32>,
|
|
globals: BTreeMap<u32, u64>,
|
|
core_synced: bool,
|
|
}
|
|
|
|
impl NativeReadiness {
|
|
fn identity(&self, spec: &CaptureSinkSpec) -> Option<SinkIdentity> {
|
|
let global_id = self.bound_global_id?;
|
|
let serial = *self.globals.get(&global_id)?;
|
|
self.core_synced.then(|| SinkIdentity {
|
|
name: spec.name().to_string(),
|
|
monitor_name: spec.monitor_name().to_string(),
|
|
global_id,
|
|
serial,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct ActorState {
|
|
spec: CaptureSinkSpec,
|
|
filter_lower: Option<String>,
|
|
readiness: NativeReadiness,
|
|
ready_tx: Option<oneshot::Sender<SinkIdentity>>,
|
|
identity: Option<SinkIdentity>,
|
|
epoch: u64,
|
|
pending: BTreeMap<u32, ObservedNode>,
|
|
routed: BTreeMap<u32, RouteRecord>,
|
|
current_targets: BTreeMap<u32, MetadataValue>,
|
|
default_metadata: Option<Rc<BoundMetadata>>,
|
|
event_tx: Option<mpsc::UnboundedSender<GraphEvent>>,
|
|
initial_sync: Option<pw::spa::utils::result::AsyncSeq>,
|
|
quiesce_sync: Option<(pw::spa::utils::result::AsyncSeq, oneshot::Sender<bool>)>,
|
|
stop_sync: Option<pw::spa::utils::result::AsyncSeq>,
|
|
closing: bool,
|
|
}
|
|
|
|
impl ActorState {
|
|
fn publish_ready_if_complete(&mut self) {
|
|
if self.identity.is_some() {
|
|
return;
|
|
}
|
|
let Some(identity) = self.readiness.identity(&self.spec) else {
|
|
return;
|
|
};
|
|
self.identity = Some(identity.clone());
|
|
if let Some(tx) = self.ready_tx.take() {
|
|
let _ = tx.send(identity);
|
|
}
|
|
}
|
|
|
|
fn observe_global(&mut self, id: u32, serial: u64) {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
self.readiness.globals.insert(id, serial);
|
|
self.publish_ready_if_complete();
|
|
}
|
|
|
|
fn observe_bound(&mut self, id: u32) {
|
|
self.readiness.bound_global_id = Some(id);
|
|
self.publish_ready_if_complete();
|
|
}
|
|
|
|
fn observe_initial_sync(&mut self) {
|
|
self.readiness.core_synced = true;
|
|
self.publish_ready_if_complete();
|
|
}
|
|
|
|
fn note_matching_stream(&mut self, id: u32, serial: u64) {
|
|
if self.closing {
|
|
return;
|
|
}
|
|
self.pending.insert(
|
|
id,
|
|
ObservedNode {
|
|
global_id: id,
|
|
serial,
|
|
epoch: self.epoch,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn remove_global(&mut self, id: u32) -> bool {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
self.readiness.globals.remove(&id);
|
|
self.pending.remove(&id);
|
|
self.current_targets.remove(&id);
|
|
let was_routed = !self.routed.is_empty();
|
|
self.routed.remove(&id);
|
|
if was_routed
|
|
&& self.routed.is_empty()
|
|
&& let Some(tx) = &self.event_tx
|
|
{
|
|
let _ = tx.send(GraphEvent::LastRoutedStreamGone);
|
|
}
|
|
self.readiness.bound_global_id == Some(id)
|
|
}
|
|
|
|
fn update_metadata(
|
|
&mut self,
|
|
subject: u32,
|
|
key: Option<&str>,
|
|
type_: Option<&str>,
|
|
value: Option<&str>,
|
|
) {
|
|
match key {
|
|
Some("target.object") => {
|
|
self.current_targets.insert(
|
|
subject,
|
|
MetadataValue {
|
|
type_: type_.map(str::to_string),
|
|
value: value.map(str::to_string),
|
|
},
|
|
);
|
|
}
|
|
None => self.current_targets.clear(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn observation_is_current(globals: &BTreeMap<u32, u64>, observed: &ObservedNode) -> bool {
|
|
globals.get(&observed.global_id) == Some(&observed.serial)
|
|
}
|
|
|
|
fn try_flush(state: &Rc<RefCell<ActorState>>) {
|
|
let (metadata, actions, notify_first) = {
|
|
let mut state = state.borrow_mut();
|
|
if state.closing || !state.readiness.core_synced {
|
|
return;
|
|
}
|
|
let Some(identity) = state.identity.clone() else {
|
|
return;
|
|
};
|
|
let Some(metadata) = state.default_metadata.clone() else {
|
|
return;
|
|
};
|
|
let owned_target = identity.serial.to_string();
|
|
let was_empty = state.routed.is_empty();
|
|
let pending = std::mem::take(&mut state.pending);
|
|
let mut actions = Vec::new();
|
|
|
|
for observed in pending.into_values() {
|
|
if !observation_is_current(&state.readiness.globals, &observed)
|
|
|| state.routed.contains_key(&observed.global_id)
|
|
{
|
|
continue;
|
|
}
|
|
let prior_target = state
|
|
.current_targets
|
|
.get(&observed.global_id)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
state.current_targets.insert(
|
|
observed.global_id,
|
|
MetadataValue {
|
|
type_: Some("Spa:Id".to_string()),
|
|
value: Some(owned_target.clone()),
|
|
},
|
|
);
|
|
state.routed.insert(
|
|
observed.global_id,
|
|
RouteRecord {
|
|
observed: observed.clone(),
|
|
prior_target,
|
|
},
|
|
);
|
|
actions.push(observed.global_id);
|
|
}
|
|
let notify_first = was_empty && !actions.is_empty();
|
|
(metadata, actions, notify_first)
|
|
};
|
|
|
|
let owned_target = state
|
|
.borrow()
|
|
.identity
|
|
.as_ref()
|
|
.expect("identity existed while staging routes")
|
|
.serial
|
|
.to_string();
|
|
for id in &actions {
|
|
metadata
|
|
.metadata
|
|
.set_property(*id, "target.object", Some("Spa:Id"), Some(&owned_target));
|
|
tracing::info!(node_id = *id, sink_serial = %owned_target, "audio graph: stream routed");
|
|
}
|
|
if notify_first && let Some(tx) = state.borrow().event_tx.as_ref() {
|
|
let _ = tx.send(GraphEvent::FirstRoutedStream);
|
|
}
|
|
}
|
|
|
|
fn restoration_for(
|
|
record: &RouteRecord,
|
|
current: Option<&MetadataValue>,
|
|
owned_target: &str,
|
|
) -> Option<MetadataValue> {
|
|
let current = current?;
|
|
(current.value.as_deref() == Some(owned_target)).then(|| record.prior_target.clone())
|
|
}
|
|
|
|
fn prepare_quiesce(
|
|
state: &Rc<RefCell<ActorState>>,
|
|
) -> (Option<Rc<BoundMetadata>>, Vec<(u32, MetadataValue)>) {
|
|
let mut state = state.borrow_mut();
|
|
state.closing = true;
|
|
state.event_tx.take();
|
|
state.pending.clear();
|
|
let metadata = state.default_metadata.clone();
|
|
let owned_target = state
|
|
.identity
|
|
.as_ref()
|
|
.map(|identity| identity.serial.to_string())
|
|
.unwrap_or_default();
|
|
let actions = state
|
|
.routed
|
|
.values()
|
|
.filter_map(|record| {
|
|
restoration_for(
|
|
record,
|
|
state.current_targets.get(&record.observed.global_id),
|
|
&owned_target,
|
|
)
|
|
.map(|value| (record.observed.global_id, value))
|
|
})
|
|
.collect();
|
|
state.routed.clear();
|
|
(metadata, actions)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn run_graph(
|
|
filter_name: Option<String>,
|
|
spec: CaptureSinkSpec,
|
|
cmd_rx: pw::channel::Receiver<Cmd>,
|
|
event_tx: mpsc::UnboundedSender<GraphEvent>,
|
|
ready_tx: oneshot::Sender<SinkIdentity>,
|
|
phase: Arc<AtomicU8>,
|
|
shutdown_observed: Arc<AtomicBool>,
|
|
health: health::Reporter,
|
|
) -> Result<()> {
|
|
let main_loop =
|
|
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
|
let context =
|
|
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
|
let core = context
|
|
.connect_rc(None)
|
|
.context("pw core connect failed (is the daemon running?)")?;
|
|
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
|
|
|
let state = Rc::new(RefCell::new(ActorState {
|
|
spec: spec.clone(),
|
|
filter_lower: filter_name.map(|name| name.to_ascii_lowercase()),
|
|
readiness: NativeReadiness::default(),
|
|
ready_tx: Some(ready_tx),
|
|
identity: None,
|
|
epoch: 0,
|
|
pending: BTreeMap::new(),
|
|
routed: BTreeMap::new(),
|
|
current_targets: BTreeMap::new(),
|
|
default_metadata: None,
|
|
event_tx: Some(event_tx),
|
|
initial_sync: None,
|
|
quiesce_sync: None,
|
|
stop_sync: None,
|
|
closing: false,
|
|
}));
|
|
|
|
let main_loop_for_cmd = main_loop.clone();
|
|
let core_for_cmd = core.clone();
|
|
let state_for_cmd = Rc::clone(&state);
|
|
let shutdown_for_cmd = Arc::clone(&shutdown_observed);
|
|
let _cmd_receiver = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
|
Cmd::Quiesce { ack } => {
|
|
let (metadata, restorations) = prepare_quiesce(&state_for_cmd);
|
|
if let Some(metadata) = metadata {
|
|
for (id, prior) in restorations {
|
|
metadata.metadata.set_property(
|
|
id,
|
|
"target.object",
|
|
prior.type_.as_deref(),
|
|
prior.value.as_deref(),
|
|
);
|
|
}
|
|
}
|
|
match core_for_cmd.sync(0) {
|
|
Ok(seq) => state_for_cmd.borrow_mut().quiesce_sync = Some((seq, ack)),
|
|
Err(error) => {
|
|
tracing::warn!("audio graph: quiesce core.sync failed: {error}");
|
|
let _ = ack.send(false);
|
|
}
|
|
}
|
|
}
|
|
Cmd::Stop => {
|
|
shutdown_for_cmd.store(true, Ordering::Release);
|
|
let (metadata, restorations) = prepare_quiesce(&state_for_cmd);
|
|
if let Some(metadata) = metadata {
|
|
for (id, prior) in restorations {
|
|
metadata.metadata.set_property(
|
|
id,
|
|
"target.object",
|
|
prior.type_.as_deref(),
|
|
prior.value.as_deref(),
|
|
);
|
|
}
|
|
}
|
|
if let Some((_, ack)) = state_for_cmd.borrow_mut().quiesce_sync.take() {
|
|
let _ = ack.send(false);
|
|
}
|
|
match core_for_cmd.sync(0) {
|
|
Ok(seq) => state_for_cmd.borrow_mut().stop_sync = Some(seq),
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
"audio graph: final route-restoration core.sync failed: {error}"
|
|
);
|
|
main_loop_for_cmd.quit();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let state_for_done = Rc::clone(&state);
|
|
let main_loop_for_done = main_loop.clone();
|
|
let state_for_core_error = Rc::clone(&state);
|
|
let main_loop_for_core_error = main_loop.clone();
|
|
let health_for_core_error = health.clone();
|
|
let _core_listener = core
|
|
.add_listener_local()
|
|
.done(move |id, seq| {
|
|
if id != pw::core::PW_ID_CORE {
|
|
return;
|
|
}
|
|
let (initial_completed, stop_completed) = {
|
|
let mut state = state_for_done.borrow_mut();
|
|
let initial_completed = state.initial_sync == Some(seq);
|
|
if initial_completed {
|
|
state.initial_sync = None;
|
|
state.observe_initial_sync();
|
|
}
|
|
if state
|
|
.quiesce_sync
|
|
.as_ref()
|
|
.is_some_and(|(pending, _)| *pending == seq)
|
|
&& let Some((_, ack)) = state.quiesce_sync.take()
|
|
{
|
|
let _ = ack.send(true);
|
|
}
|
|
let stop_completed = state.stop_sync == Some(seq);
|
|
if stop_completed {
|
|
state.stop_sync = None;
|
|
}
|
|
(initial_completed, stop_completed)
|
|
};
|
|
if initial_completed {
|
|
// Existing matching streams may have been observed before the
|
|
// initial round-trip. Readiness becoming true is itself the
|
|
// event that makes those observations routable.
|
|
try_flush(&state_for_done);
|
|
}
|
|
if stop_completed {
|
|
main_loop_for_done.quit();
|
|
}
|
|
})
|
|
.error(move |id, seq, res, message| {
|
|
tracing::warn!(id, seq, result = res, %message, "audio graph: PipeWire core error");
|
|
if let Some((_, ack)) = state_for_core_error.borrow_mut().quiesce_sync.take() {
|
|
let _ = ack.send(false);
|
|
}
|
|
health_for_core_error.poison(format!("PipeWire core error {res}: {message}"));
|
|
main_loop_for_core_error.quit();
|
|
})
|
|
.register();
|
|
|
|
let registry_weak = registry.downgrade();
|
|
let state_for_global = Rc::clone(&state);
|
|
let state_for_remove = Rc::clone(&state);
|
|
let main_loop_for_remove = main_loop.clone();
|
|
let health_for_remove = health.clone();
|
|
let shutdown_for_remove = Arc::clone(&shutdown_observed);
|
|
let _registry_listener = registry
|
|
.add_listener_local()
|
|
.global(move |obj| {
|
|
match obj.type_ {
|
|
ObjectType::Node => {
|
|
let Some(props) = obj.props.as_ref() else {
|
|
return;
|
|
};
|
|
let Some(serial) = props.get("object.serial").and_then(parse_object_serial)
|
|
else {
|
|
return;
|
|
};
|
|
{
|
|
let mut state = state_for_global.borrow_mut();
|
|
state.observe_global(obj.id, serial);
|
|
let matches_filter = state.filter_lower.as_ref().is_some_and(|filter| {
|
|
props.get("media.class") == Some("Stream/Output/Audio")
|
|
&& props
|
|
.get("application.name")
|
|
.is_some_and(|app| app.eq_ignore_ascii_case(filter))
|
|
});
|
|
if matches_filter && state.readiness.bound_global_id != Some(obj.id) {
|
|
state.note_matching_stream(obj.id, serial);
|
|
}
|
|
}
|
|
try_flush(&state_for_global);
|
|
}
|
|
ObjectType::Metadata => {
|
|
let Some(props) = obj.props.as_ref() else {
|
|
return;
|
|
};
|
|
if props.get("metadata.name") != Some("default") {
|
|
return;
|
|
}
|
|
let Some(registry) = registry_weak.upgrade() else {
|
|
return;
|
|
};
|
|
let metadata: pw::metadata::Metadata = match registry.bind(obj) {
|
|
Ok(metadata) => metadata,
|
|
Err(error) => {
|
|
tracing::warn!("audio graph: bind default metadata failed: {error}");
|
|
return;
|
|
}
|
|
};
|
|
let weak_state = Rc::downgrade(&state_for_global);
|
|
let listener = metadata
|
|
.add_listener_local()
|
|
.property(move |subject, key, type_, value| {
|
|
if let Some(state) = weak_state.upgrade() {
|
|
state
|
|
.borrow_mut()
|
|
.update_metadata(subject, key, type_, value);
|
|
// A property callback may be the last missing
|
|
// observation for a stream queued earlier.
|
|
try_flush(&state);
|
|
}
|
|
0
|
|
})
|
|
.register();
|
|
state_for_global.borrow_mut().default_metadata = Some(Rc::new(BoundMetadata {
|
|
_listener: listener,
|
|
metadata,
|
|
}));
|
|
try_flush(&state_for_global);
|
|
}
|
|
_ => {}
|
|
}
|
|
})
|
|
.global_remove(move |id| {
|
|
let own_sink_removed = state_for_remove.borrow_mut().remove_global(id);
|
|
if own_sink_removed && !shutdown_for_remove.load(Ordering::Acquire) {
|
|
health_for_remove.poison("connection-owned capture sink disappeared unexpectedly");
|
|
main_loop_for_remove.quit();
|
|
}
|
|
})
|
|
.register();
|
|
|
|
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 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) {
|
|
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
|
|
.sync(0)
|
|
.context("AudioGraphOwner initial core.sync failed")?;
|
|
state.borrow_mut().initial_sync = Some(initial_sync);
|
|
|
|
phase.store(GRAPH_RUNNING, Ordering::Release);
|
|
tracing::info!(sink = %spec.name, "audio graph: actor main loop running");
|
|
main_loop.run();
|
|
tracing::info!(sink = %spec.name, "audio graph: actor main loop exiting");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cli::{HostOpts, Quality};
|
|
use crate::repair::plan::{ModuleObservation, Shape, classify};
|
|
use nix::sys::signal::Signal;
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc as std_mpsc;
|
|
|
|
const S5_HOST_HELPER: &str = "PIXELPASS_S5_HOST_HELPER";
|
|
const S5_READY_PREFIX: &str = "PIXELPASS_S5_READY=";
|
|
|
|
fn spec(name: &str) -> CaptureSinkSpec {
|
|
CaptureSinkSpec {
|
|
name: name.to_string(),
|
|
monitor_name: format!("{name}.monitor"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capture_sink_spec_is_non_lingering_and_pulse_compatible() {
|
|
let spec = CaptureSinkSpec::for_pid(4242);
|
|
assert_eq!(spec.name(), "pixelpass_capture_4242");
|
|
assert_eq!(spec.monitor_name(), "pixelpass_capture_4242.monitor");
|
|
let props = spec.properties();
|
|
let props = props.dict();
|
|
assert_eq!(props.get("factory.name"), Some("support.null-audio-sink"));
|
|
assert_eq!(props.get("node.name"), Some("pixelpass_capture_4242"));
|
|
assert_eq!(props.get("media.class"), Some("Audio/Sink"));
|
|
assert_eq!(props.get("audio.channels"), Some("2"));
|
|
assert_eq!(props.get("audio.position"), Some("[FL,FR]"));
|
|
assert_eq!(props.get("node.virtual"), Some("true"));
|
|
assert_eq!(props.get("monitor.channel-volumes"), Some("true"));
|
|
assert_eq!(props.get("object.linger"), Some("false"));
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_correlates_the_proxy_bound_id_not_a_duplicate_name() {
|
|
let spec = spec("duplicate_name_is_not_identity");
|
|
let mut readiness = NativeReadiness::default();
|
|
// A different global could carry the same node.name. Readiness has no
|
|
// name input at all and therefore cannot accidentally select it.
|
|
readiness.globals.insert(11, 101);
|
|
readiness.bound_global_id = Some(12);
|
|
readiness.core_synced = true;
|
|
assert!(readiness.identity(&spec).is_none());
|
|
|
|
readiness.globals.insert(12, 202);
|
|
assert_eq!(
|
|
readiness.identity(&spec),
|
|
Some(SinkIdentity {
|
|
name: spec.name.clone(),
|
|
monitor_name: spec.monitor_name.clone(),
|
|
global_id: 12,
|
|
serial: 202,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_requires_the_core_round_trip_as_well_as_identity() {
|
|
let spec = spec("roundtrip_gate");
|
|
let mut readiness = NativeReadiness::default();
|
|
readiness.globals.insert(7, 70);
|
|
readiness.bound_global_id = Some(7);
|
|
assert!(readiness.identity(&spec).is_none());
|
|
readiness.core_synced = true;
|
|
assert_eq!(readiness.identity(&spec).unwrap().serial, 70);
|
|
}
|
|
|
|
#[test]
|
|
fn route_gate_revalidates_serial_but_not_unrelated_epoch_churn() {
|
|
let observed = ObservedNode {
|
|
global_id: 9,
|
|
serial: 90,
|
|
epoch: 1,
|
|
};
|
|
let mut globals = BTreeMap::from([(9, 90)]);
|
|
assert!(observation_is_current(&globals, &observed));
|
|
|
|
// Epoch is diagnostic only: unrelated graph traffic must not invalidate
|
|
// a still-live identity.
|
|
let later_epoch = ObservedNode {
|
|
epoch: 500,
|
|
..observed.clone()
|
|
};
|
|
assert!(observation_is_current(&globals, &later_epoch));
|
|
|
|
// A recycled global id is a different object and must never be routed
|
|
// from the stale observation.
|
|
globals.insert(9, 91);
|
|
assert!(!observation_is_current(&globals, &observed));
|
|
}
|
|
|
|
#[test]
|
|
fn route_restoration_never_overwrites_a_later_owner() {
|
|
let record = RouteRecord {
|
|
observed: ObservedNode {
|
|
global_id: 9,
|
|
serial: 90,
|
|
epoch: 1,
|
|
},
|
|
prior_target: MetadataValue {
|
|
type_: Some("Spa:Id".to_string()),
|
|
value: Some("44".to_string()),
|
|
},
|
|
};
|
|
let ours = MetadataValue {
|
|
type_: Some("Spa:Id".to_string()),
|
|
value: Some("55".to_string()),
|
|
};
|
|
assert_eq!(
|
|
restoration_for(&record, Some(&ours), "55"),
|
|
Some(record.prior_target.clone())
|
|
);
|
|
let user_override = MetadataValue {
|
|
type_: Some("Spa:Id".to_string()),
|
|
value: Some("66".to_string()),
|
|
};
|
|
assert_eq!(restoration_for(&record, Some(&user_override), "55"), None);
|
|
assert_eq!(restoration_for(&record, None, "55"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_has_distinct_starting_and_running_budgets() {
|
|
assert_eq!(
|
|
graph_shutdown_budget(GRAPH_STARTING),
|
|
GRAPH_STARTING_STOP_BUDGET
|
|
);
|
|
assert_eq!(
|
|
graph_shutdown_budget(GRAPH_RUNNING),
|
|
GRAPH_RUNNING_STOP_BUDGET
|
|
);
|
|
assert!(GRAPH_STARTING_STOP_BUDGET > GRAPH_RUNNING_STOP_BUDGET);
|
|
}
|
|
|
|
#[test]
|
|
fn graph_exit_is_healthy_only_after_stop_was_observed() {
|
|
let (clean, _) = health::channel();
|
|
report_graph_exit(&clean, true, Ok(()));
|
|
assert!(clean.fault().is_none());
|
|
|
|
let (unexpected, _) = health::channel();
|
|
report_graph_exit(&unexpected, false, Ok(()));
|
|
assert_eq!(
|
|
unexpected.fault().as_deref(),
|
|
Some("AudioGraphOwner exited without a Stop command")
|
|
);
|
|
|
|
let (failed, _) = health::channel();
|
|
report_graph_exit(&failed, false, Err(anyhow::anyhow!("fixture failure")));
|
|
assert!(
|
|
failed
|
|
.fault()
|
|
.as_deref()
|
|
.is_some_and(|reason| reason.contains("fixture failure"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelling_shutdown_keeps_the_graph_thread_owned_and_poisons() {
|
|
let (cmd_tx, _cmd_rx) = pw::channel::channel::<Cmd>();
|
|
let (release_tx, release_rx) = std_mpsc::channel();
|
|
let (health, _) = health::channel();
|
|
let thread = std::thread::spawn(move || {
|
|
let _ = release_rx.recv();
|
|
});
|
|
let owner = AudioGraphOwner {
|
|
cmd_tx,
|
|
thread: OwnedThread::new("graph cancellation fixture", thread, health.clone()),
|
|
phase: Arc::new(AtomicU8::new(GRAPH_STARTING)),
|
|
identity: SinkIdentity {
|
|
name: "fixture".to_string(),
|
|
monitor_name: "fixture.monitor".to_string(),
|
|
global_id: 1,
|
|
serial: 1,
|
|
},
|
|
};
|
|
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(20), owner.shutdown())
|
|
.await
|
|
.is_err(),
|
|
"the outer timeout must cancel shutdown before its policy deadline"
|
|
);
|
|
assert!(
|
|
health.fault().is_some(),
|
|
"cancellation must poison instead of detaching the OS handle"
|
|
);
|
|
release_tx.send(()).expect("release quarantined fixture");
|
|
}
|
|
|
|
async fn pulse_source_exists(name: &str) -> bool {
|
|
tokio::process::Command::new("pactl")
|
|
.args(["get-source-volume", name])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.await
|
|
.is_ok_and(|status| status.success())
|
|
}
|
|
|
|
fn whole_desktop_opts() -> HostOpts {
|
|
HostOpts {
|
|
window: false,
|
|
app: None,
|
|
strict_audio: false,
|
|
display_server: None,
|
|
quality: Quality::Auto,
|
|
bitrate: None,
|
|
framerate: None,
|
|
max_height: None,
|
|
no_hwencode: false,
|
|
max_viewers: None,
|
|
interactive: false,
|
|
capture_mode: crate::cli::CaptureMode::Legacy,
|
|
legacy_null_sink: false,
|
|
relay: None,
|
|
}
|
|
}
|
|
|
|
/// Subprocess half of the S5 two-host gate. The outer test invokes this
|
|
/// exact test twice, so each helper owns a distinct PipeWire connection,
|
|
/// process id, native sink, and ownership-tagged Pulse loopback. SIGINT
|
|
/// requests an ordinary teardown; SIGKILL deliberately skips it.
|
|
#[tokio::test]
|
|
async fn s5_connection_owned_host_helper() {
|
|
if std::env::var_os(S5_HOST_HELPER).is_none() {
|
|
return;
|
|
}
|
|
|
|
let (health, _) = health::channel();
|
|
let routing = super::super::audio::Routing::start(&whole_desktop_opts(), health.clone())
|
|
.await
|
|
.expect("S5 helper routing starts");
|
|
assert!(health.fault().is_none());
|
|
println!(
|
|
"{S5_READY_PREFIX}{} {}",
|
|
std::process::id(),
|
|
routing.sink_name()
|
|
);
|
|
std::io::stdout().flush().expect("flush S5 readiness");
|
|
|
|
tokio::signal::ctrl_c()
|
|
.await
|
|
.expect("S5 parent requests a clean SIGINT stop");
|
|
|
|
routing.shutdown().await;
|
|
assert!(health.fault().is_none());
|
|
}
|
|
|
|
struct S5Host {
|
|
child: Option<Child>,
|
|
pid: u32,
|
|
sink_name: String,
|
|
}
|
|
|
|
impl S5Host {
|
|
fn spawn() -> Result<Self> {
|
|
let executable = std::env::current_exe().context("locate the test executable")?;
|
|
let mut command = Command::new(executable);
|
|
command
|
|
.args([
|
|
"--exact",
|
|
"host::graph::tests::s5_connection_owned_host_helper",
|
|
"--nocapture",
|
|
"--test-threads=1",
|
|
])
|
|
.env(S5_HOST_HELPER, "1")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit());
|
|
let child = crate::common::contained::spawn(&mut command)
|
|
.context("spawn a contained S5 host helper")?;
|
|
let child_pid = child.id();
|
|
// From this point every early return owns a kill-and-reap fallback.
|
|
// A readiness timeout must not strand a graph-mutating helper.
|
|
let mut host = Self {
|
|
child: Some(child),
|
|
pid: child_pid,
|
|
sink_name: String::new(),
|
|
};
|
|
let stdout = host
|
|
.child
|
|
.as_mut()
|
|
.expect("S5 host was just constructed")
|
|
.stdout
|
|
.take()
|
|
.context("S5 helper stdout was not piped")?;
|
|
let (line_tx, line_rx) = std_mpsc::sync_channel(1);
|
|
std::thread::spawn(move || {
|
|
let mut readiness_sent = false;
|
|
for line in BufReader::new(stdout)
|
|
.lines()
|
|
.map_while(std::result::Result::ok)
|
|
{
|
|
if !readiness_sent && line.contains(S5_READY_PREFIX) {
|
|
readiness_sent = true;
|
|
let _ = line_tx.send(Some(line));
|
|
}
|
|
// Keep draining after readiness. Dropping this pipe while the
|
|
// helper still owns it makes libtest fail its final result
|
|
// write with BrokenPipe, masking a clean graph shutdown.
|
|
}
|
|
if !readiness_sent {
|
|
let _ = line_tx.send(None);
|
|
}
|
|
});
|
|
|
|
let ready_line = line_rx
|
|
.recv_timeout(Duration::from_secs(10))
|
|
.context("S5 helper did not report readiness within 10 seconds")?
|
|
.context("S5 helper exited before reporting readiness")?;
|
|
let marker = ready_line
|
|
.split_once(S5_READY_PREFIX)
|
|
.map(|(_, value)| value)
|
|
.context("malformed S5 readiness marker")?;
|
|
let mut fields = marker.split_whitespace();
|
|
let reported_pid: u32 = fields
|
|
.next()
|
|
.context("S5 readiness omitted the pid")?
|
|
.parse()
|
|
.context("S5 readiness pid was not numeric")?;
|
|
let sink_name = fields
|
|
.next()
|
|
.context("S5 readiness omitted the sink name")?
|
|
.to_string();
|
|
if reported_pid != child_pid {
|
|
bail!("S5 helper reported pid {reported_pid}, expected {child_pid}");
|
|
}
|
|
|
|
host.sink_name = sink_name;
|
|
Ok(host)
|
|
}
|
|
|
|
fn kill_and_reap(&mut self) -> Result<()> {
|
|
if self.child.is_none() {
|
|
return Ok(());
|
|
}
|
|
crate::common::contained::signal_group(self.pid, Signal::SIGKILL)
|
|
.context("SIGKILL the first S5 host")?;
|
|
let mut child = self.child.take().expect("S5 child stayed owned");
|
|
child.wait().context("reap the first S5 host")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn interrupt_and_reap_within(&mut self, budget: Duration) -> Result<Duration> {
|
|
if self.child.is_none() {
|
|
return Ok(Duration::ZERO);
|
|
}
|
|
let started = Instant::now();
|
|
crate::common::contained::signal_group(self.pid, Signal::SIGINT)
|
|
.context("SIGINT the surviving S5 host")?;
|
|
let deadline = started + budget;
|
|
loop {
|
|
let status = self
|
|
.child
|
|
.as_mut()
|
|
.expect("S5 child stayed owned across the wait")
|
|
.try_wait()
|
|
.context("poll the S5 helper")?;
|
|
if let Some(status) = status {
|
|
if status.success() {
|
|
self.child.take();
|
|
return Ok(started.elapsed());
|
|
}
|
|
bail!("S5 helper SIGINT stop exited with {status}");
|
|
}
|
|
if Instant::now() >= deadline {
|
|
let _ = crate::common::contained::signal_group(self.pid, Signal::SIGKILL);
|
|
if let Some(mut child) = self.child.take() {
|
|
let _ = child.wait();
|
|
}
|
|
bail!("S5 helper did not stop cleanly within {budget:?}");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for S5Host {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn capture_sink_serial(name: &str) -> Result<Option<u64>> {
|
|
let output = Command::new("pw-dump")
|
|
.output()
|
|
.context("run pw-dump for the S5 ownership 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")?;
|
|
let Some(objects) = objects.as_array() else {
|
|
bail!("pw-dump root was not an array");
|
|
};
|
|
for object in objects {
|
|
if object.get("type").and_then(serde_json::Value::as_str)
|
|
!= Some("PipeWire:Interface:Node")
|
|
{
|
|
continue;
|
|
}
|
|
let Some(props) = object.pointer("/info/props") else {
|
|
continue;
|
|
};
|
|
if props.get("node.name").and_then(serde_json::Value::as_str) != Some(name) {
|
|
continue;
|
|
}
|
|
let Some(serial) = props.get("object.serial") else {
|
|
bail!("capture sink {name:?} had no object.serial");
|
|
};
|
|
let serial = serial
|
|
.as_u64()
|
|
.or_else(|| serial.as_str().and_then(|value| value.parse::<u64>().ok()));
|
|
return serial
|
|
.map(Some)
|
|
.context("capture sink object.serial was not an integer");
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn owned_loopbacks(pid: u32) -> Result<Vec<u32>> {
|
|
let mut pulse = crate::repair::introspect::PulseSession::connect()
|
|
.context("connect to the local Pulse server")?;
|
|
let modules = pulse.list_modules().context("list Pulse modules")?;
|
|
Ok(modules
|
|
.into_iter()
|
|
.filter_map(|module| {
|
|
classify(&ModuleObservation::new(
|
|
module.id,
|
|
&module.name,
|
|
&module.args,
|
|
))
|
|
})
|
|
.filter(|fingerprint| {
|
|
fingerprint.pid == pid && fingerprint.shape == Shape::LoopbackIntoCapture
|
|
})
|
|
.map(|fingerprint| fingerprint.id)
|
|
.collect())
|
|
}
|
|
|
|
fn wait_for_sink_state(name: &str, expected_serial: Option<u64>) -> Result<()> {
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
if capture_sink_serial(name)? == expected_serial {
|
|
return Ok(());
|
|
}
|
|
if Instant::now() >= deadline {
|
|
bail!("capture sink {name:?} did not reach serial state {expected_serial:?}");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
/// S5 ownership exit gate: two real processes own two independent native
|
|
/// sinks. SIGKILL must remove exactly the killed process's sink, and
|
|
/// ownership-aware repair must remove its orphaned Pulse loopback without
|
|
/// touching the still-live host. The survivor then exercises ordinary
|
|
/// teardown so the gate itself leaves no graph residue.
|
|
#[tokio::test]
|
|
#[ignore = "live: starts two graph-owning processes, SIGKILLs one, and runs --repair"]
|
|
async fn live_two_host_sigkill_and_repair_preserve_the_survivor() {
|
|
let mut first = S5Host::spawn().expect("start the first S5 host");
|
|
let mut second = S5Host::spawn().expect("start the second S5 host");
|
|
|
|
let first_serial = capture_sink_serial(&first.sink_name)
|
|
.expect("observe the first S5 sink")
|
|
.expect("the first S5 sink is present");
|
|
let second_serial = capture_sink_serial(&second.sink_name)
|
|
.expect("observe the second S5 sink")
|
|
.expect("the second S5 sink is present");
|
|
assert_ne!(first.sink_name, second.sink_name);
|
|
assert_ne!(first_serial, second_serial);
|
|
assert_eq!(owned_loopbacks(first.pid).unwrap().len(), 1);
|
|
let second_modules = owned_loopbacks(second.pid).unwrap();
|
|
assert_eq!(second_modules.len(), 1);
|
|
|
|
first.kill_and_reap().expect("SIGKILL the first S5 host");
|
|
wait_for_sink_state(&first.sink_name, None)
|
|
.expect("the killed host's connection-owned sink disappears");
|
|
wait_for_sink_state(&second.sink_name, Some(second_serial))
|
|
.expect("the live host's connection-owned sink remains");
|
|
|
|
crate::repair::run(false)
|
|
.await
|
|
.expect("ownership-aware repair succeeds");
|
|
assert!(owned_loopbacks(first.pid).unwrap().is_empty());
|
|
assert_eq!(
|
|
owned_loopbacks(second.pid).unwrap(),
|
|
second_modules,
|
|
"repair must not unload the live host's module"
|
|
);
|
|
wait_for_sink_state(&second.sink_name, Some(second_serial))
|
|
.expect("repair must not disturb the live host's native sink");
|
|
|
|
let stop_elapsed = second
|
|
.interrupt_and_reap_within(Duration::from_secs(2))
|
|
.expect("the surviving S5 host honours SIGINT inside PeerSpeak's grace");
|
|
println!("S5_ACTIVE_SIGINT_ELAPSED_MS={}", stop_elapsed.as_millis());
|
|
assert!(
|
|
stop_elapsed < Duration::from_secs(2),
|
|
"active graph teardown reached PeerSpeak's SIGKILL fallback boundary: {stop_elapsed:?}"
|
|
);
|
|
wait_for_sink_state(&second.sink_name, None)
|
|
.expect("the surviving host's sink disappears after clean shutdown");
|
|
assert!(owned_loopbacks(second.pid).unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "creates a real connection-owned PipeWire sink; serialize live audio tests"]
|
|
async fn live_sink_survives_quiesce_and_dies_with_its_owner() {
|
|
let spec = CaptureSinkSpec::for_pid(std::process::id());
|
|
assert!(
|
|
!pulse_source_exists(spec.monitor_name()).await,
|
|
"live fixture name is already present"
|
|
);
|
|
let (health, _) = health::channel();
|
|
let (mut owner, _events) = AudioGraphOwner::start(None, spec.clone(), health.clone())
|
|
.await
|
|
.expect("connection-owned sink starts");
|
|
assert_ne!(owner.identity().global_id, u32::MAX);
|
|
assert!(owner.identity().serial > 0);
|
|
assert!(pulse_source_exists(spec.monitor_name()).await);
|
|
|
|
assert_eq!(owner.quiesce().await, QuiesceOutcome::Confirmed);
|
|
assert!(
|
|
pulse_source_exists(spec.monitor_name()).await,
|
|
"quiesce must retain the sink until dependent modules are removed"
|
|
);
|
|
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,
|
|
"sink must disappear with the actor connection"
|
|
);
|
|
assert!(health.fault().is_none());
|
|
}
|
|
}
|