diff --git a/src/repair.rs b/src/repair.rs index 8f95f43..f8a6899 100644 --- a/src/repair.rs +++ b/src/repair.rs @@ -1,12 +1,197 @@ -//! `--repair`: clean up any null sinks / loopbacks that a crashed pixelpass -//! host left behind. Phase 2 will scan PipeWire for nodes tagged with the -//! `pixelpass.session = ` property and destroy them. +//! `--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::Result; +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<()> { - eprintln!("[pixelpass] --repair: PipeWire scan not yet implemented (Phase 2)."); - eprintln!(" Run `pactl list short sinks | grep pixelpass` to spot orphans,"); - eprintln!(" and `pactl unload-module ` to remove them manually."); + 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; + } + let Some(sink) = extract_kv(&m.args, "sink") else { + continue; + }; + let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else { + continue; + }; + let Ok(pid) = pid_str.parse::() 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) +} + +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(()) }