repair: unload orphan pixelpass_capture_* sinks and paired loopbacks
Replaces the Phase-2 stub. Parses `pactl list short modules` for
`module-null-sink` entries whose `sink_name=pixelpass_capture_<pid>`
names a PID with no /proc/<pid>, and `module-loopback` entries whose
`sink=` names one of those orphan sinks. Unloads loopbacks first, then
sinks (mirrors Routing::shutdown order so PipeWire doesn't leave
zombie links).
Live PIDs — including this process and any other running pixelpass —
are skipped and reported. Same-tab parser is robust to multi-line
{ ... } argument blocks from other modules because continuation lines
never parse as a u32 module ID.
Verified with synthetic orphans against this build:
- single dead orphan (sink + loopback) → both cleaned, count = 2
- single live orphan (pid 1) → both preserved, message names the
live count
- mixed dead + live → dead pair cleaned, live pair preserved,
output reports both
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+192
-7
@@ -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 = <uuid>` property and destroy them.
|
||||
//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed
|
||||
//! pixelpass host. Identifies orphans by the `pixelpass_capture_<pid>`
|
||||
//! 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 <id>` to remove them manually.");
|
||||
let modules = list_modules().context("failed to list pactl modules")?;
|
||||
|
||||
let mut dead_sinks: Vec<OrphanSink> = Vec::new();
|
||||
let mut dead_pids: HashSet<u32> = 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::<u32>() 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<u32> = 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::<u32>() 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<Vec<Module>> {
|
||||
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::<u32>() 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user