MEASURED DEFECT, found by the §5.1 row-1 matrix run: the daemon PID was unresolvable on this host, permanently, which switched key 4's suppression off and fused every Pulse-emulated node into a single owner. Row 1's clean control forwarder was excluded, Firefox was excluded across an application.process.id bridge, and the taint reached the sunshine sinks and both sound cards -- the same machine-wide over-exclusion cascade as phase 5's F2, from a new cause. Stage 1 returned the one sec_pid shared by 2+ Clients, reasoning that only the Pulse shim repeats a value. WirePlumber repeats one too: it holds Clients 'WirePlumber' and 'WirePlumber [export]', both sec_pid 1747. Two values repeated, the rule called that ambiguous and returned None, and owner::keys_of's documented fail-closed asymmetry did the rest. The rule was wrong in both directions, so the prefilter is gone rather than patched: a second process holding two Clients defeats it (permanent, not a corner case), and a session where pipewire-pulse holds exactly one Client never repeats anything so the candidate is missed. comm was always the authoritative check; repetition stood in front of it and was a guess about other processes' Client counts. Now: candidates() lists every distinct sec_pid, resolve() picks the unique one whose /proc comm is exactly pipewire-pulse, and several matches still fail closed (a single Option<u32> cannot suppress two daemons -- recorded, not approximated). The adapter probes only PIDs entering the set, and retain_probed_comms bounds the map to live PIDs so a PID that leaves and returns is re-probed instead of answered from a stale comm. Row 1 now passes its exact partition, key named: tainted forwarder leg excluded on node.link-group, clean forwarder leg and Firefox eligible, taint confined to the tainted half. 225 tests green (WirePlumber-pair and single-Client regressions covered), clippy clean.
123 lines
6.2 KiB
Rust
123 lines
6.2 KiB
Rust
//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O.
|
|
//!
|
|
//! The owner bridge's key 4 is `application.process.id`. For a stream created
|
|
//! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared
|
|
//! verbatim across every unrelated Pulse app, so bridging on it would fuse
|
|
//! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5,
|
|
//! §6.1.2). The engine therefore needs to know that one PID so it can refuse
|
|
//! to bridge on it — and **every** way of deriving it can fail, in which case
|
|
//! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong).
|
|
//!
|
|
//! The derivation is split into two pure stages so the I/O — reading
|
|
//! `/proc/<pid>/comm` — stays in the adapter:
|
|
//!
|
|
//! 1. [`candidates`] lists the PIDs worth probing from the graph alone: every
|
|
//! distinct `pipewire.sec.pid` any Client presents.
|
|
//! 2. [`resolve`] picks the one whose `comm`, as read from `/proc` by the
|
|
//! adapter, is exactly pipewire-pulse's. This is also what closes **PID
|
|
//! reuse**: a recycled PID is rejected because `/proc/<pid>/comm` now names
|
|
//! a different process.
|
|
//!
|
|
//! Any failure — no Client carries the property, no `comm` matches, `/proc`
|
|
//! gone, or *several* PIDs claim to be pipewire-pulse — yields `None`.
|
|
//!
|
|
//! ## ⚠️ Round 10 (MEASURED): repetition is not the signal
|
|
//!
|
|
//! Stage 1 used to return a single candidate: the one `sec_pid` value shared by
|
|
//! two or more Clients, reasoning that "native PipeWire clients carry their own
|
|
//! distinct PID; only the Pulse shim repeats one value". **That is false on a
|
|
//! stock desktop, and the phase-5 §5.1 matrix caught it on row 1.** Measured on
|
|
//! this host (PipeWire 1.6.8 / WirePlumber 0.5.15): WirePlumber holds *two*
|
|
//! Clients — `WirePlumber` and `WirePlumber [export]` — both carrying
|
|
//! `sec_pid` 1747. So two values repeated (1747 and pipewire-pulse's 2528), the
|
|
//! old rule called that ambiguous and returned `None`, and the consequence was
|
|
//! not a missing optimisation but a machine-wide over-exclusion cascade: with
|
|
//! the daemon PID unknown, key 4's suppression never fires, every
|
|
//! Pulse-emulated node fuses into one owner, and the eligible half of every row
|
|
//! empties out (see `owner::keys_of`'s fail-closed asymmetry note).
|
|
//!
|
|
//! The rule failed in *both* directions, which is why the prefilter is gone
|
|
//! rather than patched:
|
|
//!
|
|
//! - **False ambiguity** — any second process holding two Clients defeats it.
|
|
//! WirePlumber always does, so this was permanent, not a corner case.
|
|
//! - **False absence** — a session where pipewire-pulse happens to hold exactly
|
|
//! one Client (one Pulse app running) never repeats a value at all, so the
|
|
//! candidate is missed and the same cascade follows.
|
|
//!
|
|
//! `comm` was always the authoritative check; repetition was a heuristic
|
|
//! standing in front of it, and it was wrong. Probing every distinct `sec_pid`
|
|
//! costs one `/proc` read per *distinct* PID (single digits — bounded by the
|
|
//! Client count, cached, and re-read only when the candidate set changes),
|
|
//! which is a cheap price for a signal that does not encode an assumption about
|
|
//! how many Clients anyone else opens.
|
|
|
|
use crate::host::taint::snapshot::ClientSnapshot;
|
|
use std::collections::BTreeSet;
|
|
|
|
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
|
|
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
|
|
/// and exact is the only safe match, since a prefix match would accept a
|
|
/// recycled PID belonging to e.g. `pipewire-pulseX`.
|
|
const PULSE_COMM: &str = "pipewire-pulse";
|
|
|
|
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
|
|
/// the Clients present.
|
|
///
|
|
/// No filtering, and deliberately so (see the module docs): any rule applied
|
|
/// here is a guess about other processes' Client counts, while stage 2 has the
|
|
/// kernel's own answer. A `BTreeSet` because the adapter diffs successive
|
|
/// candidate sets to decide what to re-probe, and that diff must not depend on
|
|
/// Client iteration order.
|
|
pub fn candidates(clients: &[ClientSnapshot]) -> BTreeSet<u32> {
|
|
clients.iter().filter_map(|client| client.sec_pid).collect()
|
|
}
|
|
|
|
/// Stage 2: confirm one candidate against the `comm` read from
|
|
/// `/proc/<candidate>/comm`.
|
|
///
|
|
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
|
|
/// gone (the process exited between derivation and probe) — which is itself a
|
|
/// reason to fail closed. A present-but-different `comm` is the **PID reuse**
|
|
/// guard: the number is live but now belongs to someone else.
|
|
pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
|
|
match comm {
|
|
Some(PULSE_COMM) => Some(candidate),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Stage 2 across the whole candidate set: the *unique* PID whose `comm` is
|
|
/// pipewire-pulse's.
|
|
///
|
|
/// `None` when none matches (nothing to suppress that we can prove) and also
|
|
/// when **several** do. Several means either two pipewire-pulse daemons are
|
|
/// live — a nested or sandboxed session — or a `comm` collision, and a single
|
|
/// `Option<u32>` cannot suppress two owners. Failing closed here lands on the
|
|
/// over-exclusion side, matching the asymmetry `owner::keys_of` already
|
|
/// documents: broad over-exclusion is annoying, a missed suppression is an
|
|
/// echo. Suppressing a *set* of daemon PIDs is the real answer if a
|
|
/// multi-daemon host ever turns up; it is not v1, and it is recorded rather
|
|
/// than silently approximated.
|
|
pub fn resolve(candidates: &BTreeSet<u32>, comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
|
let mut found = None;
|
|
for &pid in candidates {
|
|
if validate(pid, comm_of(pid).as_deref()).is_some() {
|
|
if found.is_some() {
|
|
return None;
|
|
}
|
|
found = Some(pid);
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
/// Both stages composed, for callers that can probe on demand.
|
|
///
|
|
/// The model keeps them separate — it recomputes the candidate set as Clients
|
|
/// churn and only re-probes PIDs entering it — so this is a convenience for
|
|
/// tests and for the fully-resolved path.
|
|
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
|
resolve(&candidates(clients), comm_of)
|
|
}
|