Files
pixelpass/src/repair/mod.rs
T
molluskandClaude Opus 5 58bc8e99e8 repair: one pid can carry two attributions, and they are two questions
Round 6, one blocking P2 — in code I wrote in the round-5 fix, not in the token
design, which the review passed again.

The verdict cache was keyed on pid alone. A pid can legitimately be claimed by a
tokened module *and* an untagged one at the same time — a host crashes, an older
build restarts and reuses the number — and those are not the same question: one is
answerable directly from the token, the other only if the degradation signals
allow it. Collapsing them let whichever module sorted last decide both, so under
`--repair-legacy-untagged` with a degraded probe an untagged winner made safely
attributable debris `Unknown`, and a tokened winner planned the untagged debris as
dead (the execution recheck happened to stop the destruction, which is luck, not
design). Verdicts are now cached per `(pid, Attribution)` and each fingerprint is
filtered by its own, never by looking its pid up in `dead_pids`. Those three pid
sets are documented as reporting-only, since a pid can now honestly appear in two
of them.

Mutation-verified: restoring the `dead_pids.contains(pid)` filter fails the new
test, which runs both module-id orders because the bug was order-dependent, and
both polarities — the second asserts that a *live* tokened owner does not lose its
module because an untagged claim on the same pid looked dead.

Also, the degraded-probe warning had become false (P3): it announced "refusing to
unload anything" while the tokened path can now legitimately unload, which in the
exact container-recovery case the token was added for would print a categorical
refusal and then destroy state. It is now scoped to what it actually means —
modules *without* a token will be left alone.

256 tests, clippy clean, fmt clean, field gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:31:17 -04:00

554 lines
23 KiB
Rust

//! `--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(&current, 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(&degraded, plan::Attribution::Tokened, me),
Liveness::Alive
);
assert_eq!(
liveness_for(&degraded, plan::Attribution::Untagged, me),
Liveness::Unknown
);
}
}