repair: one atomic listing, namespace-aware liveness, renderer as authority

Second review round. Two blocking P2s and two P3s, all applied.

**The two-listing correlation was unsound, so it is gone** (P2). Pairing the short
listing's indices with the JSON listing's exact arguments by position breaks with
repeated module names: if another client loads one module and unloads another
between the two calls, the counts and names still line up while the arguments have
shifted by one — and a *foreign* module inherits a canonical fingerprint. The name
check cannot see it and the retry never fires, because correlation "succeeded".

Observations now come from a single `pactl list short modules` invocation, so every
`(index, name, argument)` comes from one server response and cannot be
mis-assembled. The two costs of that format are handled rather than hoped away:

- A tab inside an argument is invisible here, so the row is marked
  `args_complete: false`. `classify` refuses such a row outright — a truncation
  could otherwise coincide with a canonical form — while the reference gate can
  still see that it names a sink.
- A crafted argument containing a newline can fabricate a row, but it only does
  damage if it claims a *real* module's index, which makes that index appear twice.
  A duplicated index now refuses the whole run.

libpulse introspection (`pa_module_info` carries index, name and argument in one
record) remains the exact route. It is a new dependency plus a mainloop in a
one-shot CLI path, so it is recorded as the upgrade rather than taken unilaterally.

**Liveness was still converting invisible-but-alive into dead** (P2). A
`/proc/self` preflight proves nothing: inside a pid namespace — a container, a
distrobox — `self` is visible while every process in the parent namespace is not,
and `hidepid` has the same shape. Repair there can reach the host's Pulse socket,
see a live host's modules, call its pid dead and unload a running host's audio.

So the probe now asks for positive confidence instead: `NSpid` in
`/proc/self/status` reports this process's pid in every namespace it appears in, so
more than one entry means our pid numbers are not the outer namespace's and every
verdict becomes `Unknown`. A kernel that does not report `NSpid`, a container
marker, and a non-local `PULSE_SERVER` all fail closed the same way. Liveness
itself is `kill(pid, 0)` via the existing `nix` dep, where `EPERM` proves
existence; pid 0 and pids past `i32::MAX` are never asked, since `kill(0, …)`
would signal our own process group.

**The renderer is the authority, not the derived template** (P3). `classify` now
re-renders the pid it extracted and demands byte equality, so the template is only
a pre-filter. `Shape` also owns the module *name* now, and `host/audio.rs` loads
through `Shape::{module_name, render_args}` — previously the "cannot drift" claim
covered only arguments while the names were still written out at both ends. The
sentinel assertion is unconditional (`assert!`), so a future shape that repeats the
pid cannot slip through a release build.

**A test I wrongly called unclosable** (P3). I argued no non-vacuous case could
prove the module name is part of the identity, because the name determines which
argument grammar can match. That was wrong: the grammars are not disjoint —
`module-echo-cancel sink_name=pixelpass_capture_9` is byte-identical to the
canonical null-sink argument, which the suite already constructs. Same index, same
arguments, different name, and `still_matches` must say no.

252 tests (+1 net; the correlation tests were replaced by parser and probe tests),
clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683. Both live
field gates re-run against the rewritten observation path: the A/B orphan test
still removes exactly the two orphans with the module table otherwise identical,
and the reference/unrecognised fixture still leaves both modules alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:07:58 -04:00
co-authored by Claude Opus 5
parent 9145b2a726
commit 01c582427b
3 changed files with 378 additions and 310 deletions
+14 -16
View File
@@ -39,7 +39,7 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use crate::cli::HostOpts;
use crate::repair::plan as repair_plan;
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;
@@ -67,7 +67,7 @@ impl Routing {
let pid = std::process::id();
let sink_name = repair_plan::sink_name_for(pid);
let sink_module = load_module("module-null-sink", &repair_plan::null_sink_args(pid))
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
@@ -83,7 +83,7 @@ impl Routing {
None
} else {
Some(
load_module("module-loopback", &repair_plan::mirror_args(pid))
load_module(Shape::LoopbackIntoCapture, pid)
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
@@ -132,10 +132,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",
&repair_plan::local_monitor_args(pid),
) {
match load_module(Shape::LoopbackOutOfCapture, pid) {
Ok(id) => {
tracing::info!(
module = id,
@@ -188,7 +185,7 @@ impl Routing {
tracing::info!(
"audio routing: last routed stream gone → restoring default-sink loopback"
);
match load_module("module-loopback", &repair_plan::mirror_args(pid)) {
match load_module(Shape::LoopbackIntoCapture, pid) {
Ok(id) => {
*loopback_for_task.lock().unwrap() = Some(id);
}
@@ -337,17 +334,18 @@ struct SinkInputProperties {
// pactl module helpers
// ──────────────────────────────────────────────────────────────────────
/// Load one Pulse module and return its index.
/// Load the Pulse module for one [`Shape`] and return its index.
///
/// `args` comes from the renderers in [`crate::repair::plan`] 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 only moved one of them
/// would leave repair silently unable to recognise the modules this build loads.
fn load_module(module: &str, args: &[String]) -> Result<u32> {
/// 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 output = Command::new("pactl")
.arg("load-module")
.arg(module)
.args(args)
.arg(shape.module_name())
.args(shape.render_args(pid))
.output()
.context("failed to run pactl load-module")?;
if !output.status.success() {