//! `--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 { 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 { 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 { 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, } impl LivenessProbe { fn new() -> Self { Self { degraded: Self::detect_degradation(), } } fn detect_degradation() -> Option { // 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 ); } }