Author SHA1 Message Date
molluskandClaude Opus 5 fa792b9927 fix(audio): close the teardown orphan race by wiring in the ledger
Teardown used to `event_task.abort()` and then read three `Option<u32>`s.
The task's work was a synchronous `pactl` call with no await point, so the
abort could not land until the load had already returned: teardown saw
`None`, unloaded the sink, and the task then stored the new module's id into
a mutex nobody would read again. An orphan loopback, pointing at a sink that
no longer existed.

Three changes close it:

- Loads and unloads are `tokio::process::Command` with `kill_on_drop` and a
  bound, so cancellation is expressible at all. They are deliberately not
  `select!`ed against a cancel signal — dropping a completed load's index on
  the floor is the defect, not the fix. Cancellation happens by dropping the
  future, and the permit's `Drop` turns that into a question.
- `Routing::shutdown` is async and *awaits* the event task through
  `&mut JoinHandle`, falling back to abort-then-await. Dropping the handle
  would detach the task, which is how a load could still land after teardown
  believed it had finished. It then runs two reconcile-then-unload rounds:
  one round can raise exactly one new question, and a second settles it.
- `Drop` stays as the narrower synchronous backstop for the paths that never
  reach `shutdown`. It cannot await or reconcile, so when the ledger is left
  unexplained it says so and names `--repair`.

A load whose outcome cannot be observed is now distinguished from one the
server refused: a clean non-zero `pactl` exit abandons the permit (nothing
was created), while a signal death, a timeout, an unreadable index or
`PA_INVALID_INDEX` all leave it unsettled for reconciliation.

Two live gates, both A/B against the real module table: teardown leaves it
byte-identical with both modules carrying owner tokens, and a load cancelled
mid-flight is reconciled rather than orphaned. The second asserts the slot is
pending *before* reconciling, so it cannot pass by aborting before the load
ever began. Both mutate global state, so they need `--test-threads=1` —
running them in parallel makes each see the other's modules, which is how the
first run failed.

273 tests, clippy clean under `-D warnings`, `--doctor` all checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:38:46 -04:00
molluskandClaude Opus 5 cc9694c13f feat(audio): add the module ledger as a pure state machine
Module ids live in three `Option<u32>`s today, two of them shared with the
event task. That representation cannot express "a load is in flight", and
the gap is reachable: teardown calls `event_task.abort()` and then reads the
ids, but the task's work is a synchronous `pactl` call with no await point
inside it, so the abort cannot land until the load has already returned.
Teardown sees `None`, unloads the sink, and the still-running task stores the
new module's id into a mutex nobody will ever read again.

A slot is therefore a state machine whose transitions admit "we do not know":
Vacant / Loading / Loaded / Unloading / Ambiguous / Poisoned. The load permit
is affine — not `Clone`, consumed by value to settle — and its `Drop` marks
the slot ambiguous when it was never settled, so a cancelled task cannot
silently forget a module the server may already have created. An ambiguous
slot refuses the next load, because two sinks may share a `node.name` and
`pulsesrc` attaches to the older one: loading over unresolved debris would
silently steal the next session's capture.

Reconciliation is by owner token, whose nonce is minted per load and so names
one attempt: exactly one match adopts, zero means the load never happened,
and two or more fails closed rather than guessing. The Pulse session it lists
through is deliberately short-lived, because `repair::introspect` documents
that the binding leaks a timed-out request's callback until disconnect —
bounded for a session that ends immediately, unacceptable for one held open
for the life of a share. That is the one deviation from the round-19 design,
and it is why loads stay on `pactl`.

Pure: no I/O in the state machine, so all 17 gates run without a Pulse server.
All 8 mutants killed, each by its own named test. The stranger-at-the-same-
index gate needed strengthening first — its original fixture was a
non-canonical module, which `classify` discards regardless of how the match
was made, so an id-only comparator would have survived it.

Wiring into `host/audio.rs` follows; the dead-code warnings go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:28:54 -04:00
mollusk 911f0521f8 build(nix): pin the Rust toolchain to 1.97.1 via rust-overlay
nixpkgs 26.05 ships rustc 1.95.0, but this crate was developed and verified on
1.97.1 (what CachyOS had, installed 2026-07-17). Taking the compiler from
oxalica/rust-overlay decouples "which Rust the project targets" from "which
release the audio stack came from", so a nixpkgs bump can no longer move the
compiler under the lint gate as a side effect.

Chosen over rustup, which would also have worked here (nix-ld is enabled, so
its prebuilt binaries run) and would have let one rust-toolchain.toml cover the
packaging distroboxes too. The deciding factor is purity: rustup records
nothing in flake.lock, so a fresh clone or darp5 would resolve whatever it
fetched that day. rust-overlay gives the same exact-version control with the
choice pinned in the lock.

`.default` is the rustup "default" profile — rustc, cargo, rust-std, rustfmt
and clippy — so those are no longer listed individually. No windows-gnu target
here: pixelpass is Linux-only, unlike peerspeak.

Verified on 1.97.1: 256 tests pass, fmt clean, and `cargo clippy --all-targets
-- -D warnings` is clean.
2026-08-07 14:03:25 -04:00
mollusk 9ad55c19de build(nix): add a devShell so pixelpass builds on NixOS
The repo assumed a distro with a system-wide Rust and system-wide GStreamer,
which is exactly what NixOS does not provide. This adds a flake devShell
carrying the whole dependency surface:

- Build: rustc/cargo/clippy/rustfmt, pkg-config, and clang — pipewire-sys,
  libspa-sys and libpulse-sys all generate bindings with bindgen, which needs
  a real libclang via LIBCLANG_PATH rather than just clang on PATH.
- Link: pipewire, libpulseaudio, and libxcb. The libxcb one is not obvious:
  x11rb is declared `default-features = false` here, but Cargo unifies
  features across the graph and arboard pulls x11rb with `libxcb` on, so the
  final link really does need -lxcb.
- Runtime: GStreamer is driven as a SUBPROCESS, not linked, so the tools and
  their plugin search path are provided here too. NixOS keeps every plugin in
  its own store path, so gst-launch-1.0 finds them only through
  GST_PLUGIN_SYSTEM_PATH_1_0 — without it the `gst-inspect-1.0 --exists
  pipewiresrc` preflight fails even with the plugins installed.

nixpkgs is pinned to nixos-26.05, the same channel the hosts run, so the
client libraries match the PipeWire daemon and PulseAudio server they talk to.

Verified: 256 tests pass, clippy clean, and `--doctor` reports all checks
passing (capture, encode, mux/audio, viewer, relay).
2026-08-07 13:46:05 -04:00
molluskandClaude Opus 5 347462cca7 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>
2026-07-27 02:41:33 -04:00
molluskandClaude Opus 5 6dd9b2d25a repair: say "once per (pid, attribution)", because that is what it is now
Codex's non-blocking round-7 nit. The comment and test name still claimed liveness
is asked once per pid, which stopped being true when one pid became two questions.
No behaviour change; the wording was the last thing pointing at the old model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:40:44 -04:00
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
molluskandClaude Opus 5 104a95a6d7 repair: let the token beat the namespace guesswork, and ask liveness first
Round 5. Two blocking P2s, both in the execution shell rather than the token
design, plus an overstated claim of mine.

**The degradation signals were defeating the token in exactly the case it exists
for.** A host crashing inside a container leaves a token that matches this
machine, boot and pid namespace perfectly — but the probe saw a container marker
or a multi-entry `NSpid` and answered `Unknown` for everything, so token-qualified
repair did nothing precisely where it had just become safe. Those signals are
guesswork about whether a bare pid is meaningful, and for an attributed pid that
is no longer a guess. Liveness is now asked with the module's attribution in hand:
a tokened pid goes straight to `kill`, while an untagged one still has to get past
the signals, because there they are the only protection left.

**Liveness now runs before the fresh snapshot, not after it.** The natural order —
verify the module, check liveness, unload — leaves the dangerous window open:
after `kill` returns ESRCH this process can be descheduled while the planned module
vanishes, a new host inherits both the pid and the module index, and its
differently-nonced arguments occupy that index. Nothing re-read those arguments, so
the reused index would have been unloaded. Asking liveness first means the
post-liveness fingerprint check catches that replacement, leaving only the
irreducible snapshot-to-unload interval.

**The nonce claim was overstated and is now true.** A token was minted once per
`Routing` session and reused for every subsequent reload, making it a host-session
nonce rather than a per-load one. It is now minted inside `load_module`, mixing a
bumped counter with the clock, so two loads by the same pid really do render
different arguments — which, combined with the reordering above, is what lets a
fingerprint tell a module from its replacement at the same index.

⚠️ **The first version of the attribution fix had no gate, and the mutation said
so.** Swapping `of_attributed` back to `of` passed all 254 tests, because on an
ordinary desktop the probe is not degraded and the two paths agree, while the
planner tests use a fake liveness closure that never touches the probe at all. The
new test constructs a *deliberately degraded* probe, which is the only state where
the distinction is observable. Both mutants — routing a tokened pid through `of`,
and re-applying the degradation gate inside `of_attributed` — now fail it.

Codex's answers to the questions I raised, recorded because they close them:
omitting the pid from the token loses nothing, since the canonical argument already
binds the token to exactly one pid; namespace inode reuse is real but only after
the old namespace is destroyed, so its host is necessarily gone and no live owner is
endangered; and refusing foreign tokens even under `--repair-legacy-untagged` is the
right line, because the flag speaks to missing evidence rather than wrong evidence.
No finding against the two-hole template derivation.

255 tests, clippy clean, fmt clean. All four live field gates re-run green: tokened
cleaned, foreign refused with and without the flag, untagged refused then cleaned on
request, A/B orphan removal byte-identical elsewhere, reference gate still firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:18:52 -04:00
molluskandClaude Opus 5 d9ef38c1c5 repair: a pid is not an owner — modules carry a machine/boot/namespace token
Closes the last blocking finding: a pid is only a number, and the same number is
a different process in a different pid namespace. Repair running inside a
container that can reach the host's Pulse socket saw a live host's modules, asked
about that pid in its own namespace, was told nothing existed, and unloaded a
running host's audio. No negative signal closes that — `NSpid == 1` does not prove
the initial namespace, since its leftmost value is relative to whichever procfs
was mounted.

So the module now carries the answer with it. Every module a host loads gets
`pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only
asks about a pid when all three identities match its own. Anything else is
reported and left alone, and its pid is never even looked up — asking is the bug,
because the answer would be meaningless.

**Untagged modules are refused by default.** Everything loaded before tokens
existed is unattributable, so `--repair` now lists those and does nothing, with
`--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the
candidates. That is a deliberate loss of reach: the failure being optimised
against is a false-positive destructive repair, and leaving an old orphan behind
is recoverable where destroying live routing is not. A foreign token is refused
even with the flag, since the flag speaks to missing evidence, not wrong evidence.

The vehicle was verified on the live server before anything was built on it: all
three shapes accept a property-list argument (`sink_properties`,
`sink_input_properties`, `source_output_properties`), the recorded argument comes
back byte-identical — so exact-form matching still holds — and the property really
lands on the resulting sink, sink-input and source-output.

