feat(host): build desktop audio exclusion foundation
This commit is contained in:
+109
-3
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user