//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed //! pixelpass host. Identifies orphans by the `pixelpass_capture_` //! name pattern + dead-PID check, then unloads paired loopbacks first //! (mirrors `Routing::shutdown`'s order so PipeWire doesn't leave zombie //! links). Live PIDs — including this process and any other running //! pixelpass — are left alone. use anyhow::{Context, Result, bail}; use std::collections::HashSet; use std::path::Path; use std::process::Command; const SINK_NAME_PREFIX: &str = "pixelpass_capture_"; pub async fn run() -> Result<()> { let modules = list_modules().context("failed to list pactl modules")?; let mut dead_sinks: Vec = Vec::new(); let mut dead_pids: HashSet = HashSet::new(); let mut live_skipped: u32 = 0; for m in &modules { if m.name != "module-null-sink" { continue; } let Some(sink_name) = extract_kv(&m.args, "sink_name") else { continue; }; let Some(pid_str) = sink_name.strip_prefix(SINK_NAME_PREFIX) else { continue; }; let Ok(pid) = pid_str.parse::() else { continue; }; if is_pid_alive(pid) { live_skipped += 1; continue; } dead_pids.insert(pid); dead_sinks.push(OrphanSink { id: m.id, sink_name: sink_name.to_string(), pid, }); } let mut dead_loopbacks: Vec = Vec::new(); for m in &modules { if m.name != "module-loopback" { continue; } // A pixelpass loopback references a capture sink either as its // destination (`sink=pixelpass_capture_` — the default→null // mirror) or as its source (`source=pixelpass_capture_.monitor` // — the local monitor that lets the sharer hear the app). Match both. let Some(pid) = loopback_capture_pid(&m.args) else { continue; }; if dead_pids.contains(&pid) { dead_loopbacks.push(m.id); } } if dead_sinks.is_empty() && dead_loopbacks.is_empty() { if live_skipped > 0 { println!( "[pixelpass] --repair: nothing to clean up ({live_skipped} live pixelpass host(s) left alone)." ); } else { println!("[pixelpass] --repair: nothing to clean up."); } return Ok(()); } let mut unloaded = 0u32; let mut failed = 0u32; for id in &dead_loopbacks { match unload_module(*id) { Ok(()) => { println!("[pixelpass] --repair: unloaded loopback module #{id}"); unloaded += 1; } Err(e) => { eprintln!("[pixelpass] --repair: failed to unload loopback #{id}: {e:#}"); failed += 1; } } } for orphan in &dead_sinks { match unload_module(orphan.id) { Ok(()) => { println!( "[pixelpass] --repair: unloaded {} (orphaned from pid {})", orphan.sink_name, orphan.pid ); unloaded += 1; } Err(e) => { eprintln!( "[pixelpass] --repair: failed to unload {} (#{}): {e:#}", orphan.sink_name, orphan.id ); failed += 1; } } } if live_skipped > 0 { println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone."); } if failed > 0 { bail!("--repair: {failed} module(s) failed to unload (see errors above)"); } println!("[pixelpass] --repair: cleaned up {unloaded} module(s)."); Ok(()) } struct Module { id: u32, name: String, args: String, } struct OrphanSink { id: u32, sink_name: String, pid: u32, } fn list_modules() -> Result> { let output = Command::new("pactl") .args(["list", "short", "modules"]) .output() .context("failed to run `pactl list short modules`")?; if !output.status.success() { bail!( "pactl list short modules failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } let text = String::from_utf8(output.stdout).context("pactl returned non-UTF-8")?; let mut modules = Vec::new(); // `pactl list short modules` is tab-separated, but some modules have // multi-line `{ ... }` argument blocks that wrap onto continuation // lines starting with whitespace. The wrap lines never parse as a // u32 ID, so the simple per-line + parse-id filter is robust. for line in text.lines() { let mut parts = line.splitn(4, '\t'); let Some(id_str) = parts.next() else { continue }; let Ok(id) = id_str.parse::() else { continue; }; let Some(name) = parts.next() else { continue }; let args = parts.next().unwrap_or("").to_string(); modules.push(Module { id, name: name.to_string(), args, }); } Ok(modules) } /// The `pixelpass_capture_` PID a loopback references, whether the capture /// sink is its destination (`sink=pixelpass_capture_`) or its source /// (`source=pixelpass_capture_.monitor`). `None` for unrelated loopbacks. fn loopback_capture_pid(args: &str) -> Option { let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX)); let from_source = extract_kv(args, "source") .and_then(|v| v.strip_prefix(SINK_NAME_PREFIX)) .and_then(|rest| rest.strip_suffix(".monitor")); from_sink .or(from_source) .and_then(|pid| pid.parse::().ok()) } fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> { for token in args.split_whitespace() { if let Some(rest) = token.strip_prefix(key) && let Some(value) = rest.strip_prefix('=') { return Some(value); } } None } fn is_pid_alive(pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() } fn unload_module(id: u32) -> Result<()> { let output = Command::new("pactl") .arg("unload-module") .arg(id.to_string()) .output() .context("failed to run pactl unload-module")?; if !output.status.success() { bail!( "pactl unload-module #{id}: {}", String::from_utf8_lossy(&output.stderr).trim() ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn loopback_pid_matches_default_null_mirror_by_sink() { // The default→null loopback: capture sink is the destination. let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20"; assert_eq!(loopback_capture_pid(args), Some(4242)); } #[test] fn loopback_pid_matches_local_monitor_by_source() { // The local monitor: capture sink's monitor is the source, and the // destination is the real default sink (not a pixelpass name). let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20"; assert_eq!(loopback_capture_pid(args), Some(4242)); } #[test] fn loopback_pid_ignores_unrelated_loopback() { assert_eq!( loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"), None ); } }