feat(host): build desktop audio exclusion foundation
This commit is contained in:
+89
-482
@@ -1,21 +1,18 @@
|
||||
//! Per-app audio routing.
|
||||
//! Connection-owned capture-sink and per-app audio routing.
|
||||
//!
|
||||
//! Two cooperating layers:
|
||||
//!
|
||||
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink
|
||||
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the
|
||||
//! default sink's monitor into it. gst captures from the null-sink's
|
||||
//! monitor, so the viewer hears whatever the user hears — by default.
|
||||
//! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a
|
||||
//! non-lingering per-PID sink named `pixelpass_capture_<pid>`. When
|
||||
//! [`HostOpts::app`] is set, the same actor finds matching
|
||||
//! `Stream/Output/Audio` nodes and writes `target.object` so WirePlumber
|
||||
//! reroutes them to that sink. The sink disappears with the actor's PipeWire
|
||||
//! connection, including after SIGKILL.
|
||||
//!
|
||||
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread):
|
||||
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the
|
||||
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose
|
||||
//! `application.name` matches the filter, and writes
|
||||
//! `target.object` to the "default" metadata so WirePlumber reroutes
|
||||
//! them to our null-sink. Once at least one stream is actually routed,
|
||||
//! the loopback is unloaded — otherwise the viewer would hear the
|
||||
//! filtered audio twice (once via the routed stream, once via the
|
||||
//! default-sink monitor loopback).
|
||||
//! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback
|
||||
//! mirrors the default sink's monitor into the native capture sink. Once at
|
||||
//! least one selected app stream is routed, that loopback is unloaded so the
|
||||
//! viewer does not hear the app twice.
|
||||
//!
|
||||
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
|
||||
//! the chosen app off the sharer's speakers into the null-sink, so without
|
||||
@@ -26,25 +23,21 @@
|
||||
//! the first routed stream (after the default-sink loopback is gone, so the
|
||||
//! two never coexist and feed back) and unloaded when the app stops.
|
||||
//!
|
||||
//! pactl is the right tool for the one-shot null-sink/loopback graph
|
||||
//! mutations. libpipewire is dragged in only when per-stream filtering
|
||||
//! is requested, because that needs registry-event subscription.
|
||||
//! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and
|
||||
//! only then releases the actor connection and native sink.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{self, Read};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::contained;
|
||||
use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome};
|
||||
use crate::host::health;
|
||||
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
|
||||
use crate::host::owned_thread::OwnedThread;
|
||||
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
||||
|
||||
/// How long a `pactl load-module` worker may run before it is killed and reaped.
|
||||
@@ -57,13 +50,11 @@ use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
||||
/// connect/list/unload requests instead of a second pactl connection.
|
||||
const PACTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1);
|
||||
const ROUTER_RUNNING_STOP_BUDGET: Duration = Duration::from_secs(2);
|
||||
const ROUTER_STARTING_STOP_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
||||
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can
|
||||
/// reconcile a load whose outcome was never observed.
|
||||
/// Owns the native graph actor plus its pactl-loaded dependent modules. Drop
|
||||
/// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly,
|
||||
/// which is the only path that can reconcile a load whose outcome was never
|
||||
/// observed and acknowledge route restoration.
|
||||
pub struct Routing {
|
||||
/// Every module this host has loaded, is loading, or must ask the server
|
||||
/// about. Shared with the event task, which loads and unloads the two
|
||||
@@ -74,41 +65,42 @@ pub struct Routing {
|
||||
/// exactly like no load at all and its module was left behind.
|
||||
ledger: Arc<ModuleLedger>,
|
||||
sink_name: String,
|
||||
stream_router: Option<StreamRouter>,
|
||||
graph_owner: Option<AudioGraphOwner>,
|
||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||
health: health::Reporter,
|
||||
}
|
||||
|
||||
impl Routing {
|
||||
/// Create the per-PID null-sink + loopback. If `opts.app` is set,
|
||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
||||
/// Create the per-PID native sink and graph actor, plus the default-monitor
|
||||
/// loopback when the selected routing mode permits it.
|
||||
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
|
||||
let pid = std::process::id();
|
||||
let sink_name = repair_plan::sink_name_for(pid);
|
||||
let ledger = ModuleLedger::new();
|
||||
// Construct the owner before the first mutation. Any error or cancellation
|
||||
// below now drops a real `Routing`, whose backstop closes, quiesces,
|
||||
// reconciles, and unloads this ledger. Previously the owner did not exist
|
||||
// until both initial modules had loaded, so constructor failure leaked
|
||||
// everything loaded up to that point.
|
||||
// Construct Routing before either ownership layer mutates the graph. Any
|
||||
// error or cancellation below drops a real owner whose backstop closes,
|
||||
// reconciles, and unloads the ledger before releasing the native sink.
|
||||
let mut routing = Self {
|
||||
ledger: Arc::clone(&ledger),
|
||||
sink_name: sink_name.clone(),
|
||||
stream_router: None,
|
||||
graph_owner: None,
|
||||
event_task: None,
|
||||
health: health.clone(),
|
||||
};
|
||||
|
||||
// Every module this host loads carries an ownership token, minted per
|
||||
// load, so `--repair` can tell whose pid the name refers to instead of
|
||||
// assuming the number means the same thing everywhere. Without it a repair
|
||||
// run in another pid namespace can unload a live host's audio; see
|
||||
// `repair::plan::OwnerToken`. That same per-load nonce is what lets
|
||||
// reconciliation identify a module whose load was interrupted before its
|
||||
// index was ever read.
|
||||
load_module(&ledger, Shape::LegacyCaptureSink, pid)
|
||||
.await
|
||||
.context("failed to load module-null-sink")?;
|
||||
// S4: the capture sink is a native, non-lingering PipeWire object owned
|
||||
// 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
|
||||
// graph lifetime and one readiness handshake.
|
||||
let (graph_owner, mut event_rx) = AudioGraphOwner::start(
|
||||
opts.app.clone(),
|
||||
CaptureSinkSpec::for_pid(pid),
|
||||
health.clone(),
|
||||
)
|
||||
.await
|
||||
.context("failed to start the connection-owned audio graph")?;
|
||||
debug_assert_eq!(graph_owner.identity().name, sink_name);
|
||||
routing.graph_owner = Some(graph_owner);
|
||||
|
||||
// In strict per-app mode we never mirror the default sink: the viewer
|
||||
// must hear *only* the chosen app, never the whole desktop (which would
|
||||
@@ -119,28 +111,31 @@ impl Routing {
|
||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||
// default of 200ms is enough to be perceptible.
|
||||
let strict_app = opts.app.is_some() && opts.strict_audio;
|
||||
if !strict_app {
|
||||
load_module(&ledger, Shape::LoopbackIntoCapture, pid)
|
||||
.await
|
||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?;
|
||||
if !strict_app
|
||||
&& let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await
|
||||
{
|
||||
// Once the actor exists, an ordinary constructor error gets a full
|
||||
// async teardown rather than falling through the narrower
|
||||
// synchronous Drop backstop and quarantining a responsive thread.
|
||||
routing.shutdown().await;
|
||||
return Err(error)
|
||||
.context("failed to load module-loopback (connection-owned sink cleaned up)");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
strict_app,
|
||||
%sink_name,
|
||||
"audio routing: null-sink ready (loopback skipped in strict app mode)"
|
||||
"audio routing: connection-owned sink ready (loopback skipped in strict app mode)"
|
||||
);
|
||||
|
||||
if let Some(app) = &opts.app {
|
||||
let (router, mut event_rx) =
|
||||
StreamRouter::spawn(app.clone(), sink_name.clone(), health.clone())?;
|
||||
if opts.app.is_some() {
|
||||
let ledger_for_task = Arc::clone(&ledger);
|
||||
let strict = opts.strict_audio;
|
||||
let event_task = tokio::spawn(async move {
|
||||
use crate::common::output::{self, AppAudioState};
|
||||
while let Some(ev) = event_rx.recv().await {
|
||||
match ev {
|
||||
Event::FirstRoutedStream => {
|
||||
GraphEvent::FirstRoutedStream => {
|
||||
tracing::info!(
|
||||
"audio routing: first stream routed → unloading default-sink loopback"
|
||||
);
|
||||
@@ -167,7 +162,7 @@ impl Routing {
|
||||
state: AppAudioState::Routed,
|
||||
});
|
||||
}
|
||||
Event::LastRoutedStreamGone => {
|
||||
GraphEvent::LastRoutedStreamGone => {
|
||||
// Routed app exited/paused mid-session. Notify the
|
||||
// front-end either way; the recovery differs by mode.
|
||||
output::emit(output::Event::AppAudio {
|
||||
@@ -207,7 +202,6 @@ impl Routing {
|
||||
}
|
||||
}
|
||||
});
|
||||
routing.stream_router = Some(router);
|
||||
routing.event_task = Some(event_task);
|
||||
}
|
||||
|
||||
@@ -228,10 +222,10 @@ impl Routing {
|
||||
&self.sink_name
|
||||
}
|
||||
|
||||
/// Stop the stream router and the event task, settle anything the ledger is
|
||||
/// unsure about, then unload every module in shape order — the loopbacks
|
||||
/// before the sink they reference, because PipeWire can leave zombie links if
|
||||
/// a sink is destroyed with active inputs.
|
||||
/// Quiesce graph mutations, stop the event task, settle anything the ledger
|
||||
/// is unsure about, unload every dependent loopback, then release the native
|
||||
/// sink. PipeWire can leave zombie links if a sink is destroyed with active
|
||||
/// inputs, so that final ordering is load-bearing.
|
||||
///
|
||||
/// The event task is **awaited, not merely aborted**. Aborting and walking
|
||||
/// away is what left orphans behind: the task's load is an await point now,
|
||||
@@ -241,28 +235,24 @@ impl Routing {
|
||||
pub async fn shutdown(mut self) {
|
||||
// Closing is synchronous and happens first: after this point the event
|
||||
// task cannot register another mutation even if it receives one last
|
||||
// router event while shutdown is in progress.
|
||||
// graph event while shutdown is in progress.
|
||||
self.ledger.close();
|
||||
let router_stopped = if let Some(router) = self.stream_router.take() {
|
||||
router.shutdown().await
|
||||
let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() {
|
||||
graph_owner.quiesce().await == QuiesceOutcome::Confirmed
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if let Some(mut task) = self.event_task.take() {
|
||||
if !router_stopped {
|
||||
// A quarantined router still owns its event sender, so this task
|
||||
// cannot finish naturally. Cancel and await it before ledger
|
||||
// reconciliation; the router timeout already poisoned the host.
|
||||
if !graph_quiesced {
|
||||
// An unresponsive graph actor may still own its event sender.
|
||||
// Cancel and await the task before ledger reconciliation.
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
} else {
|
||||
// The router's exit drops the event senders, so the task normally
|
||||
// ends by itself. Abort is the fallback, and it is awaited through
|
||||
// `&mut JoinHandle` so the future is genuinely dropped — and with
|
||||
// it any in-flight permit — before reconciliation reads the
|
||||
// ledger. Dropping the handle instead would *detach* the task,
|
||||
// which is how a load could still land after teardown believed it
|
||||
// was finished.
|
||||
// Quiesce closes the event sender while retaining the native
|
||||
// sink. Abort is the fallback and is awaited through `&mut
|
||||
// JoinHandle`, so any in-flight affine permit is dropped before
|
||||
// reconciliation reads the ledger.
|
||||
match tokio::time::timeout(PACTL_BUDGET, &mut task).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
@@ -299,6 +289,15 @@ impl Routing {
|
||||
}
|
||||
cleanup_modules(&self.ledger).await;
|
||||
|
||||
// Loopbacks are gone before the actor connection is released. This is
|
||||
// the S4 ordering invariant: dependent Pulse modules never outlive the
|
||||
// native sink they reference during an ordinary shutdown.
|
||||
if let Some(graph_owner) = self.graph_owner.take()
|
||||
&& !graph_owner.shutdown().await
|
||||
{
|
||||
tracing::warn!("audio routing: AudioGraphOwner shutdown was not confirmed");
|
||||
}
|
||||
|
||||
if !self.ledger.is_clean() {
|
||||
tracing::warn!(
|
||||
settled = self.ledger.is_settled(),
|
||||
@@ -321,14 +320,16 @@ impl Drop for Routing {
|
||||
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
|
||||
fn drop(&mut self) {
|
||||
self.ledger.close();
|
||||
if let Some(router) = self.stream_router.take() {
|
||||
drop(router);
|
||||
}
|
||||
if let Some(task) = self.event_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
self.ledger.close_and_wait();
|
||||
cleanup_modules_blocking(&self.ledger);
|
||||
// Keep the actor/sink alive until dependent modules have been handled,
|
||||
// even on this synchronous error/unwind backstop.
|
||||
if let Some(graph_owner) = self.graph_owner.take() {
|
||||
drop(graph_owner);
|
||||
}
|
||||
if !self.ledger.is_clean() {
|
||||
tracing::warn!(
|
||||
settled = self.ledger.is_settled(),
|
||||
@@ -817,271 +818,6 @@ fn cleanup_modules_blocking(ledger: &Arc<ModuleLedger>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Per-stream routing (libpipewire thread)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Command from tokio → libpipewire thread.
|
||||
enum Cmd {
|
||||
/// Clear `target.object` for everything we routed, then quit the
|
||||
/// MainLoop so the thread joins.
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Event from libpipewire thread → tokio. The pair drives loopback
|
||||
/// oscillation: unload on `FirstRoutedStream`, re-load on
|
||||
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
|
||||
/// respectively), not on every change.
|
||||
enum Event {
|
||||
/// At least one stream is now routed to our sink. Receiver unloads
|
||||
/// the default-sink loopback so the filtered audio isn't doubled.
|
||||
FirstRoutedStream,
|
||||
/// The last routed stream just disappeared (app closed, paused,
|
||||
/// switched output). Receiver re-loads the default-sink loopback so
|
||||
/// the viewer doesn't go silent.
|
||||
LastRoutedStreamGone,
|
||||
}
|
||||
|
||||
/// Handle to the libpipewire stream-router thread.
|
||||
pub struct StreamRouter {
|
||||
cmd_tx: pipewire::channel::Sender<Cmd>,
|
||||
thread: OwnedThread,
|
||||
phase: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
const ROUTER_STARTING: u8 = 0;
|
||||
const ROUTER_RUNNING: u8 = 1;
|
||||
const ROUTER_EXITED: u8 = 2;
|
||||
|
||||
impl StreamRouter {
|
||||
/// Spawn the libpipewire thread. Returns the router handle and the
|
||||
/// event receiver tokio side polls.
|
||||
fn spawn(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
health: health::Reporter,
|
||||
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
|
||||
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
|
||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||
let phase = Arc::new(AtomicU8::new(ROUTER_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 thread = std::thread::Builder::new()
|
||||
.name("pixelpass-pw-router".to_string())
|
||||
.spawn(move || {
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
run_router(
|
||||
filter_name,
|
||||
sink_name,
|
||||
cmd_rx,
|
||||
event_tx,
|
||||
Arc::clone(&phase_for_thread),
|
||||
Arc::clone(&shutdown_for_thread),
|
||||
)
|
||||
}));
|
||||
phase_for_thread.store(ROUTER_EXITED, Ordering::Release);
|
||||
match result {
|
||||
Ok(result) => report_router_exit(
|
||||
&health_for_thread,
|
||||
shutdown_for_thread.load(Ordering::Acquire),
|
||||
result,
|
||||
),
|
||||
Err(_) => {
|
||||
health_for_thread.poison("libpipewire router thread panicked");
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("failed to spawn libpipewire router thread")?;
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
cmd_tx,
|
||||
thread: OwnedThread::new("libpipewire router thread", thread, health),
|
||||
phase,
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn shutdown(mut self) -> bool {
|
||||
// Best-effort: if the send fails the thread is already gone.
|
||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||
let budget = router_shutdown_budget(self.phase.load(Ordering::Acquire));
|
||||
self.thread.join_within(budget).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamRouter {
|
||||
fn drop(&mut self) {
|
||||
// If async shutdown is cancelled, wake the MainLoop before OwnedThread's
|
||||
// Drop poisons/quarantines the still-owned handle.
|
||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn router_shutdown_budget(phase: u8) -> Duration {
|
||||
if phase == ROUTER_STARTING {
|
||||
ROUTER_STARTING_STOP_BUDGET
|
||||
} else {
|
||||
ROUTER_RUNNING_STOP_BUDGET
|
||||
}
|
||||
}
|
||||
|
||||
fn report_router_exit(health: &health::Reporter, shutdown_observed: bool, result: Result<()>) {
|
||||
match result {
|
||||
Ok(()) if shutdown_observed => {}
|
||||
Ok(()) => {
|
||||
health.poison("libpipewire router thread exited without a shutdown command");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
|
||||
health.poison(format!("libpipewire router thread failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
|
||||
/// all PipeWire proxies for the duration of the routing session.
|
||||
fn run_router(
|
||||
filter_name: String,
|
||||
sink_name: String,
|
||||
cmd_rx: pipewire::channel::Receiver<Cmd>,
|
||||
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
phase: Arc<AtomicU8>,
|
||||
shutdown_observed: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
use pipewire::{self as pw, types::ObjectType};
|
||||
|
||||
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(RouterState {
|
||||
sink_serial: None,
|
||||
default_metadata: None,
|
||||
routed_node_ids: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
}));
|
||||
|
||||
// Cmd handler: clear metadata for routed streams, then quit.
|
||||
let main_loop_for_cmd = main_loop.clone();
|
||||
let state_for_cmd = Rc::clone(&state);
|
||||
let shutdown_for_cmd = Arc::clone(&shutdown_observed);
|
||||
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
||||
Cmd::Shutdown => {
|
||||
shutdown_for_cmd.store(true, Ordering::Release);
|
||||
let s = state_for_cmd.borrow();
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for &nid in &s.routed_node_ids {
|
||||
meta.set_property(nid, "target.object", None, None);
|
||||
}
|
||||
if !s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
n = s.routed_node_ids.len(),
|
||||
"audio routing: cleared target.object on routed streams before quitting"
|
||||
);
|
||||
}
|
||||
}
|
||||
main_loop_for_cmd.quit();
|
||||
}
|
||||
});
|
||||
|
||||
let filter_lower = filter_name.to_ascii_lowercase();
|
||||
let sink_name_owned = sink_name.clone();
|
||||
let registry_weak = registry.downgrade();
|
||||
let state_for_reg = Rc::clone(&state);
|
||||
let event_tx_for_reg = event_tx.clone();
|
||||
let state_for_remove = Rc::clone(&state);
|
||||
let event_tx_for_remove = event_tx.clone();
|
||||
|
||||
let _reg_listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |obj| {
|
||||
let Some(reg) = registry_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match obj.type_ {
|
||||
ObjectType::Node => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||
match props.get("object.serial").and_then(parse_object_serial) {
|
||||
Some(serial) => {
|
||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
// Never silently: without a serial `try_flush` can
|
||||
// never route anything, so the whole app-filter mode
|
||||
// is dead and the only symptom is missing audio.
|
||||
None => tracing::warn!(
|
||||
node_id = obj.id,
|
||||
serial = props.get("object.serial").unwrap_or("<absent>"),
|
||||
"audio routing: pixelpass sink has no usable object.serial; \
|
||||
stream rerouting disabled"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||
return;
|
||||
}
|
||||
let Some(app) = props.get("application.name") else {
|
||||
return;
|
||||
};
|
||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
node_id = obj.id,
|
||||
%app,
|
||||
"audio routing: matched stream, queued for route"
|
||||
);
|
||||
state_for_reg.borrow_mut().pending.push(obj.id);
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
ObjectType::Metadata => {
|
||||
let Some(props) = obj.props.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if props.get("metadata.name") != Some("default") {
|
||||
return;
|
||||
}
|
||||
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("audio routing: bind default metadata failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
state_for_reg.borrow_mut().default_metadata = Some(metadata);
|
||||
tracing::info!("audio routing: default metadata bound");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.global_remove(move |id| {
|
||||
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
|
||||
})
|
||||
.register();
|
||||
|
||||
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
|
||||
phase.store(ROUTER_RUNNING, Ordering::Release);
|
||||
main_loop.run();
|
||||
tracing::info!("audio routing: pw thread exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a PipeWire `object.serial` property value.
|
||||
///
|
||||
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||
@@ -1102,81 +838,14 @@ pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
|
||||
raw.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
struct RouterState {
|
||||
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
||||
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
||||
sink_serial: Option<u64>,
|
||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||
routed_node_ids: Vec<u32>,
|
||||
pending: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
|
||||
/// was the last routed stream, emit `LastRoutedStreamGone` so the
|
||||
/// tokio side restores the default-sink loopback.
|
||||
fn handle_global_remove(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
id: u32,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let was_routed = !s.routed_node_ids.is_empty();
|
||||
s.routed_node_ids.retain(|&x| x != id);
|
||||
s.pending.retain(|&x| x != id);
|
||||
if was_routed && s.routed_node_ids.is_empty() {
|
||||
tracing::info!(
|
||||
node_id = id,
|
||||
"audio routing: last routed stream disappeared"
|
||||
);
|
||||
let _ = event_tx.send(Event::LastRoutedStreamGone);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain pending streams to the sink, but only once both prerequisites
|
||||
/// (sink serial known + default metadata bound) are in place. Emits
|
||||
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
|
||||
/// each time the count comes back up from zero, not just the first
|
||||
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
|
||||
fn try_flush(
|
||||
state: &Rc<RefCell<RouterState>>,
|
||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let mut s = state.borrow_mut();
|
||||
let Some(serial) = s.sink_serial else { return };
|
||||
if s.default_metadata.is_none() {
|
||||
return;
|
||||
}
|
||||
if s.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let was_empty = s.routed_node_ids.is_empty();
|
||||
let serial_str = serial.to_string();
|
||||
let pending = std::mem::take(&mut s.pending);
|
||||
if let Some(meta) = &s.default_metadata {
|
||||
for nid in &pending {
|
||||
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
|
||||
tracing::info!(
|
||||
node_id = *nid,
|
||||
sink_serial = serial,
|
||||
"audio routing: stream routed to pixelpass sink"
|
||||
);
|
||||
}
|
||||
}
|
||||
s.routed_node_ids.extend(pending);
|
||||
if was_empty && !s.routed_node_ids.is_empty() {
|
||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::ledger::SlotState;
|
||||
use crate::repair::plan::{ModuleObservation, classify};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event
|
||||
/// task — just the null-sink and its default-sink loopback.
|
||||
/// Whole-desktop routing: the graph actor owns the native sink, while the
|
||||
/// ledger owns only its default-monitor loopback.
|
||||
fn whole_desktop_opts() -> HostOpts {
|
||||
HostOpts {
|
||||
window: false,
|
||||
@@ -1190,73 +859,12 @@ mod tests {
|
||||
no_hwencode: false,
|
||||
max_viewers: None,
|
||||
interactive: false,
|
||||
capture_mode: crate::cli::CaptureMode::Legacy,
|
||||
legacy_null_sink: false,
|
||||
relay: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_exit_is_healthy_only_after_the_thread_observed_shutdown() {
|
||||
let (clean, _) = health::channel();
|
||||
report_router_exit(&clean, true, Ok(()));
|
||||
assert!(clean.fault().is_none());
|
||||
|
||||
let (unexpected, _) = health::channel();
|
||||
report_router_exit(&unexpected, false, Ok(()));
|
||||
assert_eq!(
|
||||
unexpected.fault().as_deref(),
|
||||
Some("libpipewire router thread exited without a shutdown command")
|
||||
);
|
||||
|
||||
let (failed, _) = health::channel();
|
||||
report_router_exit(&failed, false, Err(anyhow::anyhow!("fixture failure")));
|
||||
assert!(
|
||||
failed
|
||||
.fault()
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("fixture failure"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_shutdown_has_distinct_startup_and_running_budgets() {
|
||||
assert_eq!(
|
||||
router_shutdown_budget(ROUTER_STARTING),
|
||||
ROUTER_STARTING_STOP_BUDGET
|
||||
);
|
||||
assert_eq!(
|
||||
router_shutdown_budget(ROUTER_RUNNING),
|
||||
ROUTER_RUNNING_STOP_BUDGET
|
||||
);
|
||||
assert!(ROUTER_STARTING_STOP_BUDGET > ROUTER_RUNNING_STOP_BUDGET);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_router_shutdown_keeps_the_thread_owned_and_poisons() {
|
||||
let (cmd_tx, _cmd_rx) = pipewire::channel::channel::<Cmd>();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let (health, _) = health::channel();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let router = StreamRouter {
|
||||
cmd_tx,
|
||||
thread: OwnedThread::new("cancellation fixture", thread, health.clone()),
|
||||
phase: Arc::new(AtomicU8::new(ROUTER_STARTING)),
|
||||
};
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), router.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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_module_worker_uses_the_contained_spawn_path() {
|
||||
let mut command = Command::new("sh");
|
||||
@@ -1340,8 +948,8 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ours.len(),
|
||||
2,
|
||||
"the null-sink and its default-sink loopback must both be loaded"
|
||||
1,
|
||||
"only the default-monitor loopback is a Pulse module; the sink is native"
|
||||
);
|
||||
for (id, name, args) in ours {
|
||||
let fp = classify(&ModuleObservation::new(*id, name, args))
|
||||
@@ -1361,12 +969,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Exercise the real libpipewire mainloop command path, not only the
|
||||
/// whole-desktop module path above. The deliberately unmatched app filter
|
||||
/// is enough to start the router without moving an unrelated live stream.
|
||||
/// Exercise the real graph-actor command path with app routing enabled. The
|
||||
/// deliberately unmatched filter avoids moving an unrelated live stream.
|
||||
#[tokio::test]
|
||||
#[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"]
|
||||
async fn live_stream_router_stops_within_its_policy_budget() {
|
||||
async fn live_audio_graph_owner_stops_within_its_policy_budget() {
|
||||
let before = module_snapshot();
|
||||
let mut opts = whole_desktop_opts();
|
||||
opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string());
|
||||
@@ -1381,7 +988,7 @@ mod tests {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
tokio::time::timeout(Duration::from_secs(10), routing.shutdown())
|
||||
.await
|
||||
.expect("router and graph teardown stay globally bounded");
|
||||
.expect("actor and graph teardown stay globally bounded");
|
||||
|
||||
assert!(
|
||||
health.fault().is_none(),
|
||||
|
||||
Reference in New Issue
Block a user