**Audit gate passed, with the variable isolated.** The token rides on real graph
objects that phases 2/3 observe, so the partition had to be re-measured. Running
the same fixture with and without tokens gives an identical partition: 2 eligible
(FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the
same six-entry taint set. Everything that differs from the empty-graph baseline is
the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink
into the real default sink — and none of it is the property's. Attributing that to
the token without the untokened control would have been the mistake.

A side benefit: the per-load nonce narrows the ABA window I previously documented
as unclosable. Two loads by the same pid no longer render byte-identical
arguments, so a fingerprint taken from one no longer matches the other.

Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns`
dropped from the comparison, and untagged treated as judgeable regardless of
policy — each killed by its own test. ⚠️ The third "survived" on first run because
my mutation script's indentation did not match and the edit silently did nothing;
the re-run asserts the file actually changed. A mutation that was never applied
proves the same amount as no mutation at all.

Field-verified live, three fixtures for one dead pid in one run: tokened with this
machine's identity is cleaned, tokened with a foreign pid namespace is left alone
and reported (and the legacy flag does not override it), and untagged is refused
then cleaned only when asked. The two older field fixtures were tokenised too —
without that the A/B test would have failed and the reference-gate test would have
passed for the wrong reason, which is a vacuous gate in the harness rather than the
code.

253 tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:57:55 -04:00
molluskandClaude Opus 5 1cf6e915c8 repair: drop the settling ritual, record the binding's timeout leak
Both P3s from the round-4 review.

The eight non-blocking mainloop iterations in `Drop` were a ritual, not a barrier:
a fixed number of polls cannot guarantee any particular event became ready. They
were also unnecessary — PulseAudio's context unlink cancels outstanding operations
and tears down the context's socket machinery synchronously, so once
`drop(context)` returns the mainloop has no obligation left to service. Removed
rather than replaced with a time-bounded drain, since there is no asynchronous
obligation for such a drain to wait on. Re-verified live: both field gates and a
clean-graph run still exit 0, with no abort.

Also recorded, at the constant it depends on: on a request timeout the `Operation`
wrapper is dropped while still running, and libpulse-binding 2.30.1 only unrefs the
C operation, so the boxed callback and its captured `Rc`s leak until the context
cancels the operation at disconnect. Harmless here — `--repair` is a one-shot
process that exits immediately after — and it cannot become a use-after-free, since
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 the note says so
where someone would otherwise reuse this module for host-side loading.

The review's verdict on the teardown itself: disconnect, destroy the context while
the mainloop lives, then the mainloop, is the correct order, and taking the context
explicitly makes it independent of field declaration order.

247 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:41:16 -04:00
molluskandClaude Opus 5 45464b4af5 repair: read and unload Pulse modules through libpulse, not pactl text
Third review round found three more blocking P2s, and they shared one root cause:
`pactl`'s text output cannot carry the guarantees repair was claiming. All three
are closed by talking to the protocol instead. New dependency taken with the
user's explicit sign-off after vetting.

**Record boundaries were unprovable.** `pactl list short modules` prints a
module's argument raw into a tab/newline-delimited format with no escaping. A
*genuine* module whose argument contains a newline renders a first line that reads
byte-exactly like one of our canonical forms, with the remainder dropped as an
unparseable continuation — no forged index, so the duplicate-index check could not
see it. Repair would have classified and unloaded a module it never saw in full.
**Field-confirmed on the live server**, because this needed no adversary: loading a
loopback whose argument is canonical-then-newline-then-`remix=false` (a real
loopback option) produces exactly that listing. A tab in the same position is
worse: it hid a sink reference from the gate that protects a still-referenced sink.

**Index and argument could be mis-paired.** The listing carrying exact arguments
(`-f json`) carries no index at all on pactl 17; the one carrying the index cannot
carry the argument faithfully. Correlating them by position — which the previous
commit did — is unsound whenever module names repeat: another client loading one
module and unloading another between the two calls leaves counts and names aligned
while every argument has shifted by one, so a foreign module inherits a canonical
fingerprint. The name check cannot see it and the retry never fires.

**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 local pids mean nothing and a live remote host's
modules look dead. A remote server can also be selected by client config with the
variable unset entirely.

New `repair/introspect.rs` owns one verified-local connection: `pa_module_info`
gives index, name and exact argument in a single record, `pa_context_is_local()`
answers locality about the connection actually established, and unloading goes back
through that same connection so listing and destruction cannot disagree about which
server they mean. It holds no policy beyond refusing the wrong server; every
decision stays in the pure planner.

⚠️ **The field test caught a real bug that no unit test could have.** The first
version did its work correctly and then aborted on the way out:

    Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
    function mainloop_io_free(). Aborting.

SIGABRT, core dumped, exit 134 — a fully successful repair reporting failure to its
caller. Cause: Rust drops fields in declaration order and the context's teardown
frees IO events living in the mainloop, which I had declared first. **This is
exactly the invariant phase 0b exists for, met again one layer down.** Fixed, and
then hardened past the fix: `Drop` explicitly takes and destroys the context before
the mainloop, so the ordering no longer depends on where the fields are written.

Liveness keeps its `NSpid`/container checks but the claim is corrected: `NSpid > 1`
means "definitely nested", while `NSpid == 1` is NOT proof of the initial namespace
— its leftmost value is relative to the procfs that was mounted, so a nested
namespace with its own `/proc` reports one entry legitimately. These are negative
signals that fail closed, not a proof of trustworthy pids. Closing that properly
needs modules to carry an owner token (machine/boot plus pid-namespace identity),
which changes what pixelpass writes into the graph and how far back `--repair` can
clean up: recorded as a design decision, not guessed at.

libpulse-binding 2.30.1 vetted before use: MIT/Apache-2.0, 5.5M downloads, 3 new
crates total, build script does nothing but probe pkg-config, no network or
subprocess use anywhere in the sources, and all three historical RustSec advisories
(2018-0020, 2018-0021, 2019-0038) were fixed by 2.6.0. The reasoning is recorded in
Cargo.toml beside the dependency.

247 tests, clippy clean, fmt clean apart from the pre-existing taint/tests.rs:2683.
The text parser's tests are gone with the parser; the liveness probe keeps its own,
and the live field gates — A/B orphan removal, the reference/unrecognised fixture,
and the newline fixture — all pass with exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:23:23 -04:00
molluskandClaude Opus 5 01c582427b 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>
2026-07-26 22:07:58 -04:00
molluskandClaude Opus 5 9145b2a726 repair: exact-form matching, tri-state liveness, and a reference-gated sink
Codex's review of the repair planner returned changes-requested: no P1s, four
reachable P2s plus a P3, all in the observation and execution layers rather than
in the discovery fix itself. All applied.

**Only the canonical forms are ours** (P2, plan.rs). `classify` recognised any
loopback with one pixelpass-looking endpoint, so a third party's
`module-loopback source=some_mic sink=pixelpass_capture_4242` was ours to unload
once that pid died — and a `sink=` token nested inside a quoted
`sink_input_properties` value could be mistaken for a top-level argument. The
whole recorded argument string must now equal what pixelpass itself would have
written.

The matcher's templates are **generated from the loader's own renderers**, not
written out beside them: hard-coding `latency_msec=20` in a matcher means a
loader change silently blinds repair to every module the new build loads, which
is the fail-closed-and-silent failure this project has been bitten by three
times. `host/audio.rs` now loads through those same renderers, so the two cannot
drift. Blindness is also reported rather than assumed impossible —
`unrecognised_pixelpass_modules` finds modules that name our sinks but match no
canonical form, and `--repair` says so loudly.

Measured on the live server before relying on it (pactl 17.0): arguments come
back byte-for-byte as passed, joined with single spaces, with `@DEFAULT_SINK@`
NOT resolved. Both facts are load-bearing for exact matching and both have a
test.

**Ordering is not a licence either** (P2, mod.rs). The plan put loopbacks before
the sink, but an unload can fail or be skipped and a loopback can appear after
planning, so the executor could still destroy a sink that something was attached
to. The sink unload is now gated on `sink_still_referenced` against the fresh
snapshot — any other module naming that sink blocks it, ours or not, because the
question is what would break, not who owns it.

**Undecidable is not dead** (P2, mod.rs). `Path::exists()` maps permission
errors, a missing `/proc` and a foreign pid namespace all to `false`, which here
read as "dead, go ahead and unload". Liveness is now `Alive | Dead | Unknown`
via `try_exists()` behind a `/proc/self/stat` preflight, and `Unknown` is
treated exactly like alive and reported separately.

**The short listing cannot carry a fingerprint** (P2, mod.rs). Its arguments are
tab-delimited text that a module argument may itself contain, and a continuation
line beginning with a digit could fabricate a row. Observations now come from two
listings: the short one for the module index, and `pactl -f json list modules`
for the exact argument. Codex proposed JSON alone; on pactl 17 its records carry
`"index": null`, so it cannot be used on its own — verified, hence the
correlation. The pairing is positional and *checked* (same count, same name at
every position, else refuse), which is also what makes a fabricated row harmless
instead of exploitable: it has no JSON counterpart, so the sequences misalign.

Normalisation is gone (P3). Within one invocation every snapshot comes from the
same server, so re-rendering does not happen, and normalising only made
genuinely different arguments compare equal. The residual ABA window — planned
module vanishes, byte-identical one takes its index — cannot be closed through an
unload API whose only argument is an index; that is now said plainly in the
fingerprint's own doc comment rather than implied away.

Five vacuity gaps Codex found in the tests, closed: a raw-pactl-output-to-plan
test (the planner suite survived a parser that dropped every argument), the
liveness-once test now uses two pids with per-pid counters, non-canonical and
nested-quoted arguments have their own cases, and the reference gate has one.

Field-verified on the live graph, both new rules: the A/B orphan test still
removes exactly the two orphans with the module table otherwise byte-identical,
and a fixture of a dead pid's legacy sink plus a non-canonical loopback naming it
leaves both alone and reports why.

251 tests (+9), clippy clean, fmt clean apart from the pre-existing
taint/tests.rs:2683.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:31:30 -04:00
molluskandClaude Opus 5 919d5bdef2 repair: find the orphans a connection-owned sink leaves behind
Phase 0c's first landed piece. `--repair` does not get smaller when the capture
sink becomes connection-owned — it goes blind, which is the opposite of the
assumption I started from and the finding that reordered this phase.

Today repair learns a dead host's pid ONLY from `module-null-sink
sink_name=pixelpass_capture_<pid>`, and matches loopbacks only if their pid is
already in that set. After 0c the sink is a native node that removes itself with
its owning connection, so a hard-killed host leaves two Pulse loopbacks and *no
null-sink module to learn the pid from*. The set stays empty, nothing matches,
and the orphans are invisible forever. Candidate pids are now derived
independently from all three module shapes.

Split into a pure planner (`repair/plan.rs`) and an I/O shell, because every
interesting property here is a decision — which pid is dead, which module is
whose, in what order to unload — and none of them need PipeWire to exercise.

The rule that is new, and that the old code could not express: **a plan is not a
licence.** Pulse module indices are reused verbatim, so an id planned against one
module can name a different live module by the time the unload runs; a pid
recheck alone does not catch that. Every action now carries a full fingerprint
(id, module name, normalized args, derived pid, shape) which is re-verified
against a FRESH snapshot immediately before each unload, with liveness rechecked
last, closest to the destruction. Anything that does not match exactly is
skipped and said out loud — never unloaded on the strength of a stale plan.

Ordering is carried by `Shape`'s declaration order rather than by two separate
passes, so loopbacks unload before the sink they reference by construction.
Liveness is asked once per pid, not once per module: a flapping answer must not
be able to half-repair a host, which is the one outcome worse than doing nothing.

Field-tested against a real post-0c orphan, not just mocked: a connection-owned
sink created via `pw-cli create-node adapter` (module-null-sink count: 0, so the
old discovery provably could not see it), both loopback shapes loaded against it,
then SIGKILL of the owning connection. The sink vanished on its own, both
loopbacks survived, `--repair` removed exactly those two, and the module table
was otherwise byte-identical before and after — the collateral-damage half of the
two-host safety property.

Mutation-verified, five mutants, each killed by its own gate: null-sink-only
discovery (the 0c blindness itself), id-ordered unloads instead of shape-ordered,
a fingerprint that compares only the index (exactly one), no liveness filter, and
un-normalized args (exactly one).

Still owed: the live two-host gate (two real hosts, kill one, prove the other's
graph and modules are untouched) cannot run until the native sink exists, so it
is deferred to 0c's combined exit gate. This lands with test + single-host field
proof only, which Codex agreed is an acceptable phase dependency rather than an
objection to landing repair first.

242 tests (+11), clippy clean, fmt clean apart from the known pre-existing
`taint/tests.rs:2683` — pixelpass is never `cargo fmt`ed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:09:40 -04:00
molluskandClaude Opus 5 c78eb2dd39 host/taint: a self-claimed pid is not provenance (F11-1)
Boundedness now requires a strong key, or a key-4 value backed by a
**resolved Client** — an unambiguous Client yielding Some(pipewire.sec.pid),
read before pipewire-pulse suppression. A node whose Client cannot be
resolved at all is unbounded whatever `application.process.id` it puts on
itself, so it can no longer spare itself from `propagate_unresolved_owner`'s
sweep with a value it made up.

Closes the round-11 review's finding 1: the key-4 union could *reduce* taint,
because the same key list feeds boundedness and the sweep is armed by an
UNbounded tainted reader. The recorded three-step path (reader bounded by its
Client's real pid, output leg on an ambiguous Client claiming a bogus pid, no
shared key so no bridge either) is now a test.

Bridging is untouched: it still uses the full union, so boundedness is stored
on OwnerKeyIndex rather than re-derived from the key set, and `bounded_by` is
the single implementation of the predicate.

Five-case Client matrix as tests (absent · ambiguous · unique-but-pid-less ·
resolved-native · resolved-to-pipewire-pulse). The pid-less row is the one
that distinguishes the correct reading of "resolved" from "a unique Client
exists", which would have left the hole open. Mutation-verified: dropping the
provenance test fails four of the six rows and passes the two that must not
regress.

Measured cost on the live graph: zero. Before- and after-binaries audited the
same graph simultaneously (tagged producer + parec on the monitor as a real
tainted reader, so the sweep was armed) — 181 records each, the same 14
distinct decision states, none exclusive to either side, no unresolved-owner
on either, eligible half non-empty throughout. O5 unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:44:58 -04:00
mollusk 91c4dedcb0 host/observer: derive pipewire-pulse's PID from comm, not from repetition
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.
2026-07-26 02:32:07 -04:00
mollusk d462754894 host/audit: name the owner key the bridge resolved on
Impl plan section 5.1 row 1 asserts "reason = owner bridge, naming the key",
and the record could not express that: Reason::code collapses
TaintedOwnerBridge { key } to one string, so an exclusion that arrived across a
named owner key was indistinguishable from one that arrived by an incidental
link walk reaching the same verdict. Telling those apart is the entire point of
the row.

OwnerKey::code already documented itself as ending up in the phase 5 audit
output; it was simply never wired to it. Adds owner_key to AuditRow and
TaintRow, omitted when absent, and absent is meaningful: a bridge whose tainted
member shared no key directly reached the node transitively, so there is no
single key to name and naming one would be a false diagnosis.

Read-only and diagnostic-only. New test mutation-verified (stubbing the key to
None fails it); 221 tests green, clippy clean.
2026-07-26 02:21:07 -04:00
mollusk 90efa51c26 Merge phase 1: ownership carriers (taint roots from peerspeak's tags)
pixelpass-side half of phase 1: the taint engine recognises both registry-visible
ownership carriers peerspeak now emits (peerspeak.owned and the
peerspeak_owned_ node.name prefix), giving the primary taint root a path that
survives the filtered registry global event (design v3.5 section 6.7).

Reviewed by Codex rounds 10-12. F11-1 (the owner-key union can REDUCE taint via
owner_is_bounded, switching the unresolved-owner sweep off) remains OPEN and
still blocks phase 6; it is deliberately deferred to be decided with section 5.1
matrix data, and its sharpened rule is recorded in owner_is_bounded's doc.

220 tests green, clippy clean.
2026-07-26 02:14:34 -04:00
24 changed files with 4581 additions and 530 deletions
+6
View File
@@ -1 +1,7 @@
/target
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
# flake.lock ARE tracked — the lock is what pins the toolchain.
/result
/result-*
/.direnv/
Generated
+39
View File
@@ -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
View File
@@ -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"
Generated
+48
View File
@@ -0,0 +1,48 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1785989512,
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786076960,
"narHash": "sha256-jfR6OhwurCKn1tREyfOcK/Omxf1Q/DzDDFbnEr1mBLs=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "57a23bfaf4f7017267294b161175db1e32eb1c85",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+147
View File
@@ -0,0 +1,147 @@
{
description = "PixelPass P2P screen sharing CLI over iroh";
inputs = {
# Same channel the hosts run (nixos-config tracks nixos-26.05). The capture
# path talks to the live PipeWire daemon and the system PulseAudio server,
# so the client libraries here should come from the same release the server
# did.
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
# The Rust toolchain is pinned SEPARATELY from the system libraries, so a
# nixpkgs bump cannot move the compiler under the lint gate. nixpkgs 26.05
# ships 1.95.0; this crate was developed and verified on 1.97.1, and
# peerspeak — the sibling project this one is built against — has a clippy
# lint that differs between exactly those two versions. Keeping both repos
# on one pinned compiler means a check that passes here passes there.
#
# This is the reproducible alternative to rustup: the same exact-version
# control, but recorded in flake.lock, so a fresh clone resolves the
# identical toolchain rather than whatever rustup fetches that day.
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{ nixpkgs, rust-overlay, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
# Matches what CachyOS shipped (rust 1:1.97.1-1) and what peerspeak pins.
# `default` is the rustup "default" profile — rustc, cargo, rust-std,
# rustfmt and clippy — so those are NOT listed separately below. No
# windows-gnu target here: pixelpass is Linux-only (portal/PipeWire/X11
# capture), unlike peerspeak which has a Windows port.
rustToolchain = pkgs.rust-bin.stable."1.97.1".default;
# GStreamer is driven as a SUBPROCESS (gst-launch-1.0 / gst-inspect-1.0),
# not linked — there is no gstreamer-sys in Cargo.lock. So these are PATH
# dependencies at runtime rather than build inputs, and `deps.rs` refuses
# to start a share if any are missing.
gstPlugins = with pkgs; [
gst_all_1.gstreamer # gst-launch-1.0 / gst-inspect-1.0
gst_all_1.gst-plugins-base # videoscale (quality-preset downscale)
gst_all_1.gst-plugins-good # pulsesrc, ximagesrc
gst_all_1.gst-plugins-bad # h264parse, mpegtsmux, aacparse, vah264enc
gst_all_1.gst-plugins-ugly # x264enc (software-encode fallback)
gst_all_1.gst-libav # avenc_aac
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
];
# Opened with dlopen by the optional `--gui` front end (eframe/egui_glow/
# winit/glutin), never linked. Harmless for the default headless build.
guiRuntimeLibs = with pkgs; [
libGL
libxkbcommon
wayland
libx11
libxcursor
libxrandr
libxi
];
in
{
devShells.${system}.default = pkgs.mkShell {
nativeBuildInputs =
[ rustToolchain ]
++ (with pkgs; [
# Debian packaging (`cargo deb --no-build`). Build the binary
# inside a Debian/Ubuntu distrobox first so it links that distro's
# glibc — see the packaging notes in Cargo.toml.
cargo-deb
pkg-config
# pipewire-sys, libspa-sys and libpulse-sys all generate bindings
# with bindgen, which needs a real libclang at build time.
clang
])
++ gstPlugins
++ [
# The rest of what `deps::check_host_binaries` looks for.
pkgs.pulseaudio # `pactl` (PipeWire stays the actual audio server)
pkgs.mpv # the viewer-side player
pkgs.xwininfo # the `--window` click-picker on X11
# `--doctor` shells out to vainfo to confirm the VA-API H.264
# ENCODE entrypoint really exists. Without it the report can only
# say "vah264enc and a render node are present" and has to leave
# hardware encode unconfirmed — which matters, because a GPU
# missing that entrypoint produces no video at all under the
# default encoder rather than failing loudly.
pkgs.libva-utils
];
buildInputs =
with pkgs;
[
pipewire # pipewire-sys + libspa-sys
libpulseaudio # libpulse-sys: --repair reads/unloads Pulse modules
# x11rb is declared `default-features = false` here, which by itself
# is pure Rust — but Cargo unifies features across the graph, and
# arboard pulls x11rb with its `libxcb` feature on. That drags in
# as-raw-xcb-connection and makes the final link need -lxcb. It is a
# real link-time dependency of the binary, not an optional extra.
libxcb
]
++ guiRuntimeLibs;
# bindgen finds libclang through this variable specifically — having
# clang on PATH is not sufficient.
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
# NixOS keeps every GStreamer plugin in its own store path, so
# gst-launch-1.0 discovers them ONLY through this search path. Without
# it, pixelpass's `gst-inspect-1.0 --exists pipewiresrc` preflight fails
# even though the plugins are installed. Same reasoning as the
# GST_PLUGIN_SYSTEM_PATH_1_0 block in nixos-config hosts/darp5.
GST_PLUGIN_SYSTEM_PATH_1_0 = pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" gstPlugins;
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath guiRuntimeLibs;
# Only greet an interactive shell. shellHook also runs under
# `nix develop --command …`, where printing this would interleave the
# banner with the command's own output (and corrupt it outright for
# anything whose stdout is parsed, such as pixelpass's `--output json`).
shellHook = ''
if [ -t 1 ]; then
echo "pixelpass rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
echo " cargo build --release headless build (what peerspeak spawns)"
echo " cargo build --release --features gui with the egui front end"
echo " cargo test unit + integration tests"
echo " ./target/debug/pixelpass --doctor verify this machine can host"
echo
echo "GStreamer, pactl, mpv and xwininfo are on PATH in this shell, so"
echo "capture works here without a system rebuild."
fi
'';
};
};
}
+12
View File
@@ -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
+415 -147
View File
@@ -35,25 +35,45 @@ use std::cell::RefCell;
use std::collections::BTreeMap;
use std::process::Command;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use crate::cli::HostOpts;
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
/// How long a single `pactl load-module` / `unload-module` may take.
///
/// A bound, not a calibration: a local Pulse socket answers in milliseconds, and
/// this exists only so a wedged server cannot hang teardown forever. It is
/// deliberately generous because exceeding it is no longer destructive — the
/// ledger records the attempt, and reconciliation finds whatever the server
/// actually did.
const PACTL_BUDGET: Duration = Duration::from_secs(5);
/// How many reconcile-then-unload rounds teardown runs.
///
/// Two, because one round can create exactly one new question: an ambiguous load
/// resolves to a module that then needs unloading, and an uncertain unload
/// resolves to a module that is either gone or still there. A second round
/// settles either. Anything still unresolved after that is left to `--repair`
/// rather than looped over.
const TEARDOWN_ROUNDS: usize = 2;
/// Owns the pactl-loaded modules plus, when filtering is active, the
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
/// prefer [`Routing::shutdown`] explicitly so failures get logged.
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can
/// reconcile a load whose outcome was never observed.
pub struct Routing {
sink_module: Option<u32>,
/// Shared with the event task so it can `take()` and unload on the
/// first successful route. `Routing::shutdown` unloads whatever
/// remains.
loopback_module: Arc<Mutex<Option<u32>>>,
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
/// hear the routed app. Shared with the event task, which loads it on the
/// first routed stream and unloads it when the app stops. `None` outside
/// app mode and whenever no app is currently routed.
local_monitor_module: Arc<Mutex<Option<u32>>>,
/// Every module this host has loaded, is loading, or must ask the server
/// about. Shared with the event task, which loads and unloads the two
/// loopbacks as the routed app comes and goes.
///
/// This replaced three `Option<u32>`s. The reason is in [`crate::host::ledger`]:
/// an `Option` cannot say "a load is in flight", so a cancelled load looked
/// exactly like no load at all and its module was left behind.
ledger: Arc<ModuleLedger>,
sink_name: String,
stream_router: Option<StreamRouter>,
event_task: Option<tokio::task::JoinHandle<()>>,
@@ -64,9 +84,18 @@ 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 ledger = ModuleLedger::new();
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`. That same per-load nonce is what lets
// reconciliation identify a module whose load was interrupted before its
// index was ever read.
load_module(&ledger, Shape::LegacyCaptureSink, pid)
.await
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
@@ -78,34 +107,20 @@ impl Routing {
// 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible.
let strict_app = opts.app.is_some() && opts.strict_audio;
let loopback_module = if strict_app {
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)")?,
)
};
if !strict_app {
load_module(&ledger, Shape::LoopbackIntoCapture, pid)
.await
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?;
}
tracing::info!(
sink_module,
?loopback_module,
strict_app,
%sink_name,
"audio routing: null-sink ready (loopback skipped in strict app mode)"
);
let loopback_arc = Arc::new(Mutex::new(loopback_module));
let local_monitor_arc = Arc::new(Mutex::new(None));
let mut routing = Self {
sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc),
local_monitor_module: Arc::clone(&local_monitor_arc),
ledger: Arc::clone(&ledger),
sink_name: sink_name.clone(),
stream_router: None,
event_task: None,
@@ -113,22 +128,17 @@ impl Routing {
if let Some(app) = &opts.app {
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 ledger_for_task = Arc::clone(&ledger);
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await {
match ev {
Event::FirstRoutedStream => {
let mid = loopback_for_task.lock().unwrap().take();
if let Some(id) = mid {
tracing::info!(
"audio routing: first stream routed → unloading default-sink loopback"
);
unload_module(id);
}
unload_module(&ledger_for_task, Shape::LoopbackIntoCapture).await;
// Mirror the routed app back to the sharer's own
// speakers so they hear the content they're sharing.
// Loaded *after* the default-sink loopback is gone so
@@ -136,25 +146,7 @@ impl Routing {
// sourced from the null-sink monitor — the chosen app
// 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",
]) {
Ok(id) => {
tracing::info!(
module = id,
"audio routing: local monitor loaded (sharer hears the shared app)"
);
*local_monitor_for_task.lock().unwrap() = Some(id);
}
Err(e) => tracing::warn!(
"audio routing: failed to load local monitor loopback: {e:#}"
),
}
}
ensure_loaded(&ledger_for_task, Shape::LoopbackOutOfCapture, pid).await;
// Tell the front-end the chosen app's audio is live.
output::emit(output::Event::AppAudio {
state: AppAudioState::Routed,
@@ -169,13 +161,7 @@ impl Routing {
// The shared app is gone, so its null-sink is silent:
// stop mirroring it to the sharer's speakers. Re-loads
// on the next FirstRoutedStream if the app resumes.
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
tracing::info!(
module = id,
"audio routing: last routed stream gone → unloading local monitor"
);
unload_module(id);
}
unload_module(&ledger_for_task, Shape::LoopbackOutOfCapture).await;
if strict {
// Strict mode: do NOT restore the whole-desktop
// loopback. Viewers hear silence until the app
@@ -188,28 +174,12 @@ impl Routing {
}
// Best-effort mode: restore the default-sink loopback
// so the viewer hears system audio again instead of
// silence.
if loopback_for_task.lock().unwrap().is_some() {
continue;
}
// silence. Already loaded is not an error — the ledger
// refuses the load and `ensure_loaded` says so quietly.
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",
]) {
Ok(id) => {
*loopback_for_task.lock().unwrap() = Some(id);
}
Err(e) => {
tracing::warn!(
"audio routing: failed to re-load loopback: {e:#}"
);
}
}
ensure_loaded(&ledger_for_task, Shape::LoopbackIntoCapture, pid).await;
}
}
}
@@ -235,43 +205,91 @@ impl Routing {
&self.sink_name
}
/// Stop the stream router (if any), then unload loopback (if still
/// loaded), then unload the null-sink. Order matters: PipeWire can
/// leave zombie links if you destroy a sink with active inputs.
/// Stop the stream router and the event task, settle anything the ledger is
/// unsure about, then unload every module in shape order — the loopbacks
/// before the sink they reference, because PipeWire can leave zombie links if
/// a sink is destroyed with active inputs.
///
/// Every step is a `take()`, so this is idempotent — `Drop` calls it again
/// as a backstop and the second run is a no-op.
fn cleanup(&mut self) {
/// The event task is **awaited, not merely aborted**. Aborting and walking
/// away is what left orphans behind: the task's load is an await point now,
/// so dropping its future marks the slot ambiguous rather than losing the
/// module — but only a path that then reconciles can actually clean it up.
/// `Drop` cannot await, which is why it is the narrower backstop.
pub async fn shutdown(mut self) {
if let Some(router) = self.stream_router.take() {
// ⚠️ Still an unbounded join: a wedged PipeWire thread parks this
// task indefinitely. That is the pre-existing defect S3b exists for.
// Nothing here makes it worse, and the ledger is what will make
// bounding it safe when it lands.
router.shutdown();
}
if let Some(mut task) = self.event_task.take() {
// The router's exit drops the event senders, so the task normally
// ends by itself. Abort is the fallback, and it is awaited through
// `&mut JoinHandle` so the future is genuinely dropped — and with it
// any in-flight permit — before reconciliation reads the ledger.
// Dropping the handle instead would *detach* the task, which is how a
// load could still land after teardown believed it was finished.
if tokio::time::timeout(PACTL_BUDGET, &mut task).await.is_err() {
tracing::warn!(
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
cancelling it"
);
task.abort();
let _ = task.await;
}
}
for _ in 0..TEARDOWN_ROUNDS {
if let Err(e) = ledger::reconcile_pending(&self.ledger).await {
tracing::warn!("audio routing: could not reconcile the module ledger: {e:#}");
}
let loaded = self.ledger.loaded();
if loaded.is_empty() {
break;
}
for fp in loaded {
unload_module(&self.ledger, fp.shape).await;
}
}
if !self.ledger.is_settled() {
tracing::warn!(
"audio routing: some audio modules could not be accounted for; \
`pixelpass --repair` will clean up anything left behind"
);
}
}
}
impl Drop for Routing {
/// Synchronous backstop for the paths that never reach [`Routing::shutdown`]
/// — an error on the way up, or a panic. It cannot await, so it can neither
/// wait for the event task nor reconcile; it unloads what the ledger can name
/// and says so plainly when something is left unexplained.
///
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
fn drop(&mut self) {
if let Some(router) = self.stream_router.take() {
router.shutdown();
}
if let Some(task) = self.event_task.take() {
task.abort();
}
if let Some(id) = self.loopback_module.lock().unwrap().take() {
unload_module(id);
for fp in self.ledger.loaded() {
if self.ledger.begin_unload(fp.shape).is_none() {
continue;
}
// Unload the local monitor before the null-sink it reads from, so the
// sink has no active loopback reader when it's destroyed.
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
unload_module(id);
let outcome = blocking_unload(fp.id);
self.ledger.finish_unload(fp.shape, outcome);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
if !self.ledger.is_settled() {
tracing::warn!(
"audio routing: torn down without settling the module ledger; \
run `pixelpass --repair` to clean up anything left behind"
);
}
}
/// Consume the routing and tear it all down now. `Drop` is the backstop;
/// the real work lives in [`cleanup`](Self::cleanup).
pub fn shutdown(mut self) {
self.cleanup();
}
}
impl Drop for Routing {
fn drop(&mut self) {
self.cleanup();
}
}
/// The app-audio state to announce at capture start, if any. Only strict per-app
@@ -349,52 +367,178 @@ struct SinkInputProperties {
// pactl module helpers
// ──────────────────────────────────────────────────────────────────────
fn load_module(args: &[&str]) -> Result<u32> {
let output = Command::new("pactl")
.arg("load-module")
.args(args)
.output()
.context("failed to run pactl load-module")?;
if !output.status.success() {
bail!(
"pactl load-module failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
/// 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.
async fn load_module(ledger: &Arc<ModuleLedger>, shape: Shape, pid: u32) -> Result<Fingerprint> {
let owner = owner_token(pid).context("could not build an audio ownership token")?;
let permit = ledger
.begin_load(shape, pid, owner.clone())
.map_err(anyhow::Error::new)
.with_context(|| format!("cannot load the {} module", shape.label()))?;
let mut cmd = tokio::process::Command::new("pactl");
cmd.arg("load-module")
.arg(shape.module_name())
.args(shape.render_args(pid, Some(&owner)))
.kill_on_drop(true);
// Deliberately **not** `select!`ed against a cancellation signal: a completed
// load whose index was then dropped on the floor is precisely the defect the
// ledger exists to prevent. Cancellation here happens by dropping this whole
// future, and the permit's `Drop` turns that into a question reconciliation
// can answer, rather than into silence.
let output = match tokio::time::timeout(PACTL_BUDGET, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => {
// Spawning or reading failed. Whether the server was ever reached is
// not knowable from here, so leave the permit unsettled: an
// unnecessary reconcile costs one listing, a missed one costs an
// orphan.
drop(permit);
return Err(e).context("failed to run pactl load-module");
}
let id_str = String::from_utf8(output.stdout)
.context("pactl returned non-UTF-8")?
.trim()
.to_string();
Err(_) => {
drop(permit);
bail!("pactl load-module did not finish within {PACTL_BUDGET:?}");
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.code().is_some() {
// pactl exited of its own accord, having reported the server's
// refusal: nothing was created, so there is nothing to reconcile.
permit.abandon();
} else {
// Killed by a signal, which may have arrived *after* the server
// created the module.
drop(permit);
}
bail!("pactl load-module failed: {stderr}");
}
let id_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
// back verbatim. Do not widen it.
id_str
.parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
let Ok(index) = id_str.parse::<u32>() else {
// The load may well have succeeded — we simply cannot say which module it
// produced, which is exactly what reconciliation is for.
drop(permit);
bail!("pactl returned unexpected module ID: {id_str:?}");
};
let fp = permit.commit(index)?;
tracing::info!(
module = fp.id,
shape = shape.label(),
"audio routing: loaded pactl module"
);
Ok(fp)
}
fn unload_module(id: u32) {
let result = Command::new("pactl")
/// Load `shape` unless the slot already holds it.
///
/// A busy slot is not a failure on the oscillation path: `FirstRoutedStream` and
/// `LastRoutedStreamGone` can both ask for a module that is already in the state
/// they want, and the ledger is what decides that rather than a separate flag.
async fn ensure_loaded(ledger: &Arc<ModuleLedger>, shape: Shape, pid: u32) {
let Err(e) = load_module(ledger, shape, pid).await else {
return;
};
match e.downcast_ref::<LedgerError>() {
Some(LedgerError::Busy { state, .. }) => tracing::debug!(
shape = shape.label(),
state,
"audio routing: nothing to load, the slot is already occupied"
),
_ => tracing::warn!(
"audio routing: failed to load the {} module: {e:#}",
shape.label()
),
}
}
/// Unload whatever the ledger holds for `shape`, and record how it went.
///
/// A no-op for a slot holding nothing. An outcome that cannot be confirmed is
/// recorded as uncertain rather than assumed done, so the module keeps being
/// named until the server is asked about it.
async fn unload_module(ledger: &Arc<ModuleLedger>, shape: Shape) {
let Some(fp) = ledger.begin_unload(shape) else {
return;
};
let mut cmd = tokio::process::Command::new("pactl");
cmd.arg("unload-module")
.arg(fp.id.to_string())
.kill_on_drop(true);
let outcome = match tokio::time::timeout(PACTL_BUDGET, cmd.output()).await {
Ok(Ok(output)) if output.status.success() => {
tracing::info!(module = fp.id, "audio routing: unloaded pactl module");
UnloadOutcome::Confirmed
}
Ok(Ok(output)) => UnloadOutcome::Uncertain(format!(
"pactl unload-module exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)),
Ok(Err(e)) => UnloadOutcome::Uncertain(format!("could not run pactl unload-module: {e}")),
Err(_) => {
UnloadOutcome::Uncertain(format!("pactl unload-module exceeded {PACTL_BUDGET:?}"))
}
};
ledger.finish_unload(shape, outcome);
}
/// The blocking unload [`Drop`] uses, since it has no runtime to await on.
fn blocking_unload(id: u32) -> UnloadOutcome {
match Command::new("pactl")
.arg("unload-module")
.arg(id.to_string())
.output();
match result {
.output()
{
Ok(output) if output.status.success() => {
tracing::info!(module = id, "audio routing: unloaded pactl module");
UnloadOutcome::Confirmed
}
Ok(output) => {
tracing::warn!(
module = id,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"audio routing: pactl unload-module exited non-zero"
);
}
Err(e) => {
tracing::warn!(
module = id,
"audio routing: failed to run pactl unload-module: {e}"
);
}
Ok(output) => UnloadOutcome::Uncertain(format!(
"pactl unload-module exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)),
Err(e) => UnloadOutcome::Uncertain(format!("could not run pactl unload-module: {e}")),
}
}
@@ -691,6 +835,130 @@ fn try_flush(
#[cfg(test)]
mod tests {
use super::*;
use crate::repair::plan::{ModuleObservation, classify};
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event
/// task — just the null-sink and its default-sink loopback.
fn whole_desktop_opts() -> HostOpts {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None,
quality: crate::cli::Quality::Auto,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
relay: None,
}
}
/// The module table exactly as `--repair` observes it.
fn module_snapshot() -> Vec<(u32, String, String)> {
let mut session =
crate::repair::introspect::PulseSession::connect().expect("a local Pulse server");
session
.list_modules()
.expect("the server lists its modules")
.into_iter()
.map(|m| (m.id, m.name, m.args))
.collect()
}
/// A/B against the live graph: routing must leave the module table exactly
/// as it found it. The same shape as `--repair`'s field gate, because the
/// property is the same one — nothing of ours outlives the session.
#[tokio::test]
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
async fn live_teardown_leaves_the_module_table_as_it_found_it() {
let before = module_snapshot();
let routing = Routing::start(&whole_desktop_opts())
.await
.expect("routing starts");
let during = module_snapshot();
let ours: Vec<_> = during
.iter()
.filter(|m| !before.iter().any(|b| b.0 == m.0))
.collect();
assert_eq!(
ours.len(),
2,
"the null-sink and its default-sink loopback must both be loaded"
);
for (id, name, args) in ours {
let fp = classify(&ModuleObservation::new(*id, name, args))
.expect("a module we loaded must match one of our canonical forms");
assert!(
fp.owner.is_some(),
"every module we load carries an owner token, or --repair cannot \
attribute it to this host's pid namespace"
);
}
routing.shutdown().await;
assert_eq!(
module_snapshot(),
before,
"teardown must leave the module table byte-identical"
);
}
/// The orphan race, staged against a real server: a load cancelled while
/// `pactl` is in flight must still be findable and removable.
///
/// Whether the server got as far as creating the module is genuinely racy,
/// and that is the point — the gate does not care which way it went, only
/// that the ledger can account for both. With the permit's `Drop` disarmed
/// and the module created, the final comparison fails.
#[tokio::test]
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
async fn live_a_cancelled_load_is_reconciled_not_orphaned() {
let before = module_snapshot();
let ledger = ModuleLedger::new();
let pid = std::process::id();
let ledger_for_task = Arc::clone(&ledger);
let task = tokio::spawn(async move {
let _ = load_module(&ledger_for_task, Shape::LegacyCaptureSink, pid).await;
});
// Let the task run up to its first await — the spawned `pactl` — so the
// abort lands mid-flight rather than before the load ever started, which
// would make this gate vacuous.
tokio::task::yield_now().await;
task.abort();
let _ = task.await;
// Non-vacuity: the permit is taken *before* `pactl` is spawned, so a
// cancelled load must leave a question behind whichever side of the spawn
// the abort landed on. Without this the gate could pass while the abort
// fired before the load ever began, proving nothing.
assert_eq!(
ledger.pending().len(),
1,
"the cancelled load must have left exactly one question behind"
);
ledger::reconcile_pending(&ledger)
.await
.expect("the ledger reconciles against the server");
for fp in ledger.loaded() {
unload_module(&ledger, fp.shape).await;
}
assert!(
ledger.is_settled(),
"every slot must end in a state we can explain"
);
assert_eq!(
module_snapshot(),
before,
"a cancelled load must leave nothing behind"
);
}
#[test]
fn object_serial_parses_past_u32() {
+36 -5
View File
@@ -74,8 +74,9 @@ use serde::Serialize;
use crate::host::aec::{AecConfig, AecState, AecValidator};
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
use crate::host::taint::owner::OwnerKey;
use crate::host::taint::snapshot::Serial;
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, StickyState, evaluate};
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, Reason, StickyState, evaluate};
/// How long the AEC validator may sit in `Validating` after the graph first
/// reports ready before failing closed. Generous relative to the observer's own
@@ -181,6 +182,16 @@ pub struct AuditRow {
pub eligible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<&'static str>,
/// The owner key that carried the taint across, when the reason is
/// `tainted-owner-bridge` *and* the tainted member shared a key directly.
///
/// §5.1 row 1 asserts "reason = owner bridge, **naming the key**" — the
/// point being that the exclusion is provably the owner bridge on a
/// specific key rather than an incidental link walk that happens to reach
/// the same verdict. [`Reason::code`] collapses the payload, so without
/// this field that row cannot be asserted from the record at all.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_key: Option<&'static str>,
/// The exclusion was carried over from a previous snapshot rather than
/// derived from the current topology (phase-2 stickiness).
pub sticky: bool,
@@ -195,9 +206,25 @@ pub struct TaintRow {
pub serial: u64,
pub name: Option<String>,
pub reason: &'static str,
/// As [`AuditRow::owner_key`]. Present here too because the bridge that
/// matters for a row's diagnosis is often on a non-candidate node.
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_key: Option<&'static str>,
pub sticky: bool,
}
/// The owner key a `tainted-owner-bridge` reason resolved on, if it named one.
///
/// `None` for every other reason, and also for a bridge whose tainted member
/// shared no key *directly* — the taint reached it transitively, so there is no
/// single key to name and inventing one would be a false diagnosis.
fn owner_key_of(reason: Reason) -> Option<&'static str> {
match reason {
Reason::TaintedOwnerBridge { key } => key.map(OwnerKey::code),
_ => None,
}
}
/// A node carrying a peerspeak ownership carrier on a role the engine does not
/// honour it on (round 10, R10-1). `role` is the point of the row: it says
/// which non-producer role the tag turned up on, which is what distinguishes a
@@ -424,11 +451,13 @@ fn build_body(
// engine would have passed — otherwise a shut gate would erase every
// reason code in the record and the matrix would stop constraining
// the engine at all.
let (eligible, reason, sticky) = match decision.eligibility {
Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky),
let (eligible, reason, owner_key, sticky) = match decision.eligibility {
Eligibility::NotEligible { reason, sticky } => {
(false, Some(reason.code()), owner_key_of(reason), sticky)
}
Eligibility::Eligible => match gate_reason {
Some(gate) => (false, Some(gate.code()), false),
None => (true, None, false),
Some(gate) => (false, Some(gate.code()), None, false),
None => (true, None, None, false),
},
};
AuditRow {
@@ -436,6 +465,7 @@ fn build_body(
name: decision.name.clone(),
eligible,
reason,
owner_key,
sticky,
}
})
@@ -450,6 +480,7 @@ fn build_body(
serial: serial.0,
name: node_name(projection, serial),
reason: entry.reason.code(),
owner_key: owner_key_of(entry.reason),
sticky: entry.sticky,
})
.collect();
+47
View File
@@ -529,6 +529,53 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
);
}
/// §5.1 row 1's other half: the record must **name the key** the bridge
/// resolved on, not merely say "owner bridge".
///
/// Without this the row is unassertable from the record: `Reason::code`
/// collapses `TaintedOwnerBridge { key }` to one string, so an exclusion that
/// arrived by an incidental link walk and one that arrived across a named owner
/// key are indistinguishable — and the row exists precisely to tell them apart.
/// The fixture's forwarder legs are joined by `pulse.module.id`, so that is the
/// key that must be reported.
#[test]
fn row_1_names_the_owner_key_the_bridge_resolved_on() {
let mut graph = Graph::new();
let call = graph.peerspeak_node("peerspeak-call", 200);
let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30);
graph.link(call, sink);
let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30);
let _playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30);
graph.link(sink, capture);
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let playback = body
.candidates
.iter()
.find(|row| row.name.as_deref() == Some("tainted-loopback-playback"))
.expect("the forwarder's playback leg is a candidate");
assert_eq!(playback.reason, Some("tainted-owner-bridge"));
assert_eq!(
playback.owner_key,
Some("pulse.module.id"),
"the bridge key must be named in the record, not collapsed into the reason code"
);
// And it stays absent everywhere it would be a false diagnosis: the tag
// exclusion is not a bridge at all.
let call_name = owned_name("peerspeak-call", 200);
let tagged = body
.candidates
.iter()
.find(|row| row.name.as_deref() == Some(call_name.as_str()))
.expect("the tagged call playback is a candidate");
assert_eq!(tagged.reason, Some("peerspeak-owned"));
assert_eq!(tagged.owner_key, None);
}
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
/// fusion observable, because fusing all Pulse-created nodes into one owner
+890
View File
@@ -0,0 +1,890 @@
//! What this host has put into the Pulse module table — and, where it cannot be
//! sure, what it must go and find out before doing anything else.
//!
//! # The defect this exists for
//!
//! Module ids used to live in three `Option<u32>`s, two of them shared with the
//! event task behind an `Arc<Mutex<…>>`. That representation cannot express *a
//! load is in flight*, and the gap is reachable today: teardown calls
//! `event_task.abort()` and then reads the ids, but the task's work is a
//! synchronous `pactl` call with no await point inside it, so the abort cannot
//! land until the load has already returned. Teardown therefore sees `None`,
//! unloads the sink, and the still-running task stores the new module's id into a
//! mutex nobody will ever read again — an orphan loopback pointing at a sink that
//! no longer exists.
//!
//! A slot is consequently not an `Option<u32>` but a small state machine, and the
//! transitions that matter are the ones that admit *we do not know*:
//!
//! ```text
//! begin_load commit
//! Vacant ───────────────► Loading ───────────────► Loaded
//! ▲ │ │ │
//! │ abandon │ │ permit dropped │ begin_unload
//! └────────────────────┘ │ unsettled ▼
//! ▲ ▼ Unloading
//! │ Resolution::Nothing Ambiguous ◄──────────────┘
//! └───────────────────────► │ ▲ uncertain outcome
//! │ │
//! Resolution::Conflict▼ └── Resolution::Adopt ──► Loaded
//! Poisoned
//! ```
//!
//! # Why the permit is affine
//!
//! [`LoadPermit`] is not `Clone`, is consumed by value to settle, and its [`Drop`]
//! marks the slot [`Reconcile::Load`] when it was never settled. That is what makes
//! the guarantee structural rather than a discipline: a cancelled task drops its
//! locals, so an aborted load *cannot* silently forget a module the server may
//! already have created. Two permitted loads for one slot cannot both commit,
//! because [`ModuleLedger::begin_load`] issues a permit only for a `Vacant` slot
//! and every other state — including the ambiguous one — refuses.
//!
//! # Why an ambiguous slot blocks the next load
//!
//! An unresolved load may or may not have created a module carrying our sink name.
//! Starting another one on top of it risks two sinks sharing a `node.name`, which
//! is measurably not an error the server reports: it accepts both, and
//! `pulsesrc device=<name>.monitor` attaches to the *older* one. A second capture
//! session would then be silently stolen by the debris of the first. Refusing to
//! proceed until the question is answered is the whole point.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context as _, Result};
use crate::repair::introspect::PulseSession;
use crate::repair::plan::{
Fingerprint, ModuleObservation, OwnerToken, Shape, classify, recorded_argument,
};
/// PulseAudio's "no such index" — `PA_INVALID_INDEX`, `(uint32_t) -1`.
///
/// Every Pulse load callback reports failure by handing back this value rather
/// than an index. We load through `pactl`, which reports failure by exiting
/// non-zero instead, so seeing it on stdout would mean the tool printed a
/// sentinel we must not mistake for a module: unloading it would be a request
/// about something that cannot exist. Treated as a load whose outcome is unknown,
/// never as a successful one.
pub const PA_INVALID_INDEX: u32 = u32::MAX;
/// What an unresolved slot needs looked up before it can be trusted again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reconcile {
/// A load whose outcome is unknown: `pactl` was killed, timed out, or printed
/// something we could not read as an index. The server may or may not have
/// created the module, so it is looked for **by owner token** — the nonce is
/// minted per load, so it names this attempt and no other.
Load {
shape: Shape,
pid: u32,
token: OwnerToken,
},
/// An unload whose outcome is unknown. The module is looked for by its full
/// fingerprint: still present means the unload did not happen, absent means it
/// did. An id alone would not do, because Pulse reuses module indices verbatim.
Unload { fp: Fingerprint },
}
/// One shape's slot in the module table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlotState {
/// Nothing loaded, and nothing outstanding.
Vacant,
/// A load is in flight under `permit`. The id is meaningless until it commits.
Loading { token: OwnerToken, permit: u64 },
/// A module we loaded and can name exactly.
Loaded { fp: Fingerprint },
/// An unload is in flight. The fingerprint is retained deliberately: an unload
/// that times out must not leave the module unrecorded.
Unloading { fp: Fingerprint },
/// The slot's real state is unknown and must be resolved against the server.
Ambiguous(Reconcile),
/// Resolution found something we refuse to act on. Terminal.
Poisoned { reason: String },
}
impl SlotState {
/// A short, stable label for logs and refusal messages.
fn label(&self) -> &'static str {
match self {
SlotState::Vacant => "vacant",
SlotState::Loading { .. } => "loading",
SlotState::Loaded { .. } => "loaded",
SlotState::Unloading { .. } => "unloading",
SlotState::Ambiguous(_) => "ambiguous",
SlotState::Poisoned { .. } => "poisoned",
}
}
}
/// Why the ledger refused an operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LedgerError {
/// The slot was not free. Carries the state that refused, for the log line.
Busy { shape: Shape, state: &'static str },
/// The slot is poisoned and will not be used again this session.
Poisoned { shape: Shape, reason: String },
/// `pactl` reported `PA_INVALID_INDEX` where an index was expected.
InvalidIndex { shape: Shape },
}
impl std::fmt::Display for LedgerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LedgerError::Busy { shape, state } => {
write!(f, "the {} slot is {state}, not free", shape.label())
}
LedgerError::Poisoned { shape, reason } => {
write!(f, "the {} slot is poisoned: {reason}", shape.label())
}
LedgerError::InvalidIndex { shape } => write!(
f,
"pactl reported PA_INVALID_INDEX for the {} module",
shape.label()
),
}
}
}
impl std::error::Error for LedgerError {}
/// The outcome of an attempted unload, as the caller observed it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnloadOutcome {
/// The server acknowledged the unload.
Confirmed,
/// The unload may or may not have happened: the command failed, was killed,
/// or its result could not be read.
Uncertain(String),
}
/// What a reconciliation found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
/// Exactly one module answers the question. Adopt it.
Adopt(Fingerprint),
/// No module answers it: the load never happened, or the unload did.
Nothing,
/// More than one module answers it. Refuse to guess — see [`Resolution`]'s use
/// in [`ModuleLedger::apply`], which poisons the slot rather than picking.
Conflict(usize),
}
/// The ledger proper: one slot per [`Shape`], plus the permit counter.
///
/// Held behind an `Arc` because the permit needs a way back to it from [`Drop`],
/// which is what makes a cancelled load impossible to lose.
pub struct ModuleLedger {
slots: Mutex<BTreeMap<Shape, SlotState>>,
next_permit: AtomicU64,
}
impl ModuleLedger {
pub fn new() -> Arc<Self> {
Arc::new(Self {
slots: Mutex::new(BTreeMap::new()),
next_permit: AtomicU64::new(1),
})
}
/// The current state of one slot. Absent keys read as [`SlotState::Vacant`].
///
/// Test-only: production code never needs to look a slot up, because every
/// decision that depends on one is made *by* the ledger — `begin_load`
/// refuses a busy slot and says which state refused, `begin_unload` returns
/// nothing for a slot holding nothing. An accessor callers could branch on
/// would invite exactly the check-then-act races the permit removes.
#[cfg(test)]
pub fn state(&self, shape: Shape) -> SlotState {
self.slots
.lock()
.unwrap()
.get(&shape)
.cloned()
.unwrap_or(SlotState::Vacant)
}
/// Take permission to load `shape`, moving the slot to
/// [`SlotState::Loading`].
///
/// Refuses anything but a `Vacant` slot. In particular an *ambiguous* slot
/// refuses, so an unresolved load blocks the next one until it is reconciled.
pub fn begin_load(
self: &Arc<Self>,
shape: Shape,
pid: u32,
token: OwnerToken,
) -> Result<LoadPermit, LedgerError> {
let mut slots = self.slots.lock().unwrap();
let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
match current {
SlotState::Vacant => {}
SlotState::Poisoned { reason } => {
return Err(LedgerError::Poisoned { shape, reason });
}
other => {
return Err(LedgerError::Busy {
shape,
state: other.label(),
});
}
}
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
slots.insert(
shape,
SlotState::Loading {
token: token.clone(),
permit,
},
);
drop(slots);
Ok(LoadPermit {
ledger: Arc::clone(self),
shape,
pid,
token,
permit,
settled: false,
})
}
/// Move a `Loaded` slot to `Unloading` and hand back what to unload.
///
/// `None` for any other state: there is nothing to unload, or the slot is not
/// in a condition to be acted on.
pub fn begin_unload(&self, shape: Shape) -> Option<Fingerprint> {
let mut slots = self.slots.lock().unwrap();
let SlotState::Loaded { fp } = slots.get(&shape).cloned()? else {
return None;
};
slots.insert(shape, SlotState::Unloading { fp: fp.clone() });
Some(fp)
}
/// Record how an unload turned out. An uncertain one becomes ambiguous rather
/// than being assumed done — the module is not forgotten either way.
pub fn finish_unload(&self, shape: Shape, outcome: UnloadOutcome) {
let mut slots = self.slots.lock().unwrap();
let Some(SlotState::Unloading { fp }) = slots.get(&shape).cloned() else {
return;
};
let next = match outcome {
UnloadOutcome::Confirmed => SlotState::Vacant,
UnloadOutcome::Uncertain(why) => {
tracing::warn!(
shape = fp.shape.label(),
module = fp.id,
"audio ledger: unload outcome uncertain ({why}); slot needs reconciling"
);
SlotState::Ambiguous(Reconcile::Unload { fp })
}
};
slots.insert(shape, next);
}
/// Every slot currently holding a module, in [`Shape`] declaration order —
/// which is unload order: the loopbacks that reference the capture sink come
/// before the sink itself.
pub fn loaded(&self) -> Vec<Fingerprint> {
self.slots
.lock()
.unwrap()
.values()
.filter_map(|state| match state {
SlotState::Loaded { fp } => Some(fp.clone()),
_ => None,
})
.collect()
}
/// Every question outstanding against the server, in shape order.
pub fn pending(&self) -> Vec<(Shape, Reconcile)> {
self.slots
.lock()
.unwrap()
.iter()
.filter_map(|(shape, state)| match state {
SlotState::Ambiguous(r) => Some((*shape, r.clone())),
_ => None,
})
.collect()
}
/// Is every slot in a state we can explain? False while anything is ambiguous
/// or poisoned.
pub fn is_settled(&self) -> bool {
self.slots
.lock()
.unwrap()
.values()
.all(|state| !matches!(state, SlotState::Ambiguous(_) | SlotState::Poisoned { .. }))
}
/// Apply a reconciliation result to an ambiguous slot.
///
/// A slot that is no longer ambiguous is left alone: the answer is stale, and
/// overwriting a live state with it would be worse than ignoring it.
pub fn apply(&self, shape: Shape, resolution: Resolution) {
let mut slots = self.slots.lock().unwrap();
if !matches!(slots.get(&shape), Some(SlotState::Ambiguous(_))) {
return;
}
let next = match resolution {
Resolution::Adopt(fp) => {
tracing::info!(
shape = shape.label(),
module = fp.id,
"audio ledger: reconciled — adopting the module the server actually has"
);
SlotState::Loaded { fp }
}
Resolution::Nothing => {
tracing::info!(
shape = shape.label(),
"audio ledger: reconciled — the server has no such module"
);
SlotState::Vacant
}
Resolution::Conflict(n) => {
let reason =
format!("{n} modules answer to this slot's token; refusing to choose one");
tracing::error!(shape = shape.label(), "audio ledger: {reason}");
SlotState::Poisoned { reason }
}
};
slots.insert(shape, next);
}
}
/// Permission to perform exactly one load, which must be settled by value.
///
/// Dropping it unsettled is not an error — it is the *reporting* path for a
/// cancelled or panicking load, and it marks the slot ambiguous so the module the
/// server may have created is looked for rather than forgotten.
#[must_use = "an unsettled permit marks the slot ambiguous when dropped"]
pub struct LoadPermit {
ledger: Arc<ModuleLedger>,
shape: Shape,
pid: u32,
token: OwnerToken,
permit: u64,
settled: bool,
}
impl LoadPermit {
/// Record that the server created the module at `index`.
///
/// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that
/// dropping it marks the slot ambiguous — a sentinel where an index belongs
/// means the load's outcome is precisely what we do not know.
pub fn commit(mut self, index: u32) -> Result<Fingerprint, LedgerError> {
if index == PA_INVALID_INDEX {
return Err(LedgerError::InvalidIndex { shape: self.shape });
}
let fp = expected_fingerprint(self.shape, self.pid, &self.token, index);
// A permit outlives its slot's `Loading` state only if something else has
// already moved the slot on — in which case this answer is stale and the
// live state wins.
let mut slots = self.ledger.slots.lock().unwrap();
match slots.get(&self.shape) {
Some(SlotState::Loading { permit, .. }) if *permit == self.permit => {
slots.insert(self.shape, SlotState::Loaded { fp: fp.clone() });
}
other => {
let state = other.map(SlotState::label).unwrap_or("vacant");
tracing::warn!(
shape = self.shape.label(),
module = index,
"audio ledger: a load committed against a slot that is now {state}; \
leaving the live state alone"
);
}
}
drop(slots);
self.settled = true;
Ok(fp)
}
/// Record that the server definitively created nothing.
///
/// Only for a load that failed *cleanly* — `pactl` exiting non-zero of its own
/// accord, having reported the server's refusal. A killed or timed-out load is
/// not this: drop the permit instead and let the slot go ambiguous.
pub fn abandon(mut self) {
let mut slots = self.ledger.slots.lock().unwrap();
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
slots.insert(self.shape, SlotState::Vacant);
}
drop(slots);
self.settled = true;
}
}
impl Drop for LoadPermit {
fn drop(&mut self) {
if self.settled {
return;
}
let mut slots = self.ledger.slots.lock().unwrap();
// Only claim the slot if it is still *our* load. Anything else already
// moved past this permit.
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
"audio ledger: a load was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Load {
shape: self.shape,
pid: self.pid,
token: self.token.clone(),
}),
);
}
}
}
/// The fingerprint a successful load of `shape` for `pid` under `token` must have.
///
/// Built from the shape's own renderer, exactly as [`classify`] rebuilds it from
/// an observation, so an adopted module and a committed one are the same value.
fn expected_fingerprint(shape: Shape, pid: u32, token: &OwnerToken, id: u32) -> Fingerprint {
Fingerprint {
id,
module_name: shape.module_name().to_string(),
args: recorded_argument(&shape.render_args(pid, Some(token))),
pid,
shape,
owner: Some(token.clone()),
}
}
/// Answer one outstanding question against a snapshot of the module table.
///
/// Pure: the snapshot is the only input, so every branch is testable without a
/// Pulse server.
pub fn resolve(reconcile: &Reconcile, observations: &[ModuleObservation]) -> Resolution {
let matches: Vec<Fingerprint> = match reconcile {
// The nonce is minted per load, so token equality names this attempt and
// no other — including a previous load of the same shape by the same pid.
Reconcile::Load { shape, token, .. } => observations
.iter()
.filter_map(classify)
.filter(|fp| fp.shape == *shape && fp.owner.as_ref() == Some(token))
.collect(),
// A full fingerprint match, not an id: Pulse reuses module indices
// verbatim, so "something is at that index" is not "our module is".
Reconcile::Unload { fp } => observations
.iter()
.filter(|obs| fp.still_matches(obs))
.filter_map(classify)
.collect(),
};
match matches.len() {
0 => Resolution::Nothing,
1 => Resolution::Adopt(matches.into_iter().next().expect("length checked")),
n => Resolution::Conflict(n),
}
}
/// Resolve every outstanding question against one snapshot of the module table.
///
/// One listing answers all of them, so the slots are reconciled against a single
/// server response rather than several that could disagree.
///
/// ⚠️ The session is deliberately short-lived — connect, list, disconnect — which
/// is `--repair`'s pattern and not the long-lived host session round 19 sketched.
/// `repair::introspect` documents why: on a request timeout the binding leaks the
/// boxed callback until the context disconnects, which is bounded and harmless for
/// a session that ends immediately, and is not acceptable for one held open for
/// the life of a share. Reconciliation is rare and off the hot path, so paying a
/// connection for it costs nothing that matters.
pub async fn reconcile_pending(ledger: &Arc<ModuleLedger>) -> Result<()> {
let pending = ledger.pending();
if pending.is_empty() {
return Ok(());
}
tracing::info!(
n = pending.len(),
"audio ledger: reconciling unresolved module slots against the server"
);
let observations = tokio::task::spawn_blocking(|| -> Result<Vec<ModuleObservation>> {
let mut session = PulseSession::connect()?;
session.list_modules()
})
.await
.context("the Pulse listing task failed to run")?
.context("could not list Pulse modules to reconcile the audio ledger")?;
for (shape, reconcile) in pending {
ledger.apply(shape, resolve(&reconcile, &observations));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn token(nonce: u64) -> OwnerToken {
OwnerToken {
machine: "abc123".to_string(),
boot: "def456".to_string(),
pid_ns: 4_026_531_836,
nonce,
}
}
/// A module observation as the server would report it for one of our loads.
fn observed(id: u32, shape: Shape, pid: u32, token: &OwnerToken) -> ModuleObservation {
ModuleObservation::new(
id,
shape.module_name(),
&recorded_argument(&shape.render_args(pid, Some(token))),
)
}
#[test]
fn a_committed_load_becomes_loaded() {
let ledger = ModuleLedger::new();
let permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit");
assert!(matches!(
ledger.state(Shape::LegacyCaptureSink),
SlotState::Loading { .. }
));
let fp = permit.commit(9).expect("9 is a real index");
assert_eq!(fp.id, 9);
assert_eq!(
ledger.state(Shape::LegacyCaptureSink),
SlotState::Loaded { fp }
);
}
#[test]
fn dropping_a_permit_unsettled_marks_the_slot_ambiguous() {
// The orphan race in one test: a load that is cancelled between spawning
// pactl and reading its id must leave the slot asking a question, never
// looking empty.
let ledger = ModuleLedger::new();
let permit = ledger
.begin_load(Shape::LoopbackOutOfCapture, 42, token(7))
.expect("a vacant slot issues a permit");
drop(permit);
assert_eq!(
ledger.state(Shape::LoopbackOutOfCapture),
SlotState::Ambiguous(Reconcile::Load {
shape: Shape::LoopbackOutOfCapture,
pid: 42,
token: token(7),
}),
"a cancelled load must be remembered as a question, not as a vacancy"
);
assert!(!ledger.is_settled());
}
#[test]
fn abandoning_a_cleanly_failed_load_returns_the_slot_to_vacant() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("a vacant slot issues a permit")
.abandon();
assert_eq!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Vacant,
"a load the server refused created nothing to reconcile"
);
assert!(ledger.is_settled());
}
#[test]
fn a_second_permit_is_refused_while_a_load_is_in_flight() {
let ledger = ModuleLedger::new();
let _first = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit");
let second = ledger.begin_load(Shape::LegacyCaptureSink, 42, token(8));
assert_eq!(
second.err(),
Some(LedgerError::Busy {
shape: Shape::LegacyCaptureSink,
state: "loading"
}),
"two loads for one slot must not both be permitted"
);
}
#[test]
fn an_ambiguous_slot_refuses_the_next_load_until_it_is_reconciled() {
// Why this matters: two sinks may share a node.name, and pulsesrc attaches
// to the older one — so loading over unresolved debris silently steals the
// next session's capture.
let ledger = ModuleLedger::new();
drop(
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit"),
);
assert_eq!(
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(8))
.err(),
Some(LedgerError::Busy {
shape: Shape::LegacyCaptureSink,
state: "ambiguous"
})
);
// Reconciling to "nothing was created" frees it again.
ledger.apply(Shape::LegacyCaptureSink, Resolution::Nothing);
assert!(
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(9))
.is_ok()
);
}
#[test]
fn an_invalid_index_is_not_adopted_and_leaves_a_question_behind() {
let ledger = ModuleLedger::new();
let permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit");
assert_eq!(
permit.commit(PA_INVALID_INDEX).err(),
Some(LedgerError::InvalidIndex {
shape: Shape::LegacyCaptureSink
})
);
assert!(
matches!(
ledger.state(Shape::LegacyCaptureSink),
SlotState::Ambiguous(_)
),
"a sentinel where an index belongs is exactly the unknown outcome"
);
}
#[test]
fn an_uncertain_unload_is_not_forgotten() {
let ledger = ModuleLedger::new();
let fp = ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
assert_eq!(
ledger.begin_unload(Shape::LoopbackIntoCapture),
Some(fp.clone())
);
ledger.finish_unload(
Shape::LoopbackIntoCapture,
UnloadOutcome::Uncertain("pactl was killed".to_string()),
);
assert_eq!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Ambiguous(Reconcile::Unload { fp }),
"an unload whose outcome is unknown must keep naming the module"
);
}
#[test]
fn a_confirmed_unload_empties_the_slot() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
ledger.begin_unload(Shape::LoopbackIntoCapture);
ledger.finish_unload(Shape::LoopbackIntoCapture, UnloadOutcome::Confirmed);
assert_eq!(ledger.state(Shape::LoopbackIntoCapture), SlotState::Vacant);
assert!(ledger.is_settled());
}
#[test]
fn begin_unload_only_acts_on_a_loaded_slot() {
let ledger = ModuleLedger::new();
assert_eq!(ledger.begin_unload(Shape::LegacyCaptureSink), None);
let _permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit");
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink),
None,
"a load in flight has no id to unload yet"
);
}
#[test]
fn resolve_adopts_the_one_module_carrying_our_token() {
let mine = token(7);
let reconcile = Reconcile::Load {
shape: Shape::LegacyCaptureSink,
pid: 42,
token: mine.clone(),
};
let observations = vec![
// A previous load by the same pid and shape: same everything but the
// per-load nonce, which is the whole reason the nonce exists.
observed(4, Shape::LegacyCaptureSink, 42, &token(6)),
observed(5, Shape::LegacyCaptureSink, 42, &mine),
// Somebody else's module entirely.
ModuleObservation::new(6, "module-null-sink", "sink_name=other"),
];
assert_eq!(
resolve(&reconcile, &observations),
Resolution::Adopt(expected_fingerprint(Shape::LegacyCaptureSink, 42, &mine, 5))
);
}
#[test]
fn resolve_reports_nothing_when_the_server_never_created_it() {
let reconcile = Reconcile::Load {
shape: Shape::LegacyCaptureSink,
pid: 42,
token: token(7),
};
let observations = vec![observed(4, Shape::LegacyCaptureSink, 42, &token(6))];
assert_eq!(resolve(&reconcile, &observations), Resolution::Nothing);
}
#[test]
fn resolve_fails_closed_when_more_than_one_module_answers() {
let mine = token(7);
let reconcile = Reconcile::Load {
shape: Shape::LegacyCaptureSink,
pid: 42,
token: mine.clone(),
};
// Two modules carrying the same token should be impossible. If it ever
// happens, guessing which to keep is how a live sink gets unloaded.
let observations = vec![
observed(4, Shape::LegacyCaptureSink, 42, &mine),
observed(5, Shape::LegacyCaptureSink, 42, &mine),
];
assert_eq!(resolve(&reconcile, &observations), Resolution::Conflict(2));
}
#[test]
fn a_conflict_poisons_the_slot_and_it_stays_poisoned() {
let ledger = ModuleLedger::new();
drop(
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit"),
);
ledger.apply(Shape::LegacyCaptureSink, Resolution::Conflict(2));
let SlotState::Poisoned { reason } = ledger.state(Shape::LegacyCaptureSink) else {
panic!("a conflict must poison the slot");
};
assert!(!ledger.is_settled());
assert_eq!(
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(8))
.err(),
Some(LedgerError::Poisoned {
shape: Shape::LegacyCaptureSink,
reason
}),
"poisoning is terminal for the session"
);
}
#[test]
fn resolve_finds_a_module_an_uncertain_unload_left_behind() {
let mine = token(7);
let fp = expected_fingerprint(Shape::LoopbackIntoCapture, 42, &mine, 5);
let observations = vec![observed(5, Shape::LoopbackIntoCapture, 42, &mine)];
assert_eq!(
resolve(&Reconcile::Unload { fp: fp.clone() }, &observations),
Resolution::Adopt(fp.clone()),
"still present means the unload did not happen"
);
assert_eq!(
resolve(&Reconcile::Unload { fp }, &[]),
Resolution::Nothing,
"absent means it did"
);
}
#[test]
fn an_unload_reconcile_ignores_a_stranger_at_the_same_index() {
// Pulse reuses module indices verbatim, so "something is at index 5" must
// not be read as "our module is still at index 5".
//
// The stranger has to be a module that *classifies* — another pixelpass
// host's canonical loopback — or this gate is vacuous: a comparator using
// the id alone would still return `Nothing` for junk, because junk is
// discarded by `classify` regardless of how the match was made.
let fp = expected_fingerprint(Shape::LoopbackIntoCapture, 42, &token(7), 5);
let another_hosts = observed(5, Shape::LoopbackIntoCapture, 99, &token(3));
assert_eq!(
resolve(
&Reconcile::Unload { fp: fp.clone() },
std::slice::from_ref(&another_hosts)
),
Resolution::Nothing,
"another host's module at our old index is not our module"
);
// And plain junk at that index is ignored too.
let junk = ModuleObservation::new(5, "module-loopback", "source=some_mic sink=theirs");
assert_eq!(
resolve(&Reconcile::Unload { fp }, std::slice::from_ref(&junk)),
Resolution::Nothing
);
}
#[test]
fn a_stale_answer_does_not_overwrite_a_live_slot() {
let ledger = ModuleLedger::new();
// Nothing is ambiguous, so an answer arriving late is meaningless.
let fp = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
ledger.apply(Shape::LegacyCaptureSink, Resolution::Nothing);
assert_eq!(
ledger.state(Shape::LegacyCaptureSink),
SlotState::Loaded { fp },
"a live state must win over a stale reconciliation"
);
}
#[test]
fn loaded_lists_modules_in_shape_declaration_order() {
// Declaration order is unload order: the loopbacks that reference the
// capture sink must come before the sink itself.
let ledger = ModuleLedger::new();
for (shape, id) in [
(Shape::LegacyCaptureSink, 1),
(Shape::LoopbackIntoCapture, 2),
(Shape::LoopbackOutOfCapture, 3),
] {
ledger
.begin_load(shape, 42, token(u64::from(id)))
.expect("a vacant slot issues a permit")
.commit(id)
.expect("a real index");
}
let order: Vec<Shape> = ledger.loaded().into_iter().map(|fp| fp.shape).collect();
assert_eq!(order, crate::repair::plan::ALL_SHAPES.to_vec());
assert_eq!(
order.last(),
Some(&Shape::LegacyCaptureSink),
"the sink must be unloaded after everything that references it"
);
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod aec;
pub mod audio;
pub mod audit;
mod capture;
pub mod ledger;
mod observer;
mod pipeline;
mod quality;
+18 -7
View File
@@ -16,7 +16,7 @@ use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
use anyhow::{Context, Result};
use pipewire::{self as pw, types::ObjectType};
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, VecDeque};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -130,7 +130,9 @@ struct LiveGlobal {
struct ObserverState {
model: RegistryModel,
latest: Arc<Mutex<Option<Projection>>>,
last_candidate: Option<u32>,
/// The pulse-PID candidate set as of the last probe, so only PIDs entering
/// it are read from `/proc`.
last_candidates: BTreeSet<u32>,
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
sink: Option<Box<dyn ProjectionSink>>,
started_at: Instant,
@@ -148,7 +150,7 @@ impl ObserverState {
Self {
model: RegistryModel::new(0, READINESS_TIMEOUT_MILLIS),
latest,
last_candidate: None,
last_candidates: BTreeSet::new(),
live_globals: BTreeMap::new(),
sink,
started_at,
@@ -163,10 +165,17 @@ impl ObserverState {
let event_outcome = self.model.apply(event);
let mut outcome = event_outcome;
let candidate = self.model.pulse_pid_candidate();
if candidate != self.last_candidate {
self.last_candidate = candidate;
if let Some(pid) = candidate {
// Round 10: a *set* of candidates, because repetition across Clients
// turned out not to identify pipewire-pulse (see `pulse_pid`'s module
// docs — WirePlumber repeats a PID too, which made the old single
// candidate permanently ambiguous on this host).
let candidates = self.model.pulse_pid_candidates();
if candidates != self.last_candidates {
// Only PIDs *entering* the set are probed. A PID that left and came
// back is "entering" again and so is re-probed, which is what keeps
// the PID-reuse guard honest rather than answering from a cached
// `comm` for a number that now belongs to someone else.
for &pid in candidates.difference(&self.last_candidates) {
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
.ok()
.map(|comm| comm.trim_end_matches(['\r', '\n']).to_string());
@@ -178,6 +187,8 @@ impl ObserverState {
outcome = Outcome::Applied;
}
}
self.model.retain_probed_comms(&candidates);
self.last_candidates = candidates;
}
// v3.5 §6.7 decision 2: a projection the model proved identical is not
+22 -9
View File
@@ -91,7 +91,7 @@ use crate::host::taint::snapshot::{
PortSnapshot, Serial,
};
use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, VecDeque};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
/// A monotonic millisecond clock value, supplied by the adapter via
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
@@ -399,12 +399,25 @@ impl RegistryModel {
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
}
/// The pulse-PID candidate the adapter should be probing (`None` = no
/// repeated `sec_pid`, nothing to probe). Exposed so the adapter re-probes
/// only when the candidate changes.
pub fn pulse_pid_candidate(&self) -> Option<u32> {
/// The pulse-PID candidates the adapter should be probing — every distinct
/// `sec_pid` on the current Clients. Exposed so the adapter re-probes only
/// the PIDs *entering* the set rather than all of them on every event.
pub fn pulse_pid_candidates(&self) -> BTreeSet<u32> {
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
pulse_pid::candidate(&clients)
pulse_pid::candidates(&clients)
}
/// Forget probed `comm`s for PIDs no Client presents any more.
///
/// Cannot change the projection — [`Self::pulse_pid`] only ever reads
/// `comm`s for PIDs in the current candidate set — so it is deliberately
/// not an [`Outcome`]-returning `apply` arm: it must not publish, and it
/// must not count as a graph event for O5. Its purpose is to bound the map
/// (one entry per live Client PID) in a host process that runs for hours,
/// and to guarantee a PID that leaves and returns is re-probed rather than
/// answered from a stale `comm`.
pub fn retain_probed_comms(&mut self, live: &BTreeSet<u32>) {
self.probed_comm.retain(|pid, _| live.contains(pid));
}
/// Fold one observation into the model. The returned [`Outcome`] tells the
@@ -701,9 +714,9 @@ impl RegistryModel {
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
/// the safe answer (key 4 unusable).
fn pulse_pid(&self) -> Option<u32> {
let candidate = self.pulse_pid_candidate()?;
let comm = self.probed_comm.get(&candidate).and_then(|c| c.as_deref());
pulse_pid::validate(candidate, comm)
pulse_pid::resolve(&self.pulse_pid_candidates(), |pid| {
self.probed_comm.get(&pid).cloned().flatten()
})
}
/// Project the current state into the taint engine's inputs.
+82 -49
View File
@@ -11,20 +11,49 @@
//! The derivation is split into two pure stages so the I/O — reading
//! `/proc/<pid>/comm` — stays in the adapter:
//!
//! 1. [`candidate`] finds the PID that *looks* like pulse from the graph
//! alone: the `pipewire.sec.pid` value shared across multiple Clients.
//! Native PipeWire clients carry their own distinct PID; only the
//! Pulse shim repeats one value, so a repeated value is the signal.
//! 2. [`validate`] confirms that candidate against the `comm` the adapter
//! read from `/proc`. This is what closes **PID reuse**: a recycled PID
//! that coincidentally repeats in the graph is rejected because
//! `/proc/<pid>/comm` now names a different process.
//! 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 at either stage — no repeated value, two repeated values,
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
//! 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::BTreeMap;
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 —
@@ -32,40 +61,19 @@ use std::collections::BTreeMap;
/// recycled PID belonging to e.g. `pipewire-pulseX`.
const PULSE_COMM: &str = "pipewire-pulse";
/// Stage 1: the PID that looks like pipewire-pulse from the client graph.
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
/// the Clients present.
///
/// Returns `Some(pid)` only when **exactly one** `pipewire.sec.pid` value is
/// shared by two or more clients. Rationale, matched to the failure matrix:
///
/// - **consistent** — one value repeats, the rest (native clients) are
/// distinct ⇒ that value.
/// - **inconsistent** — two or more values each repeat ⇒ we cannot tell which
/// is pulse ⇒ `None`.
/// - **missing property** — the Pulse clients carry no `sec_pid` ⇒ nothing
/// repeats ⇒ `None`.
///
/// A count threshold of two is deliberate: a single client carrying a PID is
/// indistinguishable from a lone native app, and pulse always mints many.
pub fn candidate(clients: &[ClientSnapshot]) -> Option<u32> {
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for client in clients {
if let Some(pid) = client.sec_pid {
*counts.entry(pid).or_insert(0) += 1;
}
}
// Every PID seen on 2+ clients is a pulse candidate. If there is exactly
// one such PID we trust it; zero or several ⇒ fail closed.
let mut repeated = counts.iter().filter(|&(_, &n)| n >= 2).map(|(&pid, _)| pid);
let first = repeated.next()?;
if repeated.next().is_some() {
// Ambiguous: more than one value repeats.
return None;
}
Some(first)
/// 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 the candidate against the `comm` read from
/// 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
@@ -79,11 +87,36 @@ pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
}
}
/// The two stages composed, for callers that already hold the probed `comm`.
/// The model keeps them separate (it recomputes the candidate as clients
/// churn, and only re-probes when the candidate *changes*), 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> {
let candidate = candidate(clients)?;
validate(candidate, comm_of(candidate).as_deref())
/// 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)
}
+124 -13
View File
@@ -418,29 +418,107 @@ fn clients_with(pids: &[Option<u32>]) -> Vec<ClientSnapshot> {
.collect()
}
/// A `comm` probe of `pipewire-pulse` identifies the daemon regardless of how
/// many Clients it holds.
#[test]
fn pid_candidate_consistent_repeated_value() {
fn pid_resolves_on_comm_not_on_repetition() {
let clients = clients_with(&[Some(4137), Some(4137), Some(9001)]);
assert_eq!(pulse_pid::candidate(&clients), Some(4137));
let pulse = pulse_pid::derive(&clients, |pid| {
Some(
if pid == 4137 {
"pipewire-pulse"
} else {
"firefox"
}
.to_string(),
)
});
assert_eq!(pulse, Some(4137));
}
/// 🔴 **The round-10 regression, measured on this host and caught by the §5.1
/// row-1 matrix run.** WirePlumber holds two Clients (`WirePlumber` and
/// `WirePlumber [export]`) sharing one `sec_pid`, so two values repeat. The old
/// stage 1 called that ambiguous and returned `None`, which switched key 4's
/// suppression off and fused every Pulse-emulated node into a single owner —
/// a machine-wide over-exclusion cascade, on a stock desktop, permanently.
#[test]
fn a_second_process_holding_two_clients_does_not_defeat_the_derivation() {
// 1747 = WirePlumber x2, 2528 = pipewire-pulse x2, plus a native app.
let clients = clients_with(&[Some(1747), Some(1747), Some(2528), Some(2528), Some(9001)]);
let pulse = pulse_pid::derive(&clients, |pid| {
Some(
match pid {
1747 => "wireplumber",
2528 => "pipewire-pulse",
_ => "firefox",
}
.to_string(),
)
});
assert_eq!(
pulse,
Some(2528),
"the WirePlumber pair must not make this ambiguous"
);
}
/// The other direction the old rule failed in: pipewire-pulse holding exactly
/// one Client (a session with one Pulse app) repeated nothing, so it was never
/// even a candidate — same cascade, opposite cause.
#[test]
fn a_daemon_holding_a_single_client_is_still_found() {
let clients = clients_with(&[Some(2528), Some(9001)]);
let pulse = pulse_pid::derive(&clients, |pid| {
Some(
if pid == 2528 {
"pipewire-pulse"
} else {
"kwin_wayland"
}
.to_string(),
)
});
assert_eq!(pulse, Some(2528));
}
#[test]
fn pid_candidate_inconsistent_two_repeats_is_none() {
let clients = clients_with(&[Some(4137), Some(4137), Some(9001), Some(9001)]);
assert_eq!(pulse_pid::candidate(&clients), None);
fn pid_candidates_are_every_distinct_sec_pid() {
let clients = clients_with(&[Some(4137), Some(4137), Some(9001), None]);
assert_eq!(
pulse_pid::candidates(&clients),
[4137, 9001].into_iter().collect()
);
}
#[test]
fn pid_candidate_missing_property_is_none() {
fn pid_missing_property_leaves_nothing_to_probe() {
let clients = clients_with(&[None, None, None]);
assert_eq!(pulse_pid::candidate(&clients), None);
assert!(pulse_pid::candidates(&clients).is_empty());
assert_eq!(pulse_pid::derive(&clients, |_| None), None);
}
/// No Client's `comm` is pipewire-pulse's: nothing to suppress that we can
/// prove, so `None` — and key 4 stays coarse rather than wrong.
#[test]
fn pid_candidate_single_occurrence_is_none() {
// One client per pid: nothing repeats, so nothing is pipewire-pulse.
fn pid_resolve_no_match_is_none() {
let clients = clients_with(&[Some(4137), Some(9001)]);
assert_eq!(pulse_pid::candidate(&clients), None);
assert_eq!(
pulse_pid::derive(&clients, |_| Some("firefox".to_string())),
None
);
}
/// Two live pipewire-pulse daemons: a single `Option<u32>` cannot suppress
/// both, so fail closed to over-exclusion rather than pick one and leak the
/// other's fusion.
#[test]
fn pid_resolve_two_daemons_is_none() {
let clients = clients_with(&[Some(4137), Some(9001)]);
assert_eq!(
pulse_pid::derive(&clients, |_| Some("pipewire-pulse".to_string())),
None
);
}
#[test]
@@ -473,9 +551,8 @@ fn pid_validate_reuse_named_other_process_is_none() {
#[test]
fn pid_derive_end_to_end_valid() {
let clients = clients_with(&[Some(4137), Some(4137)]);
let candidate = pulse_pid::candidate(&clients).expect("candidate");
assert_eq!(
pulse_pid::validate(candidate, Some("pipewire-pulse")),
pulse_pid::derive(&clients, |_| Some("pipewire-pulse".to_string())),
Some(4137)
);
}
@@ -490,7 +567,7 @@ fn model_pulse_pid_valid_through_projection() {
m.apply(client(1, 200, Some(4137)));
m.apply(client(2, 201, Some(4137)));
m.apply(client(3, 202, Some(9001)));
assert_eq!(m.pulse_pid_candidate(), Some(4137));
assert_eq!(m.pulse_pid_candidates(), [4137, 9001].into_iter().collect());
m.apply(RegEvent::ProcCommProbed {
pid: 4137,
comm: Some("pipewire-pulse".to_string()),
@@ -507,6 +584,40 @@ fn model_pulse_pid_none_until_probed() {
assert_eq!(m.project().pipewire_pulse_pid, None);
}
/// Probed `comm`s are dropped once no Client presents the PID any more.
///
/// Two reasons, and the second is the load-bearing one: the map is bounded by
/// the live Client count in a process that runs for hours, **and** a PID that
/// leaves and returns is re-probed rather than answered from the `comm` of
/// whoever held that number before. Pruning cannot change the projection —
/// `pulse_pid` only reads PIDs in the current candidate set — which is why it
/// is not an `apply` arm and must not publish.
#[test]
fn a_departed_pid_does_not_keep_its_probed_comm() {
let mut m = model();
m.apply(client(1, 200, Some(4137)));
m.apply(RegEvent::ProcCommProbed {
pid: 4137,
comm: Some("pipewire-pulse".to_string()),
});
assert_eq!(m.project().pipewire_pulse_pid, Some(4137));
// The daemon's Client goes away; the adapter prunes to the live set.
let live = m.pulse_pid_candidates();
assert!(live.contains(&4137));
m.retain_probed_comms(&std::collections::BTreeSet::new());
// A *different* process now holds 4137 and opens a Client. Without the
// prune this would answer from the stale `comm` and suppress a real app's
// owner key.
m.apply(client(2, 201, Some(4137)));
assert_eq!(
m.project().pipewire_pulse_pid,
None,
"the stale comm must not survive its PID leaving the graph"
);
}
#[test]
fn model_pulse_pid_none_on_comm_mismatch() {
let mut m = model();
+1 -1
View File
@@ -47,7 +47,7 @@ impl CaptureHandle {
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown();
audio.shutdown().await;
}
if let Some(serve) = self.serve.take() {
serve.shutdown().await;
+4 -1
View File
@@ -904,7 +904,10 @@ fn propagate_owner_bridge(
/// unknown than one we can** (Codex round 3 — the mirror image of the
/// round-1 case):
///
/// - A *bounded* tainted reader has a strong key or a usable PID, so its
/// - A *bounded* tainted reader has a strong key, or a usable PID **backed by
/// a resolved Client** (F11-1 — a node's self-claimed
/// `application.process.id` no longer bounds anything on its own; see
/// [`owner::owner_is_bounded`]), so its
/// siblings are exactly the output legs sharing that key. Any output leg
/// that is *itself* bounded by a **different** key is provably a different
/// owner and stays eligible; only unbounded output legs are its possible
+127 -57
View File
@@ -130,6 +130,22 @@ impl OwnerCtx {
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
self.client_pids.get(&node.props.client_id?).copied()
}
/// Does this node have **protected provenance** — an unambiguous Client
/// yielding `Some(pipewire.sec.pid)`?
///
/// ⚠️ Read **before** the pipewire-pulse suppression in [`keys_of`], and
/// that ordering is the whole rule (F11-1, below). A Pulse-emulated app's
/// Client resolves to the daemon's PID; the value is then omitted from the
/// bridge keys as too coarse to *group* on, but it is still a protected
/// `pipewire.*` answer to "who is this", so the app keeps its provenance.
///
/// ❌ Not "a unique Client object exists". A unique Client with
/// `sec_pid = None` satisfies that and carries no protected identity at
/// all, which is exactly the hole [`owner_is_bounded`] closes.
fn client_is_resolved(&self, node: &NodeSnapshot) -> bool {
self.client_pid(node).is_some()
}
}
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
@@ -257,10 +273,59 @@ fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
/// the engine must fail closed rather than declare it clean
/// (v3.4 §6.1.1, final paragraph).
///
/// # 🔴 OPEN, phase-6 blocking — the key union can *reduce* taint here
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
///
/// **Round 11 review, finding 1. Verified correct; deliberately not fixed in
/// that round.** Round 10 made key 4 a union of the node's
/// **The rule, implemented below:** a strong key (`node.link-group`,
/// `pulse.module.id`) bounds an owner on its own; **key 4 bounds an owner only
/// when the node's Client resolves** — see [`OwnerCtx::client_is_resolved`].
/// A node whose Client cannot be resolved at all is *unbounded*, whatever
/// `application.process.id` it puts on itself.
///
/// The five Client cases, which is the matrix this needed (two of them are
/// indistinguishable under the wrong reading of "resolves"):
///
/// | Client | node's own pid | bounded? | why |
/// | --- | --- | --- | --- |
/// | **absent** | claimed | **no** | nothing corroborates the claim |
/// | **ambiguous** (two Clients, one id) | claimed | **no** | "we do not know who owns this" must not be papered over |
/// | **unique but pid-less** | claimed | **no** | a Client object is not an identity; `sec_pid` is |
/// | **resolved-native** (`sec_pid` = the app's) | absent | **yes** | protected pid, and it *is* key 4 |
/// | **resolved-to-pipewire-pulse** | claimed | **yes** | protected provenance; the daemon pid is suppressed as a *grouping* key only |
///
/// The last row is what keeps this from being the blunt fix. Applying
/// "self-claims are not sound" without the provenance test unbounds every
/// Pulse-emulated app — their Client's `sec_pid` is the daemon's and
/// suppressed, so the node's own claim is their only per-app identity — which
/// re-triggers the §6.1.1 mass over-exclusion the whole design exists to avoid
/// and empties the eligible half of the §5.1 matrix.
///
/// **Cost, measured on the live graph** (2026-07-26): **zero**. The
/// before- and after-binaries audited the *same* graph simultaneously — both
/// are read-only observers, which is the only way to A/B a partition without
/// churn between runs — with a tagged producer feeding the default sink,
/// `parec` on its monitor as a real tainted reader (so the sweep was armed,
/// not merely present in the code), and Firefox, `aplay` and `pacat` as
/// bystanders. **181 records each, the same 14 distinct decision states, none
/// exclusive to either side, no `unresolved-owner` on either.** The eligible
/// half stayed non-empty throughout: native (`aplay`), Pulse-emulated
/// (`pacat`) and Firefox all eligible. O5 is unmoved: identical p50 (15 µs)
/// and busy fraction (0.0012), and the after-binary's worst per-record
/// recompute was *lower* (217 µs vs 243 µs — noise, same debug build, same
/// concurrent load).
///
/// Why it costs nothing here: every real app on this box is either native
/// (Client `sec_pid` = its own pid) or Pulse-emulated (Client `sec_pid` = the
/// daemon's), and **both resolve**. Sweeping all 18 live nodes for the
/// predicate's inputs directly, the only unresolved-Client nodes were
/// `Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose;
/// session-manager device nodes are unresolved too (their Client is pid-less)
/// but exception 2 already strips key 4 from them. That is the answer the
/// deferral was waiting for: the rule bites exactly the anomalous shapes, and
/// this host has none.
///
/// ## The leak it closes (round 11 review, finding 1)
///
/// Round 10 made key 4 a union of the node's
/// `application.process.id` and its Client's `pipewire.sec.pid`, and the claim
/// that this was "strictly additive" was too strong: the same key list also
/// feeds *this* predicate, so adding a value can move a node from unbounded to
@@ -277,55 +342,50 @@ fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
/// 3. Neither the bridge nor the sweep fires, and the output stays eligible
/// while re-emitting the call.
///
/// It cannot leak today: `evaluate()` is reached only by the dry-run audit,
/// which creates no links. It becomes live when phase 6 consumes eligibility.
/// Step 2 is now unbounded ⇒ the sweep fires ⇒ the leg is excluded. Note it
/// could not leak *yet* when it was filed — `evaluate()` is reached only by the
/// dry-run audit, which creates no links — and that is why the fix waited for
/// the §5.1 measurement instead of guessing at its cost.
///
/// **Why it is not fixed yet.** The principled repair is provenance: a
/// self-claimed `application.process.id` is not a *sound* bound, only the
/// protected keys are. But applying that bluntly makes every Pulse-emulated
/// app unbounded — their Client's `sec_pid` is the daemon's and suppressed, so
/// the node's own claim is their only per-app identity — which re-triggers the
/// §6.1.1 mass over-exclusion the whole design is built to avoid, and would
/// make the eligible half of the §5.1 matrix empty.
/// ## What this is deliberately NOT
///
/// The targeted rule that closes the path above without that cost: **a node
/// whose Client cannot be resolved at all must not be bounded by its own
/// self-claimed PID.** An ambiguous Client already means "we do not know who
/// owns this", and a self-claim must not paper over it; a Pulse app's Client
/// *is* resolved (to the daemon's PID, then suppressed), so it keeps its
/// bound. Implementing it needs `OwnerCtx` to distinguish "resolved" from
/// "absent", and `OwnerKeyIndex` to carry boundedness separately from the key
/// set, since bridging must keep using the full union.
/// It is not a claim that `application.process.id` is now unused: it still
/// bridges (a self-claim is fine as *evidence that two legs are related* —
/// the fail-closed direction), and a resolved-Client node is still bounded by
/// whichever key-4 value survives suppression. Only *boundedness* — the
/// permission to say "I can enumerate this owner's other legs, so a
/// differently-keyed output is provably someone else" — now demands a
/// `pipewire.*` answer to "who is this".
///
/// ⚠️ Do this **with the §5.1 matrix data in hand**, not before: the whole
/// question is how much over-exclusion the rule actually causes on a real
/// graph, and that is measurable rather than arguable.
///
/// ## Round 12 — the deferral holds, and "resolved" has a trap in it
///
/// Codex re-examined this and agreed the deferral is defensible while
/// `evaluate()` is audit-only, and that the rule above closes the recorded path
/// without unbounding normal Pulse-emulated apps — **but only under one
/// reading of "resolves"**, and the wrong reading reintroduces the hole:
///
/// - ✅ "Resolved" must mean **an unambiguous Client that yields
/// `Some(pipewire.sec.pid)`**, taken *before* the pipewire-pulse suppression
/// step. A Pulse app then still has the daemon's protected PID as
/// provenance, even though that value is omitted from the bridge keys, so it
/// stays bounded and the eligible half survives.
/// - ❌ **Do not** implement it as "a unique Client object exists". A unique
/// Client with `sec_pid = None` would satisfy that test while providing no
/// protected identity at all, leaving exactly the self-claimed-PID hole this
/// rule is meant to close.
///
/// So the matrix needs five Client cases, not two: **absent**, **ambiguous**,
/// **unique but pid-less**, **resolved-native**, and
/// **resolved-to-pipewire-pulse**. The third is the one that distinguishes the
/// two readings, and it is the row a two-case matrix would silently skip.
/// ⚠️ Bridging must keep using the **full** union, so boundedness is carried
/// separately from the key set in [`OwnerKeyIndex`] rather than being
/// re-derived from it.
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
keys_of(node, ctx)
.iter()
.any(|(key, _)| *key != OwnerKey::ClientId)
bounded_by(&keys_of(node, ctx), node, ctx)
}
/// [`owner_is_bounded`]'s rule, over an already-computed key list.
///
/// The single implementation: [`OwnerKeyIndex::build`] has the keys in hand and
/// must not recompute them, and two copies of a predicate this load-bearing is
/// how the two spellings drift apart.
fn bounded_by(keys: &[(OwnerKey, KeyValue)], node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
let mut has_process_key = false;
for (key, _) in keys {
match key {
// Strong keys are per-instance and name the sibling set directly.
OwnerKey::LinkGroup | OwnerKey::PulseModuleId => return true,
OwnerKey::ProcessId => has_process_key = true,
// Never: one process can present two `client.id`s (the measured
// GStreamer refutation, above).
OwnerKey::ClientId => {}
}
}
// F11-1. The key may be the node's own claim, the Client's protected pid,
// or both — `keys_of` does not record which, and it does not need to: a
// resolved Client is provenance for the node *whatever* value key 4 ends
// up carrying, and without one there is no protected identity to stand on.
has_process_key && ctx.client_is_resolved(node)
}
/// Owner keys computed once per snapshot.
@@ -336,16 +396,27 @@ pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
#[derive(Debug, Default)]
pub struct OwnerKeyIndex {
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
/// Nodes whose owner is positively bounded — see [`owner_is_bounded`].
///
/// ⚠️ **Stored, not derived from `keys`.** Since F11-1 the predicate needs
/// the node's Client as well as its key list, and the two answers are
/// deliberately different: the full union still bridges, while a
/// self-claimed pid no longer bounds.
bounded: BTreeSet<Serial>,
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
Self {
keys: snapshot
.nodes()
.map(|node| (node.serial, keys_of(node, ctx)))
.collect(),
let mut keys = BTreeMap::new();
let mut bounded = BTreeSet::new();
for node in snapshot.nodes() {
let node_keys = keys_of(node, ctx);
if bounded_by(&node_keys, node, ctx) {
bounded.insert(node.serial);
}
keys.insert(node.serial, node_keys);
}
Self { keys, bounded }
}
/// The strongest key these two nodes share directly, if any.
@@ -409,11 +480,10 @@ impl OwnerKeyIndex {
})
}
/// See [`owner_is_bounded`].
/// See [`owner_is_bounded`]. A node outside this snapshot is unbounded,
/// which is the fail-closed answer.
pub fn is_bounded(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
self.bounded.contains(&serial)
}
}
+228 -1
View File
@@ -1088,6 +1088,226 @@ fn the_nodes_own_process_id_wins_over_its_clients() {
);
}
// ──────────────────────────────────────────────────────────────────────
// F11-1 — a self-claimed pid is not provenance: the five Client cases
// ──────────────────────────────────────────────────────────────────────
/// The scaffold every F11-1 row needs: peerspeak's call reaching the hardware
/// sink, a **bounded** tainted reader, and an ordinary bystander.
///
/// ⚠️ The reader must be *bounded* (`sunshine` carries a real pid). An
/// unbounded tainted reader trips `propagate_unresolved_owner`'s other tier,
/// which sweeps **every** output candidate on the box regardless of its own
/// keys — the three "unbounded" rows below would then pass without testing
/// anything. The bystander is the other half of that guard: it is bounded via
/// the ordinary Pulse shape, so an implementation that unbounded everything
/// fails every row instead of passing three of them.
///
/// Returns the graph, the hardware sink to hang nodes off, and the two nodes
/// every row must name in its partition.
fn armed_with_a_bounded_reader() -> (Graph, NodeRef, NodeRef, NodeRef) {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
graph.link(hw, sunshine);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
(graph, hw, call, bystander)
}
/// Case 1 of 5 — **Client absent.** A node that names no Client at all has
/// nothing but its own word for who owns it, so it cannot be bounded.
#[test]
fn an_absent_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let orphan = graph.node(
"no-client",
MediaRole::StreamOutput,
NodeProps {
process_id: Some(70_001),
..NodeProps::default()
},
);
graph.link(orphan, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("no-client", orphan, "unresolved-owner"),
],
);
}
/// Case 2 of 5 — **Client ambiguous.** Two live Clients claim the id, so the
/// observer missed a removal and we do not know who owns this node. A
/// self-claimed pid must not paper over that: this is step 2 of the recorded
/// leak path, and before F11-1 the claim bounded the node and spared it.
#[test]
fn an_ambiguous_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let shared_id = graph.client(Some(70_010));
graph.client_with_id(shared_id, Some(70_011));
let app = graph.node(
"ambiguous-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(shared_id),
process_id: Some(70_012),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("ambiguous-client", app, "unresolved-owner"),
],
);
}
/// Case 3 of 5 — **Client unique but pid-less**, and *the row that decides
/// which rule is implemented*.
///
/// A unique Client object exists, so "resolved = a unique Client exists" would
/// call this node bounded — leaving the self-claimed-pid hole wide open under a
/// rule that looks like it closed it. `sec_pid` is what carries protected
/// identity, so `None` means unresolved, and pid-less Clients are ordinary
/// (the session manager's is one).
///
/// A two-case absent/resolved matrix skips this silently. That is why it is
/// written out.
#[test]
fn a_unique_but_pidless_client_leaves_a_self_claimed_pid_unbounded() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let pidless = graph.client(None);
let app = graph.node(
"pidless-client",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(pidless),
process_id: Some(70_020),
..NodeProps::default()
},
);
graph.link(app, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("pidless-client", app, "unresolved-owner"),
],
);
}
/// Case 4 of 5 — **Client resolved, native.** `pipewire.sec.pid` is the app's
/// own, so provenance and key 4 are the same value and the node is bounded
/// without claiming anything itself.
#[test]
fn a_resolved_native_client_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let mpv2 = graph.native_client_node("mpv-native", MediaRole::StreamOutput, 70_030);
graph.link(mpv2, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("mpv-native", mpv2)],
&[("call", call, "peerspeak-owned")],
);
}
/// Case 5 of 5 — **Client resolved to pipewire-pulse.** The row that stops
/// this rule from being the blunt fix.
///
/// Every Pulse-emulated app looks like this: the Client's `sec_pid` is the
/// daemon's — suppressed as a *grouping* key, because it would fuse fifteen
/// unrelated apps — while the node's own `application.process.id` is the app's.
/// Provenance is read **before** that suppression, so the app keeps its bound
/// and stays eligible. Reading it after would unbound every Pulse app on the
/// box and empty the eligible half of the §5.1 matrix, which is the §6.1.1
/// catastrophe arriving through the boundedness door.
#[test]
fn a_client_resolving_to_pipewire_pulse_still_bounds_its_node() {
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 70_040);
graph.link(firefox, hw);
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander), ("firefox", firefox)],
&[("call", call, "peerspeak-owned")],
);
}
/// **The recorded leak path, end to end** (round 11 review, finding 1).
///
/// One process, two Clients. Its reading leg claims the daemon's pid — which
/// exception 1 suppresses — while its Client holds a real protected pid `A`, so
/// the union bounds the reader by `A` and the *unbounded-reader* tier never
/// arms. Its re-emitting leg sits on a second Client whose id is **ambiguous**
/// (one of the two claimants even holds `A`, so this is not "the guess would
/// have been wrong" — it is "a guess is not evidence"), and claims a pid of its
/// own. The two legs share no key, so the bridge does not fire either.
///
/// Before F11-1 the self-claim bounded the output leg, both tiers stayed quiet,
/// and it re-emitted the call while eligible. Now the leg is unbounded, the
/// bounded-reader tier sweeps it, and `mpv` shows the sweep is still targeted.
#[test]
fn a_self_claimed_pid_cannot_spare_an_output_leg_the_bridge_cannot_reach() {
let mut graph = Graph::new();
let hw = graph.device_node("hw-sink", MediaRole::Sink);
let call = graph.peerspeak_node("peerspeak", 7);
graph.link(call, hw);
let reader_client = graph.client(Some(80_000));
let reader = graph.node(
"forwarder-in",
MediaRole::StreamInput,
NodeProps {
client_id: Some(reader_client),
process_id: Some(PULSE_PID),
..NodeProps::default()
},
);
graph.link(hw, reader);
let ambiguous = graph.client(Some(80_000));
graph.client_with_id(ambiguous, Some(80_001));
let output = graph.node(
"forwarder-out",
MediaRole::StreamOutput,
NodeProps {
client_id: Some(ambiguous),
process_id: Some(80_002),
..NodeProps::default()
},
);
graph.link(output, hw);
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
graph.link(bystander, hw);
// `mpv` staying eligible is what proves the reader is bounded: an
// unbounded tainted reader sweeps **every** output candidate, `mpv`
// included, and this row would then be testing the wrong tier.
assert_partition(
&run(&graph, &ctx()),
&[("mpv", bystander)],
&[
("call", call, "peerspeak-owned"),
("forwarder-out", output, "unresolved-owner"),
],
);
}
#[test]
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
@@ -2463,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
View File
@@ -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
View File
@@ -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
);
}
}
+297
View File
@@ -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"),
}
}
+553
View File
@@ -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(&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
);
}
}
+1470
View File
File diff suppressed because it is too large Load Diff