X11 full-desktop capture used `ximagesrc use-damage=false`, which copies the whole root window every frame. On servers without working MIT-SHM (and CPU-bound everywhere else) this collapses to ~1 fps — a field test over an xlibre host played back at roughly one frame per minute. Default to `use-damage=true` (XDamage re-grabs only changed regions); keep `PIXELPASS_X11_NO_DAMAGE=1` as an escape hatch for driver artifacts. Also drop `--untimed` from both mpv invocations (viewer banner + the interactive launcher). `--untimed` displays each frame as it decodes and ignores audio timestamps, which drifts a shared *video* progressively out of sync with its audio. Pacing to the audio clock keeps A/V synced at a negligible latency cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
7.4 KiB
Rust
237 lines
7.4 KiB
Rust
//! `--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::{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<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;
|
|
}
|
|
// A pixelpass loopback references a capture sink either as its
|
|
// destination (`sink=pixelpass_capture_<pid>` — the default→null
|
|
// mirror) or as its source (`source=pixelpass_capture_<pid>.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<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)
|
|
}
|
|
|
|
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
|
|
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
|
|
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
|
|
fn loopback_capture_pid(args: &str) -> Option<u32> {
|
|
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::<u32>().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
|
|
);
|
|
}
|
|
}
|