Merge 0c step 1: --repair learns ownership, and stops parsing pactl
Nine commits, seven adversarial review rounds. The starting point was a real defect — after 0c the capture sink is connection-owned, so a dead host leaves loopbacks with no `module-null-sink` to trace its pid from, and discovery went blind rather than getting smaller. Everything after that was the review finding that the fix's foundations were softer than they looked. What landed: - Discovery derives candidate pids independently from all three module shapes, A/B-proven on the live graph against the old binary. - Recognition is exact-form only, and the matcher's templates are generated from the loader's own renderer, so the two cannot drift; anything naming our sinks that matches no known form is reported rather than silently ignored. - Observation and unloading go through libpulse introspection over one verified-local connection. `pactl`'s text output cannot carry this: a genuine module whose argument contains a newline renders a first line that is byte-exactly canonical (field-confirmed, no adversary needed), the JSON listing carries no module index at all, and `PULSE_SERVER` is a fallback list that never proved locality. - A pid is not an owner. Every module carries a machine/boot/pid-namespace token, and repair asks about a pid only when all three match — otherwise the module is reported and its pid is never even looked up. Untagged modules from older builds are refused by default, behind `--repair-legacy-untagged`. - A plan is not a licence, and neither is ordering: fingerprints are re-verified against a fresh snapshot per action, the sink unload is gated on nothing still referencing it, and liveness runs before the snapshot so a replacement arriving in that window is caught. Verified beyond the unit suite: 256 tests, the phase-5 audit re-run with and without tokens to prove the new property is inert to the taint engine, and four live field gates covering orphan removal, the reference gate, and the token's three cases. Two lessons this merge is worth remembering for: - The live field test found what unit tests structurally could not — including a drop-order bug that made a completely successful repair exit 134, which is phase 0b's invariant one layer down. - Every fix round in this branch contained a defect the next review caught. The design held; the execution shell kept slipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+39
@@ -2958,6 +2958,33 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libpulse-binding"
|
||||
version = "2.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"libpulse-sys",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libpulse-sys"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"pkg-config",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
@@ -3539,6 +3566,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -4163,6 +4201,7 @@ dependencies = [
|
||||
"iroh",
|
||||
"iroh-tickets",
|
||||
"ksni",
|
||||
"libpulse-binding",
|
||||
"nix 0.30.1",
|
||||
"notify-rust",
|
||||
"pipewire",
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ serde_json = "1"
|
||||
directories = "5"
|
||||
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
||||
pipewire = "0.9"
|
||||
# `--repair` reads and unloads Pulse modules through libpulse introspection rather
|
||||
# than by parsing `pactl` output. `pa_module_info` carries index, name and the exact
|
||||
# argument in one record, and `pa_context_is_local()` answers whether the server we
|
||||
# actually reached is local — neither of which the text listings can do (an argument
|
||||
# may contain tabs and newlines that the short format cannot escape, the JSON
|
||||
# listing carries no module index at all, and `PULSE_SERVER` is a fallback list, so
|
||||
# it never proved locality). Vetted at 2.30.1: MIT/Apache-2.0, no build script
|
||||
# beyond a pkg-config probe, no network or subprocess use, and all three historical
|
||||
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
|
||||
libpulse-binding = "2.30"
|
||||
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
iroh-tickets = "1.0.0"
|
||||
|
||||
+12
@@ -105,6 +105,18 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub repair: bool,
|
||||
|
||||
/// With `--repair`: also clean up modules that carry no ownership token,
|
||||
/// judging them by process id alone.
|
||||
///
|
||||
/// Modules loaded by pixelpass versions before ownership tokens existed cannot
|
||||
/// be attributed to a machine, boot or pid namespace, so `--repair` refuses them
|
||||
/// by default: a process id means different processes in different namespaces,
|
||||
/// and acting on the wrong one unloads a *running* host's audio. Use this only
|
||||
/// on the machine that ran the crashed host, and only when the reported
|
||||
/// candidates look right.
|
||||
#[arg(long, requires = "repair")]
|
||||
pub repair_legacy_untagged: bool,
|
||||
|
||||
/// Print an environment diagnostic report (display server, capture/encode
|
||||
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
|
||||
/// then exit. Use this to check a machine can host or view before a real
|
||||
|
||||
+51
-24
@@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::repair::plan::{self as repair_plan, Shape};
|
||||
|
||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
||||
@@ -64,9 +65,14 @@ impl Routing {
|
||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
||||
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
||||
let pid = std::process::id();
|
||||
let sink_name = format!("pixelpass_capture_{pid}");
|
||||
let sink_name = repair_plan::sink_name_for(pid);
|
||||
|
||||
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
||||
// Every module this host loads carries an ownership token, minted per
|
||||
// load, so `--repair` can tell whose pid the name refers to instead of
|
||||
// assuming the number means the same thing everywhere. Without it a repair
|
||||
// run in another pid namespace can unload a live host's audio; see
|
||||
// `repair::plan::OwnerToken`.
|
||||
let sink_module = load_module(Shape::LegacyCaptureSink, pid)
|
||||
.context("failed to load module-null-sink")?;
|
||||
|
||||
// In strict per-app mode we never mirror the default sink: the viewer
|
||||
@@ -82,13 +88,8 @@ impl Routing {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name}"),
|
||||
"latency_msec=20",
|
||||
])
|
||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
|
||||
load_module(Shape::LoopbackIntoCapture, pid)
|
||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -115,7 +116,6 @@ impl Routing {
|
||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
||||
let loopback_for_task = Arc::clone(&loopback_arc);
|
||||
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
|
||||
let sink_name_for_task = sink_name.clone();
|
||||
let strict = opts.strict_audio;
|
||||
let event_task = tokio::spawn(async move {
|
||||
use crate::common::output::{self, AppAudioState};
|
||||
@@ -137,12 +137,7 @@ impl Routing {
|
||||
// only, never the desktop/call — so it can't echo into
|
||||
// the capture.
|
||||
if local_monitor_for_task.lock().unwrap().is_none() {
|
||||
match load_module(&[
|
||||
"module-loopback",
|
||||
&format!("source={sink_name_for_task}.monitor"),
|
||||
"sink=@DEFAULT_SINK@",
|
||||
"latency_msec=20",
|
||||
]) {
|
||||
match load_module(Shape::LoopbackOutOfCapture, pid) {
|
||||
Ok(id) => {
|
||||
tracing::info!(
|
||||
module = id,
|
||||
@@ -195,12 +190,7 @@ impl Routing {
|
||||
tracing::info!(
|
||||
"audio routing: last routed stream gone → restoring default-sink loopback"
|
||||
);
|
||||
match load_module(&[
|
||||
"module-loopback",
|
||||
"source=@DEFAULT_SINK@.monitor",
|
||||
&format!("sink={sink_name_for_task}"),
|
||||
"latency_msec=20",
|
||||
]) {
|
||||
match load_module(Shape::LoopbackIntoCapture, pid) {
|
||||
Ok(id) => {
|
||||
*loopback_for_task.lock().unwrap() = Some(id);
|
||||
}
|
||||
@@ -349,10 +339,47 @@ struct SinkInputProperties {
|
||||
// pactl module helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn load_module(args: &[&str]) -> Result<u32> {
|
||||
/// 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<repair_plan::OwnerToken> {
|
||||
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.
|
||||
fn load_module(shape: Shape, pid: u32) -> Result<u32> {
|
||||
let owner = owner_token(pid).context("could not build an audio ownership token")?;
|
||||
let output = Command::new("pactl")
|
||||
.arg("load-module")
|
||||
.args(args)
|
||||
.arg(shape.module_name())
|
||||
.args(shape.render_args(pid, Some(&owner)))
|
||||
.output()
|
||||
.context("failed to run pactl load-module")?;
|
||||
if !output.status.success() {
|
||||
|
||||
@@ -2683,5 +2683,12 @@ fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() {
|
||||
client_members >= 2,
|
||||
"both ambiguous-id clients should be remembered: {sticky:#?}"
|
||||
);
|
||||
let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox);
|
||||
let _ = (
|
||||
ClientSnapshot {
|
||||
serial: Serial(0),
|
||||
id: GlobalId(0),
|
||||
sec_pid: None,
|
||||
},
|
||||
firefox,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ async fn main() -> Result<()> {
|
||||
pipewire::init();
|
||||
|
||||
if cli.repair {
|
||||
return repair::run().await;
|
||||
return repair::run(cli.repair_legacy_untagged).await;
|
||||
}
|
||||
|
||||
// Read-only diagnostic: observe the graph, report what the audio-exclusion
|
||||
|
||||
-236
@@ -1,236 +0,0 @@
|
||||
//! `--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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Structured Pulse introspection: the observation and destruction layer for
|
||||
//! `--repair`.
|
||||
//!
|
||||
//! # Why this replaced parsing `pactl`
|
||||
//!
|
||||
//! Three separate defects, all of them consequences of reading a human-oriented
|
||||
//! text format rather than the protocol:
|
||||
//!
|
||||
//! 1. **Record boundaries were unprovable.** `pactl list short modules` prints a
|
||||
//! module's argument raw into a tab-and-newline-delimited format with no
|
||||
//! escaping. A *genuine* module whose argument contains a newline — say
|
||||
//! `…latency_msec=20\nremix=false`, and `remix` is a real loopback option —
|
||||
//! renders a first line that reads byte-exactly like one of our canonical
|
||||
//! forms, with the remainder dropped as an unparseable continuation. No index
|
||||
//! is forged, so no duplicate-index check can see it: repair would classify and
|
||||
//! unload a module it had never actually seen in full. A tab in the same
|
||||
//! position instead hides a sink reference, which is worse, because the gate
|
||||
//! that protects a still-referenced sink then cannot see the reference.
|
||||
//! 2. **Index and argument could be mis-paired.** The one listing that carries the
|
||||
//! exact argument (`-f json`) carries **no index** at all on pactl 17, and the
|
||||
//! one that carries the index cannot carry the argument faithfully. Combining
|
||||
//! them by position is unsound whenever module names repeat: another client
|
||||
//! loading one module and unloading another between the two calls leaves the
|
||||
//! counts and names aligned while every argument has shifted by one.
|
||||
//! 3. **Locality was a guess.** `PULSE_SERVER` is a *fallback list*, so
|
||||
//! `unix:/missing tcp:remote:4713` passes any "starts with unix:" test and then
|
||||
//! connects to another machine — where our local pids mean nothing and a live
|
||||
//! remote host's modules look dead.
|
||||
//!
|
||||
//! `pa_module_info` carries index, name and argument together in one structured
|
||||
//! record, so (1) and (2) cannot arise. `pa_context_is_local()` answers (3) about
|
||||
//! the connection that actually got established rather than about a string we
|
||||
//! hoped described it. And because unloading goes back through the *same*
|
||||
//! connection, there is no window in which listing and destruction could disagree
|
||||
//! about which server they are talking to.
|
||||
//!
|
||||
//! # What is deliberately not here
|
||||
//!
|
||||
//! No decisions. This module observes and destroys; every judgement about what may
|
||||
//! be destroyed lives in [`super::plan`], which is pure and needs no Pulse server
|
||||
//! to test. The one policy this layer owns is *refusing to talk to the wrong
|
||||
//! server at all*.
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use libpulse_binding::callbacks::ListResult;
|
||||
use libpulse_binding::context::{Context, FlagSet as ContextFlagSet, State as ContextState};
|
||||
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
|
||||
use libpulse_binding::operation::{Operation, State as OperationState};
|
||||
use libpulse_binding::proplist::{Proplist, properties};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::plan::ModuleObservation;
|
||||
|
||||
/// How long to wait for the connection to reach `Ready`. A one-shot CLI must not
|
||||
/// hang on an unresponsive server; failing closed here costs the user a re-run.
|
||||
const CONNECT_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long any single introspection request may take.
|
||||
///
|
||||
/// ⚠️ On timeout the `Operation` wrapper is dropped while still running. In
|
||||
/// libpulse-binding 2.30.1 that only unrefs the C operation — the boxed callback
|
||||
/// and the `Rc`s it captured leak until the context cancels the operation at
|
||||
/// disconnect. That is bounded and harmless *here*, because `--repair` is a
|
||||
/// one-shot process that exits immediately afterwards, and it cannot become a
|
||||
/// use-after-free (the closure owns its clones, and the context clears callbacks
|
||||
/// before the mainloop is touched). **It would not be acceptable in the long-lived
|
||||
/// host**, so this module must not be reused for host-side loading until that
|
||||
/// binding bug is fixed or worked around; `op.cancel()` does not help.
|
||||
const REQUEST_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long to sleep between mainloop iterations while waiting. Non-blocking
|
||||
/// iteration plus a short sleep keeps the deadline enforceable, which
|
||||
/// `iterate(true)` would not.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(2);
|
||||
|
||||
/// A live, verified-local connection to the Pulse server.
|
||||
///
|
||||
/// Both listing and unloading run through this one connection, so everything
|
||||
/// repair sees and everything it destroys provably belong to the same server.
|
||||
///
|
||||
/// ⚠️ **Field order is load-bearing, and this was not theoretical.** Rust drops
|
||||
/// fields in declaration order, and the context's teardown frees IO events that
|
||||
/// live *in* the mainloop. With `mainloop` declared first, `--repair` did its work
|
||||
/// correctly and then died on the way out:
|
||||
///
|
||||
/// ```text
|
||||
/// Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
|
||||
/// function mainloop_io_free(). Aborting.
|
||||
/// ```
|
||||
///
|
||||
/// SIGABRT, a core dump, and exit 134 — so a completely successful repair reported
|
||||
/// failure to its caller. This is the same invariant phase 0b's
|
||||
/// `ScreenshareTeardown` exists for, met again one layer down.
|
||||
///
|
||||
/// Rather than leave that resting on where the fields happen to be written, [`Drop`]
|
||||
/// **explicitly** takes and drops the context first, so the ordering survives a
|
||||
/// future reorder of this struct. The declaration order below is still correct, and
|
||||
/// now it is also not load-bearing.
|
||||
pub struct PulseSession {
|
||||
/// `Option` only so that `Drop` can `take()` it and destroy it *before* the
|
||||
/// mainloop. Always `Some` for the whole of the session's usable life.
|
||||
context: Option<Context>,
|
||||
mainloop: Mainloop,
|
||||
}
|
||||
|
||||
impl Drop for PulseSession {
|
||||
fn drop(&mut self) {
|
||||
// Disconnect, then destroy the context while the mainloop it registered its
|
||||
// IO events with is still alive. The mainloop then drops after us.
|
||||
//
|
||||
// Nothing is drained afterwards on purpose. An earlier version iterated the
|
||||
// mainloop a few times here to "let teardown settle", which was a ritual
|
||||
// rather than a barrier: a fixed number of non-blocking polls cannot
|
||||
// guarantee that any particular event became ready. It is also unnecessary —
|
||||
// PulseAudio's context unlink cancels outstanding operations and removes the
|
||||
// context's socket machinery synchronously, so by the time `drop(context)`
|
||||
// returns there is no obligation left for the mainloop to service.
|
||||
if let Some(mut context) = self.context.take() {
|
||||
context.disconnect();
|
||||
drop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PulseSession {
|
||||
/// The live context. Infallible in practice: only `Drop` ever clears it, and
|
||||
/// nothing can call this afterwards.
|
||||
fn context(&mut self) -> &mut Context {
|
||||
self.context
|
||||
.as_mut()
|
||||
.expect("the context is only taken during Drop")
|
||||
}
|
||||
/// Connect, wait for readiness, and refuse anything but a local server.
|
||||
pub fn connect() -> Result<Self> {
|
||||
let mut proplist = Proplist::new().context("could not allocate a Pulse proplist")?;
|
||||
// `set_str` fails only on an invalid key, and these keys are constants.
|
||||
let _ = proplist.set_str(properties::APPLICATION_NAME, "pixelpass --repair");
|
||||
let _ = proplist.set_str(properties::APPLICATION_ID, "xyz.pixelpass.repair");
|
||||
|
||||
let mut mainloop = Mainloop::new().context("could not create a Pulse mainloop")?;
|
||||
let mut context = Context::new_with_proplist(&mainloop, "pixelpass --repair", &proplist)
|
||||
.context("could not create a Pulse context")?;
|
||||
context
|
||||
.connect(None, ContextFlagSet::NOFLAGS, None)
|
||||
.context("could not connect to the Pulse server")?;
|
||||
|
||||
let deadline = Instant::now() + CONNECT_BUDGET;
|
||||
loop {
|
||||
iterate_once(&mut mainloop)?;
|
||||
match context.get_state() {
|
||||
ContextState::Ready => break,
|
||||
ContextState::Failed => {
|
||||
bail!("the Pulse server refused the connection");
|
||||
}
|
||||
ContextState::Terminated => {
|
||||
bail!("the Pulse connection terminated before it was ready");
|
||||
}
|
||||
_ => {
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"the Pulse server did not become ready within {:?}",
|
||||
CONNECT_BUDGET
|
||||
);
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The connection is established, so this is about the server we actually
|
||||
// reached — not about what a server string appeared to promise. A remote
|
||||
// server's module table belongs to another machine's processes, where our
|
||||
// pids mean nothing, so repair must not touch it.
|
||||
match context.is_local() {
|
||||
Some(true) => {}
|
||||
Some(false) => bail!(
|
||||
"connected to a REMOTE Pulse server; --repair only ever operates on the local \
|
||||
server, because it decides what to unload from local process liveness"
|
||||
),
|
||||
None => bail!(
|
||||
"could not determine whether the Pulse server is local; refusing to unload \
|
||||
anything"
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
context: Some(context),
|
||||
mainloop,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every loaded module, with its exact argument.
|
||||
pub fn list_modules(&mut self) -> Result<Vec<ModuleObservation>> {
|
||||
// `Rc<RefCell<…>>` because the callback is owned by the C library and may
|
||||
// be invoked many times before the operation completes.
|
||||
let collected: Rc<RefCell<Vec<ModuleObservation>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let failed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||
|
||||
let sink = Rc::clone(&collected);
|
||||
let error_flag = Rc::clone(&failed);
|
||||
let op = self
|
||||
.context()
|
||||
.introspect()
|
||||
.get_module_info_list(move |result| match result {
|
||||
ListResult::Item(info) => {
|
||||
// A module with no name is not one we can identify, and an
|
||||
// argumentless module is simply one loaded without arguments.
|
||||
let name = info.name.as_deref().unwrap_or_default();
|
||||
let args = info.argument.as_deref().unwrap_or_default();
|
||||
sink.borrow_mut()
|
||||
.push(ModuleObservation::new(info.index, name, args));
|
||||
}
|
||||
ListResult::End => {}
|
||||
ListResult::Error => *error_flag.borrow_mut() = true,
|
||||
});
|
||||
|
||||
self.run_to_completion(op, "list modules")?;
|
||||
if *failed.borrow() {
|
||||
bail!("the Pulse server returned an error while listing modules");
|
||||
}
|
||||
|
||||
let modules = collected.borrow().clone();
|
||||
// Impossible per the protocol — an index identifies one module — so this is
|
||||
// a sanity check on external input, not a safety boundary. It fails closed
|
||||
// because an ambiguous index is one we could unload wrongly.
|
||||
for (i, module) in modules.iter().enumerate() {
|
||||
if modules[..i].iter().any(|earlier| earlier.id == module.id) {
|
||||
bail!(
|
||||
"the Pulse server reported module index #{} twice; refusing to unload \
|
||||
anything",
|
||||
module.id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// Unload one module, over the same connection it was observed on.
|
||||
pub fn unload_module(&mut self, index: u32) -> Result<()> {
|
||||
let succeeded: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
|
||||
let outcome = Rc::clone(&succeeded);
|
||||
let op = self
|
||||
.context()
|
||||
.introspect()
|
||||
.unload_module(index, move |success| *outcome.borrow_mut() = Some(success));
|
||||
|
||||
self.run_to_completion(op, "unload module")?;
|
||||
match *succeeded.borrow() {
|
||||
Some(true) => Ok(()),
|
||||
Some(false) => bail!("the Pulse server rejected unloading module #{index}"),
|
||||
// The operation completed without the callback running, which we cannot
|
||||
// read as success.
|
||||
None => bail!("no result was reported for unloading module #{index}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the mainloop until `op` finishes, or the budget expires.
|
||||
fn run_to_completion<T: ?Sized>(&mut self, op: Operation<T>, what: &str) -> Result<()> {
|
||||
let deadline = Instant::now() + REQUEST_BUDGET;
|
||||
loop {
|
||||
iterate_once(&mut self.mainloop)?;
|
||||
match op.get_state() {
|
||||
OperationState::Done => return Ok(()),
|
||||
OperationState::Cancelled => {
|
||||
bail!("the Pulse server cancelled the request to {what}");
|
||||
}
|
||||
OperationState::Running => {
|
||||
// A connection that dies mid-request would otherwise be waited
|
||||
// out to the full budget.
|
||||
match self.context().get_state() {
|
||||
ContextState::Ready => {}
|
||||
state => {
|
||||
bail!("the Pulse connection became {state:?} while trying to {what}")
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("the Pulse server did not {what} within {REQUEST_BUDGET:?}");
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One non-blocking mainloop iteration, with quit and error surfaced as errors.
|
||||
fn iterate_once(mainloop: &mut Mainloop) -> Result<()> {
|
||||
match mainloop.iterate(false) {
|
||||
IterateResult::Success(_) => Ok(()),
|
||||
IterateResult::Quit(code) => {
|
||||
bail!("the Pulse mainloop quit unexpectedly (code {})", code.0)
|
||||
}
|
||||
IterateResult::Err(e) => Err(e).context("the Pulse mainloop failed"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
//! `--repair`: clean up the Pulse modules left behind by a crashed pixelpass
|
||||
//! host.
|
||||
//!
|
||||
//! All of the judgement lives in [`plan`], which is pure. What remains here is
|
||||
//! I/O plus the two rules that cannot be expressed in a plan:
|
||||
//!
|
||||
//! - **Re-verify immediately before destroying anything.** Pulse module indices
|
||||
//! are reused verbatim, and a host can die (or come back) between the snapshot
|
||||
//! and the unload, so the plan is treated as evidence that expires — never as a
|
||||
//! licence.
|
||||
//! - **Gate the sink on what is still attached to it**, not on the plan's ordering
|
||||
//! having succeeded. An unload can fail or be skipped, and a loopback can appear
|
||||
//! after the plan was made.
|
||||
//!
|
||||
//! Repair never touches native PipeWire nodes. Since phase 0c the capture sink is
|
||||
//! connection-owned and removes itself when its host dies, so there is nothing
|
||||
//! there for repair to do and no safe way for it to help.
|
||||
//!
|
||||
//! # Where the observations come from
|
||||
//!
|
||||
//! Structured Pulse introspection over one verified-local connection — see
|
||||
//! [`introspect`], which also documents the three defects that parsing `pactl`'s
|
||||
//! text output turned out to have. Listing *and* unloading both go through that
|
||||
//! same connection.
|
||||
|
||||
pub mod introspect;
|
||||
pub mod plan;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::path::Path;
|
||||
|
||||
use introspect::PulseSession;
|
||||
use plan::{Fingerprint, Liveness, Shape};
|
||||
|
||||
pub async fn run(clean_untagged: bool) -> Result<()> {
|
||||
let liveness = LivenessProbe::new();
|
||||
if let Some(reason) = liveness.degraded_reason() {
|
||||
// Scoped deliberately: modules carrying a token that matches this machine,
|
||||
// boot and pid namespace are still cleaned, because the token establishes
|
||||
// what these signals can only guess at. Saying "refusing to unload
|
||||
// anything" here would be false in exactly the container-recovery case the
|
||||
// token was added for.
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: cannot independently determine process liveness \
|
||||
({reason}); modules WITHOUT an ownership token will be left alone."
|
||||
);
|
||||
}
|
||||
|
||||
let local = local_identity().context("could not establish this process's own identity")?;
|
||||
let policy = plan::Policy {
|
||||
local,
|
||||
untagged: if clean_untagged {
|
||||
plan::UntaggedPolicy::CleanByPidAlone
|
||||
} else {
|
||||
plan::UntaggedPolicy::Refuse
|
||||
},
|
||||
};
|
||||
if clean_untagged {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: --repair-legacy-untagged given; untagged modules will be \
|
||||
judged by process id ALONE. That is only safe on the machine and in the pid \
|
||||
namespace that ran the crashed host."
|
||||
);
|
||||
}
|
||||
|
||||
let mut pulse = PulseSession::connect().context("could not observe the Pulse module table")?;
|
||||
let modules = pulse
|
||||
.list_modules()
|
||||
.context("could not list Pulse modules")?;
|
||||
|
||||
// Say so loudly when something names our sinks but matches no shape we know:
|
||||
// that is either a third party using our names, or a newer pixelpass whose
|
||||
// modules this build cannot recognise. The second is how repair would go
|
||||
// silently blind, so it never gets inferred from a clean exit.
|
||||
let unrecognised = plan::unrecognised_pixelpass_modules(&modules);
|
||||
if !unrecognised.is_empty() {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: {} module(s) name a pixelpass capture sink but do not match \
|
||||
any shape this build knows; they are being LEFT ALONE:",
|
||||
unrecognised.len()
|
||||
);
|
||||
for obs in &unrecognised {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: #{} {} {}",
|
||||
obs.id, obs.name, obs.args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let planned = plan::plan(&modules, &policy, |pid, attribution| {
|
||||
liveness_for(&liveness, attribution, pid)
|
||||
});
|
||||
|
||||
// Ours by shape, but carrying no proof of whose pid they name. Never unloaded by
|
||||
// default — listed, so an explicit legacy run has something to look at first.
|
||||
if !planned.untagged.is_empty() {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: {} module(s) are pixelpass's but carry no ownership token, so \
|
||||
the process id in their name cannot be attributed to this machine or pid namespace. \
|
||||
LEFT ALONE. Re-run with --repair-legacy-untagged to clean them by pid alone:",
|
||||
planned.untagged.len()
|
||||
);
|
||||
for fp in &planned.untagged {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: #{} {} (claims pid {})",
|
||||
fp.id,
|
||||
fp.shape.label(),
|
||||
fp.pid
|
||||
);
|
||||
}
|
||||
}
|
||||
// Tokened, but the token belongs to another machine, boot or namespace.
|
||||
if !planned.foreign.is_empty() {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: {} module(s) belong to another machine, boot or pid namespace; \
|
||||
their process ids mean nothing here. LEFT ALONE:",
|
||||
planned.foreign.len()
|
||||
);
|
||||
for fp in &planned.foreign {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: #{} {} (claims pid {})",
|
||||
fp.id,
|
||||
fp.shape.label(),
|
||||
fp.pid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if planned.is_empty() {
|
||||
let mut held = Vec::new();
|
||||
if !planned.live_pids.is_empty() {
|
||||
held.push(format!(
|
||||
"{} live pixelpass host(s)",
|
||||
planned.live_pids.len()
|
||||
));
|
||||
}
|
||||
if !planned.unknown_pids.is_empty() {
|
||||
held.push(format!(
|
||||
"{} pid(s) of undeterminable liveness",
|
||||
planned.unknown_pids.len()
|
||||
));
|
||||
}
|
||||
if held.is_empty() {
|
||||
println!("[pixelpass] --repair: nothing to clean up.");
|
||||
} else {
|
||||
println!(
|
||||
"[pixelpass] --repair: nothing to clean up ({} left alone).",
|
||||
held.join(", ")
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut unloaded = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for fp in &planned.unload {
|
||||
// ORDER MATTERS, and it is the opposite of what reads naturally.
|
||||
//
|
||||
// Liveness is asked FIRST, and the fresh snapshot is taken AFTER it. The
|
||||
// tempting order — verify the module, then check liveness, then unload —
|
||||
// leaves the dangerous window wide open: between `kill` returning ESRCH and
|
||||
// the unload, this process can be descheduled long enough for the planned
|
||||
// module to vanish, a new host to inherit both the pid and the module index,
|
||||
// and its differently-nonced arguments to occupy that index. Nothing would
|
||||
// re-read those arguments, so the reused index gets unloaded.
|
||||
//
|
||||
// Asking liveness first and re-verifying the fingerprint after it means a
|
||||
// replacement arriving in that window is caught by the argument comparison,
|
||||
// and only the irreducible snapshot-to-unload interval remains.
|
||||
match attributed_liveness(&liveness, fp) {
|
||||
Liveness::Dead => {}
|
||||
Liveness::Alive => {
|
||||
println!(
|
||||
"[pixelpass] --repair: pid {} is alive again; leaving module #{} alone",
|
||||
fp.pid, fp.id
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Liveness::Unknown => {
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: pid {}'s liveness became undeterminable; \
|
||||
leaving module #{} alone",
|
||||
fp.pid, fp.id
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh snapshot per action, taken after the liveness answer. Deliberately
|
||||
// not hoisted out of the loop: each unload changes the module table, and the
|
||||
// point is to decide against the table as it is *now*.
|
||||
let current = pulse
|
||||
.list_modules()
|
||||
.context("could not re-list Pulse modules")?;
|
||||
let Some(obs) = current.iter().find(|m| m.id == fp.id) else {
|
||||
println!(
|
||||
"[pixelpass] --repair: module #{} is already gone; skipping",
|
||||
fp.id
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if !fp.still_matches(obs) {
|
||||
// The index now names something else, or the same module's
|
||||
// arguments changed. Either way we no longer know what we would be
|
||||
// destroying, so we do not destroy it.
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: module #{} no longer matches what was planned \
|
||||
(index reused?); refusing to unload it",
|
||||
fp.id
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// The sink goes last in the plan, but "last" is not the same as "nothing
|
||||
// is attached any more": a loopback unload may have failed or been
|
||||
// skipped, or a new one may have arrived since. Ask the fresh snapshot.
|
||||
if fp.shape == Shape::LegacyCaptureSink
|
||||
&& let Some(holder) = plan::sink_still_referenced(¤t, fp.pid, fp.id)
|
||||
{
|
||||
eprintln!(
|
||||
"[pixelpass] --repair: module #{} (capture sink for pid {}) is still referenced \
|
||||
by module #{}; leaving the sink loaded",
|
||||
fp.id, fp.pid, holder
|
||||
);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match pulse.unload_module(fp.id) {
|
||||
Ok(()) => {
|
||||
println!("[pixelpass] --repair: {}", describe(fp));
|
||||
unloaded += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[pixelpass] --repair: failed to unload #{}: {e:#}", fp.id);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !planned.live_pids.is_empty() {
|
||||
println!(
|
||||
"[pixelpass] --repair: left {} live pixelpass host(s) alone.",
|
||||
planned.live_pids.len()
|
||||
);
|
||||
}
|
||||
if !planned.unknown_pids.is_empty() {
|
||||
println!(
|
||||
"[pixelpass] --repair: left {} pid(s) alone whose liveness could not be determined.",
|
||||
planned.unknown_pids.len()
|
||||
);
|
||||
}
|
||||
if skipped > 0 {
|
||||
// Deliberately not "changed under us": a skip can also mean the module is
|
||||
// still referenced, or its owner's liveness stopped being decidable. The
|
||||
// per-module reason was printed above.
|
||||
println!("[pixelpass] --repair: skipped {skipped} module(s) (reasons above).");
|
||||
}
|
||||
if failed > 0 {
|
||||
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
||||
}
|
||||
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(fp: &Fingerprint) -> String {
|
||||
format!(
|
||||
"unloaded {} #{} (orphaned from pid {})",
|
||||
fp.shape.label(),
|
||||
fp.id,
|
||||
fp.pid
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask liveness with the module's *attribution* in hand.
|
||||
///
|
||||
/// A module whose token matches this machine, boot and pid namespace has already
|
||||
/// proven that its pid is a number meaningful here — that is the token's entire
|
||||
/// job. Running such a module through the probe's degradation checks would defeat
|
||||
/// it in exactly the situation it exists for: a host crashing inside a container
|
||||
/// leaves a token that matches perfectly, while a container marker or a multi-entry
|
||||
/// `NSpid` makes the probe answer `Unknown` for everything, so token-qualified
|
||||
/// repair would do nothing precisely where it is now safe.
|
||||
///
|
||||
/// The degradation signals therefore guard only the *untagged* path, where a bare
|
||||
/// pid is all there is and those signals are the only protection left.
|
||||
fn liveness_for(probe: &LivenessProbe, attribution: plan::Attribution, pid: u32) -> Liveness {
|
||||
match attribution {
|
||||
plan::Attribution::Tokened => probe.of_attributed(pid),
|
||||
plan::Attribution::Untagged => probe.of(pid),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same rule, for a fingerprint at execution time.
|
||||
fn attributed_liveness(probe: &LivenessProbe, fp: &Fingerprint) -> Liveness {
|
||||
let attribution = match fp.owner {
|
||||
Some(_) => plan::Attribution::Tokened,
|
||||
None => plan::Attribution::Untagged,
|
||||
};
|
||||
liveness_for(probe, attribution, fp.pid)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Identity
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// This process's machine, boot and pid-namespace identity.
|
||||
///
|
||||
/// Read from the kernel and the system, never guessed: without all three, a token
|
||||
/// cannot be compared and no module can be attributed. Dashes are stripped so every
|
||||
/// component is safe inside a single unquoted Pulse property value.
|
||||
pub fn local_identity() -> Result<plan::LocalIdentity> {
|
||||
let machine = read_identity_file("/etc/machine-id")
|
||||
.or_else(|_| read_identity_file("/var/lib/dbus/machine-id"))
|
||||
.context("could not read a machine id")?;
|
||||
let boot = read_identity_file("/proc/sys/kernel/random/boot_id")
|
||||
.context("could not read the boot id")?;
|
||||
let pid_ns = pid_namespace_id().context("could not read this process's pid namespace")?;
|
||||
Ok(plan::LocalIdentity {
|
||||
machine,
|
||||
boot,
|
||||
pid_ns,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_identity_file(path: &str) -> Result<String> {
|
||||
let raw = std::fs::read_to_string(path).with_context(|| format!("could not read {path}"))?;
|
||||
let cleaned: String = raw
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_hexdigit())
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
bail!("{path} held no usable identity");
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
/// The inode of `/proc/self/ns/pid` — the kernel's identity for a pid namespace.
|
||||
///
|
||||
/// This is the value that makes a pid meaningful: two processes in different pid
|
||||
/// namespaces can hold the same number, and only this distinguishes them.
|
||||
fn pid_namespace_id() -> Result<u64> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta =
|
||||
std::fs::metadata("/proc/self/ns/pid").context("could not stat /proc/self/ns/pid")?;
|
||||
Ok(meta.ino())
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Liveness
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Answers "is this pid still around", and knows when it must refuse to answer.
|
||||
///
|
||||
/// **An error is not an absence.** `Path::exists()` folds permission errors and a
|
||||
/// missing `/proc` into `false`, which here would read as "dead, go ahead and
|
||||
/// unload". Liveness is asked with `kill(pid, 0)` instead, where `EPERM` *proves*
|
||||
/// existence.
|
||||
///
|
||||
/// # The limit of what this can prove, stated rather than papered over
|
||||
///
|
||||
/// A pid can be alive and invisible. Inside a pid namespace — a container, a
|
||||
/// distrobox — `/proc/self` is perfectly visible while every process in the
|
||||
/// *parent* namespace is not, and `hidepid` has the same self-visible,
|
||||
/// others-invisible shape. Repair in such a place can reach the host's Pulse
|
||||
/// socket, see a live host's modules, get `ESRCH` for its pid and unload a running
|
||||
/// host's audio.
|
||||
///
|
||||
/// The signals below are **negative** ones: they detect *some* cases where pid
|
||||
/// numbers cannot be trusted, and every one of them fails closed. What they cannot
|
||||
/// do is prove the converse. `NSpid` reports this process's pid in each namespace
|
||||
/// that its procfs can see, and its leftmost value is relative to the pid namespace
|
||||
/// that mounted that procfs — so a nested namespace with its own `/proc` reports a
|
||||
/// single entry quite legitimately. `NSpid > 1` therefore means "definitely
|
||||
/// nested", while `NSpid == 1` means only "not detectably nested".
|
||||
///
|
||||
/// Closing that properly needs the module itself to carry an owner token (machine
|
||||
/// and boot identity plus pid-namespace identity) written at load time, with
|
||||
/// token-less modules treated as `Unknown`. That changes what pixelpass writes into
|
||||
/// the graph and how far back `--repair` can clean up, so it is a design decision
|
||||
/// recorded in the impl plan rather than guessed at here.
|
||||
struct LivenessProbe {
|
||||
/// `None` when no signal says pid numbers are untrustworthy; `Some(reason)`
|
||||
/// when every answer must be [`Liveness::Unknown`].
|
||||
degraded: Option<String>,
|
||||
}
|
||||
|
||||
impl LivenessProbe {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
degraded: Self::detect_degradation(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_degradation() -> Option<String> {
|
||||
// Locality is deliberately NOT checked here. `PULSE_SERVER` is a fallback
|
||||
// *list*, so `unix:/missing tcp:remote:4713` starts with "unix:" and still
|
||||
// connects to another machine, and a remote server can be selected by client
|
||||
// configuration with the variable unset entirely. The authoritative answer
|
||||
// comes from `pa_context_is_local()` on the connection that actually got
|
||||
// established — see `introspect::PulseSession::connect`.
|
||||
match std::fs::read_to_string("/proc/self/status") {
|
||||
Ok(status) => {
|
||||
let nspid = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("NSpid:"))
|
||||
.map(|rest| rest.split_whitespace().count());
|
||||
match nspid {
|
||||
Some(n) if n > 1 => {
|
||||
return Some(format!(
|
||||
"this process is in a nested pid namespace (NSpid has {n} entries), \
|
||||
so pids in module names may belong to processes it cannot see"
|
||||
));
|
||||
}
|
||||
// NB: a single entry is not proof of the initial namespace — see
|
||||
// the type's doc comment. It only means nothing detected it.
|
||||
// A kernel too old to report NSpid cannot rule nesting out.
|
||||
None => {
|
||||
return Some(
|
||||
"/proc/self/status does not report NSpid, so pid-namespace identity \
|
||||
cannot be established"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Err(e) => return Some(format!("/proc/self/status could not be read: {e}")),
|
||||
}
|
||||
// Belt and braces: container runtimes that leave a marker.
|
||||
for marker in ["/run/.containerenv", "/.dockerenv"] {
|
||||
if Path::new(marker).try_exists().unwrap_or(false) {
|
||||
return Some(format!("{marker} exists, so this is a container"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn degraded_reason(&self) -> Option<&str> {
|
||||
self.degraded.as_deref()
|
||||
}
|
||||
|
||||
/// Liveness for a pid this process has **no** independent reason to trust —
|
||||
/// an untagged module. Here the degradation signals are the only protection.
|
||||
fn of(&self, pid: u32) -> Liveness {
|
||||
if self.degraded.is_some() {
|
||||
return Liveness::Unknown;
|
||||
}
|
||||
self.of_attributed(pid)
|
||||
}
|
||||
|
||||
/// Liveness for a pid already proven to belong to this machine, boot and pid
|
||||
/// namespace by an [`plan::OwnerToken`].
|
||||
///
|
||||
/// The degradation checks are deliberately skipped: they exist to guess at
|
||||
/// whether a bare pid is meaningful, and here that is not a guess any more.
|
||||
fn of_attributed(&self, pid: u32) -> Liveness {
|
||||
// `kill(0, …)` signals our whole process group and a negative pid signals
|
||||
// another group, so neither may ever reach `kill`. Neither is a pid we
|
||||
// could have written into a sink name anyway.
|
||||
if pid == 0 || pid > i32::MAX as u32 {
|
||||
return Liveness::Unknown;
|
||||
}
|
||||
match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None) {
|
||||
Ok(()) => Liveness::Alive,
|
||||
// The process exists; we merely may not signal it.
|
||||
Err(nix::errno::Errno::EPERM) => Liveness::Alive,
|
||||
Err(nix::errno::Errno::ESRCH) => Liveness::Dead,
|
||||
Err(_) => Liveness::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The `pactl`-text parser that used to live here is gone, and so are its
|
||||
// tests: `introspect` gets index, name and argument as structured fields, so
|
||||
// there is no format left to mis-parse. What replaced those tests is the live
|
||||
// field gate, since the remaining risk is in talking to the server, which no
|
||||
// unit test can exercise. The decisions all live in `plan`, which is pure and
|
||||
// tested there.
|
||||
|
||||
/// The probe must never say `Dead` when it cannot see the whole pid space, and
|
||||
/// must never ask `kill` about a pid that would signal something other than one
|
||||
/// process.
|
||||
#[test]
|
||||
fn a_degraded_probe_never_reports_dead() {
|
||||
let degraded = LivenessProbe {
|
||||
degraded: Some("test".to_string()),
|
||||
};
|
||||
assert_eq!(degraded.of(1), Liveness::Unknown);
|
||||
assert_eq!(degraded.of(u32::MAX), Liveness::Unknown);
|
||||
|
||||
let probe = LivenessProbe::new();
|
||||
// Our own pid is alive by construction — unless this test itself runs
|
||||
// somewhere the probe must abstain, which is exactly the other branch.
|
||||
let me = std::process::id();
|
||||
match probe.degraded_reason() {
|
||||
None => assert_eq!(probe.of(me), Liveness::Alive),
|
||||
Some(_) => assert_eq!(probe.of(me), Liveness::Unknown),
|
||||
}
|
||||
// `kill(0, …)` would signal our whole process group, and a pid past
|
||||
// `i32::MAX` cannot be expressed to `kill` at all.
|
||||
assert_eq!(probe.of(0), Liveness::Unknown);
|
||||
assert_eq!(probe.of(u32::MAX), Liveness::Unknown);
|
||||
}
|
||||
|
||||
/// A token proves the pid is meaningful here, so the probe's namespace
|
||||
/// guesswork must not veto it. Without this, a host crashing inside a
|
||||
/// container leaves a perfectly matching token while a container marker makes
|
||||
/// every answer `Unknown` — and token-qualified repair does nothing in exactly
|
||||
/// the situation the token was built for.
|
||||
///
|
||||
/// This runs against a *degraded* probe deliberately: on an ordinary desktop
|
||||
/// the two paths agree, so a test using the real probe's state would pass
|
||||
/// whether or not the distinction exists.
|
||||
#[test]
|
||||
fn a_token_beats_the_degradation_signals_but_a_bare_pid_does_not() {
|
||||
let degraded = LivenessProbe {
|
||||
degraded: Some("pretending to be in a container".to_string()),
|
||||
};
|
||||
let me = std::process::id();
|
||||
|
||||
assert_eq!(
|
||||
degraded.of_attributed(me),
|
||||
Liveness::Alive,
|
||||
"an attributed pid must still be answered when the probe is degraded"
|
||||
);
|
||||
assert_eq!(
|
||||
degraded.of(me),
|
||||
Liveness::Unknown,
|
||||
"a bare pid must not be, since the signals are all it has"
|
||||
);
|
||||
|
||||
// And the routing between them, which is what the caller actually uses.
|
||||
assert_eq!(
|
||||
liveness_for(°raded, plan::Attribution::Tokened, me),
|
||||
Liveness::Alive
|
||||
);
|
||||
assert_eq!(
|
||||
liveness_for(°raded, plan::Attribution::Untagged, me),
|
||||
Liveness::Unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
+1470
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user