feat(host): build desktop audio exclusion foundation

This commit is contained in:
2026-08-21 15:39:07 -04:00
parent 5d3da8b006
commit 781defcd84
16 changed files with 2586 additions and 564 deletions
+89 -482
View File
@@ -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(),