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
+109 -3
View File
@@ -1,3 +1,4 @@
use anyhow::{Result, bail};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -38,6 +39,11 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub strict_audio: bool, pub strict_audio: bool,
/// Internal phase-0d trigger for the not-yet-public desktop-excluding
/// capture plan. Phase 7 replaces this with the versioned public selector.
#[arg(long, hide = true, conflicts_with = "app")]
pub internal_desktop_excluding: bool,
/// Override display server autodetection. /// Override display server autodetection.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
@@ -174,6 +180,35 @@ pub enum Quality {
Auto, Auto,
} }
/// Internal input to the typed audio-capture planner. `DesktopExcluding` is
/// deliberately reachable only through a hidden phase-0d trigger until the
/// public selector and capability contract land in phase 7.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum CaptureMode {
#[default]
Legacy,
DesktopExcluding,
}
impl CaptureMode {
fn validate_inputs(self, app: Option<&str>, legacy_null_sink: bool) -> Result<()> {
if self != Self::DesktopExcluding {
return Ok(());
}
if app.is_some() {
bail!(
"the internal desktop-excluding mode conflicts with --app; refusing to fall back to legacy per-app routing"
);
}
if legacy_null_sink {
bail!(
"the internal desktop-excluding mode conflicts with PIXELPASS_AUDIO_VIA_NULL_SINK; refusing to load the legacy default-monitor loopback"
);
}
Ok(())
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HostOpts { pub struct HostOpts {
pub window: bool, pub window: bool,
@@ -194,6 +229,13 @@ pub struct HostOpts {
pub no_hwencode: bool, pub no_hwencode: bool,
pub max_viewers: Option<u32>, pub max_viewers: Option<u32>,
pub interactive: bool, pub interactive: bool,
/// Phase-0d internal selector. This is not a public capability or protocol
/// surface; phase 7 owns that promotion.
pub(crate) capture_mode: CaptureMode,
/// Snapshot the legacy dogfood override during CLI resolution. Capture is
/// lazy, so reading the process environment later would let it change modes
/// between ticket creation and the first viewer.
pub(crate) legacy_null_sink: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults. /// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>, pub relay: Option<String>,
} }
@@ -207,8 +249,24 @@ pub struct ViewerOpts {
} }
impl Cli { impl Cli {
pub fn into_host_opts(self, interactive: bool) -> HostOpts { pub fn into_host_opts(self, interactive: bool) -> Result<HostOpts> {
HostOpts { let legacy_null_sink = std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
self.into_host_opts_with_legacy_override(interactive, legacy_null_sink)
}
fn into_host_opts_with_legacy_override(
self,
interactive: bool,
legacy_null_sink: bool,
) -> Result<HostOpts> {
let capture_mode = if self.internal_desktop_excluding {
CaptureMode::DesktopExcluding
} else {
CaptureMode::Legacy
};
capture_mode.validate_inputs(self.app.as_deref(), legacy_null_sink)?;
Ok(HostOpts {
window: self.window, window: self.window,
app: self.app, app: self.app,
strict_audio: self.strict_audio, strict_audio: self.strict_audio,
@@ -222,8 +280,10 @@ impl Cli {
no_hwencode: self.no_hwencode, no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers, max_viewers: self.max_viewers,
interactive, interactive,
capture_mode,
legacy_null_sink,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()), relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
} })
} }
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts { pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
@@ -234,3 +294,49 @@ impl Cli {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn phase_0d_trigger_is_hidden_from_help() {
let help = Cli::command().render_long_help().to_string();
assert!(!help.contains("internal-desktop-excluding"));
}
#[test]
fn phase_0d_trigger_conflicts_with_app_during_clap_parsing() {
let error = Cli::try_parse_from([
"pixelpass",
"--host",
"--internal-desktop-excluding",
"--app",
"Firefox",
])
.expect_err("clap must reject the hidden mode combined with --app");
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn phase_0d_trigger_conflicts_with_legacy_env_override_during_option_resolution() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let error = cli
.into_host_opts_with_legacy_override(false, true)
.expect_err("legacy override must be rejected before host startup");
assert!(error.to_string().contains("PIXELPASS_AUDIO_VIA_NULL_SINK"));
}
#[test]
fn phase_0d_trigger_reaches_the_internal_host_mode() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let opts = cli
.into_host_opts_with_legacy_override(false, false)
.expect("non-conflicting hidden mode resolves");
assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding);
assert!(!opts.legacy_null_sink);
}
}
+89 -482
View File
@@ -1,21 +1,18 @@
//! Per-app audio routing. //! Connection-owned capture-sink and per-app audio routing.
//! //!
//! Two cooperating layers: //! Two cooperating layers:
//! //!
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink //! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the //! non-lingering per-PID sink named `pixelpass_capture_<pid>`. When
//! default sink's monitor into it. gst captures from the null-sink's //! [`HostOpts::app`] is set, the same actor finds matching
//! monitor, so the viewer hears whatever the user hears — by default. //! `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): //! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the //! mirrors the default sink's monitor into the native capture sink. Once at
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose //! least one selected app stream is routed, that loopback is unloaded so the
//! `application.name` matches the filter, and writes //! viewer does not hear the app twice.
//! `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).
//! //!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves* //! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without //! 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 //! 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. //! 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 //! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and
//! mutations. libpipewire is dragged in only when per-stream filtering //! only then releases the actor connection and native sink.
//! is requested, because that needs registry-event subscription.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io::{self, Read}; use std::io::{self, Read};
use std::process::{Child, Command, ExitStatus, Stdio}; use std::process::{Child, Command, ExitStatus, Stdio};
use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::contained; use crate::common::contained;
use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome};
use crate::host::health; use crate::host::health;
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome}; use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
use crate::host::owned_thread::OwnedThread;
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape}; 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. /// 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. /// connect/list/unload requests instead of a second pactl connection.
const PACTL_BUDGET: Duration = Duration::from_secs(5); const PACTL_BUDGET: Duration = Duration::from_secs(5);
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1); 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 /// Owns the native graph actor plus its pactl-loaded dependent modules. Drop
/// libpipewire stream-router thread. Drop unloads modules as a backstop; /// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly,
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can /// which is the only path that can reconcile a load whose outcome was never
/// reconcile a load whose outcome was never observed. /// observed and acknowledge route restoration.
pub struct Routing { pub struct Routing {
/// Every module this host has loaded, is loading, or must ask the server /// 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 /// 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. /// exactly like no load at all and its module was left behind.
ledger: Arc<ModuleLedger>, ledger: Arc<ModuleLedger>,
sink_name: String, sink_name: String,
stream_router: Option<StreamRouter>, graph_owner: Option<AudioGraphOwner>,
event_task: Option<tokio::task::JoinHandle<()>>, event_task: Option<tokio::task::JoinHandle<()>>,
health: health::Reporter, health: health::Reporter,
} }
impl Routing { impl Routing {
/// Create the per-PID null-sink + loopback. If `opts.app` is set, /// Create the per-PID native sink and graph actor, plus the default-monitor
/// also spawn the libpipewire thread that reroutes matching streams. /// loopback when the selected routing mode permits it.
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> { pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
let pid = std::process::id(); let pid = std::process::id();
let sink_name = repair_plan::sink_name_for(pid); let sink_name = repair_plan::sink_name_for(pid);
let ledger = ModuleLedger::new(); let ledger = ModuleLedger::new();
// Construct the owner before the first mutation. Any error or cancellation // Construct Routing before either ownership layer mutates the graph. Any
// below now drops a real `Routing`, whose backstop closes, quiesces, // error or cancellation below drops a real owner whose backstop closes,
// reconciles, and unloads this ledger. Previously the owner did not exist // reconciles, and unloads the ledger before releasing the native sink.
// until both initial modules had loaded, so constructor failure leaked
// everything loaded up to that point.
let mut routing = Self { let mut routing = Self {
ledger: Arc::clone(&ledger), ledger: Arc::clone(&ledger),
sink_name: sink_name.clone(), sink_name: sink_name.clone(),
stream_router: None, graph_owner: None,
event_task: None, event_task: None,
health: health.clone(), health: health.clone(),
}; };
// Every module this host loads carries an ownership token, minted per // S4: the capture sink is a native, non-lingering PipeWire object owned
// load, so `--repair` can tell whose pid the name refers to instead of // by this actor connection, not a module owned by pipewire-pulse. The
// assuming the number means the same thing everywhere. Without it a repair // actor also absorbs the per-app router so every sink-owning mode has one
// run in another pid namespace can unload a live host's audio; see // graph lifetime and one readiness handshake.
// `repair::plan::OwnerToken`. That same per-load nonce is what lets let (graph_owner, mut event_rx) = AudioGraphOwner::start(
// reconciliation identify a module whose load was interrupted before its opts.app.clone(),
// index was ever read. CaptureSinkSpec::for_pid(pid),
load_module(&ledger, Shape::LegacyCaptureSink, pid) health.clone(),
.await )
.context("failed to load module-null-sink")?; .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 // 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 // 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 // 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible. // default of 200ms is enough to be perceptible.
let strict_app = opts.app.is_some() && opts.strict_audio; let strict_app = opts.app.is_some() && opts.strict_audio;
if !strict_app { if !strict_app
load_module(&ledger, Shape::LoopbackIntoCapture, pid) && let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await
.await {
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?; // 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!( tracing::info!(
strict_app, strict_app,
%sink_name, %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 { if opts.app.is_some() {
let (router, mut event_rx) =
StreamRouter::spawn(app.clone(), sink_name.clone(), health.clone())?;
let ledger_for_task = Arc::clone(&ledger); let ledger_for_task = Arc::clone(&ledger);
let strict = opts.strict_audio; let strict = opts.strict_audio;
let event_task = tokio::spawn(async move { let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState}; use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await { while let Some(ev) = event_rx.recv().await {
match ev { match ev {
Event::FirstRoutedStream => { GraphEvent::FirstRoutedStream => {
tracing::info!( tracing::info!(
"audio routing: first stream routed → unloading default-sink loopback" "audio routing: first stream routed → unloading default-sink loopback"
); );
@@ -167,7 +162,7 @@ impl Routing {
state: AppAudioState::Routed, state: AppAudioState::Routed,
}); });
} }
Event::LastRoutedStreamGone => { GraphEvent::LastRoutedStreamGone => {
// Routed app exited/paused mid-session. Notify the // Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode. // front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio { output::emit(output::Event::AppAudio {
@@ -207,7 +202,6 @@ impl Routing {
} }
} }
}); });
routing.stream_router = Some(router);
routing.event_task = Some(event_task); routing.event_task = Some(event_task);
} }
@@ -228,10 +222,10 @@ impl Routing {
&self.sink_name &self.sink_name
} }
/// Stop the stream router and the event task, settle anything the ledger is /// Quiesce graph mutations, stop the event task, settle anything the ledger
/// unsure about, then unload every module in shape order — the loopbacks /// is unsure about, unload every dependent loopback, then release the native
/// before the sink they reference, because PipeWire can leave zombie links if /// sink. PipeWire can leave zombie links if a sink is destroyed with active
/// a sink is destroyed with active inputs. /// inputs, so that final ordering is load-bearing.
/// ///
/// The event task is **awaited, not merely aborted**. Aborting and walking /// 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, /// 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) { pub async fn shutdown(mut self) {
// Closing is synchronous and happens first: after this point the event // Closing is synchronous and happens first: after this point the event
// task cannot register another mutation even if it receives one last // 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(); self.ledger.close();
let router_stopped = if let Some(router) = self.stream_router.take() { let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() {
router.shutdown().await graph_owner.quiesce().await == QuiesceOutcome::Confirmed
} else { } else {
true true
}; };
if let Some(mut task) = self.event_task.take() { if let Some(mut task) = self.event_task.take() {
if !router_stopped { if !graph_quiesced {
// A quarantined router still owns its event sender, so this task // An unresponsive graph actor may still own its event sender.
// cannot finish naturally. Cancel and await it before ledger // Cancel and await the task before ledger reconciliation.
// reconciliation; the router timeout already poisoned the host.
task.abort(); task.abort();
let _ = task.await; let _ = task.await;
} else { } else {
// The router's exit drops the event senders, so the task normally // Quiesce closes the event sender while retaining the native
// ends by itself. Abort is the fallback, and it is awaited through // sink. Abort is the fallback and is awaited through `&mut
// `&mut JoinHandle` so the future is genuinely dropped — and with // JoinHandle`, so any in-flight affine permit is dropped before
// it any in-flight permit — before reconciliation reads the // reconciliation reads the ledger.
// ledger. Dropping the handle instead would *detach* the task,
// which is how a load could still land after teardown believed it
// was finished.
match tokio::time::timeout(PACTL_BUDGET, &mut task).await { match tokio::time::timeout(PACTL_BUDGET, &mut task).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -299,6 +289,15 @@ impl Routing {
} }
cleanup_modules(&self.ledger).await; 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() { if !self.ledger.is_clean() {
tracing::warn!( tracing::warn!(
settled = self.ledger.is_settled(), 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. /// After a completed `shutdown` the ledger holds nothing and this does nothing.
fn drop(&mut self) { fn drop(&mut self) {
self.ledger.close(); self.ledger.close();
if let Some(router) = self.stream_router.take() {
drop(router);
}
if let Some(task) = self.event_task.take() { if let Some(task) = self.event_task.take() {
task.abort(); task.abort();
} }
self.ledger.close_and_wait(); self.ledger.close_and_wait();
cleanup_modules_blocking(&self.ledger); 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() { if !self.ledger.is_clean() {
tracing::warn!( tracing::warn!(
settled = self.ledger.is_settled(), 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. /// Parse a PipeWire `object.serial` property value.
/// ///
/// `object.serial` is a **64-bit** monotonically-increasing counter /// `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() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::host::ledger::SlotState; use crate::host::ledger::SlotState;
use crate::repair::plan::{ModuleObservation, classify}; use crate::repair::plan::{ModuleObservation, classify};
use std::sync::mpsc;
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event /// Whole-desktop routing: the graph actor owns the native sink, while the
/// task — just the null-sink and its default-sink loopback. /// ledger owns only its default-monitor loopback.
fn whole_desktop_opts() -> HostOpts { fn whole_desktop_opts() -> HostOpts {
HostOpts { HostOpts {
window: false, window: false,
@@ -1190,73 +859,12 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
legacy_null_sink: false,
relay: None, 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] #[test]
fn bounded_module_worker_uses_the_contained_spawn_path() { fn bounded_module_worker_uses_the_contained_spawn_path() {
let mut command = Command::new("sh"); let mut command = Command::new("sh");
@@ -1340,8 +948,8 @@ mod tests {
.collect(); .collect();
assert_eq!( assert_eq!(
ours.len(), ours.len(),
2, 1,
"the null-sink and its default-sink loopback must both be loaded" "only the default-monitor loopback is a Pulse module; the sink is native"
); );
for (id, name, args) in ours { for (id, name, args) in ours {
let fp = classify(&ModuleObservation::new(*id, name, args)) let fp = classify(&ModuleObservation::new(*id, name, args))
@@ -1361,12 +969,11 @@ mod tests {
); );
} }
/// Exercise the real libpipewire mainloop command path, not only the /// Exercise the real graph-actor command path with app routing enabled. The
/// whole-desktop module path above. The deliberately unmatched app filter /// deliberately unmatched filter avoids moving an unrelated live stream.
/// is enough to start the router without moving an unrelated live stream.
#[tokio::test] #[tokio::test]
#[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"] #[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 before = module_snapshot();
let mut opts = whole_desktop_opts(); let mut opts = whole_desktop_opts();
opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string()); 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::sleep(Duration::from_millis(100)).await;
tokio::time::timeout(Duration::from_secs(10), routing.shutdown()) tokio::time::timeout(Duration::from_secs(10), routing.shutdown())
.await .await
.expect("router and graph teardown stay globally bounded"); .expect("actor and graph teardown stay globally bounded");
assert!( assert!(
health.fault().is_none(), health.fault().is_none(),
+326
View File
@@ -0,0 +1,326 @@
//! Typed selection and ownership for the audio branch of a capture.
//!
//! The enum is deliberately shaped so the desktop-excluding mode cannot carry
//! either unsafe legacy input:
//!
//! - only [`CapturePlan::LegacyDesktop`] can contain [`DefaultMonitor`];
//! - only [`CapturePlan::PerApp`] can contain [`Routing`], whose legacy mode may
//! load the default-monitor Pulse loopback;
//! - [`CapturePlan::DesktopExcluding`] contains only [`BareCaptureSink`], which
//! has no module ledger or loopback constructor.
//!
//! Phase 6 will add retained native PipeWire links to the last variant. Until
//! then it intentionally captures silence through a real, connection-owned
//! sink reached by the hidden phase-0d CLI trigger.
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use super::audio::Routing;
use super::graph::BareCaptureSink;
use super::health;
use crate::cli::{CaptureMode, HostOpts};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum CapturePlanKind {
LegacyDesktop,
/// Also covers the legacy `PIXELPASS_AUDIO_VIA_NULL_SINK` dogfood path
/// when no app is selected. The odd name is retained from the design of
/// record so the three safety variants stay mechanically recognisable.
PerApp,
DesktopExcluding,
}
impl CapturePlanKind {
pub(super) fn resolve(opts: &HostOpts) -> Result<Self> {
if opts.capture_mode == CaptureMode::DesktopExcluding {
if opts.app.is_some() {
bail!(
"desktop-excluding capture cannot be combined with --app; legacy Routing is forbidden in this mode"
);
}
if opts.legacy_null_sink {
bail!(
"desktop-excluding capture cannot be combined with PIXELPASS_AUDIO_VIA_NULL_SINK; the default-monitor loopback is forbidden in this mode"
);
}
return Ok(Self::DesktopExcluding);
}
if opts.app.is_some() || opts.legacy_null_sink {
Ok(Self::PerApp)
} else {
Ok(Self::LegacyDesktop)
}
}
}
/// The real default sink's monitor. Construction is private to this module and
/// the value can only inhabit `LegacyDesktop`.
pub(super) struct DefaultMonitor(String);
impl DefaultMonitor {
async fn resolve() -> Result<Self> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(Self(format!("{sink}.monitor")))
}
fn gst_device_arg(&self) -> String {
format!("device={}", self.0)
}
}
/// Owns both the typed source choice and every graph object needed to keep that
/// choice alive for the capture lifetime.
pub(super) enum CapturePlan {
LegacyDesktop { source: DefaultMonitor },
PerApp { routing: Routing },
DesktopExcluding { capture_sink: BareCaptureSink },
}
impl CapturePlan {
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
match CapturePlanKind::resolve(opts)? {
CapturePlanKind::LegacyDesktop => Ok(Self::LegacyDesktop {
source: DefaultMonitor::resolve().await?,
}),
CapturePlanKind::PerApp => Ok(Self::PerApp {
routing: Routing::start(opts, health)
.await
.context("audio routing setup failed")?,
}),
CapturePlanKind::DesktopExcluding => Ok(Self::DesktopExcluding {
capture_sink: BareCaptureSink::start(health)
.await
.context("desktop-excluding capture-sink setup failed")?,
}),
}
}
/// The only conversion to GStreamer's stringly `pulsesrc device=...`
/// boundary. Callers cannot supply a free-form source string.
pub(super) fn gst_device_arg(&self) -> String {
match self {
Self::LegacyDesktop { source } => source.gst_device_arg(),
Self::PerApp { routing } => format!("device={}.monitor", routing.sink_name()),
Self::DesktopExcluding { capture_sink } => {
format!("device={}", capture_sink.monitor_name())
}
}
}
#[cfg(test)]
pub(super) fn legacy_fixture(monitor_name: &str) -> Self {
Self::LegacyDesktop {
source: DefaultMonitor(monitor_name.to_string()),
}
}
pub(super) async fn shutdown(self) {
match self {
Self::LegacyDesktop { .. } => {}
Self::PerApp { routing } => routing.shutdown().await,
Self::DesktopExcluding { capture_sink } => capture_sink.shutdown().await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Quality;
use std::time::{Duration, Instant};
fn opts(
app: bool,
strict_audio: bool,
capture_mode: CaptureMode,
legacy_null_sink: bool,
) -> HostOpts {
HostOpts {
window: false,
app: app.then(|| "Firefox".to_string()),
strict_audio,
display_server: None,
quality: Quality::Auto,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode,
legacy_null_sink,
relay: None,
}
}
#[test]
fn complete_phase_0d_mode_matrix() {
let mut rows = 0;
for app in [false, true] {
for strict_audio in [false, true] {
for capture_mode in [CaptureMode::Legacy, CaptureMode::DesktopExcluding] {
for legacy_null_sink in [false, true] {
rows += 1;
let opts = opts(app, strict_audio, capture_mode, legacy_null_sink);
let actual = CapturePlanKind::resolve(&opts);
let conflicts = capture_mode == CaptureMode::DesktopExcluding
&& (app || legacy_null_sink);
if conflicts {
assert!(
actual.is_err(),
"conflicting row unexpectedly resolved: {opts:?}"
);
continue;
}
let expected = if capture_mode == CaptureMode::DesktopExcluding {
CapturePlanKind::DesktopExcluding
} else if app || legacy_null_sink {
CapturePlanKind::PerApp
} else {
CapturePlanKind::LegacyDesktop
};
assert_eq!(actual.unwrap(), expected, "wrong plan for {opts:?}");
}
}
}
}
assert_eq!(rows, 16, "the full 2×2×2×2 mode matrix must run");
}
#[test]
fn default_monitor_is_confined_to_the_legacy_variant() {
let plan = CapturePlan::LegacyDesktop {
source: DefaultMonitor("alsa_output.fixture.monitor".to_string()),
};
assert_eq!(plan.gst_device_arg(), "device=alsa_output.fixture.monitor");
assert!(matches!(plan, CapturePlan::LegacyDesktop { .. }));
}
fn pulse_source_exists(name: &str) -> bool {
std::process::Command::new("pactl")
.args(["get-source-volume", name])
.output()
.is_ok_and(|output| output.status.success())
}
fn incoming_links(sink_name: &str) -> Result<Vec<serde_json::Value>> {
let output = std::process::Command::new("pw-dump")
.output()
.context("run pw-dump for the phase-0d graph assertion")?;
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 objects = objects
.as_array()
.context("pw-dump root was not an array")?;
let sink_id = objects
.iter()
.find(|object| {
object.get("type").and_then(serde_json::Value::as_str)
== Some("PipeWire:Interface:Node")
&& object
.pointer("/info/props/node.name")
.and_then(serde_json::Value::as_str)
== Some(sink_name)
})
.and_then(|object| object.get("id"))
.and_then(serde_json::Value::as_u64)
.context("bare capture sink was absent from pw-dump")?;
Ok(objects
.iter()
.filter(|object| {
if object.get("type").and_then(serde_json::Value::as_str)
!= Some("PipeWire:Interface:Link")
{
return false;
}
let Some(input_node) = object.pointer("/info/props/link.input.node") else {
return false;
};
input_node.as_u64().or_else(|| {
input_node
.as_str()
.and_then(|value| value.parse::<u64>().ok())
}) == Some(sink_id)
})
.cloned()
.collect())
}
/// Phase-0d graph assertion: the hidden mode reaches the real native sink,
/// but neither a Pulse default-monitor module nor any native link feeds it.
#[tokio::test]
#[ignore = "live: mutates the shared PipeWire graph; run alone with --test-threads=1"]
async fn live_desktop_excluding_sink_has_no_legacy_feed() {
let opts = opts(false, false, CaptureMode::DesktopExcluding, false);
let sink_name = crate::repair::plan::sink_name_for(std::process::id());
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
let (health, _) = health::channel();
let plan = CapturePlan::start(&opts, health.clone())
.await
.expect("start the hidden desktop-excluding plan");
let capture_sink = match &plan {
CapturePlan::DesktopExcluding { capture_sink } => capture_sink,
_ => panic!("hidden mode constructed the wrong capture-plan variant"),
};
assert_eq!(capture_sink.sink_name(), sink_name);
assert!(pulse_source_exists(capture_sink.monitor_name()));
let mut pulse = crate::repair::introspect::PulseSession::connect()
.expect("connect to the local Pulse server");
let modules = pulse.list_modules().expect("read the Pulse module table");
let feeding_modules: Vec<_> = modules
.into_iter()
.filter(|module| {
module.name == "module-loopback"
&& module.args.contains(&format!("sink={sink_name}"))
})
.collect();
assert!(
feeding_modules.is_empty(),
"DesktopExcluding must not construct a Pulse loopback into its sink: {feeding_modules:?}"
);
assert!(
incoming_links(&sink_name)
.expect("inspect incoming native links")
.is_empty(),
"phase 0d must leave the bare sink unfed until phase 6 owns native links"
);
plan.shutdown().await;
let deadline = Instant::now() + Duration::from_secs(2);
while pulse_source_exists(&format!("{sink_name}.monitor")) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pulse_source_exists(&format!("{sink_name}.monitor")));
assert!(health.fault().is_none());
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Pure Phase-6 fan-out planning.
//!
//! This module converts one coherent taint decision into exact per-port link
//! specifications. It deliberately cannot create PipeWire objects: the
//! mutation edge will consume this plan on the observer thread, re-evaluate in
//! that same callback, then retain the resulting non-lingering link proxies.
//!
//! Port identity is `audio.channel`, never registry enumeration order or a
//! recyclable global id. An unknown or incompatible layout makes that stream
//! unsupported; it never produces a guessed partial capture.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use super::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, PortDirection, PortSnapshot, Serial,
};
use super::taint::{Decisions, Reason};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct DesiredLink {
pub(super) output_node: GlobalId,
pub(super) output_port: GlobalId,
pub(super) input_node: GlobalId,
pub(super) input_port: GlobalId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PlanIssue {
CaptureSinkMissing,
CaptureSinkNotASink,
MissingOutputPorts,
MissingCapturePorts,
UnidentifiedChannel,
AmbiguousCaptureChannel,
DuplicateOutputChannel,
IncompatibleChannelLayout,
ExclusiveCapturePort,
}
impl PlanIssue {
pub(super) fn code(self) -> &'static str {
match self {
Self::CaptureSinkMissing => "capture-sink-missing",
Self::CaptureSinkNotASink => "capture-sink-not-a-sink",
Self::MissingOutputPorts => "missing-output-ports",
Self::MissingCapturePorts => "missing-capture-ports",
Self::UnidentifiedChannel => "unidentified-channel",
Self::AmbiguousCaptureChannel => "ambiguous-capture-channel",
Self::DuplicateOutputChannel => "duplicate-output-channel",
Self::IncompatibleChannelLayout => "incompatible-channel-layout",
Self::ExclusiveCapturePort => "exclusive-capture-port",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum StreamPlan {
Excluded { reason: Reason },
Unsupported { issue: PlanIssue },
Capture { links: BTreeSet<DesiredLink> },
}
/// Plan every candidate in the decision universe against one capture sink.
///
/// The caller must pass `decisions` produced from this exact `snapshot`. The
/// mutation edge upholds that condition structurally by evaluating and planning
/// inline in one observer callback; keeping this function pure makes the port
/// matrix independently falsifiable.
pub(super) fn plan(
snapshot: &GraphSnapshot,
decisions: &Decisions,
capture_sink: Serial,
) -> BTreeMap<Serial, StreamPlan> {
let sink = snapshot.node(capture_sink);
let sink_issue = match sink {
None => Some(PlanIssue::CaptureSinkMissing),
Some(node) if node.role != MediaRole::Sink => Some(PlanIssue::CaptureSinkNotASink),
Some(_) => None,
};
decisions
.candidates
.iter()
.map(|(&serial, decision)| {
let stream_plan = if let Some(reason) = decision.reason() {
StreamPlan::Excluded { reason }
} else if let Some(issue) = sink_issue {
StreamPlan::Unsupported { issue }
} else {
let node = snapshot
.node(serial)
.expect("a decision from this snapshot names a live node");
match pair_ports(
snapshot.ports_of(node.id),
snapshot.ports_of(sink.expect("validated above").id),
node.id,
sink.expect("validated above").id,
) {
Ok(links) => StreamPlan::Capture { links },
Err(issue) => StreamPlan::Unsupported { issue },
}
};
(serial, stream_plan)
})
.collect()
}
fn pair_ports<'a>(
candidate_ports: impl Iterator<Item = &'a PortSnapshot>,
capture_ports: impl Iterator<Item = &'a PortSnapshot>,
output_node: GlobalId,
input_node: GlobalId,
) -> Result<BTreeSet<DesiredLink>, PlanIssue> {
let outputs: Vec<&PortSnapshot> = candidate_ports
.filter(|port| port.direction == PortDirection::Out && !port.monitor)
.collect();
if outputs.is_empty() {
return Err(PlanIssue::MissingOutputPorts);
}
let inputs: Vec<&PortSnapshot> = capture_ports
.filter(|port| port.direction == PortDirection::In)
.collect();
if inputs.is_empty() {
return Err(PlanIssue::MissingCapturePorts);
}
if inputs.iter().any(|port| port.exclusive) {
return Err(PlanIssue::ExclusiveCapturePort);
}
let mut inputs_by_channel: BTreeMap<&str, GlobalId> = BTreeMap::new();
for port in &inputs {
let channel = port
.channel
.as_deref()
.ok_or(PlanIssue::UnidentifiedChannel)?;
if inputs_by_channel.insert(channel, port.id).is_some() {
return Err(PlanIssue::AmbiguousCaptureChannel);
}
}
let mut output_channels = BTreeSet::new();
let mut links = BTreeSet::new();
for output in outputs {
let channel = output
.channel
.as_deref()
.ok_or(PlanIssue::UnidentifiedChannel)?;
if !output_channels.insert(channel) {
return Err(PlanIssue::DuplicateOutputChannel);
}
if channel == "MONO" {
for &input_port in inputs_by_channel.values() {
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
});
}
continue;
}
let Some(&input_port) = inputs_by_channel.get(channel) else {
return Err(PlanIssue::IncompatibleChannelLayout);
};
links.insert(DesiredLink {
output_node,
output_port: output.id,
input_node,
input_port,
});
}
Ok(links)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::taint::fixture::{Graph, PULSE_PID};
use crate::host::taint::snapshot::{MediaRole, PortDirection};
use crate::host::taint::{ExclusionCtx, StickyState, evaluate};
fn decisions(graph: &Graph) -> (GraphSnapshot, Decisions) {
let snapshot = graph.build();
let (decisions, _) = evaluate(
&snapshot,
&ExclusionCtx {
pipewire_pulse_pid: Some(PULSE_PID),
graph_ready: true,
..ExclusionCtx::default()
},
&StickyState::default(),
);
(snapshot, decisions)
}
fn stereo_graph() -> (Graph, Serial, Serial, [GlobalId; 4]) {
let mut graph = Graph::new();
let app = graph.app_node("music", MediaRole::StreamOutput, 42);
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
let app_fl = graph.port_on_channel(app, PortDirection::Out, false, Some("FL"));
let app_fr = graph.port_on_channel(app, PortDirection::Out, false, Some("FR"));
let sink_fl = graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
let sink_fr = graph.port_on_channel(sink, PortDirection::In, false, Some("FR"));
(
graph,
app.serial,
sink.serial,
[app_fl, app_fr, sink_fl, sink_fr],
)
}
#[test]
fn stereo_ports_pair_by_channel_not_enumeration_order() {
let (graph, app, sink, [app_fl, app_fr, sink_fl, sink_fr]) = stereo_graph();
let (snapshot, decisions) = decisions(&graph);
assert_eq!(
plan(&snapshot, &decisions, sink).get(&app),
Some(&StreamPlan::Capture {
links: BTreeSet::from([
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fl,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fl,
},
DesiredLink {
output_node: snapshot.node(app).unwrap().id,
output_port: app_fr,
input_node: snapshot.node(sink).unwrap().id,
input_port: sink_fr,
},
]),
})
);
}
#[test]
fn mono_fans_to_both_stereo_inputs() {
let mut graph = Graph::new();
let app = graph.app_node("mono", MediaRole::StreamOutput, 42);
let sink = graph.native_virtual_node("pixelpass_capture_test", MediaRole::Sink, 43);
let mono = graph.port_on_channel(app, PortDirection::Out, false, Some("MONO"));
let left = graph.port_on_channel(sink, PortDirection::In, false, Some("FL"));
let right = graph.port_on_channel(sink, PortDirection::In, false, Some("FR"));
let (snapshot, decisions) = decisions(&graph);
let StreamPlan::Capture { links } =
plan(&snapshot, &decisions, sink.serial)[&app.serial].clone()
else {
panic!("mono stream must be plannable");
};
assert_eq!(links.len(), 2);
assert_eq!(
links
.iter()
.map(|link| link.output_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([mono])
);
assert_eq!(
links
.iter()
.map(|link| link.input_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([left, right])
);
}
#[test]
fn unknown_or_incompatible_layout_is_unsupported_not_guessed() {
let (mut graph, app, sink, _) = stereo_graph();
let surround = graph.app_node("surround", MediaRole::StreamOutput, 44);
graph.port_on_channel(surround, PortDirection::Out, false, Some("FC"));
let unknown = graph.app_node("unknown", MediaRole::StreamOutput, 45);
graph.port_on_channel(unknown, PortDirection::Out, false, None);
let (snapshot, decisions) = decisions(&graph);
let plans = plan(&snapshot, &decisions, sink);
assert!(matches!(plans[&app], StreamPlan::Capture { .. }));
assert_eq!(
plans[&surround.serial],
StreamPlan::Unsupported {
issue: PlanIssue::IncompatibleChannelLayout
}
);
assert_eq!(
plans[&unknown.serial],
StreamPlan::Unsupported {
issue: PlanIssue::UnidentifiedChannel
}
);
}
#[test]
fn excluded_candidate_never_produces_a_link_plan() {
let (mut graph, app, sink, _) = stereo_graph();
let call = graph.peerspeak_node("call", 7);
graph.port_on_channel(call, PortDirection::Out, false, Some("FL"));
let (snapshot, decisions) = decisions(&graph);
let plans = plan(&snapshot, &decisions, sink);
assert!(matches!(plans[&app], StreamPlan::Capture { .. }));
assert_eq!(
plans[&call.serial],
StreamPlan::Excluded {
reason: Reason::PeerspeakOwned
}
);
}
#[test]
fn replacing_the_capture_sink_changes_every_target_port() {
let (mut graph, app, old_sink, _) = stereo_graph();
let new_sink = graph.native_virtual_node("pixelpass_capture_new", MediaRole::Sink, 46);
let new_fl = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FL"));
let new_fr = graph.port_on_channel(new_sink, PortDirection::In, false, Some("FR"));
let (snapshot, decisions) = decisions(&graph);
let old = plan(&snapshot, &decisions, old_sink);
let new = plan(&snapshot, &decisions, new_sink.serial);
let StreamPlan::Capture { links: old } = &old[&app] else {
panic!("old sink plan");
};
let StreamPlan::Capture { links: new } = &new[&app] else {
panic!("new sink plan");
};
assert_ne!(old, new);
assert_eq!(
new.iter()
.map(|link| link.input_port)
.collect::<BTreeSet<_>>(),
BTreeSet::from([new_fl, new_fr])
);
}
}
+1447
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,7 +1,10 @@
pub mod aec; pub mod aec;
pub mod audio; pub mod audio;
mod audio_plan;
pub mod audit; pub mod audit;
mod capture; mod capture;
mod fanout;
mod graph;
mod health; mod health;
pub mod ledger; pub mod ledger;
mod observer; mod observer;
@@ -564,7 +567,9 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String { fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app { if opts.capture_mode == crate::cli::CaptureMode::DesktopExcluding {
bits.push("desktop-excluding-audio (internal)".to_string());
} else if let Some(app) = &opts.app {
if opts.strict_audio { if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)")); bits.push(format!("app-audio={app} (strict)"));
} else { } else {
@@ -594,6 +599,8 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
legacy_null_sink: false,
relay: None, relay: None,
} }
} }
@@ -617,6 +624,13 @@ mod tests {
capture_summary(&opts(None, true)), capture_summary(&opts(None, true)),
"fullscreen + system-audio" "fullscreen + system-audio"
); );
let mut desktop_excluding = opts(None, false);
desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding;
assert_eq!(
capture_summary(&desktop_excluding),
"fullscreen + desktop-excluding-audio (internal)"
);
} }
#[test] #[test]
+22 -6
View File
@@ -443,6 +443,7 @@ fn run_observer(
id, id,
node, node,
direction, direction,
channel: props.get("audio.channel").map(str::to_string),
exclusive: truthy(props.get("port.exclusive")), exclusive: truthy(props.get("port.exclusive")),
monitor: truthy(props.get("port.monitor")), monitor: truthy(props.get("port.monitor")),
}), }),
@@ -689,6 +690,10 @@ fn peerspeak_owned(value: Option<&str>) -> bool {
} }
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation { fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
let device_id = props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId);
NodeObservation { NodeObservation {
name: props.get("node.name").map(str::to_string), name: props.get("node.name").map(str::to_string),
role: MediaRole::parse(props.get("media.class")), role: MediaRole::parse(props.get("media.class")),
@@ -710,13 +715,11 @@ fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObs
.get("application.process.id") .get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()), .and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")), passthrough: truthy(props.get("node.passthrough")),
device_id,
session_device: false, session_device: false,
}, },
device_claim: DeviceClaim { device_claim: DeviceClaim {
device_id: props device_id,
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_api: props.get("device.api").map(str::to_string), device_api: props.get("device.api").map(str::to_string),
factory_name: props.get("factory.name").map(str::to_string), factory_name: props.get("factory.name").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string), alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
@@ -832,6 +835,7 @@ mod tests {
props.insert("media.class", "Stream/Output/Audio"); props.insert("media.class", "Stream/Output/Audio");
props.insert("node.name", "probe"); props.insert("node.name", "probe");
props.insert("client.id", "42"); props.insert("client.id", "42");
props.insert("device.id", "77");
if let Some(value) = value { if let Some(value) = value {
props.insert(PEERSPEAK_OWNED_PROP, value); props.insert(PEERSPEAK_OWNED_PROP, value);
} }
@@ -846,6 +850,8 @@ mod tests {
assert_eq!(observation.role, MediaRole::StreamOutput); assert_eq!(observation.role, MediaRole::StreamOutput);
assert_eq!(observation.name.as_deref(), Some("probe")); assert_eq!(observation.name.as_deref(), Some("probe"));
assert_eq!(observation.props.client_id, Some(GlobalId(42))); assert_eq!(observation.props.client_id, Some(GlobalId(42)));
assert_eq!(observation.props.device_id, Some(GlobalId(77)));
assert_eq!(observation.device_claim.device_id, Some(GlobalId(77)));
} }
} }
@@ -1168,9 +1174,19 @@ mod tests {
.is_some_and(|name| name.contains("alsa")) .is_some_and(|name| name.contains("alsa"))
|| matches!(node.role, MediaRole::Sink | MediaRole::Source)) || matches!(node.role, MediaRole::Sink | MediaRole::Source))
}); });
let session_device = session_device.expect(
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device",
);
assert!( assert!(
session_device.is_some(), session_device.props.device_id.is_some(),
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device" "a live session device must retain the device.id used by the hardware bridge"
);
assert!(
projection
.snapshot
.ports()
.any(|port| port.channel.is_some()),
"at least one live audio port must retain audio.channel for Phase-6 pairing"
); );
assert!(projection.graph_ready); assert!(projection.graph_ready);
+13 -1
View File
@@ -74,10 +74,14 @@ fn device_with(api: Option<&str>, driver: Option<&str>) -> DeviceProps {
} }
fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation { fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation {
let device_id = claim.device_id;
NodeObservation { NodeObservation {
name: Some(name.to_string()), name: Some(name.to_string()),
role, role,
props: NodeProps::default(), props: NodeProps {
device_id,
..NodeProps::default()
},
device_claim: claim, device_claim: claim,
} }
} }
@@ -148,6 +152,7 @@ fn port(serial: u64, id: u32, node_id: u32, dir: PortDirection) -> RegEvent {
id: gid(id), id: gid(id),
node: gid(node_id), node: gid(node_id),
direction: dir, direction: dir,
channel: None,
exclusive: false, exclusive: false,
monitor: false, monitor: false,
}) })
@@ -911,6 +916,13 @@ fn model_readiness_does_not_release_with_obligation_outstanding() {
}); });
assert_eq!(m.readiness(), Readiness::Complete); assert_eq!(m.readiness(), Readiness::Complete);
assert!(m.graph_ready()); assert!(m.graph_ready());
let projected = m.project();
let device_node = projected
.snapshot
.node(ser(100))
.expect("resolved device node is projected");
assert!(device_node.props.session_device);
assert_eq!(device_node.props.device_id, Some(gid(42)));
} }
#[test] #[test]
+66 -65
View File
@@ -5,7 +5,7 @@
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared //! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
//! and lives here. Backends call [`spawn`] with just their source-element args. //! and lives here. Backends call [`spawn`] with just their source-element args.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result};
use nix::sys::signal::Signal; use nix::sys::signal::Signal;
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
@@ -14,7 +14,7 @@ use std::time::Duration;
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use tokio::time::timeout; use tokio::time::timeout;
use super::audio::Routing; use super::audio_plan::CapturePlan;
use super::health; use super::health;
use super::quality::EffectiveQuality; use super::quality::EffectiveQuality;
use super::serve::Serve; use super::serve::Serve;
@@ -119,7 +119,7 @@ impl Drop for CaptureProcess {
pub(super) struct CaptureHandle { pub(super) struct CaptureHandle {
gst: Option<CaptureProcess>, gst: Option<CaptureProcess>,
audio: Option<Routing>, audio: Option<CapturePlan>,
serve: Option<Serve>, serve: Option<Serve>,
stopping: Arc<AtomicBool>, stopping: Arc<AtomicBool>,
} }
@@ -142,8 +142,8 @@ impl CaptureHandle {
if let Some(mut gst) = self.gst.take() { if let Some(mut gst) = self.gst.take() {
gst.shutdown().await; gst.shutdown().await;
} }
if let Some(audio) = self.audio.take() { if let Some(audio_plan) = self.audio.take() {
audio.shutdown().await; audio_plan.shutdown().await;
} }
if let Some(serve) = self.serve.take() { if let Some(serve) = self.serve.take() {
serve.shutdown().await; serve.shutdown().await;
@@ -155,7 +155,7 @@ impl Drop for CaptureHandle {
fn drop(&mut self) { fn drop(&mut self) {
self.stopping.store(true, Ordering::Release); self.stopping.store(true, Ordering::Release);
// CaptureProcess kills the whole process group and poisons the host; // CaptureProcess kills the whole process group and poisons the host;
// Routing's and Serve's own Drop impls handle their respective layers. // the typed plan's inner owner and Serve handle their own Drop layers.
} }
} }
@@ -176,8 +176,8 @@ pub(super) async fn spawn(
health: health::Reporter, health: health::Reporter,
after_spawn: impl FnOnce(), after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> { ) -> Result<CaptureHandle> {
let (audio_routing, audio_device) = setup_audio(opts, health.clone()).await?; let audio_plan = CapturePlan::start(opts, health.clone()).await?;
let args = build_args(&source_args, &audio_device, opts, quality, source_dims); let args = build_args(&source_args, &audio_plan, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0"); let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd gst_cmd
@@ -207,42 +207,12 @@ pub(super) async fn spawn(
Ok(CaptureHandle { Ok(CaptureHandle {
gst: Some(gst), gst: Some(gst),
audio: audio_routing, audio: Some(audio_plan),
serve: Some(serve), serve: Some(serve),
stopping, stopping,
}) })
} }
/// Decide whether per-app audio routing is active and produce the `device=…`
/// argument for `pulsesrc`. Routing activates when either `--app` is set
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
/// is set (no app filter — captures everything via the null-sink, used for
/// dogfooding the loopback path). Otherwise we capture the default sink's
/// monitor (system audio out), not the default source (the mic).
async fn setup_audio(
opts: &HostOpts,
health: health::Reporter,
) -> Result<(Option<Routing>, String)> {
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts, health)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
Ok((audio_routing, audio_device))
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the /// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → optional downscale → /// video branch (caller's `source` → videorate cap → optional downscale →
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.). /// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
@@ -252,7 +222,7 @@ async fn setup_audio(
/// wants I420). /// wants I420).
fn build_args( fn build_args(
source: &[String], source: &[String],
audio_device: &str, audio_plan: &CapturePlan,
opts: &HostOpts, opts: &HostOpts,
quality: &EffectiveQuality, quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>, source_dims: Option<(u32, u32)>,
@@ -406,7 +376,7 @@ fn build_args(
// not the default source (which is the mic). // not the default source (which is the mic).
args.extend([ args.extend([
"pulsesrc".into(), "pulsesrc".into(),
audio_device.to_string(), audio_plan.gst_device_arg(),
"do-timestamp=true".into(), "do-timestamp=true".into(),
"!".into(), "!".into(),
"queue".into(), "queue".into(),
@@ -428,35 +398,66 @@ fn build_args(
args args
} }
async fn default_audio_monitor() -> Result<String> {
let output = Command::new("pactl")
.arg("get-default-sink")
.output()
.await
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)",
)?;
if !output.status.success() {
bail!(
"pactl get-default-sink failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")?
.trim()
.to_string();
if sink.is_empty() {
bail!("pactl get-default-sink returned no name (is a sound server running?)");
}
Ok(format!("{sink}.monitor"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cli::{CaptureMode, Quality};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
fn legacy_opts() -> HostOpts {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None,
quality: Quality::Source,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: CaptureMode::Legacy,
legacy_null_sink: false,
relay: None,
}
}
#[test]
fn legacy_desktop_audio_tail_is_byte_identical() {
let opts = legacy_opts();
let quality = super::super::quality::resolve(&opts, 1);
let plan = CapturePlan::legacy_fixture("alsa_output.fixture.monitor");
let args = build_args(&["ximagesrc".to_string()], &plan, &opts, &quality, None);
let audio_start = args
.iter()
.position(|arg| arg == "pulsesrc")
.expect("pipeline has an audio branch");
assert_eq!(
&args[audio_start..],
[
"pulsesrc",
"device=alsa_output.fixture.monitor",
"do-timestamp=true",
"!",
"queue",
"!",
"audioconvert",
"!",
"audioresample",
"!",
"audio/x-raw,rate=48000,channels=2",
"!",
"avenc_aac",
"bitrate=128000",
"!",
"aacparse",
"!",
"mux.",
]
);
}
fn process_is_running(pid: u32) -> bool { fn process_is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false; return false;
+2
View File
@@ -214,6 +214,8 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers, max_viewers,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
legacy_null_sink: false,
relay: None, relay: None,
} }
} }
+27 -2
View File
@@ -155,7 +155,17 @@ impl Graph {
/// A device node as the session manager creates it: no strong key, /// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and /// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported. /// a `device.id`, which is what marks it as session-manager-exported.
/// Each call models a distinct physical device; use [`Self::device_node_on`]
/// when two terminals belong to the same card.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef { pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let device_id = self.id();
self.device_node_on(name, role, device_id)
}
/// A passive terminal exported by a particular physical Device. Sink
/// and source nodes given the same id model the hidden playback-to-capture
/// path that an ALSA/USB device may expose outside PipeWire's Link graph.
pub fn device_node_on(&mut self, name: &str, role: MediaRole, device_id: GlobalId) -> NodeRef {
let session = match self.session_client { let session = match self.session_client {
Some(id) => id, Some(id) => id,
None => { None => {
@@ -164,7 +174,7 @@ impl Graph {
id id
} }
}; };
self.node(name, role, device(session, SESSION_PID)) self.node(name, role, device(session, SESSION_PID, device_id))
} }
/// A node that *belongs to* a Device but is not a passive device node — /// A node that *belongs to* a Device but is not a passive device node —
@@ -247,6 +257,18 @@ impl Graph {
} }
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) { pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
self.port_on_channel(node, direction, exclusive, None);
}
/// A port with the channel identity Phase 6 uses for deterministic link
/// pairing. Returns its snapshot-local id for exact plan assertions.
pub fn port_on_channel(
&mut self,
node: NodeRef,
direction: PortDirection,
exclusive: bool,
channel: Option<&str>,
) -> GlobalId {
let serial = self.serial(); let serial = self.serial();
let id = self.id(); let id = self.id();
self.ports.push(PortSnapshot { self.ports.push(PortSnapshot {
@@ -254,9 +276,11 @@ impl Graph {
id, id,
node: node.id, node: node.id,
direction, direction,
channel: channel.map(str::to_string),
exclusive, exclusive,
monitor: false, monitor: false,
}); });
id
} }
/// A signal edge: audio flows `from → to`. /// A signal edge: audio flows `from → to`.
@@ -386,10 +410,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
/// here is deliberately *more* pessimistic than reality — it hands the /// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that /// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props. /// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps {
NodeProps { NodeProps {
client_id: Some(session_client), client_id: Some(session_client),
process_id: Some(session_pid), process_id: Some(session_pid),
device_id: Some(device_id),
session_device: true, session_device: true,
..NodeProps::default() ..NodeProps::default()
} }
+40 -1
View File
@@ -34,7 +34,12 @@
//! *is* a real Link whose output node is the sink node itself (measured). //! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one //! A port-granular walk would need a synthetic edge; a node-granular one
//! does not. //! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See //! 3. **Hardware-device bridges** — a passive sink can feed a passive source
//! on the same physical Device through a mixer/loopback path that PipeWire
//! does not expose as a Link. The observer positively classifies both
//! terminals and retains their shared `device.id`; the walk conservatively
//! adds `sink → source` for that one Device.
//! 4. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one. //! [`owner`]; this is the hard one.
//! //!
//! ## Stickiness //! ## Stickiness
@@ -784,6 +789,40 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
_ => {} _ => {}
} }
} }
// A physical sound device may route playback back into capture in its
// own mixer/firmware without publishing a PipeWire Link (HDA "Stereo
// Mix", USB loopback channels, vendor DSPs). Control-name inspection is
// neither portable nor proof of absence, so v1 fails closed: once a
// passive hardware sink is tainted, passive capture terminals exported
// by the same Device are downstream too.
//
// Both guards are load-bearing. `session_device` limits this to the
// observer's positive hardware-terminal allowlist, so an app-associated
// filter cannot invent a bridge. `device_id` limits it to one physical
// Device, so the shared WirePlumber client does not fuse every card.
let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
for (from, output_device) in hardware_outputs {
for &(to, input_device) in &hardware_inputs {
if from != to && output_device == input_device {
edges.entry(from).or_default().push(to);
receivers.insert(to);
}
}
}
for targets in edges.values_mut() { for targets in edges.values_mut() {
targets.sort_unstable(); targets.sort_unstable();
targets.dedup(); targets.dedup();
+12
View File
@@ -139,6 +139,15 @@ pub struct NodeProps {
/// The stream negotiated an encoded/passthrough format; a second link /// The stream negotiated an encoded/passthrough format; a second link
/// would refuse or corrupt it (v3.4 §6.2). /// would refuse or corrupt it (v3.4 §6.2).
pub passthrough: bool, pub passthrough: bool,
/// `device.id` — the snapshot-local PipeWire Device this node belongs
/// to. This is retained separately from [`Self::session_device`]: the
/// latter says the node is a positively-classified passive hardware
/// terminal, while this id lets the taint walk relate the playback and
/// capture terminals exported by that *same* device.
///
/// Like every [`GlobalId`], this is valid only within this snapshot. It
/// must never enter sticky identity or survive a recompute.
pub device_id: Option<GlobalId>,
/// This node is a **passive device node exported by the session /// This node is a **passive device node exported by the session
/// manager** — a real sound card's sink or source, not something that /// manager** — a real sound card's sink or source, not something that
/// forwards audio. /// forwards audio.
@@ -219,6 +228,9 @@ pub struct PortSnapshot {
/// Owning node, by snapshot-local id. /// Owning node, by snapshot-local id.
pub node: GlobalId, pub node: GlobalId,
pub direction: PortDirection, pub direction: PortDirection,
/// `audio.channel` (for example `FL`, `FR`, `MONO`). Phase 6 pairs
/// ports by channel, never by global-id or enumeration order.
pub channel: Option<String>,
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2). /// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
pub exclusive: bool, pub exclusive: bool,
/// `port.monitor`. Recorded for phase 6 link creation; taint does not /// `port.monitor`. Recorded for phase 6 link creation; taint does not
+83 -1
View File
@@ -16,7 +16,7 @@ use std::collections::BTreeSet;
use super::fixture::{Graph, NodeRef, PULSE_PID, app}; use super::fixture::{Graph, NodeRef, PULSE_PID, app};
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key}; use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial}; use super::snapshot::{GlobalId, MediaRole, NodeProps, PortDirection, Serial};
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate}; use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
fn ctx() -> ExclusionCtx { fn ctx() -> ExclusionCtx {
@@ -176,6 +176,88 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
assert_tainted(&decisions, sink, "tainted-upstream"); assert_tainted(&decisions, sink, "tainted-upstream");
} }
// ──────────────────────────────────────────────────────────────────────
// Hidden hardware playback-to-capture paths — same Device only
// ─────────────────────────────────────────────────────────────────────
#[test]
fn same_hardware_device_closes_an_unpublished_playback_to_capture_hop() {
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("card-playback", MediaRole::Sink, card);
let source = graph.device_node_on("card-capture", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let music = graph.app_node("music", MediaRole::StreamOutput, 8);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(music, sink);
// There is deliberately no sink → source Link: the hardware bridge is
// the route being modeled.
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("music", music)],
&[
("call", call, "peerspeak-owned"),
("recorder-out", recorder_out, "tainted-owner-bridge"),
],
);
assert_tainted(&decisions, sink, "tainted-upstream");
assert_tainted(&decisions, source, "tainted-upstream");
assert_tainted(&decisions, recorder_in, "tainted-upstream");
}
#[test]
fn different_hardware_devices_do_not_invent_a_capture_path() {
let mut graph = Graph::new();
let sink = graph.device_node_on("speaker", MediaRole::Sink, GlobalId(700));
let source = graph.device_node_on("usb-mic", MediaRole::Source, GlobalId(701));
let call = graph.peerspeak_node("peerspeak-call", 7);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("recorder-out", recorder_out)],
&[("call", call, "peerspeak-owned")],
);
assert_untainted(&decisions, source);
assert_untainted(&decisions, recorder_in);
}
#[test]
fn same_device_microphone_use_is_intentionally_over_excluded() {
// The hardware's private mixer/firmware path is not observable in the
// PipeWire graph. If an app captures the same device receiving the call,
// v1 cannot prove that its capture is clean, so its playback is excluded.
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("headset-output", MediaRole::Sink, card);
let mic = graph.device_node_on("headset-mic", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let firefox_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11_114);
let firefox_out = graph.app_node("firefox-audio", MediaRole::StreamOutput, 11_114);
graph.link(call, sink);
graph.link(mic, firefox_in);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox-out", firefox_out, "tainted-owner-bridge"),
],
);
}
/// Each ownership carrier must work **alone** (v3.5 §5.1). /// Each ownership carrier must work **alone** (v3.5 §5.1).
/// ///
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value /// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
+1 -1
View File
@@ -30,7 +30,7 @@ pub async fn run(cli: Cli) -> Result<()> {
if cli.quality.is_none() { if cli.quality.is_none() {
cli.quality = Some(pick_quality(&theme)?); cli.quality = Some(pick_quality(&theme)?);
} }
host::run(cli.into_host_opts(true)).await host::run(cli.into_host_opts(true)?).await
} }
_ => { _ => {
let ticket = prompt_ticket(&theme)?; let ticket = prompt_ticket(&theme)?;
+1 -1
View File
@@ -70,7 +70,7 @@ async fn main() -> Result<()> {
screen, a ticket views someone else's." screen, a ticket views someone else's."
); );
} }
return host::run(cli.into_host_opts(false)).await; return host::run(cli.into_host_opts(false)?).await;
} }
match cli.ticket.as_deref() { match cli.ticket.as_deref() {