//! Connection-owned capture-sink and per-app audio routing. //! //! Two cooperating layers: //! //! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a //! non-lingering per-PID sink named `pixelpass_capture_`. When //! [`HostOpts::app`] is set, the same actor finds matching //! `Stream/Output/Audio` nodes and writes `target.object` so WirePlumber //! reroutes them to that sink. The sink disappears with the actor's PipeWire //! connection, including after SIGKILL. //! //! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback //! mirrors the default sink's monitor into the native capture sink. Once at //! least one selected app stream is routed, that loopback is unloaded so the //! viewer does not hear the app twice. //! //! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves* //! the chosen app off the sharer's speakers into the null-sink, so without //! this the sharer would go deaf to the very content they're sharing. We //! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer //! hears it too. Only the chosen app is in the null-sink — never the //! desktop/call — so this can't echo back into the capture. It is loaded on //! 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. //! //! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and //! only then releases the actor connection and native sink. use anyhow::{Context, Result, bail}; use std::collections::BTreeMap; use std::io::{self, Read}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::cli::HostOpts; use crate::common::contained; use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome}; use crate::host::health; use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome}; use crate::repair::plan::{self as repair_plan, Fingerprint, Shape}; /// How long a `pactl load-module` worker may run before it is killed and reaped. /// /// A bound, not a calibration: a local Pulse socket answers in milliseconds, and /// this exists only so a wedged server cannot hang teardown forever. It is /// deliberately generous because exceeding it is no longer destructive: the /// ledger records the attempt, and reconciliation finds whatever the server /// actually did. Unloads use PulseSession's independently bounded native /// connect/list/unload requests instead of a second pactl connection. const PACTL_BUDGET: Duration = Duration::from_secs(5); const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1); /// Owns the native graph actor plus its pactl-loaded dependent modules. Drop /// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly, /// which is the only path that can reconcile a load whose outcome was never /// observed and acknowledge route restoration. pub struct Routing { /// Every module this host has loaded, is loading, or must ask the server /// about. Shared with the event task, which loads and unloads the two /// loopbacks as the routed app comes and goes. /// /// This replaced three `Option`s. The reason is in [`crate::host::ledger`]: /// an `Option` cannot say "a load is in flight", so a cancelled load looked /// exactly like no load at all and its module was left behind. ledger: Arc, sink_name: String, graph_owner: Option, event_task: Option>, health: health::Reporter, } impl Routing { /// Create the per-PID native sink and graph actor, plus the default-monitor /// loopback when the selected routing mode permits it. pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result { let pid = std::process::id(); let sink_name = repair_plan::sink_name_for(pid); let ledger = ModuleLedger::new(); // Construct Routing before either ownership layer mutates the graph. Any // error or cancellation below drops a real owner whose backstop closes, // reconciles, and unloads the ledger before releasing the native sink. let mut routing = Self { ledger: Arc::clone(&ledger), sink_name: sink_name.clone(), graph_owner: None, event_task: None, health: health.clone(), }; // S4: the capture sink is a native, non-lingering PipeWire object owned // by this actor connection, not a module owned by pipewire-pulse. The // actor also absorbs the per-app router so every sink-owning mode has one // graph lifetime and one readiness handshake. let (graph_owner, mut event_rx, _identity_rx) = AudioGraphOwner::start( opts.app.clone(), CaptureSinkSpec::for_pid(pid), health.clone(), ) .await .context("failed to start the connection-owned audio graph")?; debug_assert_eq!(graph_owner.identity().name, sink_name); routing.graph_owner = Some(graph_owner); // In strict per-app mode we never mirror the default sink: the viewer // must hear *only* the chosen app, never the whole desktop (which would // leak e.g. a voice call the sharer is in back to viewers — the echo // bug A23). Without strict mode (whole-desktop share, or best-effort // app filtering) we load the monitor loopback so the viewer hears // system audio immediately and during any gap before the app routes. // 20ms loopback latency keeps the mirrored audio tight; pactl's // default of 200ms is enough to be perceptible. let strict_app = opts.app.is_some() && opts.strict_audio; if !strict_app && let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await { // Once the actor exists, an ordinary constructor error gets a full // async teardown rather than falling through the narrower // synchronous Drop backstop and quarantining a responsive thread. routing.shutdown().await; return Err(error) .context("failed to load module-loopback (connection-owned sink cleaned up)"); } tracing::info!( strict_app, %sink_name, "audio routing: connection-owned sink ready (loopback skipped in strict app mode)" ); if opts.app.is_some() { let ledger_for_task = Arc::clone(&ledger); let strict = opts.strict_audio; let event_task = tokio::spawn(async move { use crate::common::output::{self, AppAudioState}; while let Some(ev) = event_rx.recv().await { match ev { GraphEvent::FirstRoutedStream => { tracing::info!( "audio routing: first stream routed → unloading default-sink loopback" ); let mirror_absent = unload_module(&ledger_for_task, Shape::LoopbackIntoCapture).await; // Mirror the routed app back to the sharer's own // speakers so they hear the content they're sharing. // Loaded *after* the default-sink loopback is gone so // the two never coexist (which would feed back), and // sourced from the null-sink monitor — the chosen app // only, never the desktop/call — so it can't echo into // the capture. if mirror_absent { ensure_loaded(&ledger_for_task, Shape::LoopbackOutOfCapture, pid) .await; } else { tracing::warn!( "audio routing: default-sink mirror absence was not confirmed; \ refusing to load the inverse local monitor" ); } // Tell the front-end the chosen app's audio is live. output::emit(output::Event::AppAudio { state: AppAudioState::Routed, }); } GraphEvent::LastRoutedStreamGone => { // Routed app exited/paused mid-session. Notify the // front-end either way; the recovery differs by mode. output::emit(output::Event::AppAudio { state: AppAudioState::Lost, }); // The shared app is gone, so its null-sink is silent: // stop mirroring it to the sharer's speakers. Re-loads // on the next FirstRoutedStream if the app resumes. let local_monitor_absent = unload_module(&ledger_for_task, Shape::LoopbackOutOfCapture).await; if strict { // Strict mode: do NOT restore the whole-desktop // loopback. Viewers hear silence until the app // produces audio again — never the rest of the // desktop (call included). tracing::info!( "audio routing: strict mode — last routed stream gone, leaving viewers silent" ); continue; } if !local_monitor_absent { tracing::warn!( "audio routing: local-monitor absence was not confirmed; \ refusing to restore the inverse default-sink mirror" ); continue; } // Best-effort mode: restore the default-sink loopback // so the viewer hears system audio again instead of // silence. Already loaded is not an error — the ledger // refuses the load and `ensure_loaded` says so quietly. tracing::info!( "audio routing: last routed stream gone → restoring default-sink loopback" ); ensure_loaded(&ledger_for_task, Shape::LoopbackIntoCapture, pid).await; } } } }); routing.event_task = Some(event_task); } // Strict per-app mode suppresses the default-sink loopback, so until the // chosen app's first stream routes the viewer hears *silence*. Emit an // initial `lost` at capture start (capture is lazy — this runs on the // first viewer) so the front-end can warn from the outset rather than // only after an app that *was* routed later stops (audit A23 P2/F1): // `LastRoutedStreamGone`→`lost` never fires for an app that never routed. if let Some(state) = initial_app_audio_state(opts) { crate::common::output::emit(crate::common::output::Event::AppAudio { state }); } Ok(routing) } pub fn sink_name(&self) -> &str { &self.sink_name } /// Quiesce graph mutations, stop the event task, settle anything the ledger /// is unsure about, unload every dependent loopback, then release the native /// sink. PipeWire can leave zombie links if a sink is destroyed with active /// inputs, so that final ordering is load-bearing. /// /// The event task is **awaited, not merely aborted**. Aborting and walking /// away is what left orphans behind: the task's load is an await point now, /// so dropping its future marks the slot ambiguous rather than losing the /// module — but only a path that then reconciles can actually clean it up. /// `Drop` cannot await, which is why it is the narrower backstop. pub async fn shutdown(mut self) { // Closing is synchronous and happens first: after this point the event // task cannot register another mutation even if it receives one last // graph event while shutdown is in progress. self.ledger.close(); let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() { graph_owner.quiesce().await == QuiesceOutcome::Confirmed } else { true }; if let Some(mut task) = self.event_task.take() { if !graph_quiesced { // An unresponsive graph actor may still own its event sender. // Cancel and await the task before ledger reconciliation. task.abort(); let _ = task.await; } else { // Quiesce closes the event sender while retaining the native // sink. Abort is the fallback and is awaited through `&mut // JoinHandle`, so any in-flight affine permit is dropped before // reconciliation reads the ledger. match tokio::time::timeout(PACTL_BUDGET, &mut task).await { Ok(Ok(())) => {} Ok(Err(e)) => { self.health .poison(format!("audio routing event task failed: {e}")); } Err(_) => { tracing::warn!( "audio routing: the event task did not finish within {PACTL_BUDGET:?}; \ cancelling it" ); self.health.poison(format!( "audio routing event task did not stop within {PACTL_BUDGET:?}" )); task.abort(); let _ = task.await; } } } } // A cancelled `spawn_blocking` await detaches its worker. Every worker is // registered before it can be spawned, so this is a real ordering // boundary: reconciliation cannot overtake late module creation/removal. let ledger_for_wait = Arc::clone(&self.ledger); if let Err(e) = tokio::task::spawn_blocking(move || { ledger_for_wait.wait_for_operations(); }) .await { tracing::warn!("audio routing: module-operation wait task failed: {e}"); self.health .poison(format!("audio module-operation wait task failed: {e}")); } cleanup_modules(&self.ledger).await; // Loopbacks are gone before the actor connection is released. This is // the S4 ordering invariant: dependent Pulse modules never outlive the // native sink they reference during an ordinary shutdown. if let Some(graph_owner) = self.graph_owner.take() && !graph_owner.shutdown().await { tracing::warn!("audio routing: AudioGraphOwner shutdown was not confirmed"); } if !self.ledger.is_clean() { tracing::warn!( settled = self.ledger.is_settled(), "audio routing: some audio modules could not be removed safely; \ `pixelpass --repair` will clean up anything left behind" ); self.health.poison( "audio routing teardown left module ownership unresolved; repair is required", ); } } } impl Drop for Routing { /// Synchronous backstop for the paths that never reach [`Routing::shutdown`] /// — an error on the way up, or a panic. It cannot await, so it can neither /// wait for the event task nor reconcile; it unloads what the ledger can name /// and says so plainly when something is left unexplained. /// /// After a completed `shutdown` the ledger holds nothing and this does nothing. fn drop(&mut self) { self.ledger.close(); if let Some(task) = self.event_task.take() { task.abort(); } self.ledger.close_and_wait(); cleanup_modules_blocking(&self.ledger); // Keep the actor/sink alive until dependent modules have been handled, // even on this synchronous error/unwind backstop. if let Some(graph_owner) = self.graph_owner.take() { drop(graph_owner); } if !self.ledger.is_clean() { tracing::warn!( settled = self.ledger.is_settled(), "audio routing: torn down with modules that could not be removed safely; \ run `pixelpass --repair` to clean up anything left behind" ); self.health .poison("audio routing Drop left module ownership unresolved; repair is required"); } } } /// The app-audio state to announce at capture start, if any. Only strict per-app /// mode warrants one: there the loopback is suppressed, so the viewer hears /// silence until the chosen app's first stream routes — surface that as an /// initial `lost`. In every other mode (whole-desktop, or best-effort app /// filtering) the loopback keeps audio flowing from the outset, so there is no /// initial gap to report. Pure: no I/O, so the emit decision is unit-testable. pub(super) fn initial_app_audio_state( opts: &HostOpts, ) -> Option { (opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost) } // ────────────────────────────────────────────────────────────────────── // App enumeration (interactive picker source) // ────────────────────────────────────────────────────────────────────── /// One deduplicated app currently producing audio. The picker in /// interactive mode shows these as the per-app capture choices. #[derive(Debug, Clone)] pub struct App { pub name: String, pub stream_count: u32, } /// Enumerate apps currently sending audio to any sink, deduplicated by /// `application.name`. Returns an empty Vec if nothing is playing. pub fn list_playing_apps() -> Result> { let output = Command::new("pactl") .args(["-f", "json", "list", "sink-inputs"]) .output() .context("failed to run `pactl -f json list sink-inputs`")?; if !output.status.success() { bail!( "pactl list sink-inputs failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } parse_sink_inputs(&output.stdout) } fn parse_sink_inputs(stdout: &[u8]) -> Result> { let entries: Vec = serde_json::from_slice(stdout).context("pactl returned unparseable JSON")?; let mut counts: BTreeMap = BTreeMap::new(); for entry in entries { let Some(name) = entry.properties.application_name else { continue; }; let trimmed = name.trim(); if trimmed.is_empty() { continue; } *counts.entry(trimmed.to_string()).or_insert(0) += 1; } Ok(counts .into_iter() .map(|(name, stream_count)| App { name, stream_count }) .collect()) } #[derive(serde::Deserialize)] struct SinkInput { properties: SinkInputProperties, } #[derive(serde::Deserialize)] struct SinkInputProperties { #[serde(rename = "application.name")] application_name: Option, } // ────────────────────────────────────────────────────────────────────── // pactl module helpers // ────────────────────────────────────────────────────────────────────── /// Mint an ownership token for one module load. /// /// **Per load, not per session.** The nonce is what makes two loads by the same pid /// render different arguments, which is what lets a fingerprint tell a module from /// its replacement at the same index. A token minted once and reused for every /// reload would be a host-session nonce and would not do that, so the counter is /// bumped on every call and mixed with the clock. fn owner_token(pid: u32) -> Result { use std::sync::atomic::{AtomicU64, Ordering}; static LOADS: AtomicU64 = AtomicU64::new(0); let local = crate::repair::local_identity()?; // A nonce only has to be unlikely to repeat, not unguessable. The counter makes // two loads within the same clock tick distinct; the clock keeps two runs of the // same process distinct. let counter = LOADS.fetch_add(1, Ordering::Relaxed); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); Ok(repair_plan::OwnerToken { machine: local.machine, boot: local.boot, pid_ns: local.pid_ns, nonce: nanos ^ (counter << 48) ^ (u64::from(pid) << 32), }) } /// Load the Pulse module for one [`Shape`] and return its index. /// /// Both the module name and its arguments come from the shape itself /// ([`crate::repair::plan::Shape`]) rather than being written out here, so that /// `--repair`'s exact-form matcher and this loader are one source of truth. A /// latency or argument change that moved only one of them would leave repair /// silently unable to recognise the modules this build loads. async fn load_module(ledger: &Arc, shape: Shape, pid: u32) -> Result { let owner = owner_token(pid).context("could not build an audio ownership token")?; let permit = ledger .begin_load(shape, pid, owner.clone()) .map_err(anyhow::Error::new) .with_context(|| format!("cannot load the {} module", shape.label()))?; let args = shape.render_args(pid, Some(&owner)); // The affine permit moves into the blocking worker. Dropping this await does // not cancel `spawn_blocking`; the worker remains registered, owns and reaps // its child, and settles the slot before teardown's quiescence barrier opens. tokio::task::spawn_blocking(move || -> Result { let output = permit.with_server_operation(|| { let mut command = Command::new("pactl"); command .arg("load-module") .arg(shape.module_name()) .args(args); bounded_output(&mut command, PACTL_BUDGET) }); let output = match output { Ok(output) => output, Err(e) => { // Whether the server was reached is unknown. Leaving the permit // unsettled makes its Drop create a reconciliation question. drop(permit); return Err(e).context("failed to run pactl load-module"); } }; if output.timed_out { drop(permit); bail!("pactl load-module did not finish within {PACTL_BUDGET:?}"); } if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if output.status.code().is_some() { // pactl exited normally and reported the server's refusal. permit.abandon(); } else { drop(permit); } bail!("pactl load-module failed: {stderr}"); } let id_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); // Genuinely 32-bit: this is a Pulse module index, not object.serial. let Ok(index) = id_str.parse::() else { drop(permit); bail!("pactl returned unexpected module ID: {id_str:?}"); }; let fp = permit.commit(index)?; tracing::info!( module = fp.id, shape = shape.label(), "audio routing: loaded pactl module" ); Ok(fp) }) .await .context("the pactl load worker failed")? } /// Load `shape` unless the slot already holds it. /// /// A busy slot is not a failure on the oscillation path: `FirstRoutedStream` and /// `LastRoutedStreamGone` can both ask for a module that is already in the state /// they want, and the ledger is what decides that rather than a separate flag. async fn ensure_loaded(ledger: &Arc, shape: Shape, pid: u32) { let Err(e) = load_module(ledger, shape, pid).await else { return; }; match e.downcast_ref::() { Some(LedgerError::Busy { state, .. }) => tracing::debug!( shape = shape.label(), state, "audio routing: nothing to load, the slot is already occupied" ), _ => tracing::warn!( "audio routing: failed to load the {} module: {e:#}", shape.label() ), } } /// Unload whatever the ledger holds for `shape`, and record how it went. /// /// A no-op for a slot holding nothing. An outcome that cannot be confirmed is /// recorded as uncertain rather than assumed done, so the module keeps being /// named until the server is asked about it. async fn unload_module(ledger: &Arc, shape: Shape) -> bool { unload_module_inner(ledger, shape, false).await } async fn unload_module_inner(ledger: &Arc, shape: Shape, cleanup: bool) -> bool { let begun = if cleanup { ledger.begin_cleanup_unload(shape) } else { ledger.begin_unload(shape) }; let permit = match begun { Ok(Some(permit)) => permit, Ok(None) => return true, Err(e) => { tracing::warn!( shape = shape.label(), "audio routing: refusing module unload: {e}" ); return false; } }; match tokio::task::spawn_blocking(move || finish_verified_unload(permit)).await { Ok(confirmed_absent) => confirmed_absent, Err(e) => { // A panicking worker drops its affine permit and therefore leaves an // unload reconciliation question behind. tracing::warn!( shape = shape.label(), "audio routing: unload worker failed: {e}" ); false } } } fn finish_verified_unload(permit: ledger::UnloadPermit) -> bool { let fp = permit.fingerprint().clone(); let result = permit.with_server_operation(|| verified_unload(&fp)); match result { Ok(()) => { permit.finish(UnloadOutcome::Confirmed); true } Err(e) => { permit.finish(UnloadOutcome::Uncertain(format!("{e:#}"))); false } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UnloadPresence { Exact, Absent, Replaced, } fn unload_presence(fp: &Fingerprint, current: &[repair_plan::ModuleObservation]) -> UnloadPresence { match current.iter().find(|module| module.id == fp.id) { None => UnloadPresence::Absent, Some(observed) if fp.still_matches(observed) => UnloadPresence::Exact, Some(_) => UnloadPresence::Replaced, } } /// Re-list and unload through one verified-local Pulse connection. The exact /// fingerprint is checked immediately before destruction; if the index vanished /// or was reused, our module is already absent and the replacement is left alone. fn verified_unload(fp: &Fingerprint) -> Result<()> { let mut session = crate::repair::introspect::PulseSession::connect() .context("could not connect to verify a module unload")?; let current = session .list_modules() .context("could not list modules immediately before unload")?; match unload_presence(fp, ¤t) { UnloadPresence::Exact => {} UnloadPresence::Absent => { tracing::info!( module = fp.id, shape = fp.shape.label(), "audio routing: tracked module was already absent" ); return Ok(()); } UnloadPresence::Replaced => { tracing::warn!( module = fp.id, shape = fp.shape.label(), "audio routing: module index was reused; leaving the replacement alone" ); return Ok(()); } } session .unload_module(fp.id) .with_context(|| format!("the server did not confirm unloading module #{}", fp.id))?; tracing::info!( module = fp.id, shape = fp.shape.label(), "audio routing: unloaded verified Pulse module" ); Ok(()) } /// One bounded child result. On deadline the child is killed and synchronously /// reaped before this returns, so server reconciliation cannot overtake a late /// `pactl` request merely because its async waiter was cancelled. struct BoundedOutput { status: ExitStatus, stdout: Vec, stderr: Vec, timed_out: bool, } /// Ensures every early-return and panic after spawn kills and reaps the child. /// The normal path marks it reaped after `try_wait`/`wait` obtained the status. struct ReapedChild { child: Child, reaped: bool, } impl Drop for ReapedChild { fn drop(&mut self) { if self.reaped { return; } self.kill_group(); match self.reap_within(PACTL_REAP_BUDGET) { Ok(Some(_)) => {} Ok(None) => tracing::warn!( "audio routing: killed pactl child was not reaped within {PACTL_REAP_BUDGET:?}" ), Err(e) => tracing::warn!("audio routing: could not reap killed pactl child: {e}"), } } } impl ReapedChild { fn kill_group(&mut self) { let _ = contained::signal_group(self.child.id(), nix::sys::signal::Signal::SIGKILL); // Backstop in case the group disappeared between lookup and signal. let _ = self.child.kill(); } fn reap_within(&mut self, budget: Duration) -> io::Result> { let deadline = Instant::now() + budget; loop { if let Some(status) = self.child.try_wait()? { self.reaped = true; return Ok(Some(status)); } if Instant::now() >= deadline { return Ok(None); } std::thread::sleep(Duration::from_millis(5)); } } } fn bounded_output(command: &mut Command, budget: Duration) -> io::Result { command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = ReapedChild { child: contained::spawn(command)?, reaped: false, }; let stdout = child .child .stdout .take() .ok_or_else(|| io::Error::other("pactl stdout was not piped"))?; let stderr = child .child .stderr .take() .ok_or_else(|| io::Error::other("pactl stderr was not piped"))?; let stdout_reader = std::thread::Builder::new() .name("pixelpass-pactl-stdout".to_string()) .spawn(move || { let mut bytes = Vec::new(); let mut stdout = stdout; stdout.read_to_end(&mut bytes).map(|_| bytes) })?; let stderr_reader = std::thread::Builder::new() .name("pixelpass-pactl-stderr".to_string()) .spawn(move || { let mut bytes = Vec::new(); let mut stderr = stderr; stderr.read_to_end(&mut bytes).map(|_| bytes) })?; let deadline = Instant::now() + budget; let (status, timed_out) = loop { if let Some(status) = child.child.try_wait()? { child.reaped = true; break (status, false); } if Instant::now() >= deadline { child.kill_group(); let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else { return Err(io::Error::new( io::ErrorKind::TimedOut, format!("pactl did not exit within {PACTL_REAP_BUDGET:?} after SIGKILL"), )); }; break (status, true); } std::thread::sleep(Duration::from_millis(5)); }; let stdout = stdout_reader .join() .map_err(|_| io::Error::other("pactl stdout reader panicked"))??; let stderr = stderr_reader .join() .map_err(|_| io::Error::other("pactl stderr reader panicked"))??; Ok(BoundedOutput { status, stdout, stderr, timed_out, }) } /// Async teardown: keep reconciling and unloading while a pass changes state. /// This replaces the arbitrary two-round count. With loads closed, the state /// graph is monotonic except for `Ambiguous(Unload) -> Loaded -> Ambiguous` when /// the same unload remains uncertain; that produces an identical snapshot and /// stops here for `--repair` rather than spinning. async fn cleanup_modules(ledger: &Arc) { loop { let before = ledger.snapshot(); if ledger.is_clean() { break; } if let Err(e) = ledger::reconcile_pending(ledger).await { tracing::warn!("audio routing: could not reconcile the module ledger: {e:#}"); } for fp in ledger.loaded() { unload_module_inner(ledger, fp.shape, true).await; } if ledger.is_clean() || ledger.snapshot() == before { break; } } } /// Synchronous teardown backstop. PulseSession bounds every connect/list/unload /// request, and `close_and_wait` has already drained any registered pactl worker. fn cleanup_modules_blocking(ledger: &Arc) { loop { let before = ledger.snapshot(); if ledger.is_clean() { break; } if let Err(e) = ledger::reconcile_pending_blocking(ledger) { tracing::warn!("audio routing: could not reconcile the module ledger: {e:#}"); } for fp in ledger.loaded() { let permit = match ledger.begin_cleanup_unload(fp.shape) { Ok(Some(permit)) => permit, Ok(None) => continue, Err(e) => { tracing::warn!( shape = fp.shape.label(), "audio routing: refusing blocking module unload: {e}" ); continue; } }; finish_verified_unload(permit); } if ledger.is_clean() || ledger.snapshot() == before { break; } } } /// Parse a PipeWire `object.serial` property value. /// /// `object.serial` is a **64-bit** monotonically-increasing counter /// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id /// (those are `u32` and get recycled — the serial exists precisely so /// that recycled ids can be disambiguated). Parsing it as `u32` silently /// yields `None` past `u32::MAX`, which on a long-lived daemon means the /// sink is never registered and no stream is ever routed. /// /// Strict on purpose: PipeWire emits a bare decimal, so anything else /// (empty, signed, whitespace-padded, non-numeric, overflowing) is a /// property we do not understand and must not guess at. Leading zeroes /// are accepted — they are unambiguous and parse to the same value. pub(crate) fn parse_object_serial(raw: &str) -> Option { if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) { return None; } raw.parse::().ok() } #[cfg(test)] mod tests { use super::*; use crate::host::ledger::SlotState; use crate::repair::plan::{ModuleObservation, classify}; /// Whole-desktop routing: the graph actor owns the native sink, while the /// ledger owns only its default-monitor loopback. fn whole_desktop_opts() -> HostOpts { HostOpts { window: false, app: None, strict_audio: false, display_server: None, quality: crate::cli::Quality::Auto, bitrate: None, framerate: None, max_height: None, no_hwencode: false, max_viewers: None, interactive: false, capture_mode: crate::cli::CaptureMode::Legacy, aec: crate::host::aec::AecConfig::Off, legacy_null_sink: false, relay: None, } } #[test] fn bounded_module_worker_uses_the_contained_spawn_path() { let mut command = Command::new("sh"); command.args([ "-c", "read pid comm state ppid pgrp rest < /proc/self/stat; printf '%s %s' \"$pid\" \"$pgrp\"", ]); let output = bounded_output(&mut command, Duration::from_secs(1)) .expect("run contained module-worker fixture"); assert!(output.status.success()); let ids = String::from_utf8(output.stdout).expect("ascii pid/pgid"); let mut ids = ids.split_whitespace(); let pid = ids.next().expect("child pid"); let pgid = ids.next().expect("child process group"); assert_eq!(pid, pgid, "module worker must lead its own process group"); } /// The module table exactly as `--repair` observes it. fn module_snapshot() -> Vec<(u32, String, String)> { let mut session = crate::repair::introspect::PulseSession::connect().expect("a local Pulse server"); session .list_modules() .expect("the server lists its modules") .into_iter() .map(|m| (m.id, m.name, m.args)) .collect() } #[test] fn normal_unload_requires_the_full_fingerprint_not_only_the_index() { fn observation(id: u32, pid: u32, nonce: u64) -> ModuleObservation { let token = repair_plan::OwnerToken { machine: "abc123".to_string(), boot: "def456".to_string(), pid_ns: 4_026_531_836, nonce, }; ModuleObservation::new( id, Shape::LoopbackIntoCapture.module_name(), &repair_plan::recorded_argument( &Shape::LoopbackIntoCapture.render_args(pid, Some(&token)), ), ) } let ours = observation(5, 42, 7); let fp = classify(&ours).expect("the fixture is canonical"); assert_eq!( unload_presence(&fp, std::slice::from_ref(&ours)), UnloadPresence::Exact ); assert_eq!(unload_presence(&fp, &[]), UnloadPresence::Absent); // Same live index, but another perfectly canonical host module. This is // the non-vacuous reuse case: an id-only normal unload would destroy it. let replacement = observation(5, 99, 8); assert_eq!( unload_presence(&fp, std::slice::from_ref(&replacement)), UnloadPresence::Replaced ); } /// A/B against the live graph: routing must leave the module table exactly /// as it found it. The same shape as `--repair`'s field gate, because the /// property is the same one — nothing of ours outlives the session. #[tokio::test] #[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"] async fn live_teardown_leaves_the_module_table_as_it_found_it() { let before = module_snapshot(); let (health, _) = health::channel(); let routing = Routing::start(&whole_desktop_opts(), health) .await .expect("routing starts"); let during = module_snapshot(); let ours: Vec<_> = during .iter() .filter(|m| !before.iter().any(|b| b.0 == m.0)) .collect(); assert_eq!( ours.len(), 1, "only the default-monitor loopback is a Pulse module; the sink is native" ); for (id, name, args) in ours { let fp = classify(&ModuleObservation::new(*id, name, args)) .expect("a module we loaded must match one of our canonical forms"); assert!( fp.owner.is_some(), "every module we load carries an owner token, or --repair cannot \ attribute it to this host's pid namespace" ); } routing.shutdown().await; assert_eq!( module_snapshot(), before, "teardown must leave the module table byte-identical" ); } /// Exercise the real graph-actor command path with app routing enabled. The /// deliberately unmatched filter avoids moving an unrelated live stream. #[tokio::test] #[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"] async fn live_audio_graph_owner_stops_within_its_policy_budget() { let before = module_snapshot(); let mut opts = whole_desktop_opts(); opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string()); opts.strict_audio = true; let (health, _) = health::channel(); let routing = Routing::start(&opts, health.clone()) .await .expect("per-app routing starts"); // Let the OS thread reach its normal running phase so this covers the // tighter steady-state budget rather than only startup containment. tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::timeout(Duration::from_secs(10), routing.shutdown()) .await .expect("actor and graph teardown stay globally bounded"); assert!( health.fault().is_none(), "an observed shutdown and successful join must remain healthy" ); assert_eq!( module_snapshot(), before, "per-app teardown must leave the module table byte-identical" ); } /// The orphan race, staged against a real server: a load cancelled while /// `pactl` is in flight must still be findable and removable. /// /// Whether the server got as far as creating the module is genuinely racy, /// and that is the point — the gate does not care which way it went, only /// that the ledger can account for both. With the permit's `Drop` disarmed /// and the module created, the final comparison fails. #[tokio::test] #[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"] async fn live_a_cancelled_load_is_reconciled_not_orphaned() { let before = module_snapshot(); let ledger = ModuleLedger::new(); let pid = std::process::id(); let ledger_for_task = Arc::clone(&ledger); let task = tokio::spawn(async move { let _ = load_module(&ledger_for_task, Shape::LegacyCaptureSink, pid).await; }); // Wait until the affine permit is registered. A single yield is not a // scheduling guarantee and made the old version of this gate capable of // aborting before the task had started. tokio::time::timeout(PACTL_BUDGET, async { while matches!(ledger.state(Shape::LegacyCaptureSink), SlotState::Vacant) { tokio::task::yield_now().await; } }) .await .expect("the load registers its operation"); task.abort(); let _ = task.await; // Cancellation detaches `spawn_blocking`; closing plus this registered- // operation wait is the ordering boundary that prevents reconciliation // from overtaking the worker's late server mutation. ledger.close(); let ledger_for_wait = Arc::clone(&ledger); tokio::task::spawn_blocking(move || ledger_for_wait.wait_for_operations()) .await .expect("the operation wait runs"); // The worker either committed a known module or left a reconciliation // question. Both are correct; vacancy here would mean the live operation // disappeared from the ledger. assert_eq!( ledger.pending().len() + ledger.loaded().len(), 1, "the cancelled load must retain exactly one tracked outcome" ); cleanup_modules(&ledger).await; assert!( ledger.is_clean(), "every tracked module must be removed, not merely explained" ); assert_eq!( module_snapshot(), before, "a cancelled load must leave nothing behind" ); } #[test] fn object_serial_parses_past_u32() { // The regression this fix exists for: a serial one past `u32::MAX` // used to parse as `None` and silently disable rerouting. let beyond = u64::from(u32::MAX) + 1; assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond)); assert_eq!( parse_object_serial(&u64::MAX.to_string()), Some(u64::MAX), "the full 64-bit range must round-trip" ); } #[test] fn object_serial_accepts_ordinary_serials() { // Without this the valid cases are only 1, 10 and 20 digits long, and // a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`) // survives the whole suite while rejecting every serial a freshly // started daemon actually hands out. (Codex, round 1.) for serial in 0_u64..=1024 { assert_eq!(parse_object_serial(&serial.to_string()), Some(serial)); } assert_eq!(parse_object_serial("123456789"), Some(123_456_789)); assert_eq!( parse_object_serial("007"), Some(7), "leading zeroes are fine" ); } #[test] fn object_serial_boundary_values() { assert_eq!(parse_object_serial("0"), Some(0)); assert_eq!(parse_object_serial("1"), Some(1)); let max32 = u64::from(u32::MAX); assert_eq!(parse_object_serial(&max32.to_string()), Some(max32)); assert_eq!( parse_object_serial(&(max32 - 1).to_string()), Some(max32 - 1) ); } #[test] fn object_serial_round_trips_through_the_metadata_string() { // `try_flush` writes the serial back out as a decimal string for // `target.object`; widening must not introduce a formatting change. for raw in ["0", "4294967296", "18446744073709551615"] { let parsed = parse_object_serial(raw).expect("valid serial"); assert_eq!(parsed.to_string(), raw); } } #[test] fn object_serial_rejects_malformed() { for raw in [ "", " 12", "12 ", "+12", "-1", "1.0", "0x10", "12a", "abc", // u64::MAX + 1 — overflow must be rejected, not wrapped. "18446744073709551616", ] { assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}"); } } }