Author SHA1 Message Date
molluskandClaude Opus 5 b8b8b78b09 host/taint: pin what "resolved" must mean before F11-1 is implemented
Round 12 re-examined the deferral and agreed it holds while evaluate()
is audit-only, and that the recorded rule closes the path without
unbounding Pulse-emulated apps — but only under one reading of
"a node whose Client cannot be resolved at all".

The trap is worth writing down before anyone implements it: reading
"resolved" as "a unique Client object exists" passes for a unique Client
with sec_pid = None, which supplies no protected identity and leaves
exactly the self-claimed-PID hole the rule exists to close. It has to
mean an unambiguous Client yielding Some(pipewire.sec.pid), taken before
pipewire-pulse suppression.

That also means the §5.1 matrix needs five Client cases rather than two:
absent, ambiguous, unique-but-pid-less, resolved-native, and
resolved-to-pipewire-pulse. The pid-less row is the one that
distinguishes the two readings and the one a two-case matrix skips
without saying so.

Docs only. Still deferred, still to be decided with matrix data in hand.
220 tests green, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 02:01:07 -04:00
molluskandClaude Opus 5 289016d071 host/taint: record the open owner-union/boundedness interaction
Round 11 review, finding 1. Verified correct: round 10's claim that the key
union was "strictly additive" was too strong. The same key list feeds
owner_is_bounded, and the unresolved-owner sweep is triggered by an UNbounded
tainted reader -- so adding the Client's PID can move a reader from unbounded
to bounded and switch the sweep off, letting a same-process output leg with an
ambiguous Client and a bogus self-claimed PID stay eligible.

Cannot leak today (evaluate() is audit-only); becomes live in phase 6.

Not fixed in this round, and the doc says why: the blunt repair -- only
protected keys bound an owner -- makes every Pulse-emulated app unbounded,
which re-triggers the mass over-exclusion the design exists to avoid and would
empty the eligible half of the 5.1 matrix. The targeted rule (a node whose
Client cannot be resolved at all is not bounded by its own self-claimed PID)
is written down along with what it needs structurally, to be implemented with
matrix data in hand rather than argued from a whiteboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:09:17 -04:00
molluskandClaude Opus 5 4b2b192601 host/taint: correct the constant's own doc, and record the ProcessId ambiguity
Verification round on the round-10 review fixes.

Finding 6 named the fixture, taint/tests.rs and snapshot.rs, but the same
stale claim was also on PEERSPEAK_OWNED_VALUE itself — the definition site
for the very literal the finding was about, still arguing that any truthy
value counts and that this is the fail-closed direction. Corrected with the
reason the argument fails.

Also records a known imprecision the union widened: OwnerKey::ProcessId now
covers both application.process.id and the Client's pipewire.sec.pid, so a
bridge reported under the former may have resolved on the latter.
Pre-existing since R10-3; not fixed here because these codes are a stable
contract for the audit output and the phase 6 status event, so splitting one
wants its own decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:38 -04:00
molluskandClaude Opus 5 993befdedd host/taint: carry both pids as owner keys; gate the wiring and the contract
Round 10 review, findings 1, 4 and 6.

Finding 1 (P1, phase 6) — key 4 was `node.or_else(client)`, so a node's
client-controlled application.process.id REPLACED its Client's protected
pipewire.sec.pid. One process using two Clients could therefore split its
identity: the tainted reader reports a bogus node pid, the output leg omits
the node pid and falls back to the Client's real one, the legs are bounded
by different values, and they neither bridge nor trip the unbounded sweep —
the output stays eligible while re-emitting the call. Now a union of both
values, deduplicated, with exception 1 applied to each independently so the
pipewire-pulse pid still cannot fuse unrelated Clients.

Mutation-verified: reverting to or_else fails ONLY the new split-Client test
(so the union changes nothing else), dropping exception 1 fails 32 rows, and
using the Client pid alone fails 16.

Not reachable today — evaluate() is reached only by the dry-run audit, which
creates no links. It becomes live when phase 6 consumes these decisions.

Finding 4 — R10-4's test called peerspeak_owned() directly, so reverting
node_observation_from_props to truthy() left it green; the only case it
shared with production, exact "1", passes under both. A new test builds a
real pw_properties dict and drives the production wiring, and the mutation
now fails exactly that test while the helper test still passes.

Finding 6 — the cross-repo fixture still documented carrier 1 as "any value
other than false/0", which R10-4 made exact-"1". A producer following it
could emit "true" and silently lose the carrier. Fixture updated in both
repos (byte-identical, verified), along with the stale prose in taint/tests
and snapshot.rs, and the contract is now also exercised through the
production adapter rather than only against the constants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:57:09 -04:00
molluskandClaude Opus 5 abaf5d9c10 host/taint: a pid-less Client still makes its id ambiguous
Verification round on R10-3's own fix. The ambiguity guard detected a
duplicate client id by looking it up in the pid map — which is only
populated for Clients that carry a sec_pid at all. A pid-less first
claimant therefore left no trace, so the next Client claiming the same id
looked unique and its pid was used, resolving an ambiguous id: exactly
the guess the guard exists to refuse.

Reachable, not theoretical — pid-less Clients are ordinary here (the
session manager's is one). Reproduced: the bystander app went eligible
off a coin-toss owner attribution.

Claimed ids are now tracked separately from resolved pids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:42 -04:00
molluskandClaude Opus 5 67f4ff931e host/observer: match the ownership carrier exactly, not leniently
The lenient `truthy` spelling was wrong for this one property. Under it,
`peerspeak.owned=""` and `peerspeak.owned="false "` both read as owned,
so any process could suppress a rival application's audio from the share
with a property it did not have to spell correctly.

The justification for leniency was that treating an unexpected value as
"owned" over-excludes and is therefore safe. That does not hold: leniency
here buys false-positive exclusion, not safety. Fail-closed on this
feature is about ancestry — an unresolvable graph is not eligible — not
about parsing. The producer emits exactly PEERSPEAK_OWNED_VALUE at all
three of its sites and is pinned to it by the shared cross-repo fixture,
and a garbled property still leaves carrier 2's node.name prefix, which
is a union with this one.

`truthy` stays as it is for port.exclusive, port.monitor and
node.passthrough: those are PipeWire's own, their spelling varies by
producer, and each causes exclusion when true, so leniency really is the
safe direction there. Both halves now have a row saying so.

Codex phase-1 review F6. Round 10, R10-4. Mutation-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:59:17 -04:00
molluskandClaude Opus 5 295a575b15 host/taint: owner key 4 falls back to the Client's pipewire.sec.pid
Native PipeWire clients put no application.process.id on their nodes —
only client.id. keys_of read node properties alone, so those nodes had no
key 4, were therefore unbounded, and propagate_unresolved_owner excluded
them the moment any tainted reader existed anywhere on the machine.

Measured: an untagged mpv was eligible alone, and became unresolved-owner
the instant peerspeak played audio. Since peerspeak playing audio is the
only situation in which this feature runs, that amounted to "native
PipeWire apps are never shareable". The tainted reader that armed it was
sunshine, which is itself bounded — so this is the bounded-reader arm,
not the keyless-reader case §6.1.1 narrates.

The pid is one hop away, on the node's Client, already in the snapshot.

RISK, and the guard on it: every Pulse-emulated Client carries
pipewire-pulse's own PID as sec_pid — measured, 15 unrelated Clients
sharing 2528 on this host. An unguarded fallback would fuse all of them
into one owner. Exception 1 therefore applies to the fallback exactly as
it does to the node's own property, so the fallback strictly *adds*
correct bounding rather than trading it.

Ambiguous client ids yield no fallback pid: inventing an owner key is the
one direction that can reduce taint, so a coin toss is the wrong guess.

The client index is threaded through a new OwnerCtx rather than a sixth
positional Option<u32>, and evaluate() builds one and shares it, so the
components and the key index cannot disagree about who is bounded.

Round 10, R10-3. 6 new rows; 3 mutations verified — removing the
fallback, dropping the pulse-pid exception (11 rows die), and resolving
an ambiguous client id instead of dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:57:47 -04:00
molluskandClaude Opus 5 bf5f2508b8 host/taint: honour the ownership carriers on producers only
Neither ownership carrier is a security boundary — both are strings any
unprivileged process can put on its own node — so an unrestricted taint
root is a denial of the whole feature. An unlinked Stream/Input/Audio
named `peerspeak_owned_rogue` is a tainted *reader* (receivers includes
nodes by role, no link required) and an unbounded one, so
propagate_unresolved_owner fails every candidate on the machine closed.

Measured before this change: BASELINE eligible=1 excluded=[] became
WITH IMPOSTOR eligible=0 excluded=[firefox -> unresolved-owner].

Restricting the root to Stream/Output/Audio costs nothing real —
peerspeak only ever tags playback streams — and the AEC's virtual
sink/source is untouched, since it roots on module id, not on this tag.

A tag that is ignored is not silent: misplaced_ownership_tags feeds a
new `ignored_ownership_tags` audit field (omitted when empty), because
the fix *removes* an exclusion, and the two causes of a dropped tag —
a peerspeak tagging bug, or an impersonation attempt — both want seeing.

Codex phase-1 review F2, reproduced live. Round 10, R10-1.
5 new rows, mutation-verified: dropping the role restriction kills both
engine rows, and stubbing the diagnostic kills the third.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:46:40 -04:00
molluskandClaude Opus 5 45ca5057f8 host/taint: refuse an ambiguous contract fixture instead of resolving it
Codex phase-1 review, finding 3 (P2), concrete half. This side searched
a list and took the first match for a key; peerspeak's side collected
into a map and took the last. A byte-identical fixture containing a
duplicated key would therefore leave both suites green while the two
repos had selected *different* contracts — the precise drift the shared
file exists to prevent.

Both sides now assert the key is not already defined. Verified by
appending a duplicate `prop_value` to both fixtures: both suites fail.

The rest of finding 3 — one CI gate that feeds peerspeak's real
tag_child output through this repo's actual adapter and classifier,
rather than two per-repo literal tests — is a larger piece of work and
is not attempted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:22 -04:00
molluskandClaude Opus 5 8b41f64e12 host/observer: tag the live prop-recovery row with the real wire value
Verification-round follow-up to 1b01847. The phase-3r live row proves
carrier 1 survives the bind, which is the property F1 destroyed — but
it tagged its fixture sink with `peerspeak.owned=true`, not the `1`
the contract pins and peerspeak actually emits. It would have passed
even if the real literal did not.

Adds PEERSPEAK_OWNED_VALUE so the fixture can name the producer's
value, and asserts it against the shared contract file alongside the
other two literals. The sink's name still deliberately avoids the
`peerspeak_owned_` prefix, so carrier 2 cannot stand in for carrier 1
in that row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:06:06 -04:00
molluskandClaude Opus 5 1b01847c66 host/taint: match peerspeak's second ownership carrier
The consumer half of phase 1 (plan §5.1, impl plan §3). The engine's
tag root becomes a union: `peerspeak.owned` truthy OR `node.name`
starting with `peerspeak_owned_`. Round 8 added the second carrier
because a node property is invisible to the registry `global` event
and recoverable only by binding the node — which is exactly how the
phase-5 gate failed — while `node.name` is announced directly.

The union lives in `local_root_reason`, not in the adapter. Folding
both into the one `peerspeak_owned` bool at the observation boundary
would make each carrier untestable alone, which is the phase-3r
lesson: a gate asserting a value two sources can satisfy gates
neither. The existing `peerspeak_tagged_nodes_…` fixture now carries
both carriers, so it would keep passing if either were deleted; two
new tests pin them individually, and a third pins that the prefix
matches only at the start of a name.

Both literals are now named constants — they are a cross-repo wire
contract with peerspeak, not local naming — and asserted against
tests/fixtures/ownership-tag-contract.txt, committed byte-identical
in both repos. That test also runs the fixture's own worked example
name through the engine, so the shared file cannot document a value
this side does not actually exclude.

Five mutations verified: drop either carrier, loosen `starts_with` to
`contains`, or rename either constant, and exactly the intended test
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:39 -04:00
mollusk 0af0124e17 Merge round 9: uncertainty is not history
Found by the phase-5 audit minutes after phase 3r landed — a permanent sticky
taint on a hardware sink, from one link seen during enumeration. Sticky state
is now built from an evidence-only pass; decisions still fail closed.
2026-07-25 18:49:06 -04:00
mollusk e34289fdb5 Merge phase 3r: the registry global is an index, properties come from a bind
v3.5 §6.7, the fix for the phase-5 gate failure (F1/F2). Pure core + adapter,
each half reviewed by the other author; four-part exit gate passed, including
row 1 live on this host, plus an added live gate for the Device-side path.
2026-07-25 18:49:06 -04:00
mollusk 471b8221ff host/observer: address the Codex phase-3r review (2 fixes, both verified)
Codex's adversarial review of the pure core found no *certain* P1. Two
findings taken, both mutation-verified (the fix reverted, the intended test
dies, nothing else moves):

**F2, certain, P2 — `device_props` tested the wrong kind of ambiguity.** It
required exactly one live *Device* on the claimed id rather than exactly one
live *global*. With `[Device, Port]` on one id — a missed removal, the same
precondition as every other recycled-id hazard — it kept answering from the
older Device, so a node claiming that id held a stale `session_device = true`.
That flag strips the node's owner keys and its fail-closed backstop, so a
forwarder wearing it can put its output leg back on the eligible side. Now:
one slot total, and it must be the Device.

**F3, worth checking, P3 — `device.api` was corroborating by presence.**
`device.api=v4l2` under `factory.name=api.alsa.pcm.sink` satisfied the
positive classifier. No truthful configuration produces that pair, which is
the argument for reading it as an observation gone wrong rather than as
corroboration. The API must now equal the one the factory allowlist is
written for, an empty value is not a value, and the two sides disagreeing
fails closed. Tied to the allowlist being ALSA-only via a named constant.

Two findings NOT fixed here, both pre-existing and neither introduced by
round 8 — raised to the design doc instead:

- **P1, worth checking: hardware playback-to-capture paths** (Stereo Mix,
  Digital Loopback) on a card whose driver is an ordinary `snd_hda_intel`.
  Both its sink and source classify `session_device`, taint cannot cross the
  hardware hop, and a capture app reading that source can re-emit the call.
  This is `snd_aloop` again in a form the driver name cannot detect;
  distinguishing it needs ALSA control inspection, which is a design change
  and a new I/O surface, not a local fix.
- **P3: the 2 s readiness budget** can in principle never see an
  obligation-free instant under sustained startup churn, and `TimedOut` is
  sticky by design, so the process would be silent for its lifetime.
  Measured here: readiness at ~3 ms with 19 binds, so the margin is three
  orders of magnitude — but it wants a calibration argument, not a guess.

197 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:48:53 -04:00
mollusk 64f98990c8 host/taint: uncertainty is not history — it never enters sticky state
Found by the phase-5 audit on the live graph, immediately after phase 3r
landed: a hardware sink carried a permanent `unresolved-ancestry` taint. The
cause was one link observed while its output node was still unbound — a
correct fail-closed answer — which was then written into sticky state, where
retirement requires every member object to be absent. A live sound card never
is, so the mark survived readiness, 21 recomputes and deliberate churn.

Phase 3r makes this systematic rather than rare: every node is now withheld
until its bind resolves, so any link seen across that gap raises
`UnresolvedAncestry` on its input side. It fires at startup, every startup.

User decision (2026-07-25): uncertainty-based taint retires once the
uncertainty is gone; evidence-based taint keeps the absence rule.

Retiring by reason *code* would not be enough, because uncertainty launders
itself — an unresolved node propagates `TaintedUpstream`, which is
indistinguishable from real contamination once recorded. So the split is by
**provenance**: `evaluate` runs the fixpoint twice. Pass 1 fails closed
exactly as before and is what every decision is made from; pass 2 raises no
uncertainty root at all, and is the only thing sticky state is built from.
Nothing derived from an uncertainty can reach the sticky path.

Decisions are unchanged by construction — all 57 existing taint tests pass
untouched, including the fail-closed and sticky-survival rows.

3 new tests, mutation-verified (pointing `build_sticky` back at the
fail-closed taint kills exactly the two new uncertainty tests and nothing
else): unresolved ancestry clears once resolved; taint laundered downstream
of an uncertainty clears with it; real taint still survives its topology
disappearing.

Live: the audit's post-readiness records now report taint 0 where they
reported a permanent sticky entry before. Recompute cost roughly doubles as
expected (two fixpoints) — 80 µs worst case observed, against a 47 Hz event
rate.

`taint/tests.rs` keeps its one pre-existing hand-formatted line; everything
else in both files is rustfmt-clean.
2026-07-25 18:46:56 -04:00
mollusk 306b601490 host/observer: phase 3r adapter — bind every Node and Device
The I/O half of round 8 (Codex, gpt-5.6-sol xhigh; reviewed, formatted and
extended here). The adapter now reads `object.serial` and nothing else off a
Node or Device global, binds the object, and takes every property the engine
reasons about from its `info` props.

- `BoundProxy` generalises `BoundLink` to Node/Device/Link, each holding its
  listener *before* its proxy so the listener is dropped first — the original
  Link variant had that order inverted.
- Bind attachment now finds its slot by never-recycled serial rather than
  taking the queue's back, so nested callback activity during a bind cannot
  attach one generation's proxy to another's slot on a recycled id. A proxy
  that finds no slot is returned to the caller and dropped after the borrow
  ends. Removal still pops oldest-first, matching the model's `live_ids`.
- An `info` is parsed and emitted on the first callback carrying props and
  thereafter only when `change_mask` contains PROPS. I considered emitting
  unconditionally and leaning on the model's suppression rule, and rejected
  it: if a state-only `info` ever delivered a partial props dict, that would
  overwrite a complete observation with an incomplete one — a worse failure
  than the one it guards against, and the same class as F1.
- Ports stay unbound (v3.5 §6.7 / impl plan §4 item 6).

Gates: exit-gate row 1 (live prop recovery) passes on this host — the tagged
null sink projects `peerspeak.owned`, `pulse.module.id`, `node.passthrough`,
the loopback legs share a `node.link-group`, and a real ALSA node classifies
`session_device`.

Added a second live test for the Device half. Row 1's `session_device`
assertion is satisfied by a *union*: WirePlumber 0.5.15 copies `device.api`
and `alsa.driver_name` onto ALSA nodes here, so it passes through the node
fallback and would keep passing if the Device bind delivered nothing —
leaving §6.7 decision 4 ungated on the development machine. The new test
binds every Device and requires an ALSA card to announce both keys.
Mutation-verified: breaking the Device-side driver read fails the new test
while row 1 still passes, which is the gap as claimed.

195 unit + 3 live green, clippy -D warnings and fmt clean.
2026-07-25 18:32:27 -04:00
mollusk b3d71724ae host/observer: phase 3r pure core — node/device props come from a bind
v3.5 §6.7. The registry `global` event announces only a fixed 13-key subset
of a Node's properties, and eight the engine depends on are never among them
(phase-5 gate failure F1/F2). The core now treats the global as an index and
takes every property from the object's bound `info`.

- `RegEvent::NodeAdded { serial, id }` is identity only; `RegEvent::NodeInfo`
  carries the properties and is both the first resolution and every later
  PROPS change for the node's lifetime (decision 2). Same split for Device
  (`DeviceAdded` / `DeviceInfo`).
- A node with no `info` is withheld from the snapshot and is a readiness
  obligation; an unresolvable bind ends in sticky `TimedOut`, fail closed
  (decision 3). Devices are keyed by serial too, so a recycled device id with
  two live claimants is ambiguous ⇒ withheld rather than guessed.
- One live-node map replaces the admitted/withheld pair; classification is
  recomputed at projection time from current inputs, since both sides of it
  now change over an object's lifetime.
- `classify` takes the bound Device's props: presence is a union with the
  Device winning (this recovers a real card whose node was never given
  `alsa.driver_name` — the phase-3 review's owed fix), while the
  non-terminal-driver denylist is a union in the safe direction.
- `apply` returns `Outcome`, the only sound place to enforce the suppression
  rule: a property update is dropped only when model state provably did not
  change, i.e. the resulting projection is identical.

Adapter: stops reading properties off Node/Device globals and emits the new
index events. Binding every Node and Device — the I/O half — is the next
commit (Codex's), so until then every node is withheld and readiness times
out by design.

Tests: 55 observer (was 38) — the prop-update matrix, readiness with node
binds, and recycled-Node-id churn (phase 3r gate rows 2–4). 195 green,
clippy -D warnings and fmt clean.
2026-07-25 18:17:48 -04:00
molluskandClaude Opus 5 a1ac7ea8d5 Merge phase 5: dry-run audit mode (read-only)
The audit machinery is complete and verified live. The §5.1 gate itself
FAILED — see peerspeak docs/screenshare-audio-exclusion-phase5-results.md —
but both findings are defects in phase 3's observation boundary, not in this
code, and round 8 needs the audit tool on main to re-run the matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:39 -04:00
molluskandClaude Opus 5 bbf6744444 host/audit: phase 5 — dry-run audit mode (read-only)
Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.

Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.

The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.

Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.

- audit/mod.rs   Auditor: AEC validator + taint engine + record building.
                 The AEC gate and the engine's own reasons stay
                 distinguishable — a shut gate must not erase the reason codes
                 the §5.1 rows assert.
- audit/metrics.rs  O5: event rate, bucketed recompute distribution + exact
                 max, busy fraction, and a documented lower-bound queueing
                 proxy (libpipewire exposes no queue depth).
- audit/sink.rs  JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
                 stdout — peerspeak parses that stream.
- audit/run.rs   Env parsing; a malformed AEC value is fatal, matching phase
                 4's rule that it must not silently become "no AEC".

Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.

33 new tests, 178 green, clippy -D warnings and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:10 -04:00
molluskandClaude Opus 4.8 e0fe47d5a9 Merge phase 4: AEC identity validation state machine (pure core)
Bounded read-only state machine (NotConfigured/Validating/Validated/
Failed/Revoked) that validates peerspeak's live echo-cancel module
identity and fills ExclusionCtx.aec_module_id — the last field the taint
engine needed. Design v3.4 §5.2/§5.3, impl plan §4.

Adversarial Codex review: no merge-blockers; five worth-checking items
addressed via documentation + closing two test holes (both fixes
mutation-verified). No core logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:10 -04:00
molluskandClaude Opus 4.8 4a844ac6fa host/aec: address Codex phase-4 review (no merge-blockers; docs + test holes)
Codex adversarial review found no merge-blocking defects. Five
worth-checking items, all triaged for reachability:

- F1 (spurious pre-ready revoke): unreachable — the AEC's four nodes are
  two Stream/* legs + a null-sink-like virtual sink/source, none claiming
  a device.id, so the phase-3 observer never withholds them; index_present
  goes false only on a genuine full unload. Documented why revoke is NOT
  gated on graph_ready, and why gating it would reopen the reused-index
  alias trap (F4) during a hot-reload-under-churn. Pinned with
  revokes_on_empty_even_while_not_ready (mutation-verified: `&& graph_ready`
  on the revoke guard dies here).
- F4 (test relies on observing the empty gap): documented the phase-5/6
  integration contract it rests on (one observe per graph event, no
  coalescing across a module lifetime boundary) and owed the robust fix
  (serial-continuity / observer-generation) to a later hardening round.
- F2 (late positive evidence beats the deadline): intentional and correct
  — a demonstrably-present identity is ground truth. Documented +
  late_positive_evidence_wins_over_expired_deadline (both arms: node-first
  validates, Tick-first fails closed and stays sticky).
- F3 (real P3 coverage hole): strengthened deadline_is_not_armed_until_
  graph_ready to prove the budget starts at first-ready, not construction
  (mutation-verified: a construction-relative deadline now dies).
- F5 (`+7` grammar mismatch): documented the producer contract — peerspeak
  emits bare decimal (pactl returns unsigned decimal), the narrow parser
  is deliberate. Unreachable on the measured stack.

No core logic change. 24 pure aec tests, cargo test --bins green (145 unit
+ 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:01 -04:00
molluskandClaude Opus 4.8 b74ed17823 host/aec: phase 4 — AEC identity validation state machine (pure core)
Bounded, read-only state machine that validates peerspeak's live
echo-cancel module identity before the taint engine trusts it, filling
the last ExclusionCtx field (aec_module_id). Design v3.4 §5.2/§5.3,
impl plan §4.

States (v3.4 §5.3 verbatim): NotConfigured / Validating / Validated /
Failed / Revoked. No fan-out while Validating; Failed and Revoked are
sticky terminals so a reused module index (indices ARE reused, §5.2
correction 3) cannot alias a Revoked epoch onto an unrelated reload.
Revocation is loss of the whole identity (every node bearing the index
gone), never one leg corking. The Failed deadline is armed only on the
first graph_ready, so a slow initial enumeration is "unknown" not
"absent" and never times out spuriously.

parse_aec_arg handles --aec=off|pulse-module:<idx> (D5): bare-u64
decimal accepted past u32::MAX, rejecting sign/whitespace/non-digit/
overflow/unknown-form.

22 pure tests (the exit-gate transition matrix), cargo test --bins
green (143 unit + 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:51:49 -04:00
8973e5dc19 Merge phase 3: registry observer (pure core + I/O adapter)
Split-seam mutual-review build: pure reducer/classifiers (Claude) + libpipewire
adapter (Codex), each reviewed by the other. Two review rounds closed 3 P1s
(dynamic graph_ready over invisible edges; snd_aloop absent-driver fail-closed;
FIFO lockstep). Exit gate incl. live topology-diff row passes on the host.

Additive/read-only — does not yet replace the audio.rs router (integration
phase). DAG: 0a -> 2 -> 3 done; next is Phase 4 (AEC validation state machine).

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:01:32 -04:00
molluskandClaude Opus 4.8 f90bee63f7 host/observer: close the snd_aloop absent-driver leak (Codex re-review)
Codex's re-review of the phase-3 fixes confirmed finding 1/5/6 closed but
found the finding-2 fix incomplete: the denylist only rejected a *present*
snd_aloop driver, so an snd_aloop node whose alsa.driver_name was not copied
onto the node still classified session_device=true — the original leak. The
absence is reachable: PipeWire >=1.2.6 stopped overwriting node props with
card props, and WirePlumber only began copying alsa.* onto nodes in 0.5.13.

Fix: session_device now requires a PRESENT, non-denied ALSA driver; a missing
alsa.driver_name fails closed to NotSessionDevice (a real card without the
prop is over-excluded — safe; recovering it needs reading the driver from the
backing Device global, owed to a later round). Mutation-verified: reverting to
fail-open on absence is killed by classify_alsa_without_driver_name_fails_closed.

Also: corrected the finding-3 limitation doc to cite PipeWire's object.serial
identity contract rather than overclaiming the live gate proves it (Codex P3,
non-blocking).

121 unit + live gate row 6 green, clippy clean, observer files fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:40:47 -04:00
molluskandClaude Opus 4.8 557c1030a7 host/observer: address Codex phase-3 review (2 P1 + P3s)
Cross-review round: Codex adversarially reviewed my pure core, found two
merge-blocking P1s and several P3s. Triaged each for reachability; fixes below,
each mutation-verified (revert killed by its intended test).

P1 finding 1 — graph_ready was sticky-once-Complete, so a Link added
post-enumeration whose endpoints are still binding (an INVISIBLE edge, absent
from the snapshot) left graph_ready=true and a candidate could be reported
eligible over unseen tainted ancestry. graph_ready is now dynamic:
Complete AND no outstanding obligations. Readiness::Complete stays sticky as
the epoch marker. New regression test + flipped the old sticky-churn test.

P1 finding 2 — snd_aloop presents with an allowlisted ALSA factory and
device.api=alsa exactly like a real card but forwards audio through a kernel
hop the Link graph cannot see; it was classified session_device=true, dropping
its owner keys + backstop (leak). Added alsa.driver_name to DeviceClaim and a
NON_TERMINAL_ALSA_DRIVERS denylist under the factory allowlist; adapter now
populates it. Negative fixture added.

P3 finding 5 — the BlueZ allowlist entries (api.bluez5.pcm.*) were invented;
removed them (real names are api.bluez5.media.*). A BT sink now over-excludes
(safe) pending a measured fixture. P3 finding 6 — strengthened the timeout
test to assert TimedOut stays sticky through later DeviceAdded/sync/tick.

Findings 3 (dropped-link unrepresented) and 4 (missed-removal generation
ambiguity) documented as accepted low-reachability limitations (links carry
object.serial — confirmed by the live gate; registry does not drop removals).

Codex confirmed the pulse-PID matrix fails safe and the adapter add() FIFO is
lockstep. 120 unit + live gate row 6 green, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:28:31 -04:00
66a0dd54df host/observer: phase 3 I/O adapter (read-only registry observer)
Codex authored the adapter half of the phase-3 split; I reviewed it and
applied one robustness fix (below). Additive/read-only — does not yet replace
the existing audio.rs router (that migration is a later integration phase).

adapter.rs: a dedicated libpipewire main-loop thread translating registry
globals into RegEvents and publishing the latest Projection via
RegistryObserverHandle::latest(). core.sync(0)/done is matched one-shot →
ServerSynced; a 250 ms loop timer emits Tick for the fail-closed readiness
timeout; pulse-PID candidates are probed from /proc/<pid>/comm only when the
candidate changes; Links missing endpoint props are bound (LinkInfoRef, weak
back-ref to avoid the listener cycle) and resolved via LinkEndpointsResolved.
mod.rs: `pub mod adapter;`. audio.rs: parse_object_serial → pub(crate) so the
adapter reuses the strict 64-bit parser.

Review fix: record_global was called unconditionally per global (including
unknown object types and dropped globals), which could desync the bound-link
FIFO from the model's live_ids and leak a Link proxy on a recycled id. Now
folded into `add()` so a slot is recorded only when an Added event is applied
— the two id queues are provably lockstep.

Exit gate complete: 5 pure rows + the live topology-diff row (row 6) — the
#[ignore] adapter test PASSES on this host against the live daemon
(module-null-sink + module-loopback observed appearing and disappearing).
cargo test --bins 117 + 1 live green, clippy clean, no fmt sweep.

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 13:06:36 -04:00
molluskandClaude Opus 4.8 8206864a43 host/observer: phase 3 pure core — RegistryModel reducer + classifiers
My half of the phase-3 split (impl plan §4). Pure, no PipeWire: the adapter
(Codex's half) translates live registry callbacks / binds / /proc reads /
core.sync into RegEvents and feeds this reducer.

- RegistryModel::apply folds RegEvents into serial-keyed maps with an
  insertion-ordered id index so global_remove accounts for the oldest
  generation first; recycled ids stay Ambiguous until accounted (v3.4 §6.1.3).
- Readiness epoch: graph_ready false until ServerSynced + no outstanding
  obligations (withheld nodes, pending link binds); bounded timeout fails
  closed. Gates sticky retirement only; sticky once terminal.
- session_device classifier: hardware-PCM factory allowlist, exact match,
  fail closed to false; a node on an unresolved Device is withheld, never
  admitted provisional.
- pulse-PID derivation split into pure candidate (repeated sec_pid) + validate
  (/proc comm), so the 6-case failure matrix is unit-testable; any failure =>
  None (key 4 unusable).

34 tests cover 5 of 6 exit-gate rows (the live topology-diff row is the
adapter's). cargo test --bins 117 green, fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:43:59 -04:00
molluskandClaude Opus 4.8 ab597c332d Merge phase 2: pure graph model + taint engine
Impl-plan §4/Phase 2, design v3.4 §6.1-§6.1.3. Pure engine
(evaluate(&GraphSnapshot,&ExclusionCtx,&StickyState)->(Decisions,StickyState)),
never linked against libpipewire; fed by the phase-3 observer to come.

Six adversarial review rounds with Codex (gpt-5.6-sol xhigh). Real echo
leaks found and closed in rounds 1-3 (owner-bridge, sticky-client
contamination, asymmetric forwarders, device mis-classification); my F1
narrowing refuted and conceded in round 4; contract strengthenings in 5-6.
Every fix mutation-verified (reverting it is killed by its intended test).
57 tests, each asserting an exact eligible/excluded partition.

KNOWN v1 LIMITATION (user-accepted 2026-07-22, owed to design doc round 8):
an app that buffers the call, fully tears down its PipeWire objects, and
replays after reconnecting can leak. In-threat-model but contrived; the
fix (process-generation sticky lifetime, revising v3.4 §6.1.3) is deferred
to phase 3's process-liveness work. Documented in src/host/taint/mod.rs.

Phase-3 obligations recorded in the taint module docs and Codex's round-6
report. Unblocks phase 3 (registry observer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:42:52 -04:00
molluskandClaude Opus 4.8 279903e56e host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6)
Codex refuted my round-5 disposition and was right: the buffered-echo gap
is NOT limited to keyless/unbounded readers. A normal PID-bearing app —
recorder, DAW, GStreamer — can read the call, buffer it in application
memory, fully tear down its PipeWire Node *and* Client, then (still the
same live process) open a fresh Client + output and replay. `seed_sticky`
drops the PID fingerprint once every old serial is gone, so the replayed
leg is Eligible. That is in-threat-model, so my "outside the threat model"
claim was false.

- Rewrote the module-doc gap note honestly: in-threat-model, reachable by
  non-adversarial software, sitting on the design's §6.1.3 "full teardown
  ⇒ starts clean" boundary. Framed the two options — (A) accept as a
  documented v1 limitation, (B) process-generation lifetime (PID + /proc
  start-time, phase 3 supplies liveness, §6.1.3 revised). This is a
  designer's decision (it revises the security surface); NOT resolved in
  code. `a_fingerprint_does_not_outlive_its_owner` currently encodes
  Option A and flips under B.
- P2 (fixed): pinned the ambiguous-client-id branch. A mutation
  remembering only the first of two clients claiming one global id
  survived the suite; added a test scoped to the ambiguous owner (the
  global count was masked by the peerspeak owner's client). Verified the
  `.next()` mutation now fails it.

57 tests. Phase 2 is NOT converged — the buffered-echo design decision is
owed to the user before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:29:40 -04:00
molluskandClaude Opus 4.8 65fde92628 host/taint: pin the role-receiver mutation; doc fixes; document the unbounded-buffer limit (Codex round 5)
Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/
link-group fingerprint complete, and raised three more:

- P2 (fixed): a mutation deleting the *role-based* receiver insertion
  survived all 55 tests — every tested bridge source also had an inbound
  link. A pixelpass capture sink is a taint root before anything links
  into it, and its re-emitting sibling must bridge from it on role alone.
  Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation
  now killed.
- P3 (fixed): doc drift. The backstop's preamble still described the old
  "targets must be unbounded / apps never swept" rule; rewritten to the
  two-tier trigger/sweep. The `session_device` factory guidance now says
  explicit allowlist, not "and the like".
- P1 (dispositioned as a documented v1 limitation, not fixed): a buffered
  echo across a *full* teardown of an *unbounded* reader. Grounds, in the
  module docs: (1) it needs a stream exposing no PID/module-id/link-group,
  which is malformed/identity-hiding and outside v3.4 §2's non-adversarial
  threat model; (2) it contradicts the design's explicit "reappears after
  full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a
  design change; (3) the only closed-form fix is a whole-share hammer
  (one keyless stream ⇒ desktop unshareable for the share). Reachable
  cases — a reader live now — are already covered by the backstop.
  Owed to the design doc as a round-8 note.

56 tests. Taking the P1 disposition to Codex for ratification, then to
the user as a design decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:06:45 -04:00
molluskandClaude Opus 4.8 2183084ec8 host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4)
Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam
sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker
(YES) — but my F1 narrowing was unsound (NO), with a clean counterexample.

F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every
output" to spare outputs carrying a real, non-daemon PID, arguing an
unbounded reader must be daemon-owned. Codex refuted it:
`application.process.id` is optional and client-controlled, so one real
process can present NO pid on its reading leg (unbounded) and a real pid
on its output leg — the narrowing spares that output and leaks the call.
App properties cannot carry a soundness argument; only `pipewire.*` has
protected identity. Reverted to the broad rule: an unbounded tainted
reader excludes the whole candidate universe. Added the exact
counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`)
and kept a bounded-reader test to show the round-1 blast-radius guarantee
still holds for the bounded tier.

F2 (doc corrected): removed the "a mis-classified filter is still braced"
claim — Codex showed a filter with no shared strong key, wrongly marked
`session_device`, cannot trip the backstop from its reading leg and leaks
through a differently-keyed output. A false positive is now documented as
leak-capable; the only defence is the correct positive classifier.

F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup
fingerprints survived all 53 tests, because the strong-key fingerprint
test used pulse.module.id. Added a link-group new-connection test.

Mutation-verified 2/2. 55 tests.

Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted
is not a phase-2 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:54:08 -04:00
molluskandClaude Opus 4.8 f35bab0379 host/taint: close the inverse asymmetric leak; strengthen contracts (Codex round 3)
Round 3 was the second verification round. One real leak, one accepted
narrowing of Codex's own suggested fix, two contract strengthenings, and
a test-gap fix.

F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted
reader that is itself *unbounded* (client.id only, daemon PID suppressed)
whose re-emitting leg carried an *unmatched* strong key left that leg
"bounded" and Eligible. An unbounded reader cannot be positively related
to any output, so a strong key that does not match it back proves nothing.

  Two-tier backstop. A bounded tainted reader excludes only unbounded
  outputs (a differently-keyed output is provably a different owner). An
  unbounded tainted reader also excludes daemon-owned outputs — but NOT
  ordinary apps.

  ⚠️ Deliberately narrower than Codex's suggested "exclude every output".
  An unbounded reader is necessarily daemon-owned (a real app has its own
  PID, which is a usable key, so it would be bounded), so its sibling is
  another daemon leg, never an app. Sweeping in real apps would lose the
  round-1 "blast radius stays small" guarantee for no safety gain. When
  the daemon PID is unknown the app/leg distinction collapses and the rule
  degrades to Codex's exclude-all. Both directions are pinned by tests,
  and the over-aggressive variant fails the spares-real-apps test.

F2 (contract, strengthened): `session_device` is documented as a positive
high-confidence phase-3 classification, not `device.id`+`device.api`
(measured insufficient — a card filter can carry both; node.physical is
null on the real ALSA nodes so it is not a discriminator). Fail closed:
unknown ⇒ false. Documented why a mis-classified filter still does not
leak in practice — its legs share a link-group (strong-key bridge) and an
unbounded reading leg trips the two-tier backstop.

F5 (P2, test gap): a mutation keeping only PID fingerprints survived all
49 tests. Added a strong-key (pulse.module.id) new-connection fixture.

Mutation-verified 3/3 including the over-aggressive counter-mutation.

Still OWED to round 3, carried to round 4 for adjudication: finding 3
(a not-ready epoch can persist provisional owner *fusion* as sticky
over-exclusion). It is over-exclusion, never an echo leak, and closing it
needs a readiness/provenance model decision rather than a local patch —
see the round-4 handoff. 53 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:40:24 -04:00
molluskandClaude Opus 4.8 31084edcfa host/taint: close the partial fixes found in Codex round 2
The verification round earned its place: five of the six round-1 fixes
were partial, and two of the gaps were worse than the bugs they replaced.

1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in.
   `client_serials_of` recorded the shared `WirePlumber [export]` client as
   a member of a tainted hardware sink's owner, so the *second* recompute
   expanded that client to every sound card on the box, tainted the
   microphone, and excluded every app holding one — the §6.1.1 catastrophe
   arriving one epoch late instead of never. `client.id` may now only be
   recorded, or expanded, for nodes where it is a usable owner key.
   The regression test evaluates an unchanged snapshot three times: a
   correct engine's answer must not drift when nothing has.
2. Sticky followed a surviving *connection*, not a surviving *owner*. A
   process can leave one client idle and open a second — GStreamer opens
   one per stream as a matter of course — and the new leg escaped.
   `StickyOwner` now carries owner **fingerprints** (strong keys and a
   usable PID, never `client.id`), applied only while some serial member
   is still live, so a recyclable key cannot resurrect a dead owner.
3. An **ambiguous** link input endpoint tainted every claimant but made
   none of them a receiver, so their sibling output legs stayed eligible.
   Taint without receiver status cannot start an owner bridge.
4. `device.id` is a raw observation, not the classification the coarse-key
   exception needs — PipeWire defines it only as "the Device this node
   belongs to", so a forwarding node carrying one would have lost both its
   owner keys and its ability to trip the backstop. Replaced by
   `session_device`, a phase-3 obligation (`device.id` AND `device.api`)
   documented to fail closed when it cannot classify.
5. Readiness now gates sticky **retirement only**. Round 1 stopped a
   not-ready epoch erasing history; it also stopped it recording any, so a
   reader could consume and buffer the call during that epoch, vanish
   before readiness, and leave its output eligible.
6. Added the unresolved-output-plus-unknown-role fixture: deleting one
   `receivers.insert` survived all 42 previous tests.

Mutation-verified: 7/7 reverts killed by their intended test. Two attempts
did not land first time and both were my error, not the engine's — the
client-key guard is applied at two sites so removing one is not a revert
(removing the pair is, and that is killed), and the fingerprint-lifetime
test put the recycled node in a snapshot *after* the entry had already
been retired, so the guard was never consulted. Rewritten to place it in
the same snapshot that first sees the owner gone.

Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)).

49 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:12:21 -04:00
molluskandClaude Opus 4.8 a46c4cd20c host/taint: close five leaks found in Codex round 1
All five were reachable, all five now have a regression test, and each
test was verified by injecting the mutation that reverts its fix.

1. Sticky taint ignored surviving Client members. An app can close every
   stream while keeping its PipeWire connection open and then open a new
   one — Firefox does this constantly — and the new leg came back
   Eligible while the owner's buffers still held the call. Sticky seeding
   now resolves live Client serials to their current nodes.
2. "Receives audio" was inferred from `media.class` alone, so a node with
   an absent or unexpected class sitting on a real inbound link could not
   start an owner bridge and its sibling re-emitted the call. A node is
   now a receiver if it appears as a resolved `link.input.node` OR has a
   receiving role.
3. The device-node coarse-key exception was keyed on `media.class` being
   `Audio/Sink|Source|Duplex`, which also stripped the only correlation a
   *native virtual sink* has (own client, no link-group, no module id).
   Now keyed on `device.id`, measured on the live graph as the exact
   discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share
   `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink`
   nodes carry no device.id and hold their own clients.
4. The unbounded-owner backstop required the tainted *reader* to be
   unbounded. Properties can be asymmetric — a reader with a link-group
   whose re-emitting leg has none is bounded while its sibling is not
   findable — so that condition is dropped; targets stay restricted to
   unbounded output legs, which keeps the blast radius small.
5. A not-ready snapshot could retire sticky owners, erasing taint history
   on the strength of a graph already declared untrustworthy. `evaluate`
   now returns the prior state unchanged while `!graph_ready`.

Test-quality findings, also fixed:
- a single pass of each rule survived all 32 tests (every fixture needed
  at most one owner hop) → two-chained-forwarder test with a clean
  control, plus a 60-layer chain to catch an accidental blow-up
- first-write-wins `raise()` survived → a node reached by bridge on one
  pass and by a direct link on the next must report the stronger reason
- `drop_clients` left the fixture's client caches stale, so "a fresh
  client after teardown" was really a dangling id; the recycling row now
  reuses node id, client id AND `pulse.module.id` verbatim

Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth
D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per
snapshot instead of per candidate pair.

42 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:47:38 -04:00
molluskandClaude Opus 4.8 6ead1fe9f8 host/taint: pure graph model + taint engine (phase 2)
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.

    evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
        -> (Decisions, StickyState)

- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
  (object.serial, 64-bit, identity) with `GlobalId` retained strictly as
  a snapshot-local lookup key. Two live objects claiming one id resolve
  as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
  client.id, application.process.id) with equality-not-first-present
  semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
  (gated on the tainted member being one that *receives* audio) and the
  unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
  an explicit priority so the reported reason never depends on traversal
  order.

Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:

1. Coarse keys (client.id, application.process.id) may not bridge
   device-role nodes. Every ALSA device is created by one WirePlumber
   process, so they share a client and a PID; peerspeak's playback taints
   the default sink on every recompute, and without this rule that taint
   reaches the microphone source and then every app holding a mic loses
   its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
   bound an owner (the measured GStreamer split-client refutation), so
   the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
   a forwarder's output leg keeps `tainted-owner-bridge` instead of
   inheriting its input leg's `tainted-upstream`.

32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:24:15 -04:00
molluskandClaude Opus 4.8 d54e2b99fc Merge phase 0a: object.serial u32→u64
Impl-plan §2/0a. Exit gate (boundary parse tests) met; reviewed by Codex
(gpt-5.6-sol xhigh) round 1 — APPROVE-WITH-NITS, one P3 fixed and its
mutant verified. Unblocks phase 2 (pure taint engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:49 -04:00
molluskandClaude Opus 4.8 87de5213fe audio: cover ordinary serial lengths in parse tests
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.

Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:34 -04:00
molluskandClaude Opus 4.8 9b6c8bb5c3 audio: parse object.serial as u64 (phase 0a)
`object.serial` is a 64-bit PipeWire counter, not a u32 object id.
Parsing it with `parse::<u32>()` returns None past u32::MAX, which
silently leaves `RouterState::sink_serial` unset — `try_flush` then
routes nothing and app-filter mode is dead with no diagnostic.

- factor the parse into a pure `parse_object_serial(&str) -> Option<u64>`
  (strict decimal; rejects signs, padding, overflow) with unit tests at
  the u32 boundary, past it, and at u64::MAX
- widen `RouterState::sink_serial` to `Option<u64>`
- log a warning when the sink's serial is unusable instead of returning
  silently
- audit the other `parse::<u32>` in this file: `load_module` returns a
  PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not
  changed

Prerequisite for the taint engine's lifetime-awareness, which is keyed
on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:47:30 -04:00
molluskandClaude Opus 4.8 40604c716c debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in
addition to the normal fd=1 serve path, so the host-side stream can be
ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its
own queue so the disk sink cannot backpressure the live serve branch.

No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG.

Used to establish that the host produces an A/V-clean realtime stream
(+/-18 ms over 170 s), ruling out the capture side in the screen-share
drift investigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:49:42 -04:00
mollusk 3b92bcbe52 chore: update dependencies for RustSec advisories 2026-07-15 06:29:05 -04:00
molluskandClaude Opus 4.8 b6240c17c5 viewer: drop forced --hwdec=auto (froze video on frame 1)
The screen-share viewer ran mpv with --profile=low-latency (hwdec off by
default) and then forced --hwdec=auto back on. On some drivers the HW H.264
decoder stalls mid-stream: a viewer receiving a software-x264 share froze on
the first frame while audio kept playing (one MPEG-TS byte stream, so bytes
were still flowing — the video decoder gave up, the audio decoder didn't).
Screen-share H.264 at these bitrates decodes trivially in software, so leave
hwdec at the low-latency default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 c1b21b32c7 Add pixelpass --doctor environment diagnostic
Screen-share failures are usually environment gaps, not pixelpass bugs —
most often a GPU/driver with no working VA-API H.264 encoder, so the
default vah264enc pipeline produces no video and the viewer "can't
connect." doctor probes the whole chain and prints one actionable report
so a remote tester can read it over a call instead of us guessing from
logs, and it validates any X11/Wayland test environment we stand up.

Checks (each a ✓/!/✗ line with a distro-aware install hint):
- display server (Wayland/X11 + session env), and the X server vendor/
  version so an xlibre server is distinguishable from stock Xorg
- capture: gst tools + the backend's source element (pipewiresrc/ximagesrc)
- encode: hardware H.264 (vah264enc + DRM render node + a VA-API H.264
  *encode* entrypoint parsed from vainfo) and the software x264 fallback
- mux/audio tail + pactl
- viewer player (mpv/vlc)
- network: binds a real endpoint and checks relay reachability

Unlike deps::check_host_binaries (bails on first miss), doctor runs every
check and reports them together. Closes with a specific hosting verdict and
exits non-zero on any hard failure so scripts/CI can gate. Pure seams
(vainfo entrypoint parse, summary tally, hosting verdict) are unit-tested;
deps.rs gained pub(crate) which/gst_element_exists/install-hint/distro
helpers so doctor reuses the same package-name knowledge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:19:06 -04:00
31 changed files with 12932 additions and 615 deletions
Generated
+584 -591
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -32,7 +32,7 @@ name = "pixelpass"
path = "src/main.rs"
[dependencies]
iroh = "1.0.0-rc.0"
iroh = "1.0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4", features = ["derive"] }
@@ -48,7 +48,7 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0-rc.0"
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] }
+31
View File
@@ -23,6 +23,8 @@ Working:
- Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`)
@@ -135,6 +137,35 @@ sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
If the viewer is running on battery, set the CPU governor to performance
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
## Diagnostics
`pixelpass --doctor` prints a one-shot report of everything the above
requirements cover and exits — run it on any machine before a real session:
```sh
pixelpass --doctor
```
It checks, and prints a `✓ / ! / ✗` line for each:
- **display server** — Wayland vs. X11 (autodetected), the raw session env
vars, and the X server's vendor/version (so an xlibre server is visible)
- **capture** — the GStreamer tools plus the source element for your backend
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
whether the software `x264enc` fallback is available. This is the usual
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
produces no video under the default encoder — the report tells you to host
with `--no-hwencode`
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
- **viewer** — whether `mpv` or `vlc` is installed
- **network** — binds a real endpoint and checks a relay is reachable
Each failing line includes a distro-aware install hint, and the closing summary
says whether the machine can host and how. The exit code is non-zero if any
hard requirement is missing, so it can gate a script or CI.
## Build
```sh
+20
View File
@@ -105,11 +105,31 @@ pub struct Cli {
#[arg(long)]
pub repair: 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
/// session — especially to confirm hardware H.264 encode works, since a GPU
/// without it silently produces no video under the default encoder.
#[arg(long)]
pub doctor: bool,
/// Re-run the bandwidth pre-flight test, save the result, then exit.
/// Use this if your connection has changed (new ISP, moved house, etc.)
/// or if the previously saved test result is stale.
#[arg(long)]
pub reconfigure: bool,
/// Run the read-only audio-exclusion dry-run audit against the live
/// PipeWire graph, then exit on ctrl-c. Emits one JSON object per line to
/// stderr (or to `PIXELPASS_AUDIO_AUDIT_FILE`) describing which audio
/// streams would be eligible for a screen share and why the rest would not.
/// Creates no links and changes no routing.
///
/// Hidden: this is development instrumentation for the screen-share audio
/// exclusion work (impl plan phase 5), not a user-facing feature, and the
/// record schema is free to change until phase 6 fixes it.
#[arg(long, hide = true)]
pub audit_audio: bool,
}
#[derive(ValueEnum, Clone, Copy, Debug)]
+15 -10
View File
@@ -56,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
}
fn require_gst_element(name: &str) -> Result<()> {
let ok = Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
if !gst_element_exists(name) {
bail!(
"GStreamer element `{name}` not available.\n{}",
install_hint_for_gst_element(name)
@@ -70,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
pub(crate) fn gst_element_exists(name: &str) -> bool {
Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
@@ -81,7 +86,7 @@ fn which(bin: &str) -> Option<PathBuf> {
None
}
fn install_hint_for_bin(bin: &str) -> String {
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
let distro = detect_distro();
let pkg = match bin {
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
@@ -113,7 +118,7 @@ fn install_hint_for_bin(bin: &str) -> String {
install_command(&distro, pkg)
}
fn install_hint_for_gst_element(name: &str) -> String {
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
let distro = detect_distro();
let pkg = match name {
"pipewiresrc" => match distro.as_deref() {
@@ -210,7 +215,7 @@ fn install_command(distro: &Option<String>, pkg: &str) -> String {
format!("Install hint: {cmd}")
}
fn detect_distro() -> Option<String> {
pub(crate) fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") {
+10
View File
@@ -1,5 +1,15 @@
use anyhow::{Context, Result};
use tokio::signal::unix::{Signal, SignalKind};
use tokio_util::sync::CancellationToken;
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when
/// something other than a human at a terminal asks them to (`timeout`, a test
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
pub fn terminate_stream() -> Result<Signal> {
tokio::signal::unix::signal(SignalKind::terminate())
.context("could not install a SIGTERM handler")
}
/// Install a ctrl-c handler that triggers the returned token.
///
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
+648
View File
@@ -0,0 +1,648 @@
//! `pixelpass doctor` — environment diagnostics.
//!
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
//! a GPU/driver with no working VA-API H.264 encoder, so the default
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
//! `doctor` probes all of that up front and prints one actionable report, so a
//! remote tester can read it over a call instead of us guessing from logs. It
//! also validates any X11/Wayland test environment we stand up.
//!
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
//! missing dependency, doctor runs *every* check and reports them together — a
//! diagnostic wants the whole picture, not the first failure.
use anyhow::Result;
use std::time::Duration;
use crate::common::deps;
use crate::common::display::DisplayServer;
use crate::common::endpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// Working as needed.
Ok,
/// Degraded but not fatal (e.g. a fallback path is available).
Warn,
/// Screen-sharing will not work until this is fixed.
Fail,
/// Neutral fact, no judgement.
Info,
}
impl Status {
fn icon(self) -> char {
match self {
Self::Ok => '✓',
Self::Warn => '!',
Self::Fail => '✗',
Self::Info => '·',
}
}
}
/// One line in the report: a status, a short label, a detail, and an optional
/// remediation hint printed on its own indented line.
pub struct Check {
pub status: Status,
pub label: String,
pub detail: String,
pub hint: Option<String>,
}
impl Check {
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status,
label: label.into(),
detail: detail.into(),
hint: None,
}
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Ok, label, detail)
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Warn, label, detail)
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Fail, label, detail)
}
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Info, label, detail)
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
/// Tally of the non-trivial statuses across every section.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Summary {
pub fails: usize,
pub warns: usize,
}
/// A named group of checks, printed under a header.
struct Section {
name: &'static str,
checks: Vec<Check>,
}
/// Run all diagnostics and print the report. Always prints; the process exit
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
/// and CI can gate on it while a human still sees everything.
pub async fn run(relay: Option<String>) -> Result<()> {
let display = DisplayServer::detect();
let sections = vec![
system_section(display),
capture_section(display),
encode_section(),
mux_audio_section(),
viewer_section(),
network_section(relay.as_deref()).await,
];
print_report(&sections);
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
print_summary(summary, &sections);
if summary.fails > 0 {
std::process::exit(1);
}
Ok(())
}
// ── sections ──────────────────────────────────────────────────────────────
fn system_section(display: DisplayServer) -> Section {
let mut checks = vec![
Check::info(
"pixelpass",
format!("{} (gui: {})", env!("CARGO_PKG_VERSION"), gui_built()),
),
Check::info("distro", distro_detail()),
display_check(display),
];
// Probe the actual X server when one is reachable — this is where an xlibre
// vs. Xorg difference (the thing we most want to see on a tester's box)
// shows up. Skip it on a pure Wayland session with no X at all.
if display == DisplayServer::X11 || std::env::var_os("DISPLAY").is_some() {
checks.push(x_server_check());
}
Section {
name: "System",
checks,
}
}
fn capture_section(display: DisplayServer) -> Section {
let mut checks = vec![
bin_check("gst-launch-1.0", "gstreamer tools"),
bin_check("gst-inspect-1.0", "gstreamer tools"),
];
match display {
DisplayServer::Wayland => {
checks.push(gst_check("pipewiresrc", "Wayland capture"));
}
DisplayServer::X11 => {
checks.push(gst_check("ximagesrc", "X11 capture"));
checks.push(match deps::which("xwininfo") {
Some(p) => Check::ok("window picker", p.display().to_string())
.with_hint("needed only for `--window` (share a single window)"),
None => Check::info("window picker", "xwininfo not found")
.with_hint("optional — only `--window` needs it"),
});
}
DisplayServer::Unknown => {
checks.push(
Check::info("capture backend", "unknown — cannot probe a source element")
.with_hint("force one with `--display-server x11|wayland` when hosting"),
);
}
}
Section {
name: "Capture (host)",
checks,
}
}
fn encode_section() -> Section {
Section {
name: "Encode",
checks: vec![hardware_encode_check(), software_encode_check()],
}
}
/// The load-bearing check for the common "viewer can't connect" report: the
/// default host pipeline uses `vah264enc`, which needs both the GStreamer VA
/// plugin *and* a GPU/driver that actually exposes an H.264 encode entrypoint.
/// A box with the plugin but no encode entrypoint (or no render node) produces
/// no video — the exact silent failure `--no-hwencode` works around.
fn hardware_encode_check() -> Check {
if !deps::gst_element_exists("vah264enc") {
return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
"{} — or just host with `--no-hwencode` (software x264)",
deps::install_hint_for_gst_element("vah264enc")
));
}
if !has_render_node() {
return Check::warn(
"hardware H.264",
"vah264enc present, but no DRM render node (/dev/dri/renderD*)",
)
.with_hint("GPU encode is unavailable here — host with `--no-hwencode`");
}
match vainfo_output() {
Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
"hardware H.264",
"VA-API H.264 encode available (vah264enc)",
),
Some(_) => Check::warn(
"hardware H.264",
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
)
.with_hint("this GPU/driver can't hardware-encode H.264 — host with `--no-hwencode`"),
None => Check::info(
"hardware H.264",
"vah264enc + render node present; couldn't confirm the VA-API encode entrypoint",
)
.with_hint("install `vainfo` (libva-utils) to verify, or just test a real host session"),
}
}
fn software_encode_check() -> Check {
if deps::gst_element_exists("x264enc") {
Check::ok("software H.264", "x264enc available (`--no-hwencode`)")
} else {
Check::warn("software H.264", "x264enc not installed").with_hint(format!(
"{} — the fallback for GPUs without VA-API H.264 encode",
deps::install_hint_for_gst_element("x264enc")
))
}
}
fn mux_audio_section() -> Section {
// These live in plugins-bad/-good/-libav and plugins-base; all are required
// for either backend, so a miss here is a hard Fail.
let tail = [
"h264parse",
"mpegtsmux",
"aacparse",
"avenc_aac",
"pulsesrc",
"videoscale",
];
let missing: Vec<&str> = tail
.iter()
.copied()
.filter(|e| !deps::gst_element_exists(e))
.collect();
let tail_check = if missing.is_empty() {
Check::ok("mux + audio tail", tail.join(", "))
} else {
Check::fail(
"mux + audio tail",
format!("missing: {}", missing.join(", ")),
)
.with_hint(deps::install_hint_for_gst_element(missing[0]))
};
Section {
name: "Mux / audio",
checks: vec![tail_check, bin_check("pactl", "pactl")],
}
}
fn viewer_section() -> Section {
let mpv = deps::which("mpv");
let vlc = deps::which("vlc");
let check = match (mpv, vlc) {
(Some(p), _) => Check::ok("player", format!("mpv ({})", p.display())),
(None, Some(p)) => Check::ok("player", format!("vlc ({})", p.display()))
.with_hint("mpv is the recommended player; vlc needs the dvb + ffmpeg plugins"),
(None, None) => Check::warn("player", "neither mpv nor vlc found")
.with_hint("a viewer needs one of them; the GUI launches mpv by default"),
};
Section {
name: "Viewer",
checks: vec![check],
}
}
/// Bind a real video-plane endpoint and wait briefly for a relay, mirroring
/// what a host does. Directly relevant to "couldn't connect": if this machine
/// can't reach a relay, hole-punching to a peer is unlikely to work either.
async fn network_section(relay: Option<&str>) -> Section {
let check = match endpoint::bind(relay).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
// without calling close" error into the middle of the report.
ep.close().await;
if online && relay_count > 0 {
Check::ok("relay", format!("home relay reachable{where_}"))
} else if online {
Check::warn(
"relay",
format!("endpoint online but no relay address{where_}"),
)
.with_hint(
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
)
} else {
Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
"check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
)
}
}
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
};
Section {
name: "Network",
checks: vec![check],
}
}
// ── small check builders ────────────────────────────────────────────────────
fn bin_check(bin: &str, label: &str) -> Check {
match deps::which(bin) {
Some(p) => Check::ok(label, format!("{bin} ({})", p.display())),
None => Check::fail(label, format!("{bin} not found on PATH"))
.with_hint(deps::install_hint_for_bin(bin)),
}
}
fn gst_check(element: &str, label: &str) -> Check {
if deps::gst_element_exists(element) {
Check::ok(label, element.to_string())
} else {
Check::fail(
label,
format!("GStreamer element `{element}` not available"),
)
.with_hint(deps::install_hint_for_gst_element(element))
}
}
fn display_check(display: DisplayServer) -> Check {
let env = display_env_summary();
match display {
DisplayServer::Wayland => Check::ok("display server", format!("Wayland ({env})")),
DisplayServer::X11 => Check::ok("display server", format!("X11 ({env})")),
DisplayServer::Unknown => Check::fail("display server", format!("undetected ({env})"))
.with_hint(
"no WAYLAND_DISPLAY/DISPLAY/XDG_SESSION_TYPE — capture can't start; \
run inside a graphical session or pass `--display-server`",
),
}
}
/// Connect to the X server and report its vendor + version. This is how an
/// xlibre server distinguishes itself from stock Xorg (vendor string / release
/// number), which is exactly what we want to see on a tester's machine.
fn x_server_check() -> Check {
use x11rb::connection::Connection;
match x11rb::connect(None) {
Ok((conn, _screen)) => {
let setup = conn.setup();
let vendor = String::from_utf8_lossy(&setup.vendor);
let detail = format!(
"vendor \"{}\", protocol {}.{}, release {}",
vendor.trim(),
setup.protocol_major_version,
setup.protocol_minor_version,
setup.release_number,
);
let label = "X server";
if vendor.to_lowercase().contains("xlibre") {
Check::info(label, format!("XLibre — {detail}"))
} else {
Check::info(label, detail)
}
}
Err(_) => Check::info("X server", "DISPLAY set but the X server is unreachable"),
}
}
// ── environment helpers ─────────────────────────────────────────────────────
fn gui_built() -> &'static str {
if cfg!(feature = "gui") { "yes" } else { "no" }
}
fn distro_detail() -> String {
let id = deps::detect_distro();
let pretty = os_release_field("PRETTY_NAME");
match (id, pretty) {
(Some(id), Some(p)) => format!("{id} ({p})"),
(Some(id), None) => id,
(None, Some(p)) => p,
(None, None) => "unknown".to_string(),
}
}
fn os_release_field(key: &str) -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
return Some(rest.trim_matches('"').to_string());
}
}
None
}
fn display_env_summary() -> String {
let mut parts = Vec::new();
for var in [
"WAYLAND_DISPLAY",
"DISPLAY",
"XDG_SESSION_TYPE",
"XDG_CURRENT_DESKTOP",
] {
if let Some(v) = std::env::var_os(var) {
parts.push(format!("{var}={}", v.to_string_lossy()));
}
}
if parts.is_empty() {
"no display env vars set".to_string()
} else {
parts.join(", ")
}
}
fn has_render_node() -> bool {
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
return false;
};
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
}
fn vainfo_output() -> Option<String> {
deps::which("vainfo")?;
let out = std::process::Command::new("vainfo").output().ok()?;
// vainfo prints its profile/entrypoint table to stdout; some builds also
// spill driver banners to stderr. Concatenate both so parsing is robust.
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
Some(s)
}
/// Pure: does a `vainfo` dump advertise an H.264 *encode* entrypoint? vainfo
/// lists one `VAProfile… : VAEntrypoint…` pair per line; hardware H.264 encode
/// is any `VAProfileH264*` profile paired with an `EncSlice`/`EncSliceLP`
/// entrypoint. VLD-only H.264 (decode) does not count.
fn vainfo_has_h264_encode(output: &str) -> bool {
output.lines().any(|line| {
line.contains("VAProfileH264")
&& (line.contains("VAEntrypointEncSlice") || line.contains("VAEntrypointEncSliceLP"))
})
}
// ── reporting ───────────────────────────────────────────────────────────────
fn print_report(sections: &[Section]) {
println!("pixelpass doctor\n");
for section in sections {
println!("{}", section.name);
for check in &section.checks {
println!(
" {} {:<16} {}",
check.status.icon(),
check.label,
check.detail
);
if let Some(hint) = &check.hint {
println!("{hint}");
}
}
println!();
}
}
fn summarize<'a>(checks: impl Iterator<Item = &'a Check>) -> Summary {
let mut summary = Summary::default();
for check in checks {
match check.status {
Status::Fail => summary.fails += 1,
Status::Warn => summary.warns += 1,
Status::Ok | Status::Info => {}
}
}
summary
}
fn print_summary(summary: Summary, sections: &[Section]) {
let hosting = hosting_verdict(sections);
let counts = match (summary.fails, summary.warns) {
(0, 0) => "all checks passed".to_string(),
(0, w) => format!("{w} warning{}", plural(w)),
(f, 0) => format!("{f} failure{}", plural(f)),
(f, w) => format!("{f} failure{}, {w} warning{}", plural(f), plural(w)),
};
println!("Summary: {counts}. {hosting}");
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// A one-line verdict on whether this box can host, and how. Reads the actual
/// encode + capture checks rather than the raw tally so the advice is specific.
fn hosting_verdict(sections: &[Section]) -> String {
let find = |section: &str, label: &str| -> Option<Status> {
sections
.iter()
.find(|s| s.name == section)?
.checks
.iter()
.find(|c| c.label == label)
.map(|c| c.status)
};
let hw = find("Encode", "hardware H.264");
let sw_ok = find("Encode", "software H.264") == Some(Status::Ok);
let capture_broken = sections
.iter()
.find(|s| s.name == "Capture (host)")
.map(|s| s.checks.iter().any(|c| c.status == Status::Fail))
.unwrap_or(false);
if capture_broken {
"Hosting will fail: the capture backend is incomplete (see Capture above).".to_string()
} else if hw == Some(Status::Ok) {
"Hosting will work (hardware H.264 encode).".to_string()
} else if hw == Some(Status::Info) && sw_ok {
// Plugin + render node present but VA-API unverified (no vainfo): the
// default encoder is likely fine; `--no-hwencode` is the safe fallback.
"Hosting should work (hardware H.264 likely; `--no-hwencode` is the fallback).".to_string()
} else if sw_ok {
"Hosting should work with `--no-hwencode` (software H.264 encode).".to_string()
} else {
"Hosting may fail: no working H.264 encoder found (see Encode above).".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vainfo_detects_h264_encode_entrypoint() {
// Realistic AMD/RADV-style dump: H.264 has both decode (VLD) and encode.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264Main : VAEntrypointEncSlice
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_low_power_encode_counts() {
let dump = "VAProfileH264ConstrainedBaseline: VAEntrypointEncSliceLP";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_decode_only_h264_is_not_encode() {
// Decode-only H.264 (VLD) plus HEVC encode must NOT be read as H.264
// encode — this is exactly the "default encoder fails" case.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(!vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_empty_is_not_encode() {
assert!(!vainfo_has_h264_encode(""));
}
#[test]
fn summarize_counts_fails_and_warns_only() {
let checks = [
Check::ok("a", "x"),
Check::info("b", "x"),
Check::warn("c", "x"),
Check::warn("d", "x"),
Check::fail("e", "x"),
];
let summary = summarize(checks.iter());
assert_eq!(summary, Summary { fails: 1, warns: 2 });
}
#[test]
fn hosting_verdict_prefers_hardware_then_software() {
let hw = vec![Section {
name: "Encode",
checks: vec![
Check::ok("hardware H.264", "ok"),
Check::ok("software H.264", "ok"),
],
}];
assert!(hosting_verdict(&hw).contains("hardware"));
let sw = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::ok("software H.264", "ok"),
],
}];
assert!(sw_verdict_uses_no_hwencode(&hosting_verdict(&sw)));
let none = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::warn("software H.264", "no"),
],
}];
assert!(hosting_verdict(&none).contains("may fail"));
}
fn sw_verdict_uses_no_hwencode(v: &str) -> bool {
v.contains("--no-hwencode")
}
#[test]
fn capture_failure_dominates_verdict() {
let sections = vec![
Section {
name: "Capture (host)",
checks: vec![Check::fail("X11 capture", "missing")],
},
Section {
name: "Encode",
checks: vec![Check::ok("hardware H.264", "ok")],
},
];
assert!(hosting_verdict(&sections).contains("capture"));
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Phase 4 — the AEC identity validation state machine (impl plan §4, design
//! v3.4 §5.2/§5.3).
//!
//! peerspeak's echo canceller (`module-echo-cancel`) creates four graph nodes
//! that all carry `pulse.module.id == <the index pactl returned>`, and the
//! playback leg among them is a `Stream/Output/Audio` node wired straight to
//! the speakers — a fan-out candidate that would copy the whole remote call
//! into the share unless it is excluded (v3.4 §5.2, measured ≈desktop level).
//! The taint engine (phase 2) already excludes it *given* the module index in
//! [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx); this
//! module is what decides, at runtime and fail-closed, whether that index may
//! be trusted and handed over.
//!
//! **Why a state machine and not a one-shot check (v3.4 §5.3).** The identity
//! is an *observed correlation on PipeWire 1.6.8*, not a documented contract,
//! and a start-time enumeration races in both directions: peerspeak's
//! `enable()` returns before the playback hazard leg is even in the graph, and
//! pixelpass's capture spawns lazily on the first viewer, at a moment peerspeak
//! does not control. So validation is a bounded epoch, and the identity can be
//! *lost* mid-share (the module unloads) as well as *gained*.
//!
//! **The two traps this is shaped around:**
//!
//! - **Revocation is loss of the whole module identity, not one leg corking**
//! (v3.4 §5.3). Each [`AecValidator::observe`] rescans the snapshot for *any*
//! node bearing the index; [`AecState::Validated`] drops to
//! [`AecState::Revoked`] only when that set becomes **empty**. A single leg
//! corking or relinking (still ≥1 present) stays `Validated` — getting this
//! wrong turns a normal cork into a spurious share-wide audio stop.
//! - **Module indices are reused verbatim across unload/reload** (v3.4 §5.2
//! correction 3 — both a reload's module index *and* its `node.link-group`
//! came back byte-identical, and node ids were recycled *and reassigned
//! across legs*). So [`AecState::Failed`] and [`AecState::Revoked`] are
//! **sticky terminal**: a later node reappearing with the same index does
//! **not** un-revoke and alias onto the new module. A genuine reload gets a
//! *fresh* [`AecValidator`] (peerspeak re-tells pixelpass the index on every
//! load), never a resurrected one.
//!
//! **Scope.** This is the validation state machine + `--aec` parsing only.
//! Foreign / second-AEC detection (a non-owned `echo-cancel-*` group, v3.4
//! §5.4 / D3) and the `foreign_aec_warning`/`aec_failed`/`aec_revoked` status
//! *events* are phase 6's, which reads this machine's [`AecState`]. Wiring the
//! parsed [`AecConfig`] out of the CLI and calling [`AecValidator::observe`]
//! in the recompute loop is integration (phases 5/8). The node-side
//! `pulse.module.id` parse (JSON-number-vs-string, u64-not-u32) is phase 3's
//! adapter; this module consumes the already-parsed
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
#[cfg(test)]
mod tests;
use crate::host::observer::Millis;
use crate::host::taint::snapshot::GraphSnapshot;
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
Off,
/// `--aec=pulse-module:<idx>` — validate this live module index before
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
/// there is no fail-closed *default* index, because a wrong index would exclude
/// the wrong node (or nothing), so a malformed value must not silently become
/// "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
/// The value was empty.
Empty,
/// Not `off` and not `pulse-module:...`.
UnknownForm,
/// `pulse-module:` with nothing after the colon.
MissingIndex,
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
/// `> u64::MAX`).
InvalidIndex,
}
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
///
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
/// case-sensitive: the argument is machine-generated by peerspeak from
/// `EchoCancelGuard::module_index`, not typed by a user.
///
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
/// `pactl load-module` returns an unsigned decimal, so the stored index is
/// already canonical and no reachable value is rejected; if peerspeak ever
/// changes how it formats the index it must canonicalize (`value.to_string()`),
/// not widen this parser — the narrow grammar is the point.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
// A bare decimal only: reject a leading sign (Rust's `u64` parser
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
// before letting the parser catch overflow. Leading zeros are harmless.
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
/// The validation epoch (v3.4 §5.3, verbatim).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecState {
/// `--aec=off` — no AEC identity, fan-out proceeds with no exclusion.
/// Terminal.
NotConfigured,
/// Waiting for the first node bearing the index. **No fan-out occurs here**
/// — silence is the safe direction. Ends at `Validated` on first sight, or
/// `Failed` once the graph is fully enumerated and the bounded deadline
/// passes with the index never seen.
Validating,
/// The index was observed live. Fan-out is permitted, excluding that
/// identity transitively (phase 2 / v3.4 §6.1).
Validated,
/// The deadline expired with the index never observed. **Fail closed** — no
/// fan-out; the caller reports a capability failure rather than sharing.
/// Sticky terminal.
Failed,
/// The whole module identity disappeared mid-share (every node bearing the
/// index gone). **Stop fan-out now** and drop the owned link proxies; do
/// not keep the numeric index and hope, because it is reused. Sticky
/// terminal — see the module header's second trap.
Revoked,
}
/// The bounded, read-only AEC identity validator. Fold the live graph in with
/// [`AecValidator::observe`] once per recompute; read the result with
/// [`AecValidator::state`], [`AecValidator::fan_out_permitted`], and
/// [`AecValidator::validated_module_id`].
#[derive(Clone, Debug)]
pub struct AecValidator {
/// The index to validate. `None` iff [`AecConfig::Off`] (state stays
/// [`AecState::NotConfigured`] forever).
target: Option<u64>,
state: AecState,
/// The `Validating → Failed` budget, applied *after* the deadline is armed.
timeout: Millis,
/// The absolute `Failed` deadline, armed the first time the graph reports
/// ready (the "registry sync barrier" of v3.4 §5.3) and never re-armed —
/// `graph_ready` is dynamic and can flap, but the epoch budget must not
/// restart. `None` until then: while the initial enumeration is still in
/// flight, a not-yet-seen index is *unknown*, not *absent*, so it must not
/// time out to `Failed`.
deadline: Option<Millis>,
}
impl AecValidator {
/// `timeout` is the `Validating → Failed` budget, counted from the moment
/// the graph first becomes ready (not from construction). An `Off` config
/// starts (and stays) [`AecState::NotConfigured`].
pub fn new(config: AecConfig, timeout: Millis) -> Self {
match config {
AecConfig::Off => Self {
target: None,
state: AecState::NotConfigured,
timeout,
deadline: None,
},
AecConfig::PulseModule(index) => Self {
target: Some(index),
state: AecState::Validating,
timeout,
deadline: None,
},
}
}
pub fn state(&self) -> AecState {
self.state
}
/// The validated index to place in
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
/// including `NotConfigured` (no AEC ⇒ nothing to exclude) and the
/// fail-closed states (whose `None` must be paired with
/// [`Self::fan_out_permitted`] `== false`, i.e. no fan-out at all — *not*
/// a fan-out that merely skips AEC exclusion).
pub fn validated_module_id(&self) -> Option<u64> {
match self.state {
AecState::Validated => self.target,
_ => None,
}
}
/// Whether fan-out may proceed at all right now. True only in
/// [`AecState::NotConfigured`] (fan out, no exclusion) and
/// [`AecState::Validated`] (fan out, excluding the identity). `Validating`,
/// `Failed` and `Revoked` all forbid it — silence over echo.
pub fn fan_out_permitted(&self) -> bool {
matches!(self.state, AecState::NotConfigured | AecState::Validated)
}
/// Fold one recompute's view of the graph into the machine.
///
/// `graph_ready` is the observer's dynamic readiness
/// ([`Projection::graph_ready`](crate::host::observer::Projection)); `now`
/// is a monotonic millisecond clock. Positive evidence (a node bearing the
/// index) is authoritative and validates regardless of `graph_ready` —
/// seeing the node *is* seeing it — but the `Failed` deadline only begins
/// once `graph_ready` has first become true, so a slow initial enumeration
/// can never masquerade as a genuinely-absent module.
pub fn observe(&mut self, snapshot: &GraphSnapshot, graph_ready: bool, now: Millis) {
// `Off` (NotConfigured) and both sticky terminals are no-ops: there is
// nothing to look for, and a reappearing reused index must not revive a
// Failed/Revoked epoch (v3.4 §5.2 correction 3).
let Some(target) = self.target else {
return;
};
match self.state {
AecState::Validating => {
// Presence is checked *before* the deadline on purpose: a
// demonstrably-present identity validates regardless of the
// clock, even if the node is first seen just past the deadline
// (Codex phase-4 review, finding 2). The deadline only bounds
// the wait for an identity that is never seen — seeing it, late
// or not, is ground truth that the module exists, and excluding
// a real echo leg is always the safe answer. (A `Failed` can
// still pre-empt this when a `Tick`-only observation crosses the
// deadline first; that only makes the machine *more* fail-closed,
// never less.)
if self.index_present(snapshot, target) {
self.state = AecState::Validated;
return;
}
// Arm the deadline once, on the first ready graph.
if self.deadline.is_none() && graph_ready {
self.deadline = Some(now.saturating_add(self.timeout));
}
if self.deadline.is_some_and(|deadline| now >= deadline) {
self.state = AecState::Failed;
}
}
AecState::Validated => {
// Revocation is the whole identity gone (no node bears the
// index), not one leg corking — see the module header.
//
// ⚠️ **Deliberately NOT gated on `graph_ready`** (Codex
// phase-4 review, findings 1 + 4). Two forces pull opposite
// ways and this is the resolution:
//
// - Gating revoke on readiness would avoid a *spurious* revoke
// from a transient empty snapshot seen while the module is
// still live. But for the AEC that transient does not exist:
// its four nodes are two `Stream/*` legs plus a null-sink-like
// virtual sink/source, none of which claim a `device.id`, so
// the phase-3 observer never *withholds* them
// (`observer::classify` withholds only device-claiming nodes).
// `index_present` therefore goes false only on a genuine
// `global_remove` of every leg — a real unload — and a real
// unload *should* revoke.
// - Worse, gating on readiness would REOPEN the reused-index
// alias trap: if an unload+reload (indices recycle, §5.2
// correction 3) both complete inside one not-ready churn
// window, the ready snapshot would already show the *new*
// module's node and we would never observe the empty gap —
// silently aliasing onto an unrelated module. Revoking the
// instant the gap appears, ready or not, is what closes it.
//
// This correctness rests on the phase-5/6 integration contract:
// **one `observe` per graph event, no coalescing across a module
// lifetime boundary.** Under coalescing, the empty gap between an
// old unload and a reused-index reload can be skipped. The
// robust fix that would not depend on that contract is a
// serial-continuity / observer-generation signal (the AEC nodes'
// `object.serial`s are fresh across a reload even when the index
// is not) — owed to a later hardening round, not built here.
if !self.index_present(snapshot, target) {
self.state = AecState::Revoked;
}
}
AecState::NotConfigured | AecState::Failed | AecState::Revoked => {}
}
}
/// Whether any node in the snapshot bears the target module index. The same
/// exact-`u64`-equality predicate the taint engine roots on
/// (`taint/mod.rs`), kept here so "is the identity live?" has one
/// definition.
fn index_present(&self, snapshot: &GraphSnapshot, target: u64) -> bool {
snapshot
.nodes()
.any(|node| node.props.pulse_module_id == Some(target))
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Phase 4 exit gate (impl plan §4): a fake-clock / event-sequence transition
//! matrix, because these are timing semantics a live poke cannot cover.
use super::*;
use crate::host::taint::snapshot::{
GlobalId, GraphSnapshot, MediaRole, NodeProps, NodeSnapshot, Serial,
};
/// A `Stream/Output/Audio` node carrying `pulse.module.id == module` (or none).
/// Only the fields the validator reads matter; the rest take their defaults.
fn node(serial: u64, module: Option<u64>) -> NodeSnapshot {
NodeSnapshot {
serial: Serial(serial),
id: GlobalId(serial as u32),
name: None,
role: MediaRole::StreamOutput,
props: NodeProps {
pulse_module_id: module,
..NodeProps::default()
},
}
}
/// A snapshot holding exactly the given nodes (no ports/links/clients — the
/// validator reads only nodes).
fn snapshot(nodes: Vec<NodeSnapshot>) -> GraphSnapshot {
GraphSnapshot::new(nodes, vec![], vec![], vec![])
}
fn empty() -> GraphSnapshot {
snapshot(vec![])
}
const IDX: u64 = 536_870_919; // 0x20000007 — a real pipewire-pulse module index.
const TIMEOUT: Millis = 2_000;
// ---------------------------------------------------------------------------
// Parsing (D5): off / pulse-module:<idx> / > u32::MAX / absent / malformed.
// ---------------------------------------------------------------------------
#[test]
fn parses_off() {
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
}
#[test]
fn parses_pulse_module_index() {
assert_eq!(
parse_aec_arg("pulse-module:536870919"),
Ok(AecConfig::PulseModule(536_870_919)),
);
}
#[test]
fn parses_index_beyond_u32() {
// v3.4 §5.2: compare as u64, never u32. A value one past u32::MAX must
// round-trip, not truncate or reject.
let big = u64::from(u32::MAX) + 1;
assert_eq!(
parse_aec_arg(&format!("pulse-module:{big}")),
Ok(AecConfig::PulseModule(big)),
);
assert_eq!(
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
Ok(AecConfig::PulseModule(u64::MAX)),
);
}
#[test]
fn rejects_empty() {
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
}
#[test]
fn rejects_unknown_form() {
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
assert_eq!(parse_aec_arg("module:5"), Err(AecParseError::UnknownForm));
assert_eq!(parse_aec_arg("536870919"), Err(AecParseError::UnknownForm));
}
#[test]
fn rejects_missing_index() {
assert_eq!(
parse_aec_arg("pulse-module:"),
Err(AecParseError::MissingIndex),
);
}
#[test]
fn rejects_malformed_index() {
for bad in [
"pulse-module:-1", // sign
"pulse-module:+7", // sign
"pulse-module: 7", // leading whitespace
"pulse-module:7 ", // trailing whitespace
"pulse-module:0x7", // hex
"pulse-module:7.0", // non-integer
"pulse-module:abc", // non-numeric
"pulse-module:18446744073709551616", // u64::MAX + 1 (overflow)
] {
assert_eq!(
parse_aec_arg(bad),
Err(AecParseError::InvalidIndex),
"{bad} should be InvalidIndex",
);
}
}
// ---------------------------------------------------------------------------
// NotConfigured (--aec=off): benign, terminal, fan-out with no exclusion.
// ---------------------------------------------------------------------------
#[test]
fn off_is_not_configured_and_permits_fan_out_with_no_identity() {
let mut v = AecValidator::new(AecConfig::Off, TIMEOUT);
assert_eq!(v.state(), AecState::NotConfigured);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
// Even a snapshot full of module nodes never moves it off NotConfigured.
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 10_000);
assert_eq!(v.state(), AecState::NotConfigured);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
// ---------------------------------------------------------------------------
// Row: Validating → Validated on first matching node; no fan-out before.
// ---------------------------------------------------------------------------
#[test]
fn validating_forbids_fan_out_and_exposes_no_identity() {
let v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn validating_to_validated_on_first_matching_node() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// A node with a *different* index does not validate.
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 0);
assert_eq!(v.state(), AecState::Validating);
v.observe(&snapshot(vec![node(2, Some(IDX))]), true, 100);
assert_eq!(v.state(), AecState::Validated);
assert!(v.fan_out_permitted());
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn positive_evidence_validates_even_before_graph_ready() {
// Seeing the node is authoritative; readiness only gates the Failed clock.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), false, 0);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn validated_index_is_compared_beyond_u32() {
let big = u64::from(u32::MAX) + 7;
let mut v = AecValidator::new(AecConfig::PulseModule(big), TIMEOUT);
// A node whose id equals `big` only in its low 32 bits must not match.
v.observe(
&snapshot(vec![node(1, Some(big & u64::from(u32::MAX)))]),
true,
0,
);
assert_eq!(v.state(), AecState::Validating);
v.observe(&snapshot(vec![node(2, Some(big))]), true, 1);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(big));
}
// ---------------------------------------------------------------------------
// Row: Validating → Failed on deadline expiry; and the deadline is armed only
// once the graph is ready (the registry sync barrier).
// ---------------------------------------------------------------------------
#[test]
fn validating_to_failed_on_deadline_expiry() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0); // arms deadline at 0 + 2000
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, TIMEOUT - 1);
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, TIMEOUT); // now >= deadline
assert_eq!(v.state(), AecState::Failed);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn deadline_is_not_armed_until_graph_ready() {
// The whole point of arming-on-ready: a slow initial enumeration is
// "unknown", not "absent", and must never time out to Failed.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// Long past the would-be deadline, but the graph has never been ready.
v.observe(&empty(), false, 10 * TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
// Still no Failed even much later, as long as ready stays false.
v.observe(&empty(), false, 100 * TIMEOUT);
assert_eq!(v.state(), AecState::Validating);
// And when readiness finally arrives, the FULL budget starts *there*, not
// relative to construction (Codex phase-4 review, finding 3): a mutant that
// armed a construction-relative deadline would fail immediately here.
let late = 200_000;
v.observe(&empty(), true, late); // first ready → arm at `late`
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, late + TIMEOUT - 1);
assert_eq!(v.state(), AecState::Validating);
v.observe(&empty(), true, late + TIMEOUT);
assert_eq!(v.state(), AecState::Failed);
}
#[test]
fn late_positive_evidence_wins_over_expired_deadline() {
// A node first seen just past the deadline still validates: the deadline
// only bounds the wait for an identity that is never seen, and a
// demonstrably-present module is ground truth (Codex phase-4 review,
// finding 2). Reachable only when the first post-deadline observation
// carries the node with no intervening Tick-only observation.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0); // arm deadline at 2000
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
// Whereas a Tick-only observation that crosses the deadline first pre-empts
// it to Failed (stickily), even if the node then shows up — fail-closed.
let mut w = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
w.observe(&empty(), true, 0);
w.observe(&empty(), true, TIMEOUT); // Tick-only crosses the line first
assert_eq!(w.state(), AecState::Failed);
w.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(w.state(), AecState::Failed);
}
#[test]
fn revokes_on_empty_even_while_not_ready() {
// Revocation is deliberately NOT gated on graph_ready (Codex phase-4 review,
// findings 1 + 4): the instant every node bearing the index is gone we
// revoke, ready or not, because gating on readiness would let an
// unload+reload that reused the index inside one not-ready churn window
// silently alias onto the new module. A mutant adding `&& graph_ready` to
// the revoke guard survives every other test but dies here.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
v.observe(&empty(), false, 10); // identity gone during not-ready churn
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
}
#[test]
fn deadline_armed_once_survives_ready_flapping() {
// graph_ready is dynamic (it drops back to false while a Link is binding).
// The epoch budget must be armed on the *first* ready and not restarted.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 1_000); // arm at 1000 → deadline 3000
v.observe(&empty(), false, 2_000); // ready flaps off; must not disarm
assert_eq!(v.state(), AecState::Validating);
// At the original deadline it fails, even though ready is false now — the
// budget did not restart from the flap.
v.observe(&empty(), false, 3_000);
assert_eq!(v.state(), AecState::Failed);
}
#[test]
fn failed_is_sticky_even_if_the_index_reappears() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&empty(), true, 0);
v.observe(&empty(), true, TIMEOUT);
assert_eq!(v.state(), AecState::Failed);
// A node bearing the index shows up late — must not resurrect the epoch.
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
assert_eq!(v.state(), AecState::Failed);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
// ---------------------------------------------------------------------------
// Row: partial-node disappearance ⇒ stays Validated; all gone ⇒ Revoked.
// ---------------------------------------------------------------------------
#[test]
fn partial_leg_disappearance_stays_validated() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
// The module's four nodes all carry the index.
let four = snapshot(vec![
node(1, Some(IDX)),
node(2, Some(IDX)),
node(3, Some(IDX)),
node(4, Some(IDX)),
]);
v.observe(&four, true, 0);
assert_eq!(v.state(), AecState::Validated);
// Three legs cork/relink away; one still bears the index → still Validated.
v.observe(&snapshot(vec![node(4, Some(IDX))]), true, 10);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
#[test]
fn all_nodes_gone_revokes() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
// The whole identity unloads: no node bears the index any more.
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
}
#[test]
fn revoked_stops_fan_out_and_exposes_no_identity() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn a_node_that_merely_changes_index_revokes() {
// Not a disappearance in the id sense, but the *identity* is gone: no node
// bears our index any more, even though a same-serial node lingers.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 10);
assert_eq!(v.state(), AecState::Revoked);
}
// ---------------------------------------------------------------------------
// Row: a retained stale index does not alias onto a reloaded module — indices
// ARE reused (v3.4 §5.2 correction 3). This is the sharpest safety property.
// ---------------------------------------------------------------------------
#[test]
fn revoked_index_does_not_alias_onto_a_reloaded_module() {
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
v.observe(&empty(), true, 10);
assert_eq!(v.state(), AecState::Revoked);
// A *different* module later reloads and pactl hands it the very same
// index (measured: 536870919 came back verbatim). A resurrecting machine
// would silently start excluding this unrelated module's node. Ours must
// stay Revoked and fail closed; a real reload gets a fresh validator.
v.observe(&snapshot(vec![node(99, Some(IDX))]), true, 20);
assert_eq!(v.state(), AecState::Revoked);
assert!(!v.fan_out_permitted());
assert_eq!(v.validated_module_id(), None);
}
#[test]
fn a_fresh_validator_re_validates_the_reused_index() {
// The counterpart: because peerspeak re-tells pixelpass the index on every
// load, the correct response to a reload is a new machine, which validates
// the reused index cleanly — proving stickiness costs nothing legitimate.
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
assert_eq!(v.state(), AecState::Validated);
assert_eq!(v.validated_module_id(), Some(IDX));
}
+117 -8
View File
@@ -365,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
.context("pactl returned non-UTF-8")?
.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:?}"))
@@ -532,13 +535,21 @@ fn run_router(
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) {
if let Some(serial) = props
.get("object.serial")
.and_then(|s| s.parse::<u32>().ok())
{
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
match props.get("object.serial").and_then(parse_object_serial) {
Some(serial) => {
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
}
// Never silently: without a serial `try_flush` can
// never route anything, so the whole app-filter mode
// is dead and the only symptom is missing audio.
None => tracing::warn!(
node_id = obj.id,
serial = props.get("object.serial").unwrap_or("<absent>"),
"audio routing: pixelpass sink has no usable object.serial; \
stream rerouting disabled"
),
}
return;
}
@@ -591,8 +602,30 @@ fn run_router(
Ok(())
}
/// Parse a PipeWire `object.serial` property value.
///
/// `object.serial` is a **64-bit** monotonically-increasing counter
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
/// (those are `u32` and get recycled — the serial exists precisely so
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
/// sink is never registered and no stream is ever routed.
///
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
/// property we do not understand and must not guess at. Leading zeroes
/// are accepted — they are unambiguous and parse to the same value.
pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
raw.parse::<u64>().ok()
}
struct RouterState {
sink_serial: Option<u32>,
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>,
pending: Vec<u32>,
@@ -654,3 +687,79 @@ fn try_flush(
let _ = event_tx.send(Event::FirstRoutedStream);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_serial_parses_past_u32() {
// The regression this fix exists for: a serial one past `u32::MAX`
// used to parse as `None` and silently disable rerouting.
let beyond = u64::from(u32::MAX) + 1;
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
assert_eq!(
parse_object_serial(&u64::MAX.to_string()),
Some(u64::MAX),
"the full 64-bit range must round-trip"
);
}
#[test]
fn object_serial_accepts_ordinary_serials() {
// Without this the valid cases are only 1, 10 and 20 digits long, and
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
// survives the whole suite while rejecting every serial a freshly
// started daemon actually hands out. (Codex, round 1.)
for serial in 0_u64..=1024 {
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
}
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
assert_eq!(
parse_object_serial("007"),
Some(7),
"leading zeroes are fine"
);
}
#[test]
fn object_serial_boundary_values() {
assert_eq!(parse_object_serial("0"), Some(0));
assert_eq!(parse_object_serial("1"), Some(1));
let max32 = u64::from(u32::MAX);
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
assert_eq!(
parse_object_serial(&(max32 - 1).to_string()),
Some(max32 - 1)
);
}
#[test]
fn object_serial_round_trips_through_the_metadata_string() {
// `try_flush` writes the serial back out as a decimal string for
// `target.object`; widening must not introduce a formatting change.
for raw in ["0", "4294967296", "18446744073709551615"] {
let parsed = parse_object_serial(raw).expect("valid serial");
assert_eq!(parsed.to_string(), raw);
}
}
#[test]
fn object_serial_rejects_malformed() {
for raw in [
"",
" 12",
"12 ",
"+12",
"-1",
"1.0",
"0x10",
"12a",
"abc",
// u64::MAX + 1 — overflow must be rejected, not wrapped.
"18446744073709551616",
] {
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
}
}
}
+269
View File
@@ -0,0 +1,269 @@
//! O5 measurement, pure (impl plan §5.2).
//!
//! v3.4 §6.4 asserts "a full recompute per graph event is fine for v1". The
//! impl plan closes O5 by refusing to let that rest on a node count: what has
//! to be recorded is the **graph-event rate**, the **recompute duration
//! distribution and maximum**, and **whether events queue behind recompute or
//! logging**.
//!
//! Everything here is arithmetic over samples the caller supplies. The clock
//! reads live at the I/O edge ([`super::sink`]), which is what keeps the
//! statistics unit-testable: a test feeds a hand-written sample sequence and
//! asserts the summary exactly, with no timing flake.
//!
//! **The queueing measure is a proxy, and a one-directional one.** libpipewire
//! dispatches registry callbacks serially on its own loop thread and exposes no
//! queue depth, so nothing here can read a backlog directly. What it can see is
//! that the observer thread was *continuously busy*: if an event begins being
//! handled within [`QUEUE_THRESHOLD_US`] of the previous sample's completion,
//! it was almost certainly already waiting while that recompute ran. That makes
//! [`Summary::queued_events`] a **lower bound** — a genuine backlog always shows
//! up in it, but a burst that happens to arrive exactly as the loop goes idle is
//! counted as un-queued. Combined with [`Summary::busy_fraction`] (which needs
//! no inference at all) it is enough to answer O5 in the direction that matters:
//! a low busy fraction with zero queued events is headroom, and anything else is
//! a number to argue about rather than an assumption to inherit.
use serde::Serialize;
use crate::host::observer::EventKind;
/// An event beginning this close behind the previous sample's completion is
/// counted as having queued. Deliberately tight: the cost of being wrong in the
/// generous direction is a metric that overstates backlog and sends a later
/// round chasing a non-problem.
pub const QUEUE_THRESHOLD_US: u64 = 100;
/// Upper bounds of the duration histogram, microseconds. A twelfth (overflow)
/// bucket catches everything at or above the last bound. Log-ish spacing: the
/// interesting question is which order of magnitude a recompute lands in, not
/// its exact microsecond.
pub const BUCKET_BOUNDS_US: [u64; 11] = [
50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
];
/// Human labels for the histogram buckets, parallel to [`BUCKET_BOUNDS_US`]
/// plus the overflow bucket.
pub const BUCKET_LABELS: [&str; 12] = [
"<50us", "<100us", "<250us", "<500us", "<1ms", "<2.5ms", "<5ms", "<10ms", "<25ms", "<50ms",
"<100ms", ">=100ms",
];
/// A bucketed duration distribution with exact count, sum and maximum.
///
/// Bounded memory by construction — the audit runs for as long as a share does,
/// and keeping every sample to compute an exact percentile would grow without
/// limit. The maximum, which is the number O5 actually cares about, is kept
/// exactly; percentiles are reported as the bucket they fall in.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Histogram {
buckets: [u64; 12],
count: u64,
sum_us: u64,
max_us: u64,
}
impl Histogram {
pub fn record(&mut self, us: u64) {
let index = BUCKET_BOUNDS_US
.iter()
.position(|&bound| us < bound)
.unwrap_or(BUCKET_BOUNDS_US.len());
self.buckets[index] += 1;
self.count += 1;
self.sum_us = self.sum_us.saturating_add(us);
self.max_us = self.max_us.max(us);
}
pub fn count(&self) -> u64 {
self.count
}
pub fn max_us(&self) -> u64 {
self.max_us
}
pub fn sum_us(&self) -> u64 {
self.sum_us
}
pub fn mean_us(&self) -> Option<u64> {
(self.count > 0).then(|| self.sum_us / self.count)
}
/// The label of the bucket the `q`-quantile falls in (`q` in `0.0..=1.0`),
/// or `None` when nothing has been recorded.
///
/// Uses the *nearest-rank* definition: the bucket containing the
/// `ceil(q · count)`-th sample in ascending order. Reported as a bucket
/// rather than a number because interpolating inside a bucket would invent
/// precision the histogram does not have.
pub fn quantile_bucket(&self, q: f64) -> Option<&'static str> {
if self.count == 0 {
return None;
}
let q = q.clamp(0.0, 1.0);
// Rank is 1-based; q = 0 still names the bucket holding the smallest
// sample rather than degenerating to "no samples".
let rank = ((q * self.count as f64).ceil() as u64).max(1);
let mut cumulative = 0u64;
for (index, &n) in self.buckets.iter().enumerate() {
cumulative += n;
if cumulative >= rank {
return Some(BUCKET_LABELS[index]);
}
}
// Unreachable while `count` is the sum of the buckets, but returning the
// top bucket is the fail-loud answer rather than a panic in a metric.
Some(BUCKET_LABELS[BUCKET_LABELS.len() - 1])
}
/// Non-empty buckets as `(label, count)`, ascending. Empty buckets are
/// dropped so a summary line stays readable.
pub fn distribution(&self) -> Vec<(&'static str, u64)> {
self.buckets
.iter()
.enumerate()
.filter(|&(_, &n)| n > 0)
.map(|(index, &n)| (BUCKET_LABELS[index], n))
.collect()
}
}
/// One handled event, as timed by the I/O edge.
///
/// Ticks are the AEC validator's clock, not graph changes, so [`Metrics`] counts
/// them separately — folding them into the event rate would inflate it by a
/// constant 4 Hz and hide the real graph churn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sample {
/// Monotonic microseconds (since observer start) at which handling began.
pub at_us: u64,
/// Microseconds between the previous sample's completion and `at_us`. Zero
/// for the first sample.
pub gap_us: u64,
/// Time spent in the AEC observe + taint recompute.
pub recompute_us: u64,
/// Time spent serialising and writing the record, zero when nothing was
/// emitted. Separate from `recompute_us` because O5 asks about queueing
/// behind recompute **or logging** — and if logging turns out to dominate,
/// that is a fixable problem of a different kind.
pub emit_us: u64,
pub kind: EventKind,
}
/// Rolling O5 state. Fold samples in with [`Metrics::record`]; read with
/// [`Metrics::summary`].
#[derive(Clone, Debug, Default)]
pub struct Metrics {
graph_events: u64,
tick_events: u64,
emitted_records: u64,
recompute: Histogram,
emit: Histogram,
busy_us: u64,
queued_events: u64,
first_event_us: Option<u64>,
last_completion_us: u64,
}
impl Metrics {
pub fn record(&mut self, sample: Sample) {
match sample.kind {
EventKind::Graph => self.graph_events += 1,
EventKind::Tick => self.tick_events += 1,
}
self.recompute.record(sample.recompute_us);
if sample.emit_us > 0 {
self.emitted_records += 1;
self.emit.record(sample.emit_us);
}
self.busy_us = self
.busy_us
.saturating_add(sample.recompute_us)
.saturating_add(sample.emit_us);
// The first sample has no predecessor to have queued behind.
if self.first_event_us.is_some() && sample.gap_us <= QUEUE_THRESHOLD_US {
self.queued_events += 1;
}
self.first_event_us.get_or_insert(sample.at_us);
self.last_completion_us = sample
.at_us
.saturating_add(sample.recompute_us)
.saturating_add(sample.emit_us);
}
pub fn summary(&self) -> Summary {
let span_us = self
.first_event_us
.map(|first| self.last_completion_us.saturating_sub(first))
.unwrap_or(0);
// A rate needs a span to divide by; one event in zero elapsed time has
// no rate, and reporting a made-up one is worse than reporting none.
let graph_events_per_sec = (span_us > 0)
.then(|| self.graph_events as f64 * 1_000_000.0 / span_us as f64)
.map(round_2);
let busy_fraction = (span_us > 0).then(|| round_4(self.busy_us as f64 / span_us as f64));
Summary {
graph_events: self.graph_events,
tick_events: self.tick_events,
emitted_records: self.emitted_records,
span_us,
graph_events_per_sec,
recompute_max_us: self.recompute.max_us(),
recompute_mean_us: self.recompute.mean_us(),
recompute_p50: self.recompute.quantile_bucket(0.50),
recompute_p90: self.recompute.quantile_bucket(0.90),
recompute_p99: self.recompute.quantile_bucket(0.99),
recompute_distribution: self.recompute.distribution(),
emit_max_us: self.emit.max_us(),
emit_mean_us: self.emit.mean_us(),
emit_distribution: self.emit.distribution(),
busy_us: self.busy_us,
busy_fraction,
queued_events: self.queued_events,
queue_threshold_us: QUEUE_THRESHOLD_US,
}
}
}
/// The O5 answer, as emitted.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Summary {
pub graph_events: u64,
pub tick_events: u64,
pub emitted_records: u64,
/// First event to last completion, microseconds.
pub span_us: u64,
pub graph_events_per_sec: Option<f64>,
pub recompute_max_us: u64,
pub recompute_mean_us: Option<u64>,
pub recompute_p50: Option<&'static str>,
pub recompute_p90: Option<&'static str>,
pub recompute_p99: Option<&'static str>,
pub recompute_distribution: Vec<(&'static str, u64)>,
pub emit_max_us: u64,
pub emit_mean_us: Option<u64>,
pub emit_distribution: Vec<(&'static str, u64)>,
/// Total observer-thread time spent recomputing and logging.
pub busy_us: u64,
/// `busy_us / span_us` — the share of wall time the observer thread could
/// not be servicing PipeWire. Needs no inference, unlike `queued_events`.
pub busy_fraction: Option<f64>,
/// Events that began within `queue_threshold_us` of the previous sample's
/// completion — a **lower bound** on backlog, see the module header.
pub queued_events: u64,
pub queue_threshold_us: u64,
}
/// Keep the JSON readable: a rate to two decimals and a fraction to four are
/// well past the precision any of this is good to.
fn round_2(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
fn round_4(value: f64) -> f64 {
(value * 10_000.0).round() / 10_000.0
}
+487
View File
@@ -0,0 +1,487 @@
//! Phase 5 — dry-run audit mode 🚦 (impl plan §5).
//!
//! **This phase adds no capability. Its entire purpose is to be wrong loudly
//! and safely.** It runs phases 24 against the *live* graph on every graph
//! event and reports what they conclude. It creates no links, loads no modules,
//! and changes no routing — the only thing it produces is a line of JSON.
//!
//! Why this is the gate the plan marks 🚦: the defects that matter here are
//! graph-*reasoning* defects. The 57 phase-2 fixture tests prove the engine
//! matches my model of PipeWire; only a live run proves my model matches
//! PipeWire. A wrong answer at this phase costs a log line. The same wrong
//! answer in phase 6 costs an echo — the sharer's own voice, copied back into
//! the share, which is the failure this whole design exists to prevent.
//!
//! ## The one structural requirement (§5.1)
//!
//! Every emitted record carries the **complete candidate universe partitioned
//! into exact eligible and excluded sets**, with a stable reason code on each
//! excluded row — never a spot check on named nodes. Checking only the nodes a
//! row names constrains nothing about the rest, and it lets the degenerate
//! "exclude everything" implementation pass: that build is silent, produces no
//! echo, and satisfies any assertion phrased purely as *this must be excluded*.
//! Asserting the eligible half of each row is what fails it. That requirement is
//! also the plan's answer to open question O7 (over-exclusion needs no separate
//! gate — it is subsumed by this one).
//!
//! ## What is deliberately *not* here
//!
//! - **No link creation, and no code path that could reach one.** The auditor
//! consumes a [`Projection`] and returns a record. It has no handle to
//! anything mutable.
//! - **No stdout.** Records go to stderr as JSON Lines
//! ([`sink`]) because peerspeak parses pixelpass's stdout event stream
//! (`screenshare/mod.rs:92`); a stray line there corrupts it.
//! - **No `--aec` CLI flag.** That surface is phase 7's mode selector. The audit
//! takes its AEC identity from `PIXELPASS_AUDIO_AUDIT_AEC` through the
//! *same* [`parse_aec_arg`] the real flag will use, so the parser and the
//! validator are both exercised without committing to a public interface
//! before it is designed.
//!
//! ## Fan-out gating vs. taint (read before interpreting a record)
//!
//! Two independent things can exclude a candidate and the record keeps them
//! distinguishable:
//!
//! - The **taint engine** (phase 2) excludes individual nodes with its own
//! reason codes — `peerspeak-owned`, `aec-identity`, `tainted-upstream`, …
//! - The **AEC validator** (phase 4) can forbid fan-out *entirely*, regardless
//! of taint, whenever the configured identity is unvalidated, failed or
//! revoked. Silence over echo.
//!
//! When the gate is shut, a candidate the engine would have called eligible is
//! reported excluded with an audit-level reason ([`GateReason`]); a candidate
//! the engine excluded on its own keeps *its* reason, because that names the
//! mechanism that actually applies to it. `fan_out_permitted` on the record
//! carries the gate state, so the two cases are always tellable apart.
//!
//! **Consequence for the §5.1 matrix:** every row whose point is the
//! eligible/excluded partition must run with `PIXELPASS_AUDIO_AUDIT_AEC=off`
//! (state `NotConfigured`, gate open). Row 12 — the AEC lifecycle row — is the
//! one that runs with a real `pulse-module:<idx>`, and the gate slamming shut is
//! precisely what it asserts.
#![allow(dead_code)] // Trigger paths are wired by `sink` + `run`; rows are read by tests.
pub mod metrics;
pub mod run;
pub mod sink;
#[cfg(test)]
mod tests;
use serde::Serialize;
use crate::host::aec::{AecConfig, AecState, AecValidator};
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
use crate::host::taint::snapshot::Serial;
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, 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
/// 2 s readiness budget: in the audit a `Failed` is a diagnostic, and timing out
/// early would report an absent module that was merely slow to appear.
pub const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
/// Everything the auditor needs beyond the live graph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AuditConfig {
/// The AEC identity to validate, as parsed from
/// `PIXELPASS_AUDIO_AUDIT_AEC`. Defaults to [`AecConfig::Off`] — an audit
/// run is not a share, so "there is no echo canceller in play" is the
/// honest default, and it is what leaves the fan-out gate open for the
/// partition rows.
pub aec: AecConfig,
pub aec_timeout: Millis,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
aec: AecConfig::Off,
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
}
}
}
/// An audit-level exclusion: the AEC validator has shut the fan-out gate. These
/// codes are disjoint from the taint engine's
/// [`Reason::code`](crate::host::taint::Reason::code) values, so a reader never
/// has to know which layer produced a code to interpret it.
// The shared `Aec` prefix is the point: `GateReason::Validating` and
// `AecState::Validating` would be one careless glob import away from being
// confused, and these three are the *audit's* view of that machine, not the
// machine itself.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GateReason {
/// The configured AEC identity has not been seen yet. Not an error — the
/// module may still be loading — but no fan-out happens meanwhile.
AecValidating,
/// The deadline passed with the identity never observed.
AecFailed,
/// The whole identity disappeared mid-run: every node bearing the index is
/// gone (v3.4 §5.3).
AecRevoked,
}
impl GateReason {
pub fn code(self) -> &'static str {
match self {
Self::AecValidating => "aec-validating",
Self::AecFailed => "aec-failed",
Self::AecRevoked => "aec-revoked",
}
}
/// The gate reason implied by a validator state, or `None` when fan-out is
/// permitted. Mirrors [`AecValidator::fan_out_permitted`] — kept as one
/// `match` over the same enum so the two cannot drift: every state that
/// permits fan-out maps to `None` and every state that forbids it maps to a
/// code.
pub fn from_state(state: AecState) -> Option<Self> {
match state {
AecState::NotConfigured | AecState::Validated => None,
AecState::Validating => Some(Self::AecValidating),
AecState::Failed => Some(Self::AecFailed),
AecState::Revoked => Some(Self::AecRevoked),
}
}
}
/// Stable string for an [`AecState`], for the record's `aec_state` field.
///
/// Defined here rather than on [`AecState`] to keep the merged phase-4 module
/// untouched by a reporting concern.
fn aec_state_code(state: AecState) -> &'static str {
match state {
AecState::NotConfigured => "not-configured",
AecState::Validating => "validating",
AecState::Validated => "validated",
AecState::Failed => "failed",
AecState::Revoked => "revoked",
}
}
/// Stable string for the observer's readiness epoch.
fn readiness_code(readiness: Readiness) -> &'static str {
match readiness {
Readiness::Waiting => "waiting",
Readiness::Complete => "complete",
Readiness::TimedOut => "timed-out",
}
}
/// One candidate node's effective answer. `reason` is `None` exactly when
/// `eligible` is true.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRow {
pub serial: u64,
pub name: Option<String>,
pub eligible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: 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,
}
/// A tainted node of *any* media role, not just fan-out candidates. Candidates
/// already appear in [`AuditBody::candidates`]; this is the diagnostic view —
/// when a candidate's exclusion is a surprise, the taint that reached it is the
/// next question, and it usually sits on a node that is not itself a candidate.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TaintRow {
pub serial: u64,
pub name: Option<String>,
pub reason: &'static str,
pub sticky: bool,
}
/// 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
/// producer-side bug from an impersonation attempt.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct IgnoredTagRow {
pub serial: u64,
pub name: Option<String>,
pub role: &'static str,
}
/// The decision content of one recompute — everything except which recompute it
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
/// derived `==` rather than a hand-maintained field comparison that a later
/// field addition could silently fall out of.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditBody {
/// The observer's dynamic readiness. False ⇒ every candidate is excluded
/// `graph-not-ready`; no decision from a partial graph is a decision.
pub graph_ready: bool,
/// The sticky readiness epoch, which distinguishes the three ways
/// `graph_ready` can be false (see [`Projection::readiness`]).
pub epoch: &'static str,
pub aec_state: &'static str,
/// The index handed to the taint engine — `Some` only while `Validated`.
#[serde(skip_serializing_if = "Option::is_none")]
pub aec_module_id: Option<u64>,
/// Whether the AEC validator permits fan-out at all right now.
pub fan_out_permitted: bool,
/// The audit-level reason fan-out is forbidden, when it is.
#[serde(skip_serializing_if = "Option::is_none")]
pub gate_reason: Option<&'static str>,
/// **The complete candidate universe**, ascending by serial — every
/// `Stream/Output/Audio` node in the snapshot, partitioned. §5.1's exact
/// partition is `candidates`, not a subset of it.
pub candidates: Vec<AuditRow>,
pub eligible_count: usize,
pub excluded_count: usize,
/// Taint across all node roles, ascending by serial.
pub taint: Vec<TaintRow>,
/// Nodes carrying a peerspeak ownership carrier that the engine
/// **ignored** because they are not `Stream/Output/Audio` (round 10,
/// R10-1). Normally empty; a non-empty list means either peerspeak is
/// tagging something it should not, or a process is impersonating the
/// tag. Neither is an exclusion, and neither should be silent.
///
/// Omitted from the JSONL when empty, so it costs nothing on the common
/// path and is impossible to miss when it is not.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ignored_ownership_tags: Vec<IgnoredTagRow>,
}
impl AuditBody {
/// Serials of eligible candidates, ascending — the half of the partition an
/// exclude-everything build fails.
pub fn eligible(&self) -> Vec<u64> {
self.candidates
.iter()
.filter(|row| row.eligible)
.map(|row| row.serial)
.collect()
}
/// `(serial, reason code)` for excluded candidates, ascending.
pub fn excluded(&self) -> Vec<(u64, &'static str)> {
self.candidates
.iter()
.filter(|row| !row.eligible)
.map(|row| (row.serial, row.reason.unwrap_or("?")))
.collect()
}
/// The eligible candidate with this name, if any. Convenience for the
/// matrix rows, which name nodes rather than serials.
pub fn row_named(&self, name: &str) -> Option<&AuditRow> {
self.candidates
.iter()
.find(|row| row.name.as_deref() == Some(name))
}
}
/// One recompute, as emitted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRecord {
/// Monotonic per-run counter over *every* recompute, emitted or suppressed,
/// so a gap in the emitted sequence is visibly a suppression rather than a
/// lost line.
pub seq: u64,
pub trigger: &'static str,
/// Observer-clock milliseconds at which this recompute ran.
pub at_ms: Millis,
#[serde(flatten)]
pub body: AuditBody,
}
/// What one [`Auditor::observe`] produced.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditOutcome {
pub record: AuditRecord,
/// Whether the record should be written. See [`Auditor::observe`].
pub emit: bool,
}
/// The dry-run auditor: phases 24 folded together over a live projection.
///
/// Read-only by construction — it borrows a [`Projection`] and owns only the
/// state phases 2 and 4 thread explicitly ([`StickyState`], [`AecValidator`]).
/// There is no field here through which a link could be created.
#[derive(Clone, Debug)]
pub struct Auditor {
validator: AecValidator,
sticky: StickyState,
seq: u64,
/// The body of the last record actually written, for change suppression.
last_emitted: Option<AuditBody>,
}
impl Auditor {
pub fn new(config: AuditConfig) -> Self {
Self {
validator: AecValidator::new(config.aec, config.aec_timeout),
sticky: StickyState::default(),
seq: 0,
last_emitted: None,
}
}
pub fn aec_state(&self) -> AecState {
self.validator.state()
}
pub fn sticky(&self) -> &StickyState {
&self.sticky
}
/// Fold one projection into the audit.
///
/// **Called once per applied registry event — never on a coalesced batch.**
/// That is not a performance preference, it is the phase-4 integration
/// contract (`aec/mod.rs`, the `Validated` arm): revocation is detected by
/// observing the *empty gap* between a module unload and the next reload,
/// and module indices are reused verbatim (v3.4 §5.2 correction 3). Coalesce
/// across that gap and a fresh module silently inherits a dead module's
/// validated identity. [`sink`] is what upholds this, by running the
/// recompute inline on the observer thread rather than polling
/// [`RegistryObserverHandle::latest`](crate::host::observer::adapter::RegistryObserverHandle::latest),
/// which coalesces by nature.
///
/// `emit` is true for every graph-triggered recompute, and for a
/// tick-triggered one only when the decision content changed. Ticks arrive
/// at a constant 4 Hz purely to drive the AEC deadline; emitting an
/// identical record four times a second would bury the graph events the
/// audit exists to show. `seq` still advances on suppressed records, so
/// nothing about the run is silently unaccounted for.
pub fn observe(
&mut self,
projection: &Projection,
kind: EventKind,
now: Millis,
) -> AuditOutcome {
self.seq += 1;
// Phase 4 first: its verdict is an *input* to phase 2 via
// `ExclusionCtx::aec_module_id`, so observing the graph in the other
// order would evaluate taint against the previous recompute's identity.
self.validator
.observe(&projection.snapshot, projection.graph_ready, now);
let aec_state = self.validator.state();
let gate_reason = GateReason::from_state(aec_state);
let ctx = ExclusionCtx {
aec_module_id: self.validator.validated_module_id(),
pipewire_pulse_pid: projection.pipewire_pulse_pid,
// The audit creates nothing, so it owns nothing. Another host's
// capture sink is still caught — by the `pixelpass_capture_*` name
// prefix (v3.4 §6.2), which is what §5.1 row 7 exercises — so an
// empty set costs the matrix nothing.
pixelpass_owned: Default::default(),
graph_ready: projection.graph_ready,
};
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
self.sticky = sticky;
let body = build_body(
projection,
&decisions,
aec_state,
self.validator.validated_module_id(),
gate_reason,
);
let emit = kind == EventKind::Graph || self.last_emitted.as_ref() != Some(&body);
if emit {
self.last_emitted = Some(body.clone());
}
AuditOutcome {
record: AuditRecord {
seq: self.seq,
trigger: kind.code(),
at_ms: now,
body,
},
emit,
}
}
}
fn build_body(
projection: &Projection,
decisions: &Decisions,
aec_state: AecState,
aec_module_id: Option<u64>,
gate_reason: Option<GateReason>,
) -> AuditBody {
let candidates: Vec<AuditRow> = decisions
.candidates
.values()
.map(|decision| {
// The engine's own reason wins when it has one: it names the
// mechanism that actually excluded *this* node, which is what the
// §5.1 rows assert. The gate reason applies only to candidates the
// 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),
Eligibility::Eligible => match gate_reason {
Some(gate) => (false, Some(gate.code()), false),
None => (true, None, false),
},
};
AuditRow {
serial: decision.serial.0,
name: decision.name.clone(),
eligible,
reason,
sticky,
}
})
.collect();
let eligible_count = candidates.iter().filter(|row| row.eligible).count();
let taint: Vec<TaintRow> = decisions
.taint
.iter()
.map(|(&serial, entry)| TaintRow {
serial: serial.0,
name: node_name(projection, serial),
reason: entry.reason.code(),
sticky: entry.sticky,
})
.collect();
let ignored_ownership_tags: Vec<IgnoredTagRow> =
crate::host::taint::misplaced_ownership_tags(&projection.snapshot)
.into_iter()
.map(|node| IgnoredTagRow {
serial: node.serial.0,
name: node.name.clone(),
role: node.role.code(),
})
.collect();
AuditBody {
graph_ready: projection.graph_ready,
epoch: readiness_code(projection.readiness),
aec_state: aec_state_code(aec_state),
aec_module_id,
fan_out_permitted: gate_reason.is_none(),
gate_reason: gate_reason.map(GateReason::code),
excluded_count: candidates.len() - eligible_count,
eligible_count,
candidates,
taint,
ignored_ownership_tags,
}
}
fn node_name(projection: &Projection, serial: Serial) -> Option<String> {
projection
.snapshot
.node(serial)
.and_then(|node| node.name.clone())
}
+170
View File
@@ -0,0 +1,170 @@
//! Triggering the dry-run audit: environment parsing and the two entry points.
//!
//! The impl plan §5 specifies a **hidden trigger**, `PIXELPASS_AUDIO_AUDIT=1`.
//! It is honoured in two places, which answer two different questions:
//!
//! - **Inside a real `pixelpass host` run** ([`spawn_if_enabled`]) — proves the
//! audit works in the code path phase 6 will actually mutate. This is the
//! plan-literal reading of the trigger.
//! - **Standalone** ([`run_standalone`], behind the hidden `--audit-audio`
//! flag) — observer plus auditor and nothing else: no iroh endpoint, no
//! display-server detection, no capture pipeline, no ticket. This is what
//! drives the §5.1 matrix, because a row that fails should fail for a reason
//! about *audio*, not because a relay was unreachable.
//!
//! Both paths run the same [`AuditSink`] over the same observer, so neither is a
//! simulation of the other.
use std::fs::OpenOptions;
use std::io::Write;
use anyhow::{Context, Result, bail};
use super::sink::AuditSink;
use super::{AEC_VALIDATION_TIMEOUT_MILLIS, AuditConfig};
use crate::common::signal;
use crate::host::aec::{AecConfig, AecParseError, parse_aec_arg};
use crate::host::observer::adapter::RegistryObserverHandle;
/// The hidden trigger (impl plan §5). Exactly `1` enables the audit; anything
/// else, including `true` or `yes`, does not.
///
/// Deliberately strict. This variable can only arrive by someone typing it, and
/// a value that *looks* enabling but is not would produce a silent no-op — the
/// single most annoying failure mode for a diagnostic tool. A mistyped value
/// gets a warning (see [`enabled`]) rather than silence.
pub const AUDIT_ENV: &str = "PIXELPASS_AUDIO_AUDIT";
/// The AEC identity for the audit, in the `--aec` grammar (`off` or
/// `pulse-module:<idx>`). Absent ⇒ `off`.
pub const AUDIT_AEC_ENV: &str = "PIXELPASS_AUDIO_AUDIT_AEC";
/// Redirect the JSON Lines stream to this file instead of stderr.
pub const AUDIT_FILE_ENV: &str = "PIXELPASS_AUDIO_AUDIT_FILE";
/// Whether the hidden trigger is set.
pub fn enabled() -> bool {
match std::env::var(AUDIT_ENV) {
Ok(value) if value == "1" => true,
Ok(value) => {
tracing::warn!(
"{AUDIT_ENV}={value:?} is not `1`; the audio audit stays off. \
Set {AUDIT_ENV}=1 to enable it."
);
false
}
Err(_) => false,
}
}
/// Build the audit configuration from the environment.
///
/// A malformed `PIXELPASS_AUDIO_AUDIT_AEC` is **fatal**, matching the phase-4
/// rule that a bad `--aec` value must not silently become "no AEC": there is no
/// fail-closed default index, so a wrong or dropped one would exclude the wrong
/// node (or nothing at all) and the audit would confidently report a partition
/// computed against an identity nobody asked for.
pub fn config_from_env() -> Result<AuditConfig> {
let aec = match std::env::var(AUDIT_AEC_ENV) {
Ok(raw) => parse_aec_arg(&raw).map_err(|e| {
anyhow::anyhow!(
"{AUDIT_AEC_ENV}={raw:?} is not a valid AEC argument ({}). \
Expected `off` or `pulse-module:<index>`, where the index is a bare decimal.",
describe(e)
)
})?,
Err(std::env::VarError::NotPresent) => AecConfig::Off,
Err(e) => bail!("{AUDIT_AEC_ENV} is not readable: {e}"),
};
Ok(AuditConfig {
aec,
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
})
}
fn describe(error: AecParseError) -> &'static str {
match error {
AecParseError::Empty => "the value was empty",
AecParseError::UnknownForm => "not `off` and not `pulse-module:...`",
AecParseError::MissingIndex => "`pulse-module:` with no index after the colon",
AecParseError::InvalidIndex => {
"the index was not a bare decimal (no sign, whitespace, or non-digits) that fits in u64"
}
}
}
/// Where the JSON Lines go. Stderr unless `PIXELPASS_AUDIO_AUDIT_FILE` names a
/// file, which is appended to rather than truncated — a matrix run that restarts
/// the process mid-scenario should not lose the rows it already recorded.
fn writer_from_env() -> Result<Box<dyn Write + Send>> {
match std::env::var(AUDIT_FILE_ENV) {
Ok(path) if !path.is_empty() => {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("{AUDIT_FILE_ENV}={path:?} could not be opened"))?;
tracing::info!("audio audit: writing records to {path}");
Ok(Box::new(file))
}
_ => Ok(Box::new(std::io::stderr())),
}
}
/// Construct the sink and spawn the observer behind it.
fn spawn_audit() -> Result<RegistryObserverHandle> {
let config = config_from_env()?;
let sink = AuditSink::new(config, writer_from_env()?);
tracing::info!(
aec = ?config.aec,
"audio audit: dry run active — decisions are logged, no links are created"
);
RegistryObserverHandle::spawn_with_sink(Some(Box::new(sink)))
}
/// Start the audit if the hidden trigger is set, for a `pixelpass host` run.
///
/// The returned handle must be held for the lifetime of the run: dropping it
/// stops the observer thread and flushes the final O5 summary.
///
/// Returns `Err` only when the trigger *was* set and starting failed — a
/// misconfigured audit is worth failing the run over, because the alternative is
/// a host that silently is not being audited while its operator believes it is.
pub fn spawn_if_enabled() -> Result<Option<RegistryObserverHandle>> {
if !enabled() {
return Ok(None);
}
spawn_audit().map(Some)
}
/// The standalone audit: run the observer and the auditor, and nothing else,
/// until ctrl-c.
///
/// Does not consult [`AUDIT_ENV`] — reaching this function required passing the
/// hidden `--audit-audio` flag, which is already an explicit request. The
/// environment still supplies the AEC identity and the output file.
pub async fn run_standalone() -> Result<()> {
let cancel = signal::install_ctrl_c();
let handle = spawn_audit()?;
eprintln!(
"pixelpass audio audit (dry run): observing the live PipeWire graph.\n\
No links are created and no routing changes. Ctrl-C to stop."
);
// SIGTERM as well as ctrl-c, because this mode is driven by scripts as much
// as by hand — `timeout`, a matrix harness, and systemd all send SIGTERM,
// and the default disposition would kill the process before the sink's
// `Drop` writes the final O5 summary. Losing that summary is losing the
// whole §5.2 measurement for that run.
let mut sigterm = signal::terminate_stream()?;
tokio::select! {
_ = cancel.cancelled() => {}
_ = sigterm.recv() => tracing::info!("SIGTERM received, shutting down"),
}
// Explicit rather than incidental: this drop stops the PipeWire thread,
// which drops the sink, which writes the final metrics line. Letting it fall
// out of scope would do the same thing, but the ordering is the point.
drop(handle);
Ok(())
}
+184
View File
@@ -0,0 +1,184 @@
//! The audit's I/O edge: timing, JSON Lines emission, O5 accounting.
//!
//! Everything impure about phase 5 lives here, and it is deliberately thin —
//! read the clock, call [`Auditor::observe`], write a line, fold a
//! [`metrics::Sample`]. The decisions are all upstream in the pure core, which
//! is why the matrix can be argued about in unit tests rather than only in front
//! of a live daemon.
//!
//! ## Why this runs on the observer thread
//!
//! [`AuditSink`] is a [`ProjectionSink`], invoked inline from the PipeWire
//! observer thread once per applied registry event. The obvious alternative —
//! a consumer task polling
//! [`RegistryObserverHandle::latest`](super::super::observer::adapter::RegistryObserverHandle::latest)
//! — was rejected: polling **coalesces**, and phase 4's revocation logic
//! detects a module unload by observing the *empty gap* before the next module
//! appears. Module indices are reused verbatim across an unload/reload (v3.4
//! §5.2 correction 3), so a poller that misses the gap silently aliases a fresh
//! module onto a dead module's validated identity. Running inline is what makes
//! "one `observe` per graph event, no coalescing" — the contract phase 4
//! documents as owed — actually true.
//!
//! The cost of that choice is that recompute and logging happen on the thread
//! servicing PipeWire, which is precisely the risk O5 asks about. That is not an
//! accident: this arrangement puts the cost exactly where the measurement can
//! see it. See [`metrics`].
//!
//! ## Output contract
//!
//! One JSON object per line, to **stderr** by default, each tagged with a `kind`
//! discriminator (`"audit"` or `"metrics"`). Never stdout: peerspeak parses
//! pixelpass's stdout event stream, and the impl plan §5 is explicit that
//! unstructured output must not go there. `PIXELPASS_AUDIO_AUDIT_FILE`
//! redirects the records to a file instead, which is how the §5.1 matrix is
//! driven — it separates the audit stream from interleaved `tracing` output
//! without needing either side to change format.
use std::io::Write;
use std::time::Instant;
use serde::Serialize;
use super::metrics::{self, Metrics, Summary};
use super::{AuditConfig, AuditRecord, Auditor};
use crate::host::observer::adapter::ProjectionSink;
use crate::host::observer::{EventKind, Millis, Projection};
/// Emit a rolling metrics line every this many ticks. Ticks are 250 ms, so this
/// is every 10 s — often enough that a run killed abruptly still leaves a
/// usable O5 record, rare enough that it does not crowd out the audit records.
const SUMMARY_INTERVAL_TICKS: u64 = 40;
/// The live audit: pure auditor + clock + writer.
pub struct AuditSink {
auditor: Auditor,
metrics: Metrics,
writer: Box<dyn Write + Send>,
/// Set once the first sample has completed, so the first event is not
/// counted as having queued behind a predecessor that does not exist.
last_completion_us: Option<u64>,
ticks_since_summary: u64,
/// Wall-clock origin for the microsecond timings. Only used for durations,
/// never for the AEC deadline — that runs on the observer's own clock,
/// handed in as `now_us`, so the validator and the readiness epoch cannot
/// disagree about what time it is.
epoch: Instant,
}
impl AuditSink {
pub fn new(config: AuditConfig, writer: Box<dyn Write + Send>) -> Self {
Self {
auditor: Auditor::new(config),
metrics: Metrics::default(),
writer,
last_completion_us: None,
ticks_since_summary: 0,
epoch: Instant::now(),
}
}
fn elapsed_us(&self) -> u64 {
u64::try_from(self.epoch.elapsed().as_micros()).unwrap_or(u64::MAX)
}
/// Write one line. Failures are logged once per occurrence and otherwise
/// ignored: a broken stderr must not take down the observer thread, and the
/// audit is diagnostic — losing a line is a worse audit, not a worse share.
fn write_line<T: Serialize>(&mut self, line: &T) {
match serde_json::to_string(line) {
Ok(json) => {
if let Err(e) = writeln!(self.writer, "{json}") {
tracing::warn!("audit: failed to write record: {e}");
}
}
Err(e) => tracing::warn!("audit: failed to serialise record: {e}"),
}
}
fn write_summary(&mut self, at_ms: Millis) {
let summary = self.metrics.summary();
self.write_line(&MetricsLine {
kind: "metrics",
at_ms,
summary: &summary,
});
let _ = self.writer.flush();
}
}
impl ProjectionSink for AuditSink {
fn on_projection(&mut self, projection: &Projection, kind: EventKind, now_us: u64) {
let at_us = self.elapsed_us();
let gap_us = self
.last_completion_us
.map(|previous| at_us.saturating_sub(previous))
.unwrap_or(0);
let recompute_start = self.elapsed_us();
let outcome = self.auditor.observe(projection, kind, now_us / 1_000);
let recompute_us = self.elapsed_us().saturating_sub(recompute_start);
let emit_us = if outcome.emit {
let emit_start = self.elapsed_us();
self.write_line(&AuditLine {
kind: "audit",
recompute_us,
record: &outcome.record,
});
// Flushed per record so a run ended with SIGKILL (or a matrix row
// that reads the file while the process is still up) still shows
// every decision made before that instant. The cost is measured, not
// assumed — it is inside `emit_us`.
let _ = self.writer.flush();
self.elapsed_us().saturating_sub(emit_start).max(1)
} else {
0
};
self.metrics.record(metrics::Sample {
at_us,
gap_us,
recompute_us,
emit_us,
kind,
});
self.last_completion_us = Some(self.elapsed_us());
if kind == EventKind::Tick {
self.ticks_since_summary += 1;
if self.ticks_since_summary >= SUMMARY_INTERVAL_TICKS {
self.ticks_since_summary = 0;
self.write_summary(now_us / 1_000);
}
}
}
}
impl Drop for AuditSink {
/// The final O5 record. The observer thread drops its sink when the main
/// loop quits, so an ordinary ctrl-c leaves a complete summary behind
/// without the runner having to ask for one.
fn drop(&mut self) {
let at_ms = self.elapsed_us() / 1_000;
self.write_summary(at_ms);
}
}
#[derive(Serialize)]
struct AuditLine<'a> {
kind: &'static str,
/// This record's own recompute cost, so a surprising row can be correlated
/// with a cost spike without cross-referencing the periodic summary.
recompute_us: u64,
#[serde(flatten)]
record: &'a AuditRecord,
}
#[derive(Serialize)]
struct MetricsLine<'a> {
kind: &'static str,
at_ms: Millis,
#[serde(flatten)]
summary: &'a Summary,
}
+960
View File
@@ -0,0 +1,960 @@
//! Pure tests for the phase-5 auditor and its O5 metrics.
//!
//! Two things are being tested here and they are worth keeping distinct:
//!
//! - **Audit-layer behaviour** — the fan-out gate, record suppression, sequence
//! accounting, epoch reporting, and above all that every record carries the
//! *complete* candidate universe (§5.1). These are properties nothing else
//! tests, because nothing else exists at this layer.
//! - **A few §5.1 matrix shapes in fixture form** — row 1 (owner-bridge
//! forwarder), row 3 (two modules, one tainted), row 12 (AEC lifecycle). These
//! are *not* re-litigating phase 2, whose 57 tests already own those verdicts.
//! They exist so that a plumbing mistake between the engine and the record —
//! a dropped reason code, an inverted partition — fails here, at compile-time
//! speed, rather than only in front of a live daemon.
//!
//! The live half of the gate cannot live in this file by definition: a fixture
//! tests my model against my own assumptions, and §5's whole argument is that
//! only a live run tests my model against PipeWire. See the matrix runs recorded
//! in the phase-5 results file.
use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
use super::*;
use crate::host::aec::AecConfig;
use crate::host::observer::{EventKind, Readiness};
use crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX;
use crate::host::taint::fixture::{self, Graph, NodeRef};
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
/// the same constant the engine matches on, so these audit rows report the
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
fn owned_name(role: &str, pid: u32) -> String {
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
}
const AEC_MODULE: u64 = 7;
const TIMEOUT: Millis = 5_000;
fn ready(snapshot: GraphSnapshot) -> Projection {
Projection {
snapshot,
pipewire_pulse_pid: Some(fixture::PULSE_PID),
graph_ready: true,
readiness: Readiness::Complete,
}
}
fn not_ready(snapshot: GraphSnapshot, readiness: Readiness) -> Projection {
Projection {
snapshot,
pipewire_pulse_pid: Some(fixture::PULSE_PID),
graph_ready: false,
readiness,
}
}
fn auditor_off() -> Auditor {
Auditor::new(AuditConfig {
aec: AecConfig::Off,
aec_timeout: TIMEOUT,
})
}
fn auditor_aec(index: u64) -> Auditor {
Auditor::new(AuditConfig {
aec: AecConfig::PulseModule(index),
aec_timeout: TIMEOUT,
})
}
/// One graph-triggered recompute at `now`.
fn observe(auditor: &mut Auditor, projection: &Projection, now: Millis) -> AuditOutcome {
auditor.observe(projection, EventKind::Graph, now)
}
/// Candidate names split into (eligible, excluded-with-reason), which is how the
/// §5.1 rows are phrased. Names rather than serials so a failure reads as the
/// scenario rather than as an integer.
fn partition(body: &AuditBody) -> (Vec<&str>, Vec<(&str, &str)>) {
let eligible = body
.candidates
.iter()
.filter(|row| row.eligible)
.map(|row| row.name.as_deref().unwrap_or("<unnamed>"))
.collect();
let excluded = body
.candidates
.iter()
.filter(|row| !row.eligible)
.map(|row| {
(
row.name.as_deref().unwrap_or("<unnamed>"),
row.reason.unwrap_or("<none>"),
)
})
.collect();
(eligible, excluded)
}
// ── the §5.1 structural requirement ───────────────────────────────────────
/// The record must contain **every** `Stream/Output/Audio` node, not only the
/// interesting ones. This is the property the whole exact-partition requirement
/// rests on: if the record could omit a candidate, then asserting a complete
/// partition over the record would still not constrain the graph.
#[test]
fn the_record_carries_the_complete_candidate_universe() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.app_node("game", MediaRole::StreamOutput, 101);
graph.app_node("recorder", MediaRole::StreamInput, 102);
graph.device_node("speakers", MediaRole::Sink);
let projection = ready(graph.build());
let outcome = observe(&mut auditor_off(), &projection, 0);
let (eligible, excluded) = partition(&outcome.record.body);
// Both playback streams, neither the capture stream nor the sink. Ordered by
// serial (creation order), which is what makes the partition assertions in
// every other row stable rather than dependent on a hash iteration.
assert_eq!(eligible, vec!["music", "game"]);
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
assert_eq!(outcome.record.body.candidates.len(), 2);
assert_eq!(outcome.record.body.eligible_count, 2);
assert_eq!(outcome.record.body.excluded_count, 0);
}
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
/// an ownership carrier on a non-producer, which means the fix removes an
/// exclusion — so the only way an operator learns a tag was seen and dropped is
/// this field. A matrix row that silently grew an impostor would otherwise read
/// as a clean pass.
#[test]
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
// The bystander is untouched — the point of the fix.
let (eligible, excluded) = partition(&body);
assert_eq!(eligible, vec!["music"]);
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
// ...but the tag is not silent, and the row names the role it appeared on.
assert_eq!(body.ignored_ownership_tags.len(), 1);
let row = &body.ignored_ownership_tags[0];
assert_eq!(row.serial, impostor.serial.0);
assert_eq!(row.role, "stream-input");
assert_eq!(
row.name.as_deref(),
Some(owned_name("rogue", 4_242).as_str())
);
}
/// The common path stays quiet: a correctly tagged peerspeak producer is
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
/// this, a diagnostic that fired on every normal run would be worthless.
#[test]
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
let mut graph = Graph::new();
let sink = graph.device_node("speakers", MediaRole::Sink);
let call = graph.peerspeak_node("call", 200);
graph.link(call, sink);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
assert_eq!(body.excluded_count, 1);
assert!(
body.ignored_ownership_tags.is_empty(),
"honoured tag reported as misplaced: {:?}",
body.ignored_ownership_tags
);
}
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
/// panic and no invented row.
#[test]
fn an_empty_graph_yields_an_empty_partition() {
let projection = ready(Graph::new().build());
let outcome = observe(&mut auditor_off(), &projection, 0);
assert!(outcome.record.body.candidates.is_empty());
assert!(outcome.record.body.taint.is_empty());
assert_eq!(outcome.record.body.eligible_count, 0);
assert_eq!(outcome.record.body.excluded_count, 0);
assert!(outcome.record.body.fan_out_permitted);
}
/// `eligible_count + excluded_count` is the candidate count, always. A partition
/// that does not partition would let a row's two assertions both pass while the
/// record described no coherent state.
#[test]
fn the_counts_always_partition_the_candidates() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.peerspeak_node("peerspeak-playback", 200);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
assert_eq!(
body.eligible_count + body.excluded_count,
body.candidates.len()
);
assert_eq!(body.eligible().len(), body.eligible_count);
assert_eq!(body.excluded().len(), body.excluded_count);
}
/// Every excluded row names a reason and every eligible row does not. The
/// §5.1 rows assert "excluded, with reason code" — a `None` reason on an
/// excluded row would make that assertion unwritable.
#[test]
fn reason_presence_is_exactly_the_exclusion() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.peerspeak_node("peerspeak-playback", 200);
let projection = ready(graph.build());
for row in observe(&mut auditor_off(), &projection, 0)
.record
.body
.candidates
{
assert_eq!(
row.eligible,
row.reason.is_none(),
"row {row:?} has eligibility and reason out of step"
);
}
}
// ── readiness ─────────────────────────────────────────────────────────────
/// No decision made from a partial graph is a decision. Note this is asserted on
/// the *eligible* half too: an implementation that reported nothing at all while
/// not ready would also be wrong, because the audit must still show what it can
/// see.
#[test]
fn a_not_ready_graph_excludes_every_candidate() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.app_node("game", MediaRole::StreamOutput, 101);
let projection = not_ready(graph.build(), Readiness::Waiting);
let body = observe(&mut auditor_off(), &projection, 0).record.body;
let (eligible, excluded) = partition(&body);
assert!(eligible.is_empty());
assert_eq!(
excluded,
vec![("music", "graph-not-ready"), ("game", "graph-not-ready"),]
);
assert!(!body.graph_ready);
}
/// The three ways `graph_ready` can be false are distinguishable in the record.
/// Collapsing them would make a timed-out observer — a fail-closed *fault* —
/// indistinguishable from an enumeration that is merely still running.
#[test]
fn the_epoch_distinguishes_the_ways_a_graph_can_be_unready() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let snapshot = graph.build();
for (readiness, expected) in [
(Readiness::Waiting, "waiting"),
(Readiness::TimedOut, "timed-out"),
// A completed epoch momentarily blocked on a current obligation: the
// interesting one, because `graph_ready` alone makes it look like a
// brand-new observer.
(Readiness::Complete, "complete"),
] {
let projection = not_ready(snapshot.clone(), readiness);
let body = observe(&mut auditor_off(), &projection, 0).record.body;
assert_eq!(body.epoch, expected);
assert!(!body.graph_ready);
}
let body = observe(&mut auditor_off(), &ready(snapshot), 0).record.body;
assert_eq!(body.epoch, "complete");
assert!(body.graph_ready);
}
// ── the fan-out gate (phase 4 → audit) ────────────────────────────────────
/// `--aec=off` leaves the gate open: `NotConfigured` is "there is no echo
/// canceller", not "we failed to find one".
#[test]
fn aec_off_leaves_the_gate_open() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let projection = ready(graph.build());
let body = observe(&mut auditor_off(), &projection, 0).record.body;
assert_eq!(body.aec_state, "not-configured");
assert!(body.fan_out_permitted);
assert_eq!(body.gate_reason, None);
assert_eq!(body.aec_module_id, None);
assert_eq!(partition(&body).0, vec!["music"]);
}
/// While the configured identity has not been seen, nothing may fan out —
/// silence over echo — and the record says why in a code, not in prose.
#[test]
fn a_validating_gate_excludes_every_engine_eligible_candidate() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.app_node("game", MediaRole::StreamOutput, 101);
let projection = ready(graph.build());
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
.record
.body;
let (eligible, excluded) = partition(&body);
assert_eq!(body.aec_state, "validating");
assert!(!body.fan_out_permitted);
assert_eq!(body.gate_reason, Some("aec-validating"));
assert!(eligible.is_empty());
assert_eq!(
excluded,
vec![("music", "aec-validating"), ("game", "aec-validating")]
);
}
/// A shut gate must not erase the engine's own reason codes. If it did, every
/// §5.1 row run under a shut gate would report one uniform code and the matrix
/// would stop constraining the taint engine at all — the record would say
/// "nothing may fan out" while hiding *which* nodes were tainted and how.
#[test]
fn a_shut_gate_preserves_the_engines_own_reasons() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
graph.peerspeak_node("peerspeak-playback", 200);
let projection = ready(graph.build());
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
.record
.body;
let (_, excluded) = partition(&body);
let playback = owned_name("peerspeak-playback", 200);
assert!(!body.fan_out_permitted);
assert_eq!(
excluded,
vec![
("music", "aec-validating"),
// Tagged, so it keeps the reason that actually applies to it.
(playback.as_str(), "peerspeak-owned"),
]
);
}
/// The deadline is armed on the first ready graph, so a slow enumeration reads
/// as "unknown", not "absent" (the phase-4 user design call). Past it with the
/// identity never seen, the gate latches shut.
#[test]
fn the_gate_fails_closed_after_the_deadline() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let snapshot = graph.build();
let mut auditor = auditor_aec(AEC_MODULE);
// Still enumerating well past the timeout: not a failure, because absence
// has not been established.
let waiting = not_ready(snapshot.clone(), Readiness::Waiting);
let body = observe(&mut auditor, &waiting, TIMEOUT * 3).record.body;
assert_eq!(body.aec_state, "validating");
// Ready arms the deadline; the clock has to advance past it from here.
let projection = ready(snapshot);
let body = observe(&mut auditor, &projection, TIMEOUT * 3).record.body;
assert_eq!(body.aec_state, "validating");
let body = observe(&mut auditor, &projection, TIMEOUT * 6 + 1)
.record
.body;
assert_eq!(body.aec_state, "failed");
assert_eq!(body.gate_reason, Some("aec-failed"));
assert_eq!(partition(&body).1, vec![("music", "aec-failed")]);
}
// ── §5.1 row 12: the AEC lifecycle ────────────────────────────────────────
/// Build the four nodes `module-echo-cancel` creates, all bearing one index:
/// two `Stream/*` legs plus the virtual sink/source pair (v3.4 §5.2). The
/// playback leg is the hazard — a `Stream/Output/Audio` wired to the speakers.
fn aec_nodes(graph: &mut Graph, index: u64) -> Vec<NodeRef> {
vec![
graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, index),
graph.module_node("echo-cancel-capture", MediaRole::StreamInput, index),
graph.module_node("echo-cancel-sink", MediaRole::Sink, index),
graph.module_node("echo-cancel-source", MediaRole::Source, index),
]
}
/// §5.1 row 12: AEC loaded → validated, its playback leg excluded by identity
/// while everything else stays eligible → unloaded → `Revoked`, gate shut.
///
/// The eligible half is the load-bearing assertion in the first phase: an
/// implementation that excluded the whole graph the moment an AEC appeared would
/// satisfy "the four nodes are excluded" and still be wrong.
#[test]
fn row_12_aec_loaded_then_unloaded_validates_then_revokes() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let aec = aec_nodes(&mut graph, AEC_MODULE);
let mut auditor = auditor_aec(AEC_MODULE);
let loaded = ready(graph.build());
let body = observe(&mut auditor, &loaded, 0).record.body;
let (eligible, excluded) = partition(&body);
assert_eq!(body.aec_state, "validated");
assert!(body.fan_out_permitted);
assert_eq!(body.aec_module_id, Some(AEC_MODULE));
assert_eq!(eligible, vec!["music"]);
assert_eq!(excluded, vec![("echo-cancel-playback", "aec-identity")]);
// Every node bearing the index goes away: a real unload.
let unloaded = ready(graph.build_without(&aec));
let body = observe(&mut auditor, &unloaded, 1).record.body;
let (eligible, excluded) = partition(&body);
assert_eq!(body.aec_state, "revoked");
assert!(!body.fan_out_permitted);
assert_eq!(body.gate_reason, Some("aec-revoked"));
assert_eq!(body.aec_module_id, None);
assert!(eligible.is_empty());
assert_eq!(excluded, vec![("music", "aec-revoked")]);
}
/// One leg corking is not a revocation (v3.4 §5.3). Getting this wrong turns an
/// ordinary cork into a share-wide audio stop, so the audit must report the
/// identity as still live.
#[test]
fn row_12_partial_leg_loss_does_not_revoke() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let aec = aec_nodes(&mut graph, AEC_MODULE);
let mut auditor = auditor_aec(AEC_MODULE);
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
assert_eq!(body.aec_state, "validated");
// The capture leg alone disappears; three nodes still bear the index.
let partial = ready(graph.build_without(&aec[1..2]));
let body = observe(&mut auditor, &partial, 1).record.body;
assert_eq!(body.aec_state, "validated");
assert!(body.fan_out_permitted);
assert_eq!(partition(&body).0, vec!["music"]);
}
/// Revocation is sticky terminal: module indices are reused verbatim across an
/// unload/reload (v3.4 §5.2 correction 3), so a reappearing index must not
/// resurrect the epoch and alias onto an unrelated module. A genuine reload gets
/// a fresh validator, never this one.
#[test]
fn row_12_a_reused_index_does_not_resurrect_a_revoked_epoch() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let aec = aec_nodes(&mut graph, AEC_MODULE);
let mut auditor = auditor_aec(AEC_MODULE);
observe(&mut auditor, &ready(graph.build()), 0);
let unloaded = graph.build_without(&aec);
let body = observe(&mut auditor, &ready(unloaded), 1).record.body;
assert_eq!(body.aec_state, "revoked");
// A second module comes back with the same index — different objects,
// identical number.
let mut reloaded = Graph::new();
reloaded.app_node("music", MediaRole::StreamOutput, 100);
aec_nodes(&mut reloaded, AEC_MODULE);
let body = observe(&mut auditor, &ready(reloaded.build()), 2)
.record
.body;
assert_eq!(body.aec_state, "revoked");
assert!(!body.fan_out_permitted);
assert_eq!(body.gate_reason, Some("aec-revoked"));
}
// ── §5.1 rows in fixture form (plumbing, not phase-2 verdicts) ────────────
/// §5.1 row 1: a `module-null-sink` + `module-loopback` forwarder. The output
/// leg is excluded across the **owner bridge** — naming the mechanism, not a
/// link walk — while an identically-shaped forwarder with no tainted input stays
/// eligible. The second half is what an exclude-everything build fails.
#[test]
fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
let mut graph = Graph::new();
// Tainted root: peerspeak's own call playback, feeding a sink the forwarder
// reads back out.
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 _ = playback;
// Control: the same shape, fed by nothing tainted.
let clean_sink = graph.module_node("clean-null-sink", MediaRole::Sink, 31);
let clean_capture = graph.module_node("clean-loopback-capture", MediaRole::StreamInput, 31);
let clean_playback = graph.module_node("clean-loopback-playback", MediaRole::StreamOutput, 31);
graph.link(clean_sink, clean_capture);
let _ = clean_playback;
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["clean-loopback-playback"]);
assert_eq!(
excluded,
vec![
(call_name.as_str(), "peerspeak-owned"),
("tainted-loopback-playback", "tainted-owner-bridge"),
]
);
}
/// §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
/// would drag the innocent module's output leg down with the tainted one.
#[test]
fn row_3_one_tainted_module_does_not_taint_the_other() {
let mut graph = Graph::new();
let call = graph.peerspeak_node("peerspeak-call", 200);
let sink = graph.module_node("null-sink-a", MediaRole::Sink, 40);
graph.link(call, sink);
let capture_a = graph.module_node("module-a-capture", MediaRole::StreamInput, 40);
let playback_a = graph.module_node("module-a-playback", MediaRole::StreamOutput, 40);
graph.link(sink, capture_a);
let _ = playback_a;
// A second, entirely independent module reading an untainted source.
let mic = graph.device_node("microphone", MediaRole::Source);
let capture_b = graph.module_node("module-b-capture", MediaRole::StreamInput, 41);
let playback_b = graph.module_node("module-b-playback", MediaRole::StreamOutput, 41);
graph.link(mic, capture_b);
let _ = playback_b;
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let (eligible, excluded) = partition(&body);
let call_name = owned_name("peerspeak-call", 200);
assert_eq!(eligible, vec!["module-b-playback"]);
assert_eq!(
excluded,
vec![
(call_name.as_str(), "peerspeak-owned"),
("module-a-playback", "tainted-owner-bridge"),
]
);
}
/// §5.1 row 7 (cycle prevention, v3.4 §6.2): a forwarder reading *another*
/// pixelpass host's capture sink must be excluded by its **named output
/// serial**, or two hosts sharing to each other build an audio cycle.
#[test]
fn row_7_a_forwarder_reading_another_hosts_capture_sink_is_excluded() {
let mut graph = Graph::new();
// The other host's own client, in *this* graph — a node pointing at a client
// that does not exist would exercise the unresolved-owner path instead of the
// capture-sink-name path this row is about.
let other_client = graph.client(Some(fixture::PULSE_PID));
let other_sink = graph.node(
"pixelpass_capture_deadbeef",
MediaRole::Sink,
fixture::app(other_client, 300),
);
let capture = graph.module_node("cycle-loopback-capture", MediaRole::StreamInput, 50);
let playback = graph.module_node("cycle-loopback-playback", MediaRole::StreamOutput, 50);
graph.link(other_sink, capture);
let _ = playback;
graph.app_node("music", MediaRole::StreamOutput, 100);
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let (eligible, excluded) = partition(&body);
assert_eq!(eligible, vec!["music"]);
assert_eq!(
excluded,
vec![("cycle-loopback-playback", "tainted-owner-bridge")]
);
// The sink itself is tainted, by the mechanism that names it.
let sink_taint = body
.taint
.iter()
.find(|row| row.name.as_deref() == Some("pixelpass_capture_deadbeef"))
.expect("the other host's capture sink must be tainted");
assert_eq!(sink_taint.reason, "pixelpass-owned");
}
/// Sticky taint (§5.1 row 10) is reported as sticky, not silently folded into
/// an ordinary exclusion. The flag is how the audit distinguishes "this is
/// tainted right now" from "this was tainted and its owner has not fully torn
/// down" — two different things to be surprised by.
#[test]
fn sticky_exclusions_are_flagged_as_sticky() {
let mut graph = Graph::new();
let call = graph.peerspeak_node("peerspeak-call", 200);
let sink = graph.module_node("null-sink", MediaRole::Sink, 60);
graph.link(call, sink);
let capture = graph.module_node("loopback-capture", MediaRole::StreamInput, 60);
graph.module_node("loopback-playback", MediaRole::StreamOutput, 60);
graph.link(sink, capture);
let mut auditor = auditor_off();
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
let playback = body
.row_named("loopback-playback")
.expect("the output leg must be a candidate");
assert!(!playback.eligible);
assert!(!playback.sticky, "first sight is not sticky");
// The tainted input leg goes away; the output leg lives on.
let body = observe(&mut auditor, &ready(graph.build_without(&[capture])), 1)
.record
.body;
let playback = body
.row_named("loopback-playback")
.expect("the output leg must still be a candidate");
assert!(!playback.eligible);
assert!(playback.sticky, "the taint is carried over, and says so");
}
// ── record accounting ─────────────────────────────────────────────────────
/// Ticks exist to drive the AEC deadline, not to describe the graph. Emitting an
/// identical record four times a second would bury the graph events the audit
/// exists to show — but a tick that *does* change something must still be
/// emitted, or a `Validating → Failed` transition (which only a tick can cause)
/// would never appear in the log at all.
#[test]
fn an_unchanged_tick_is_suppressed_but_a_changed_one_is_not() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let projection = ready(graph.build());
let mut auditor = auditor_aec(AEC_MODULE);
assert!(auditor.observe(&projection, EventKind::Graph, 0).emit);
assert!(
!auditor.observe(&projection, EventKind::Tick, 100).emit,
"an identical tick record is noise"
);
assert!(
!auditor.observe(&projection, EventKind::Tick, 200).emit,
"still noise"
);
// The deadline expires on a tick: the state changes, so this one is emitted.
let outcome = auditor.observe(&projection, EventKind::Tick, TIMEOUT + 1);
assert!(outcome.emit);
assert_eq!(outcome.record.body.aec_state, "failed");
}
/// A graph event always emits, even when the decision content is identical — a
/// suppressed graph event would erase the evidence that the graph changed at all,
/// and "PipeWire told us something and nothing moved" is itself a finding.
#[test]
fn an_unchanged_graph_event_still_emits() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let projection = ready(graph.build());
let mut auditor = auditor_off();
assert!(observe(&mut auditor, &projection, 0).emit);
assert!(observe(&mut auditor, &projection, 1).emit);
}
/// `seq` counts every recompute, emitted or not, so a gap in the emitted
/// sequence is visibly a suppression rather than a lost line. Without this, a
/// reader cannot tell a quiet audit from a broken one.
#[test]
fn seq_counts_suppressed_recomputes_too() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let projection = ready(graph.build());
let mut auditor = auditor_off();
assert_eq!(observe(&mut auditor, &projection, 0).record.seq, 1);
let suppressed = auditor.observe(&projection, EventKind::Tick, 1);
assert!(!suppressed.emit);
assert_eq!(suppressed.record.seq, 2);
assert_eq!(observe(&mut auditor, &projection, 2).record.seq, 3);
}
/// Suppression compares against the last record actually *written*, not the last
/// one computed. Comparing against the last computed record would let a change
/// that appears and reverts between two ticks vanish from the log entirely,
/// leaving a reader with a record that no longer matches the state.
#[test]
fn suppression_compares_against_the_last_emitted_record() {
let mut graph = Graph::new();
graph.app_node("music", MediaRole::StreamOutput, 100);
let with_music = ready(graph.build());
let empty = ready(Graph::new().build());
let mut auditor = auditor_off();
assert!(observe(&mut auditor, &with_music, 0).emit);
// A tick sees a different graph and emits.
assert!(auditor.observe(&empty, EventKind::Tick, 1).emit);
// The next tick sees the original graph again — different from what was last
// written, so it must be emitted.
assert!(auditor.observe(&with_music, EventKind::Tick, 2).emit);
// And now it matches the last written record.
assert!(!auditor.observe(&with_music, EventKind::Tick, 3).emit);
}
/// The trigger and clock are reported verbatim, which is what lets the O5 event
/// rate be recomputed from the record stream alone rather than trusted from the
/// summary.
#[test]
fn the_record_reports_its_trigger_and_clock() {
let projection = ready(Graph::new().build());
let mut auditor = auditor_off();
let outcome = auditor.observe(&projection, EventKind::Graph, 42);
assert_eq!(outcome.record.trigger, "graph");
assert_eq!(outcome.record.at_ms, 42);
let outcome = auditor.observe(&projection, EventKind::Tick, 43);
assert_eq!(outcome.record.trigger, "tick");
assert_eq!(outcome.record.at_ms, 43);
}
/// The taint view spans every media role, not just candidates. A candidate's
/// exclusion is usually explained by taint on a node that is not itself a
/// candidate — the sink in the middle of a forwarder — and without that the
/// record shows the verdict but not the evidence.
#[test]
fn the_taint_view_covers_non_candidate_roles() {
let mut graph = Graph::new();
let call = graph.peerspeak_node("peerspeak-call", 200);
let sink = graph.module_node("null-sink", MediaRole::Sink, 70);
graph.link(call, sink);
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
.record
.body;
let tainted: Vec<(&str, &str)> = body
.taint
.iter()
.map(|row| (row.name.as_deref().unwrap_or("?"), row.reason))
.collect();
assert!(
tainted.contains(&("null-sink", "tainted-upstream")),
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
);
let call_name = owned_name("peerspeak-call", 200);
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
}
/// A record must serialise to a single line. Newlines inside a JSON Lines
/// record would split one record into two unparseable ones — and node names come
/// from PipeWire properties, which are attacker-adjacent free text.
#[test]
fn a_record_serialises_to_exactly_one_line() {
let mut graph = Graph::new();
graph.app_node("evil\nname\r\nwith breaks", MediaRole::StreamOutput, 100);
let projection = ready(graph.build());
let outcome = observe(&mut auditor_off(), &projection, 0);
let json = serde_json::to_string(&outcome.record).expect("a record must serialise");
assert_eq!(json.lines().count(), 1, "record split across lines: {json}");
assert!(
json.contains(r"evil\nname"),
"the name must survive escaped"
);
}
// ── O5 metrics ────────────────────────────────────────────────────────────
fn sample(kind: EventKind, at_us: u64, gap_us: u64, recompute_us: u64, emit_us: u64) -> Sample {
Sample {
at_us,
gap_us,
recompute_us,
emit_us,
kind,
}
}
/// Bucket bounds are exclusive upper bounds, so a value exactly on a bound lands
/// in the next bucket up. Asserted because an off-by-one here silently shifts
/// the whole distribution the O5 conclusion rests on.
#[test]
fn histogram_bounds_are_exclusive_upper_bounds() {
let mut metrics = Metrics::default();
for us in [0, 49, 50, 99_999, 100_000, 1_000_000] {
metrics.record(sample(EventKind::Graph, 0, 1_000, us, 0));
}
let summary = metrics.summary();
assert_eq!(
summary.recompute_distribution,
vec![
("<50us", 2), // 0 and 49
("<100us", 1), // 50
("<100ms", 1), // 99_999
(">=100ms", 2), // 100_000 and 1_000_000
]
);
assert_eq!(summary.recompute_max_us, 1_000_000);
}
/// The maximum is exact, not bucketed. O5 asks for the maximum specifically, and
/// ">= 100 ms" is not an answer to "how bad does it get?".
#[test]
fn the_maximum_is_exact_not_bucketed() {
let mut metrics = Metrics::default();
metrics.record(sample(EventKind::Graph, 0, 1_000, 137, 0));
metrics.record(sample(EventKind::Graph, 0, 1_000, 4_211, 0));
metrics.record(sample(EventKind::Graph, 0, 1_000, 90, 0));
let summary = metrics.summary();
assert_eq!(summary.recompute_max_us, 4_211);
assert_eq!(summary.recompute_mean_us, Some((137 + 4_211 + 90) / 3));
}
/// Nearest-rank quantiles over the buckets.
#[test]
fn quantiles_use_nearest_rank_over_the_buckets() {
let mut metrics = Metrics::default();
// 99 fast samples and one very slow one: the tail must show up at p99 and
// nowhere earlier, which is the whole reason for reporting p99 at all.
for _ in 0..99 {
metrics.record(sample(EventKind::Graph, 0, 1_000, 10, 0));
}
metrics.record(sample(EventKind::Graph, 0, 1_000, 200_000, 0));
let summary = metrics.summary();
assert_eq!(summary.recompute_p50, Some("<50us"));
assert_eq!(summary.recompute_p90, Some("<50us"));
assert_eq!(summary.recompute_p99, Some("<50us"));
assert_eq!(summary.recompute_max_us, 200_000);
}
#[test]
fn an_empty_histogram_reports_no_quantiles_and_no_rate() {
let summary = Metrics::default().summary();
assert_eq!(summary.recompute_p50, None);
assert_eq!(summary.recompute_mean_us, None);
assert_eq!(summary.graph_events_per_sec, None);
assert_eq!(summary.busy_fraction, None);
assert_eq!(summary.recompute_max_us, 0);
assert!(summary.recompute_distribution.is_empty());
}
/// Ticks are counted separately from graph events. Folding them in would inflate
/// the measured event rate by a constant 4 Hz and hide the real graph churn —
/// which is the number O5 is actually about.
#[test]
fn ticks_do_not_count_toward_the_graph_event_rate() {
let mut metrics = Metrics::default();
// Two graph events one second apart, with ticks in between.
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
for i in 1..4 {
metrics.record(sample(EventKind::Tick, i * 250_000, 250_000, 100, 0));
}
metrics.record(sample(EventKind::Graph, 1_000_000, 250_000, 100, 0));
let summary = metrics.summary();
assert_eq!(summary.graph_events, 2);
assert_eq!(summary.tick_events, 3);
// Span runs to the last sample's completion: 1_000_000 + 100 µs.
assert_eq!(summary.span_us, 1_000_100);
assert_eq!(summary.graph_events_per_sec, Some(2.0));
}
/// The queueing proxy: an event beginning within the threshold of the previous
/// sample's completion was almost certainly already waiting. The first sample is
/// never counted — it has no predecessor to have queued behind, and counting it
/// would put a phantom backlog in every run.
#[test]
fn the_queueing_proxy_counts_back_to_back_events_only() {
let mut metrics = Metrics::default();
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
metrics.record(sample(EventKind::Graph, 100, QUEUE_THRESHOLD_US, 100, 0));
metrics.record(sample(
EventKind::Graph,
200,
QUEUE_THRESHOLD_US + 1,
100,
0,
));
metrics.record(sample(EventKind::Graph, 300, 0, 100, 0));
let summary = metrics.summary();
assert_eq!(
summary.queued_events, 2,
"exactly the two within the threshold, never the first sample"
);
assert_eq!(summary.queue_threshold_us, QUEUE_THRESHOLD_US);
}
/// Emission cost is tracked separately from recompute cost, and a suppressed
/// record contributes neither an emitted-record count nor an emit sample —
/// otherwise the logging distribution would be diluted by every tick that wrote
/// nothing.
#[test]
fn emission_cost_is_tracked_separately_from_recompute() {
let mut metrics = Metrics::default();
metrics.record(sample(EventKind::Graph, 0, 0, 300, 80));
metrics.record(sample(EventKind::Tick, 1_000, 900, 200, 0));
metrics.record(sample(EventKind::Graph, 2_000, 900, 400, 120));
let summary = metrics.summary();
assert_eq!(summary.emitted_records, 2);
assert_eq!(summary.emit_max_us, 120);
assert_eq!(summary.emit_mean_us, Some(100));
assert_eq!(
summary.emit_distribution,
vec![("<100us", 1), ("<250us", 1)]
);
// Busy time is recompute *and* logging: 300+80+200+400+120.
assert_eq!(summary.busy_us, 1_100);
}
/// The busy fraction needs no inference, unlike the queueing proxy, so it is the
/// number the O5 verdict should lean on.
#[test]
fn the_busy_fraction_is_the_share_of_wall_time_spent_working() {
let mut metrics = Metrics::default();
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
// Ends at 1_000_000 + 900 → a span of 1_000_900 µs with 1_000 µs of work.
metrics.record(sample(EventKind::Graph, 1_000_000, 999_900, 900, 0));
let summary = metrics.summary();
assert_eq!(summary.busy_us, 1_000);
assert_eq!(summary.span_us, 1_000_900);
assert_eq!(summary.busy_fraction, Some(0.001));
}
/// Bucket labels and bounds must stay parallel, or the distribution mislabels
/// itself — a silent failure that would misreport every O5 result.
#[test]
fn bucket_labels_cover_every_bound_plus_overflow() {
assert_eq!(
BUCKET_LABELS.len(),
super::metrics::BUCKET_BOUNDS_US.len() + 1
);
}
+10
View File
@@ -1,8 +1,12 @@
pub mod aec;
pub mod audio;
pub mod audit;
mod capture;
mod observer;
mod pipeline;
mod quality;
mod serve;
pub mod taint;
mod wayland;
mod x11;
@@ -74,6 +78,12 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let cancel = signal::install_ctrl_c();
// Phase 5 dry-run audit, off unless `PIXELPASS_AUDIO_AUDIT=1`. Read-only:
// it observes the graph and logs what phases 24 conclude, creating no
// links. Bound to a name so the handle lives as long as the run — dropping
// it stops the observer thread and flushes the final O5 summary.
let _audio_audit = audit::run::spawn_if_enabled()?;
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
// Relay-only ticket: wait for the home relay to connect, then keep only
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
//! The `session_device` classifier — pure, no PipeWire.
//!
//! `NodeProps::session_device` (see [`super::super::taint::snapshot`]) is a
//! **positive high-confidence** claim that a node is a passive hardware
//! terminal: a real sound card's sink or source that terminates audio rather
//! than forwarding it. Setting it *removes* two protections at once — the
//! node's coarse owner keys and its ability to trip the fail-closed backstop
//! — so a false positive is a **leak**, and the whole classifier is shaped so
//! that anything less than a positive identification resolves to `false`.
//!
//! The observer (phase 3) owes this classification; the adapter must never
//! stuff a raw property through. Two facts from the design (v3.4 §6.1.1,
//! Codex rounds 24) drive the shape here:
//!
//! - `device.id` / `device.api` describe *which* Device a node belongs to and
//! *how* that Device is reached — **neither promises the node passively
//! terminates audio.** A filter chain associated with a card satisfies
//! both. So the discriminator is `factory.name` on an **allowlist** of
//! real hardware-PCM factories, never a substring or a denylist: an unknown
//! factory is not a device.
//! - The backing Device must actually have been **bound and resolved**. A node
//! that claims a `device.id` whose Device's properties we do not hold is
//! **withheld**, not admitted with a provisional `false` — a provisional
//! `false` during the not-ready window fuses sink and mic on the shared
//! session client and that fusion can persist as sticky over-exclusion
//! (round-3 finding 3).
//!
//! **Round 8 (v3.5 §6.7 decision 4): the Device is the authority on
//! `device.api` and `alsa.driver_name`.** Both are absent from the Node
//! *global* and both are present on the **bound Device**'s `info` props
//! (measured 2026-07-25). Reading them from the Device closes the phase-3
//! review's owed fix: on PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13 the driver
//! name is not copied onto the node, and the fail-closed "absent driver ⇒ not
//! a session device" rule would over-exclude real sound cards. `factory.name`
//! exists only on the node, which is why the node bind is required regardless.
use crate::host::taint::snapshot::GlobalId;
/// Factory names that positively identify a passive hardware-PCM terminal.
///
/// **An allowlist, deliberately.** Membership *removes* protections, so the
/// safe error direction is to leave a genuine-but-unlisted device off the
/// list (it merely keeps its owner keys — over-exclusion, no echo). Adding a
/// backend here is a security-relevant change and wants the same measurement
/// the ALSA entries got (snapshot.rs `session_device` contract: the target
/// box's five ALSA nodes carry `factory.name=api.alsa.pcm.{sink,source}`; the
/// three `support.null-audio-sink` nodes carry neither).
///
/// `support.null-audio-sink`, `*.loopback`, and any filter factory are
/// intentionally **absent**: those forward audio, which is exactly the shape
/// this feature must be able to exclude.
///
/// ⚠️ **ALSA only, and only these two, because they are the only factories
/// measured on the target box.** BlueZ was previously listed here as
/// `api.bluez5.pcm.{sink,source}` — those are invented; the real BlueZ
/// terminals are `api.bluez5.media.{sink,source}` with profile aliases
/// (Codex phase-3 review, finding 5). Rather than allowlist an unmeasured
/// guess, BlueZ is left off entirely: a real Bluetooth sink then keeps its
/// owner keys (over-exclusion — safe). Add BlueZ back only with a *measured*
/// factory name and a fixture.
const HARDWARE_PCM_FACTORIES: &[&str] = &[
// ALSA — measured on the target box.
"api.alsa.pcm.sink",
"api.alsa.pcm.source",
];
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
/// A single value rather than a list, because the allowlist is ALSA-only;
/// this constant is the thing to change when that stops being true.
const HARDWARE_PCM_API: &str = "alsa";
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
/// passive terminals — audio written in reappears on their capture side
/// through a path the PipeWire Link graph cannot see, so classifying them
/// `session_device` (which drops owner keys and the fail-closed backstop)
/// would let tainted audio loop back untainted (Codex phase-3 review,
/// finding 2). `factory.name` alone cannot distinguish these from a real
/// card — `snd_aloop` presents as `api.alsa.pcm.{sink,source}` exactly like
/// `snd_hda_intel` — so a real ALSA terminal must present an `alsa.driver_name`
/// that is **present and not on this denylist**; a missing driver fails closed
/// (see [`classify`]). `snd_dummy` is intentionally absent: it is virtual but
/// does not couple playback to capture, so it is not a loopback hazard.
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
/// The node-side properties the classifier reads, exactly as the adapter
/// parsed them off the **bound Node's `info`** (never off the registry
/// global — v3.5 §6.7). Kept separate from
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
/// *decision* whose output is the `session_device` field — they are inputs,
/// not part of the graph the engine reasons over.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeviceClaim {
/// `device.id` — the Device this node belongs to, if any. Absent on
/// `Stream/*` nodes, which is exactly why their absence means "not a
/// device", not "unknown".
pub device_id: Option<GlobalId>,
/// `device.api` **as copied onto the node**, when it is — the access API
/// of that Device (e.g. `alsa`, `bluez5`). Its mere presence is **not**
/// sufficient (a card-associated filter has it too); required only as a
/// corroborating signal alongside the factory allowlist. The
/// authoritative copy is [`DeviceProps::device_api`]; this is the
/// fallback.
pub device_api: Option<String>,
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
/// factory earns `session_device`.
pub factory_name: Option<String>,
/// `alsa.driver_name` — the kernel driver behind an ALSA node (e.g.
/// `snd_hda_intel`, `snd_usb_audio`, `snd_aloop`). Needed because the
/// factory allowlist cannot tell a real card from a loopback driver that
/// shares the same factory. `session_device` requires this to be
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
/// denylist, or an absent value, both fail closed (see [`classify`]).
/// Frequently absent here — PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13
/// does not copy `alsa.*` onto the node — which is why the authoritative
/// copy is [`DeviceProps::alsa_driver_name`] and this is only the
/// fallback.
pub alsa_driver_name: Option<String>,
}
/// The **bound Device's** `info` properties — the authoritative half of the
/// `session_device` decision (v3.5 §6.7 decision 4).
///
/// Absent from the Device *registry global* exactly as the node's properties
/// are absent from the Node global; both are recovered by binding. A node
/// claiming a `device.id` is withheld until this struct exists for that
/// Device (see [`Classification::Withhold`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DeviceProps {
/// `device.api` on the Device — `alsa`, `bluez5`, `v4l2`, …
pub device_api: Option<String>,
/// `alsa.driver_name` on the Device — the kernel driver behind the card,
/// authoritative regardless of whether the session manager copied it onto
/// the node.
pub alsa_driver_name: Option<String>,
}
/// The outcome of classifying one node's device claim.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Classification {
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
NotADevice,
/// A `device.id` is claimed but the backing Device's properties are not
/// held: never observed, its bind still outstanding, or its global id
/// ambiguously shared by two live Devices. **Withhold the node and keep
/// the readiness epoch not-ready**; re-classify when the Device resolves.
Withhold { device_id: GlobalId },
/// Positively a passive hardware terminal. Admit with
/// `session_device=true`.
SessionDevice,
/// Backed by a *resolved* Device but not a hardware-PCM terminal — a
/// filter or virtual node on a card, an unknown factory, or a Device with
/// no `device.api`. Admit with `session_device=false` (fail closed).
NotSessionDevice,
}
/// Classify a node's device claim against its backing Device.
///
/// `device` is the bound Device's properties, and `None` means the claim is
/// **unresolved** — never observed, bind outstanding, or an ambiguous
/// recycled id. It is only consulted when a `device_id` is present. Pure: the
/// model looks the Device up, and the I/O of *binding* it lives in the
/// adapter.
///
/// Where the two sides disagree the rule is deliberately asymmetric, and
/// safety picks the direction (v3.5 §6.7 decision 4):
///
/// - **Presence: the Device wins, the node is the fallback.** That is what
/// recovers a real card whose node was never given `alsa.driver_name`.
/// - **The denylist is a union.** If *either* side names a non-terminal
/// driver the node is not a session device. A disagreement here is not
/// expected on any measured configuration, and treating it as "the Device
/// says it is fine" would be the one reading that can leak.
pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classification {
let Some(device_id) = claim.device_id else {
// No backing Device: a stream. Not withheld, not a device.
return Classification::NotADevice;
};
let Some(device) = device else {
// Backed by a Device we have not resolved — the one case that blocks
// readiness. A provisional answer here is the leak the contract
// forbids.
return Classification::Withhold { device_id };
};
let on_factory_allowlist = claim
.factory_name
.as_deref()
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
// A **present, non-denied** ALSA driver is required — absence fails closed
// (Codex phase-3 re-review). The factory allowlist cannot tell a real card
// from `snd_aloop`, which presents the same `api.alsa.pcm.*` factory, so a
// *missing* value must not be read as "not a loopback". Round 8 makes the
// bound Device the primary source, so a real card is no longer
// over-excluded merely because the session manager did not copy `alsa.*`
// onto its node.
let driver = device
.alsa_driver_name
.as_deref()
.or(claim.alsa_driver_name.as_deref());
let driver_denied = [
device.alsa_driver_name.as_deref(),
claim.alsa_driver_name.as_deref(),
]
.into_iter()
.flatten()
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
let driver_ok = driver.is_some() && !driver_denied;
// The API must positively be the one the factory allowlist is written
// for, not merely present (Codex phase-3r review, finding 3). "Present"
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
// — a contradiction no truthful configuration produces, which is exactly
// why it should be read as an observation gone wrong rather than as
// corroboration. Disagreement between the two sides fails closed for the
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
// adding a BlueZ factory means allowing `bluez5` here too.
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
(Some(from_device), Some(from_node)) if from_device != from_node => false,
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
(None, None) => false,
};
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
if is_hardware_pcm {
Classification::SessionDevice
} else {
// Resolved, but not positively a terminal: fail closed to false so
// the node keeps its owner keys and its backstop.
Classification::NotSessionDevice
}
}
+760
View File
@@ -0,0 +1,760 @@
//! The registry observer's **pure core** (impl plan §4, phases 3 and 3r).
//!
//! This is my half of the phase-3 split: a reducer that folds a stream of
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
//! live registry callbacks, binds, `/proc` reads, and the `core.sync`/`done`
//! round-trip into these events and feeds them in. Every test in this module
//! builds the event stream by hand.
//!
//! ## 🔴 Round 8 (v3.5 §6.7): the global is an INDEX, not a source of truth
//!
//! Phase 3 shipped reading node properties off the registry `global` event.
//! The registry announces only a fixed 13-key subset for a Node, and **eight
//! properties this feature depends on are never among them** — they read as
//! absent rather than failing, so the engine was silently, permanently
//! starved of both its primary taint root and every strong owner key (the
//! phase-5 gate failure, F1/F2). The rule that replaces it:
//!
//! > A node's properties come from a **bind**, never from the global. The
//! > global tells us an object exists, its id and its serial. Everything
//! > else — including `node.name` and `media.class`, so there is exactly one
//! > source — arrives on [`RegEvent::NodeInfo`]. Same for `Device`
//! > ([`RegEvent::DeviceInfo`]).
//!
//! Consequences visible in this file: a Node is admitted to the snapshot
//! **only** once its `info` has arrived (until then it is withheld and is a
//! readiness obligation); a Device resolves a node's claim only once *its*
//! `info` has arrived; and `info` may fire again for the lifetime of the
//! object, so [`RegEvent::NodeInfo`] is both the first resolution and every
//! later property change (v3.5 §6.7 decisions 14).
//!
//! Three things this core is shaped to get right, each an exit-gate row:
//!
//! - **Removal by recycled id.** `global_remove` names only a 32-bit global
//! id, and those recycle. The model keeps an insertion-ordered index per id
//! so a removal accounts for the *oldest* generation first, and the
//! snapshot projection treats any id still claimed by two live objects as
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3). Everything the
//! model *owns* is keyed by never-recycled `object.serial`; ids are only
//! ever a lookup.
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
//! is fully observed: the server has synced **and** no binds/withheld nodes
//! remain outstanding. A bounded timeout makes it fail closed. It gates
//! sticky *retirement* only; withholding after completion is per-object.
//! - **Withholding on unresolved input.** A node with no `info` yet, or one
//! claiming a `device.id` whose Device we have not resolved, is held out of
//! the snapshot entirely rather than admitted with provisional ownership
//! (see [`classify`]).
//!
//! **Three accepted limitations, all low-reachability, owed to a later
//! hardening round:**
//!
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
//! The adapter drops such a global before it reaches [`RegistryModel`], so
//! readiness can reach `Complete` while permanently omitting that Link — an
//! invisible edge that could hide tainted ancestry. **Not reachable in
//! practice:** PipeWire's native protocol defines `object.serial` as the
//! unique identity every global carries, so a Link without one requires a
//! protocol/server failure, not ordinary churn. (The live gate is
//! consistent with this but does not *prove* it — it only counts Links the
//! strict parser already admitted.) A full fix needs a pure
//! "required-observation-failed" token that holds readiness false; deferred
//! rather than built for a case that does not occur.
//! - *Removal generation ordering assumes no removal is silently lost.* On a
//! recycled id with two live claimants, [`Self::on_removed`] retires the
//! oldest generation first; if the *first* generation's removal was never
//! delivered, a later removal is misattributed. PipeWire's registry does not
//! silently drop `global_remove`, so this needs callback loss to trigger.
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
//! (fail closed) meanwhile.
//! - *An unresolvable bind takes the whole graph down, not just its node*
//! (v3.5 §6.7 decision 3). A node whose `info` never arrives keeps
//! readiness false until the deadline, then sticky-[`Readiness::TimedOut`]
//! — no fan-out at all, identical to a never-resolving Link bind. Per-node
//! quarantine (that node ineligible **and** taint-bearing, the rest of the
//! graph still working) is strictly better and is deferred because it is a
//! new concept in the *pure engine*, not a fix to the observer.
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
pub mod adapter;
pub mod classify;
pub mod pulse_pid;
#[cfg(test)]
mod tests;
use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial,
};
use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, VecDeque};
/// A monotonic millisecond clock value, supplied by the adapter via
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
pub type Millis = u64;
/// A Node's **bound `info` properties** — the sole source of node properties
/// (v3.5 §6.7), delivered by [`RegEvent::NodeInfo`].
///
/// This carries no identity: the serial names the node on the event and the
/// global id was recorded by [`RegEvent::NodeAdded`], so the adapter cannot
/// contradict the index it already published. `session_device` inside
/// [`NodeObservation::props`] is left at its `false` default; the model
/// overwrites it from the [`classify`] result at projection time, once the
/// backing Device (if any) is resolved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeObservation {
pub name: Option<String>,
pub role: MediaRole,
pub props: NodeProps,
pub device_claim: DeviceClaim,
}
/// The four endpoint references a Link carries. Node endpoints are required —
/// a Link with unknown nodes is useless — so this whole struct is what the
/// adapter must resolve (from the global's props if present, else by binding
/// `LinkInfoRef`, the correctness path) before a Link enters the snapshot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LinkEndpoints {
pub output_node: GlobalId,
pub input_node: GlobalId,
pub output_port: Option<GlobalId>,
pub input_port: Option<GlobalId>,
}
/// A typed observation of the live graph. The adapter produces these; the
/// model consumes them in [`RegistryModel::apply`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RegEvent {
/// A Node global appeared. **Index only** — the global's properties are a
/// filtered subset and are not read (v3.5 §6.7). The node is withheld
/// from the snapshot and is a readiness obligation until its
/// [`RegEvent::NodeInfo`] arrives.
NodeAdded { serial: Serial, id: GlobalId },
/// A bound Node's `info` properties. **Both** the first resolution and
/// every later `PROPS` change for the node's lifetime — the model tells
/// them apart, so the adapter holds no per-node "have I seen info yet?"
/// state to get wrong. An `info` for a serial we do not hold (a node
/// already removed) is ignored.
NodeInfo {
serial: Serial,
observation: NodeObservation,
},
/// A Port global appeared.
PortAdded(PortSnapshot),
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
ClientAdded(ClientSnapshot),
/// A Device global appeared. Index only, exactly as for a Node: it does
/// not resolve anything until [`RegEvent::DeviceInfo`] arrives.
DeviceAdded { serial: Serial, id: GlobalId },
/// A bound Device's `info` properties — the **authoritative** source of
/// `device.api` and `alsa.driver_name` (v3.5 §6.7 decision 4). Resolves
/// every node withheld on this Device's id.
DeviceInfo { serial: Serial, props: DeviceProps },
/// A Link global appeared. `endpoints` is `Some` when the global carried
/// them (the optimisation) and `None` when the adapter must bind to learn
/// them (the correctness path) — the latter is an outstanding obligation
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
///
/// Unlike Nodes and Devices, Link endpoint props **are** announced on the
/// global (measured, phase-5 results F1), so this asymmetry is real and
/// deliberate.
LinkAdded {
serial: Serial,
id: GlobalId,
endpoints: Option<LinkEndpoints>,
},
/// The bind-`LinkInfoRef` fallback resolved a Link's endpoints.
LinkEndpointsResolved {
serial: Serial,
endpoints: LinkEndpoints,
},
/// The adapter read `/proc/<pid>/comm` (`None` = the read failed / the
/// process is gone). Validates the pulse-PID candidate.
ProcCommProbed { pid: u32, comm: Option<String> },
/// Any global was removed. Only its 32-bit id is known.
Removed { id: GlobalId },
/// A `core.sync()` issued after the initial enumeration completed its
/// round-trip (`done`). One half of readiness; the other is that no
/// binds/withheld nodes are still outstanding.
ServerSynced,
/// A monotonic clock sample. Drives the readiness timeout only.
Tick { now: Millis },
}
/// What kind of observation drove a projection.
///
/// Derived from the event itself ([`RegEvent::kind`]) rather than passed
/// alongside it, so a consumer's view of "was this a real graph change?" cannot
/// disagree with what the model was actually fed. The distinction matters to the
/// phase-5 audit twice over: ticks arrive at a constant rate and would inflate
/// any measured graph-event rate, and a record that is identical to the previous
/// one is worth suppressing on a tick but never on a graph event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EventKind {
/// A registry observation: an add, a removal, a bind resolution, a `/proc`
/// probe, or the server sync.
Graph,
/// The periodic clock sample. Carries no graph information; it exists so the
/// readiness timeout and the AEC validation deadline have a clock.
Tick,
}
impl EventKind {
pub fn code(self) -> &'static str {
match self {
Self::Graph => "graph",
Self::Tick => "tick",
}
}
}
impl RegEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::Tick { .. } => EventKind::Tick,
_ => EventKind::Graph,
}
}
}
/// Whether an applied event could have changed the projection.
///
/// The suppression rule of v3.5 §6.7 decision 2, in the one place that can
/// enforce it: **a property update may be dropped only when the resulting
/// [`Projection`] is identical to the current one.** The projection is a pure
/// function of model state, so "state provably unchanged" *is* "projection
/// identical" — which is what [`Outcome::Suppressed`] means and why the check
/// is a cheap field comparison rather than building and diffing two snapshots.
///
/// Anything looser (dropping updates that do change state) breaks phase 4's
/// no-coalescing contract, which needs to see the empty gap between an AEC
/// module unload and a reload that reuses the index. Anything stricter
/// (publishing on every `info`, including the state-only changes PipeWire
/// emits constantly) inflates the O5 event rate with non-events.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
/// Model state may have changed; the caller must publish the projection.
Applied,
/// Model state provably did not change; publishing is optional and the
/// adapter skips it.
Suppressed,
}
/// Which slot in the id index a live object occupies. `global_remove` gives
/// only the id, so the index remembers what each id currently holds. Every
/// slot names its object by never-recycled serial.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Slot {
Node(Serial),
Port(Serial),
Link(Serial),
Client(Serial),
Device(Serial),
}
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
/// both terminal states are sticky (a completed graph is not un-completed by
/// later per-object withholding, and a timed-out observer stays fail-closed
/// for its lifetime).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Readiness {
/// The initial enumeration is still in flight.
Waiting,
/// The initial enumeration finished at least once (server synced with no
/// obligations then outstanding). **Sticky** — later per-object
/// withholding does not revert it. Note this is *not* the same as
/// [`RegistryModel::graph_ready`], which additionally requires no *current*
/// obligation (Codex finding 1); `Complete` only records that the epoch
/// was reached.
Complete,
/// The bounded deadline passed with obligations outstanding.
/// `graph_ready` stays false — fail closed.
TimedOut,
}
/// The pure handoff to the taint engine: a coherent [`GraphSnapshot`] plus the
/// two context fields phase 3 owns. The caller merges these into
/// [`crate::host::taint::ExclusionCtx`] alongside `aec_module_id` (phase 4)
/// and `pixelpass_owned` (pixelpass's own tracking).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Projection {
pub snapshot: GraphSnapshot,
pub pipewire_pulse_pid: Option<u32>,
pub graph_ready: bool,
/// The sticky readiness epoch behind `graph_ready`. Carried so a consumer
/// can tell the three not-ready causes apart — enumeration still in flight
/// ([`Readiness::Waiting`]), a fail-closed timeout ([`Readiness::TimedOut`]),
/// or a completed epoch momentarily blocked on a current obligation
/// ([`Readiness::Complete`] with `graph_ready == false`). `graph_ready`
/// alone collapses all three into "no". The phase-5 audit reports it as the
/// epoch column; nothing gates on it.
pub readiness: Readiness,
}
/// A live Node: its global id (for link endpoint lookup) plus its bound
/// properties once they arrive.
#[derive(Clone, Debug, PartialEq, Eq)]
struct NodeEntry {
id: GlobalId,
/// `None` while the bind is outstanding — withheld from the snapshot and
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
obs: Option<NodeObservation>,
}
/// A live Device: its global id plus its bound properties once they arrive.
#[derive(Clone, Debug, PartialEq, Eq)]
struct DeviceEntry {
id: GlobalId,
/// `None` while the bind is outstanding. A node claiming this Device
/// stays withheld until it is `Some` — the Device's `device.api` and
/// `alsa.driver_name` are the authoritative inputs to `session_device`
/// (v3.5 §6.7 decision 4), so classifying without them would be the same
/// provisional answer the contract forbids.
props: Option<DeviceProps>,
}
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
#[derive(Clone, Debug)]
pub struct RegistryModel {
/// **Every** live Node, keyed by serial — admitted or withheld. Admission
/// is decided at projection time from the entry's own state, so there is
/// no admitted/withheld pair of maps to drift apart.
nodes: BTreeMap<Serial, NodeEntry>,
/// Every live Device, keyed by serial.
devices: BTreeMap<Serial, DeviceEntry>,
ports: BTreeMap<Serial, PortSnapshot>,
links: BTreeMap<Serial, LinkSnapshot>,
clients: BTreeMap<Serial, ClientSnapshot>,
/// Links whose endpoints the adapter is still binding; the id is kept so
/// removal and resolution can find them.
pending_links: BTreeMap<Serial, GlobalId>,
/// Insertion-ordered holders of each live global id. `global_remove`
/// accounts for the oldest generation first (v3.4 §6.1.3).
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
/// `/proc/<pid>/comm` reads keyed by pid, for pulse-PID validation.
probed_comm: BTreeMap<u32, Option<String>>,
server_synced: bool,
readiness: Readiness,
deadline: Millis,
last_now: Millis,
}
impl RegistryModel {
/// `now` seeds the clock; `timeout` is the readiness budget. The deadline
/// is `now + timeout`; a [`RegEvent::Tick`] at or past it while still
/// [`Readiness::Waiting`] fails the epoch closed.
pub fn new(now: Millis, timeout: Millis) -> Self {
Self {
nodes: BTreeMap::new(),
devices: BTreeMap::new(),
ports: BTreeMap::new(),
links: BTreeMap::new(),
clients: BTreeMap::new(),
pending_links: BTreeMap::new(),
live_ids: BTreeMap::new(),
probed_comm: BTreeMap::new(),
server_synced: false,
readiness: Readiness::Waiting,
deadline: now.saturating_add(timeout),
last_now: now,
}
}
pub fn readiness(&self) -> Readiness {
self.readiness
}
/// Whether the graph is trustworthy enough to make eligibility and sticky
/// **retirement** decisions right now.
///
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
/// true only when the initial enumeration has completed **and** there are
/// no current obligations outstanding (a node whose bind is outstanding, a
/// node withheld on an unresolved Device, or a Link still being bound).
/// The distinction is the fix for Codex phase-3 review finding 1: a Link
/// whose endpoints are still resolving is an **invisible edge** — it is
/// absent from the snapshot, not merely dangling — so a decision made
/// while one exists can miss real tainted ancestry and wrongly report a
/// candidate eligible. Unresolved ancestry ⇒ fail closed is the governing
/// invariant (v3.4 §6.1), and round 8 adds the far more common case: an
/// unbound node is an invisible *vertex*, which hides everything the edge
/// case hides and its ownership besides.
///
/// [`Readiness::Complete`] stays sticky (it records that the initial
/// enumeration happened, for logging and to distinguish "not started" from
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
/// check on top. Downstream (phase 6) may debounce the brief blips a
/// normal bind causes; the observer's job is to report the truth.
pub fn graph_ready(&self) -> bool {
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> {
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
pulse_pid::candidate(&clients)
}
/// Fold one observation into the model. The returned [`Outcome`] tells the
/// caller whether the projection can have changed; see [`Outcome`] for why
/// that is the only sound place to enforce the suppression rule.
pub fn apply(&mut self, event: RegEvent) -> Outcome {
match event {
RegEvent::NodeAdded { serial, id } => {
self.push_id(id, Slot::Node(serial));
self.nodes.insert(serial, NodeEntry { id, obs: None });
// A node awaiting its bind is a fresh obligation, so this can
// only ever *hold* readiness, never complete it — but the
// re-check is cheap and keeps the invariant local.
self.maybe_complete();
Outcome::Applied
}
RegEvent::NodeInfo {
serial,
observation,
} => self.on_node_info(serial, observation),
RegEvent::PortAdded(port) => {
self.push_id(port.id, Slot::Port(port.serial));
self.ports.insert(port.serial, port);
Outcome::Applied
}
RegEvent::ClientAdded(client) => {
self.push_id(client.id, Slot::Client(client.serial));
self.clients.insert(client.serial, client);
// A new client can change the pulse candidate; the adapter
// learns that via `pulse_pid_candidate`. No readiness effect.
Outcome::Applied
}
RegEvent::DeviceAdded { serial, id } => {
self.push_id(id, Slot::Device(serial));
self.devices.insert(serial, DeviceEntry { id, props: None });
self.maybe_complete();
Outcome::Applied
}
RegEvent::DeviceInfo { serial, props } => self.on_device_info(serial, props),
RegEvent::LinkAdded {
serial,
id,
endpoints,
} => {
self.on_link_added(serial, id, endpoints);
Outcome::Applied
}
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
self.on_link_resolved(serial, endpoints)
}
RegEvent::ProcCommProbed { pid, comm } => {
let previous = self.probed_comm.insert(pid, comm.clone());
if previous.as_ref() == Some(&comm) {
Outcome::Suppressed
} else {
Outcome::Applied
}
}
RegEvent::Removed { id } => self.on_removed(id),
RegEvent::ServerSynced => {
let already = self.server_synced;
self.server_synced = true;
self.maybe_complete();
if already {
Outcome::Suppressed
} else {
Outcome::Applied
}
}
RegEvent::Tick { now } => {
self.last_now = now;
self.maybe_timeout(now);
Outcome::Applied
}
}
}
/// First resolution *and* every later property change (v3.5 §6.7
/// decision 2). The model distinguishes them by what it already holds, so
/// the adapter can forward every `info` callback unconditionally.
fn on_node_info(&mut self, serial: Serial, observation: NodeObservation) -> Outcome {
let Some(entry) = self.nodes.get_mut(&serial) else {
// A late `info` for a node already removed. Re-inserting it here
// would resurrect a dead node with no id index behind it.
tracing::debug!(serial = serial.0, "observer: node info for an unknown node");
return Outcome::Suppressed;
};
if entry.obs.as_ref() == Some(&observation) {
// The state-only `info` callbacks PipeWire emits constantly: same
// properties, so the projection is provably identical.
return Outcome::Suppressed;
}
entry.obs = Some(observation);
// The first `info` retires this node's obligation, which can be the
// last one outstanding.
self.maybe_complete();
Outcome::Applied
}
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
let Some(entry) = self.devices.get_mut(&serial) else {
tracing::debug!(
serial = serial.0,
"observer: device info for an unknown device"
);
return Outcome::Suppressed;
};
if entry.props.as_ref() == Some(&props) {
return Outcome::Suppressed;
}
entry.props = Some(props);
// Resolving a Device admits every node that was withheld on it —
// which happens at projection time; here it can only retire
// obligations.
self.maybe_complete();
Outcome::Applied
}
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
self.push_id(id, Slot::Link(serial));
match endpoints {
Some(e) => {
self.links.insert(serial, link_snapshot(serial, id, e));
}
None => {
// Correctness path: withhold the Link until the bind fallback
// resolves it. Counts as an outstanding obligation.
self.pending_links.insert(serial, id);
}
}
self.maybe_complete();
}
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) -> Outcome {
// `remove` also guards against a stale resolution for a Link already
// gone: unknown serial ⇒ ignore.
if let Some(id) = self.pending_links.remove(&serial) {
self.links
.insert(serial, link_snapshot(serial, id, endpoints));
self.maybe_complete();
Outcome::Applied
} else {
Outcome::Suppressed
}
}
fn on_removed(&mut self, id: GlobalId) -> Outcome {
let Some(queue) = self.live_ids.get_mut(&id) else {
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
return Outcome::Suppressed;
};
// Oldest generation first — the id may be shared during a
// missed-removal window.
let slot = queue.pop_front();
if queue.is_empty() {
self.live_ids.remove(&id);
}
match slot {
Some(Slot::Node(serial)) => {
self.nodes.remove(&serial);
}
Some(Slot::Port(serial)) => {
self.ports.remove(&serial);
}
Some(Slot::Link(serial)) => {
self.links.remove(&serial);
self.pending_links.remove(&serial);
}
Some(Slot::Client(serial)) => {
self.clients.remove(&serial);
}
Some(Slot::Device(serial)) => {
self.devices.remove(&serial);
}
None => {
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
return Outcome::Suppressed;
}
}
// A removal can drain the last obligation (an unbound node, a node
// withheld on a Device, or a pending link vanished before it
// resolved).
self.maybe_complete();
Outcome::Applied
}
fn push_id(&mut self, id: GlobalId, slot: Slot) {
self.live_ids.entry(id).or_default().push_back(slot);
}
/// The bound properties of the Device a node claims by global id, or
/// `None` when that claim is unresolved — which covers every fail-closed
/// case at once: no such Device observed, its bind still outstanding, or
/// **the id claimed by more than one live global**, where there is no way
/// to tell whose properties these are (v3.4 §6.1.3).
///
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
/// one id — a missed removal, the same precondition as every other
/// recycled-id hazard — it keeps answering with the older Device's
/// properties, so a node claiming that id holds a stale
/// `session_device = true`. That flag *removes* the node's owner keys and
/// its fail-closed backstop, so a forwarder wearing it can put its output
/// leg back on the eligible side: echo, from a lookup that was merely
/// looking at the wrong object type.
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
let slots = self.live_ids.get(&id)?;
if slots.len() != 1 {
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
}
let Slot::Device(serial) = slots.front()? else {
// The id is live, but it is not a Device any more.
return None;
};
self.devices.get(serial)?.props.as_ref()
}
/// Classify one node's device claim against the currently resolved
/// Devices. Recomputed per projection rather than cached at admission:
/// the inputs (this node's props, its Device's props) both change over an
/// object's lifetime now, and a cached classification is exactly the kind
/// of stale provisional answer §6.1.3 forbids.
fn classification(&self, obs: &NodeObservation) -> Classification {
let device = obs
.device_claim
.device_id
.and_then(|id| self.device_props(id));
classify::classify(&obs.device_claim, device)
}
/// Every obligation that must clear before the initial graph is trusted:
/// no node awaiting its bind, no node withheld on an unresolved Device,
/// no Link awaiting its bind.
fn obligations_outstanding(&self) -> bool {
if !self.pending_links.is_empty() {
return true;
}
self.nodes.values().any(|entry| match &entry.obs {
None => true,
Some(obs) => matches!(self.classification(obs), Classification::Withhold { .. }),
})
}
/// Completion needs no clock — only the sync flag and an empty obligation
/// set — so it may fire on any mutating event. Sticky once reached.
fn maybe_complete(&mut self) {
if self.readiness != Readiness::Waiting {
return;
}
if self.server_synced && !self.obligations_outstanding() {
self.readiness = Readiness::Complete;
tracing::info!("observer: readiness epoch reached (synced + no obligations)");
}
}
/// Only the timeout consults the clock.
fn maybe_timeout(&mut self, now: Millis) {
if self.readiness != Readiness::Waiting {
return;
}
if now >= self.deadline {
self.readiness = Readiness::TimedOut;
tracing::warn!(
unbound_nodes = self.unbound_node_count(),
withheld = self.withheld_node_count(),
pending_links = self.pending_links.len(),
"observer: readiness epoch timed out with obligations outstanding — fail closed"
);
}
}
/// Nodes whose bind has not delivered `info` yet — diagnostics only.
fn unbound_node_count(&self) -> usize {
self.nodes
.values()
.filter(|entry| entry.obs.is_none())
.count()
}
/// Nodes held out on an unresolved Device — diagnostics only.
fn withheld_node_count(&self) -> usize {
self.nodes
.values()
.filter(|entry| {
entry.obs.as_ref().is_some_and(|obs| {
matches!(self.classification(obs), Classification::Withhold { .. })
})
})
.count()
}
/// pipewire-pulse's PID from the current clients, validated against the
/// 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)
}
/// Project the current state into the taint engine's inputs.
///
/// A node enters the snapshot only if its bind has delivered `info`
/// **and** its device claim classifies terminally; anything else is
/// withheld (and is already holding `graph_ready` false).
pub fn project(&self) -> Projection {
let nodes: Vec<NodeSnapshot> = self
.nodes
.iter()
.filter_map(|(&serial, entry)| {
let obs = entry.obs.as_ref()?;
let session_device = match self.classification(obs) {
Classification::Withhold { .. } => return None,
Classification::SessionDevice => true,
Classification::NotADevice | Classification::NotSessionDevice => false,
};
let mut props = obs.props.clone();
props.session_device = session_device;
Some(NodeSnapshot {
serial,
id: entry.id,
name: obs.name.clone(),
role: obs.role,
props,
})
})
.collect();
let snapshot = GraphSnapshot::new(
nodes,
self.ports.values().cloned().collect(),
self.links.values().cloned().collect(),
self.clients.values().cloned().collect(),
);
Projection {
snapshot,
pipewire_pulse_pid: self.pulse_pid(),
graph_ready: self.graph_ready(),
readiness: self.readiness,
}
}
}
fn link_snapshot(serial: Serial, id: GlobalId, e: LinkEndpoints) -> LinkSnapshot {
LinkSnapshot {
serial,
id,
output_node: e.output_node,
input_node: e.input_node,
output_port: e.output_port,
input_port: e.input_port,
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O.
//!
//! The owner bridge's key 4 is `application.process.id`. For a stream created
//! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared
//! verbatim across every unrelated Pulse app, so bridging on it would fuse
//! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5,
//! §6.1.2). The engine therefore needs to know that one PID so it can refuse
//! to bridge on it — and **every** way of deriving it can fail, in which case
//! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong).
//!
//! The derivation is split into two pure stages so the I/O — reading
//! `/proc/<pid>/comm` — stays in the adapter:
//!
//! 1. [`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.
//!
//! Any failure at either stage — no repeated value, two repeated values,
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
use crate::host::taint::snapshot::ClientSnapshot;
use std::collections::BTreeMap;
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
/// and exact is the only safe match, since a prefix match would accept a
/// recycled PID belonging to e.g. `pipewire-pulseX`.
const PULSE_COMM: &str = "pipewire-pulse";
/// Stage 1: the PID that looks like pipewire-pulse from the client graph.
///
/// 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)
}
/// Stage 2: confirm the candidate against the `comm` read from
/// `/proc/<candidate>/comm`.
///
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
/// gone (the process exited between derivation and probe) — which is itself a
/// reason to fail closed. A present-but-different `comm` is the **PID reuse**
/// guard: the number is live but now belongs to someone else.
pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
match comm {
Some(PULSE_COMM) => Some(candidate),
_ => None,
}
}
/// 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())
}
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -189,9 +189,32 @@ fn build_args(
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
];
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
// Each tee branch has its own queue so the disk sink can't backpressure the
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
let path = dump.to_string_lossy().into_owned();
args.extend([
"tee".into(),
"name=dbgtee".into(),
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
"dbgtee.".into(),
"!".into(),
"queue".into(),
"!".into(),
"filesink".into(),
format!("location={path}"),
]);
} else {
args.extend(["fdsink".into(), "fd=1".into()]);
}
// Downscale step for the quality presets. `None` = encode at native size
// (the "Source" preset, or a source already at/below the target height — we
+403
View File
@@ -0,0 +1,403 @@
//! Synthetic graph builders for the taint-engine tests.
//!
//! Serials are handed out monotonically and never reused, exactly as
//! PipeWire does; global ids are handed out separately and **may be reused
//! on purpose**, which is what the recycling tests need.
use std::collections::BTreeMap;
use super::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortDirection, PortSnapshot, Serial,
};
/// pipewire-pulse's PID, as measured on the target machine.
pub const PULSE_PID: u32 = 2541;
/// WirePlumber's PID — one process owning every device node on the box.
pub const SESSION_PID: u32 = 900;
/// A node's identity in a fixture: what tests pass around.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct NodeRef {
pub serial: Serial,
pub id: GlobalId,
}
#[derive(Default)]
pub struct Graph {
next_serial: u64,
next_id: u32,
nodes: Vec<NodeSnapshot>,
ports: Vec<PortSnapshot>,
links: Vec<LinkSnapshot>,
clients: Vec<ClientSnapshot>,
/// One client connection per process / per module, which is what the
/// live graph looks like. Tests that need the *split*-client shape
/// (GStreamer opens one per stream) pass clients explicitly instead.
client_by_app: BTreeMap<u32, GlobalId>,
client_by_module: BTreeMap<u64, GlobalId>,
/// Native (non-Pulse-emulated) clients, whose `pipewire.sec.pid` is the
/// app's **own** pid rather than pipewire-pulse's. See
/// [`Graph::native_client_node`].
native_client_by_app: BTreeMap<u32, GlobalId>,
session_client: Option<GlobalId>,
}
impl Graph {
pub fn new() -> Self {
Self {
// Start past u32::MAX so every fixture also exercises the phase
// 0a widening: a serial that a u32 model would have truncated.
next_serial: u64::from(u32::MAX) + 1,
next_id: 1,
..Self::default()
}
}
fn serial(&mut self) -> Serial {
self.next_serial += 1;
Serial(self.next_serial)
}
fn id(&mut self) -> GlobalId {
self.next_id += 1;
GlobalId(self.next_id)
}
/// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's
/// PID for Pulse-emulated clients.
pub fn client(&mut self, sec_pid: Option<u32>) -> GlobalId {
let serial = self.serial();
let id = self.id();
self.clients.push(ClientSnapshot {
serial,
id,
sec_pid,
});
id
}
/// The client connection an ordinary process holds — one per PID,
/// created on demand.
pub fn client_of_app(&mut self, pid: u32) -> GlobalId {
if let Some(id) = self.client_by_app.get(&pid) {
return *id;
}
let id = self.client(Some(PULSE_PID));
self.client_by_app.insert(pid, id);
id
}
/// A **native PipeWire** client's stream: `client.id` on the node, **no
/// `application.process.id`**, and the app's real pid only on the Client
/// as `pipewire.sec.pid`.
///
/// ⚠️ This is what an ordinary app actually looks like when it does not go
/// through pipewire-pulse — measured for mpv on its default ao and for
/// peerspeak's own playback stream. [`Graph::app_node`] models the
/// Pulse-emulated shape, where the pid is on the node and the Client's
/// `sec_pid` is the *daemon's*; both shapes are live on this host, and
/// only this one exercises key 4's Client fallback (round 10, R10-3).
pub fn native_client_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = match self.native_client_by_app.get(&pid) {
Some(id) => *id,
None => {
let id = self.client(Some(pid));
self.native_client_by_app.insert(pid, id);
id
}
};
self.node(
name,
role,
NodeProps {
client_id: Some(client),
..NodeProps::default()
},
)
}
/// An ordinary application stream: its own client, its own PID.
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
/// The client a pactl module holds. Measured: each module gets its own
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
/// module does not fuse with the next.
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
match self.client_by_module.get(&module) {
Some(id) => *id,
None => {
let id = self.client(Some(PULSE_PID));
self.client_by_module.insert(module, id);
id
}
}
}
/// A leg of a pactl-loaded module: one client per module, and the
/// node's `application.process.id` is **pipewire-pulse's own**, because
/// pipewire-pulse genuinely is the client.
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
let client = self.client_of_module(module);
self.node(name, role, pulse_module(client, module, PULSE_PID))
}
/// A leg joined to its siblings by `node.link-group` — loopback,
/// filter-chain, echo-cancel.
pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, link_group(group, client, pid))
}
/// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let session = match self.session_client {
Some(id) => id,
None => {
let id = self.client(None);
self.session_client = Some(id);
id
}
};
self.node(name, role, device(session, SESSION_PID))
}
/// A node that *belongs to* a Device but is not a passive device node —
/// a filter associated with a card. Phase 3 must not classify this as a
/// session device, or it loses both its coarse owner keys and its
/// ability to trip the fail-closed backstop.
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
/// A **virtual** sink an application created natively: an `Audio/Sink`
/// with no `device.id` and no strong key, sharing one client with the
/// stream that re-emits what it receives. Coarse keys must still bridge
/// these two, or the whole call leaks through the re-emitting leg.
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
/// A peerspeak-owned node carrying **both** ownership carriers, as a
/// live one does. `name` gets the real `node.name` prefix so the fixture
/// cannot pass on the property alone.
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
}
/// Both ownership carriers on a node of **any** role — an impostor, or a
/// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a
/// taint root (round 10, R10-1); every other role must be ignored, and
/// these are the fixtures that prove it.
pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, role, peerspeak_owned(client, pid))
}
/// Carrier 1 alone: the `peerspeak.owned` property present, the
/// `node.name` prefix absent. What the engine sees for a node it had to
/// bind to observe (v3.5 §6.7).
pub fn peerspeak_node_prop_only(&mut self, name: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
}
/// Carrier 2 alone: the `node.name` prefix present, the property absent
/// — indistinguishable from an ordinary app in every other respect.
/// This is the case that survives the F1 observation defect, and the
/// reason round 8 added a second carrier at all.
pub fn peerspeak_node_name_only(&mut self, role: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
let name = format!("{}{role}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
self.node(&name, MediaRole::StreamOutput, app(client, pid))
}
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
let id = self.id();
self.node_with_id(name, role, id, props)
}
/// Force a global id — for reproducing id recycling after teardown.
pub fn node_with_id(
&mut self,
name: &str,
role: MediaRole,
id: GlobalId,
props: NodeProps,
) -> NodeRef {
let serial = self.serial();
self.nodes.push(NodeSnapshot {
serial,
id,
name: Some(name.to_string()),
role,
props,
});
NodeRef { serial, id }
}
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
let serial = self.serial();
let id = self.id();
self.ports.push(PortSnapshot {
serial,
id,
node: node.id,
direction,
exclusive,
monitor: false,
});
}
/// A signal edge: audio flows `from → to`.
pub fn link(&mut self, from: NodeRef, to: NodeRef) {
self.link_ids(from.id, to.id);
}
/// A link naming raw ids, so a test can dangle an endpoint.
pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) {
let serial = self.serial();
let id = self.id();
self.links.push(LinkSnapshot {
serial,
id,
output_node: from,
input_node: to,
output_port: None,
input_port: None,
});
}
/// An id that belongs to nothing — for unresolved-endpoint tests.
pub fn dangling_id(&mut self) -> GlobalId {
self.id()
}
pub fn build(&self) -> GraphSnapshot {
self.build_without(&[])
}
/// A later snapshot in which some nodes have gone away, along with
/// their ports and every link touching them. Surviving objects keep
/// their serials, which is what makes sticky-taint sequences testable.
pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot {
let gone_serials: Vec<Serial> = dropped.iter().map(|n| n.serial).collect();
let nodes: Vec<NodeSnapshot> = self
.nodes
.iter()
.filter(|n| !gone_serials.contains(&n.serial))
.cloned()
.collect();
// Filter by what was *dropped*, not by what is live: a link to an id
// that never had a node is a dangling endpoint, and dropping those
// here would quietly disarm every unresolved-ancestry test.
let gone_ids: Vec<GlobalId> = dropped.iter().map(|n| n.id).collect();
GraphSnapshot::new(
nodes,
self.ports
.iter()
.filter(|p| !gone_ids.contains(&p.node))
.cloned()
.collect(),
self.links
.iter()
.filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node))
.cloned()
.collect(),
self.clients.clone(),
)
}
/// Drop clients too — full owner teardown.
///
/// Invalidates the per-app/per-module caches as well: leaving them
/// stale made a later `client_of_app` hand back the *removed* client's
/// id, so a test that meant "a brand-new client after teardown" was
/// really building a node pointing at a client object that no longer
/// existed (Codex round 1, finding 8).
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
self.clients.retain(|c| !ids.contains(&c.id));
self.client_by_app.retain(|_, id| !ids.contains(id));
self.client_by_module.retain(|_, id| !ids.contains(id));
if self.session_client.is_some_and(|id| ids.contains(&id)) {
self.session_client = None;
}
}
/// A client that reuses a global id a dead client had — the recycling
/// case, with a fresh serial.
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
let serial = self.serial();
self.clients.push(ClientSnapshot {
serial,
id,
sec_pid,
});
id
}
}
/// An ordinary application stream: real PID, one client connection.
pub fn app(client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
client_id: Some(client),
process_id: Some(pid),
..NodeProps::default()
}
}
/// A pactl-module-created stream: the daemon is the client, so the node's
/// `application.process.id` is pipewire-pulse's own.
pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps {
NodeProps {
pulse_module_id: Some(module),
client_id: Some(client),
process_id: Some(pulse_pid),
..NodeProps::default()
}
}
/// A PipeWire-module leg joined to its siblings by `node.link-group`
/// (loopback, filter-chain, echo-cancel).
pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
link_group: Some(group.to_string()),
client_id: Some(client),
process_id: Some(pid),
..NodeProps::default()
}
}
/// A device node as the session manager creates it: no strong key, and the
/// session manager's own client and PID — shared with every other device.
///
/// Measured 2026-07-21: real ALSA device nodes carry the shared
/// `client.id` but **no** `application.process.id` at all. Giving them one
/// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
NodeProps {
client_id: Some(session_client),
process_id: Some(session_pid),
session_device: true,
..NodeProps::default()
}
}
pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
peerspeak_owned: true,
..app(client, pid)
}
}
File diff suppressed because it is too large Load Diff
+561
View File
@@ -0,0 +1,561 @@
//! The owner bridge — grouping nodes that belong to the same *owner* even
//! though the graph shows no Link between them.
//!
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
//! exists to handle: a `module-loopback` forwarder's input leg and output
//! leg have **no Link between them**, so walking Links alone from the
//! leaking output leg finds no inbound links at all — a dead end that reads
//! as "clean". The legs are related only by shared properties.
//!
//! ## The rule
//!
//! A union of keys, strongest first:
//!
//! | # | key | scope |
//! | --- | --- | --- |
//! | 1 | `node.link-group` | per module/filter instance |
//! | 2 | `pulse.module.id` | per pactl module |
//! | 3 | `client.id` | per **connection** |
//! | 4 | `application.process.id` | per process |
//!
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
//! EQUAL — not "the first key present".** A first-present implementation
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
//! first-present stops at key 3, sees a mismatch, and concludes "different
//! owners". The legs are in fact one process (`application.process.id`
//! 20172 on both). So: try each key in order, and a key resolves only if
//! both legs carry it and the values are equal; otherwise fall through.
//!
//! ## Two exceptions, both guarding against mass over-exclusion
//!
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
//! bridging on it fuses every Pulse module into one owner and a single
//! tainted module input would exclude every module-created stream on the
//! box. Keys 1 and 2 already cover those cases precisely.
//!
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
//! in design v3.4; it was found while implementing, and it is the exact
//! analogue of exception 1 for the session manager.
//! ✅ **MEASURED on the live graph 2026-07-21:**
//!
//! | node | `client.id` | `device.id` | `factory.name` |
//! | --- | --- | --- | --- |
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
//!
//! So one shared coarse key genuinely does relate every hardware device
//! on the box, and `device.id` cleanly separates that set from virtual
//! sinks. Without the rule, the hardware sink carrying peerspeak's
//! playback (tainted by design, every single recompute) would bridge to
//! *every other device node including the microphone source*, whose
//! readers would then taint their owners' playback legs — reproducing
//! precisely the §6.1.1 catastrophe ("excludes any app using a
//! microphone") through a different door.
//!
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
//! The first cut suppressed coarse keys for every device-*role* node,
//! and Codex refuted it: a **native virtual sink** — an app that creates
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
//! `link-group` and no `pulse.module.id` — would then have had its only
//! correlation stripped, and it would have leaked the whole call. Such a
//! sink has no `device.id`, so it now bridges on `client.id` as it
//! should.
//!
//! Grouping is **transitive** (union-find). That is the fail-closed
//! direction: bigger owner components mean more taint, never less.
use std::collections::{BTreeMap, BTreeSet};
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
/// Everything owner-key derivation needs from outside a single node.
///
/// Introduced by round 10 (R10-3). Before it, `keys_of` read only node
/// properties, and key 4 was therefore available **only** to nodes carrying
/// `application.process.id` — which native PipeWire clients do not. mpv on its
/// default ao, and peerspeak's own playback stream, expose nothing but
/// `client.id`, so both were *unbounded*, and the moment any tainted reader
/// existed anywhere, `propagate_unresolved_owner` excluded every one of them.
/// Measured: an untagged mpv went from eligible (alone) to `unresolved-owner`
/// the instant peerspeak played audio. That is "native-PipeWire apps are never
/// shareable", which is not a feature.
///
/// The missing pid is not missing at all — it is one hop away, on the node's
/// **Client**, as `pipewire.sec.pid`, and already in the snapshot.
pub struct OwnerCtx {
pub pipewire_pulse_pid: Option<u32>,
/// `client.id` → that Client's `pipewire.sec.pid`.
///
/// Clients whose global id is **ambiguous** (two live objects claiming it,
/// i.e. the observer missed a removal) are deliberately absent: resolving
/// an ambiguous id to a pid would attribute a node to whichever Client won
/// a coin toss, and inventing an owner key is the one direction that can
/// *reduce* taint. Absent ⇒ unbounded ⇒ fails closed, as before.
client_pids: BTreeMap<GlobalId, u32>,
}
impl OwnerCtx {
pub fn new(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
let mut client_pids: BTreeMap<GlobalId, u32> = BTreeMap::new();
// ⚠️ Tracked separately from `client_pids`, and that is the point: a
// Client with no `sec_pid` still *claims* its id. Detecting duplicates
// by looking in the pid map would let a pid-less first claimant leave
// no trace, so the next Client claiming the same id would look unique
// and its pid would be used — resolving an ambiguous id, which is the
// one guess this guard exists to refuse. Pid-less Clients are ordinary
// (the session manager's is one).
let mut seen: BTreeSet<GlobalId> = BTreeSet::new();
for client in snapshot.clients() {
if !seen.insert(client.id) {
// Two Clients claiming one id: drop it entirely rather than
// pick. See the field docs.
client_pids.remove(&client.id);
continue;
}
if let Some(pid) = client.sec_pid {
client_pids.insert(client.id, pid);
}
}
Self {
pipewire_pulse_pid,
client_pids,
}
}
/// The `pipewire.sec.pid` of this node's Client, if it has one and that
/// Client's id is unambiguous.
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
self.client_pids.get(&node.props.client_id?).copied()
}
}
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
/// load-bearing for "report the strongest shared key".
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum OwnerKey {
LinkGroup,
PulseModuleId,
ClientId,
ProcessId,
}
impl OwnerKey {
/// Stable, machine-readable — this ends up in the phase 5 audit output
/// and the phase 6 status event.
///
/// ⚠️ **Known imprecision, deliberately not fixed here.** `ProcessId` now
/// covers two sources — the node's `application.process.id` and its
/// Client's `pipewire.sec.pid` (see [`keys_of`]) — so a bridge reported as
/// `application.process.id` may in fact have resolved on the Client's
/// protected pid. Pre-existing since R10-3 made the Client a fallback, and
/// widened by the review's finding 1 making it a union. Splitting it would
/// add a code to a set that is explicitly a stable contract for the audit
/// output and the "why isn't this app being shared?" answer, so it wants
/// its own decision rather than a drive-by.
pub fn code(self) -> &'static str {
match self {
Self::LinkGroup => "node.link-group",
Self::PulseModuleId => "pulse.module.id",
Self::ClientId => "client.id",
Self::ProcessId => "application.process.id",
}
}
}
/// The value a node presents for a given key, if it presents one at all.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum KeyValue {
Text(String),
Num(u64),
}
/// Owner keys usable on this node, strongest first.
///
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
/// on a device node) is **absent** here — that is the whole mechanism of the
/// two exceptions.
fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
let mut out = Vec::new();
if let Some(group) = &node.props.link_group {
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
}
if let Some(module) = node.props.pulse_module_id {
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
}
// Exception 2: coarse keys never bridge passive session-manager device
// nodes — they all share the session manager's client.
if node.props.session_device {
return out;
}
if let Some(client) = node.props.client_id {
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
}
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
// union rather than a fallback by the round-10 review, finding 1).
//
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
// The node's `application.process.id` is client-controlled and optional;
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
// one that can carry a soundness argument (the same reason
// `propagate_unresolved_owner` sweeps everything for an unbounded reader).
// Letting the node's value *replace* the Client's meant one process using
// two Clients could escape the bridge entirely: its tainted reader reports
// a bogus node pid, its output leg omits the node pid and falls back to
// the Client's real one, the two legs are bounded by different values, so
// they neither bridge nor trip the unbounded sweep — and the output stays
// eligible while re-emitting the call. Carrying both values costs nothing
// and closes it: a leg that presents *either* value bridges.
//
// ⚠️ **Exception 1 applies to each value independently, and that is the
// whole risk here.** Measured on this host: 15 unrelated Clients share
// `sec_pid` 2528, which is pipewire-pulse's own — every Pulse-emulated app
// has one. Suppressing it per value is what keeps the union from fusing
// all fifteen into a single owner while still keeping each app's real
// per-app pid. For the common Pulse shape (node pid = the app's, Client
// `sec_pid` = the daemon's) the union therefore reduces to exactly the
// node's pid, as before.
//
// Note the fail-closed asymmetry when the daemon PID is unknown (`None`):
// the exception does *not* fire, key 4 applies to everything, and Pulse
// modules fuse into one owner. That is broad over-exclusion — annoying and
// safe — which is the direction v3.4 §6.1.2's failure-mode paragraph asks
// for.
for pid in [node.props.process_id, ctx.client_pid(node)]
.into_iter()
.flatten()
{
if Some(pid) == ctx.pipewire_pulse_pid {
continue;
}
let key = (OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)));
// The two agree far more often than not; a duplicate entry would be
// harmless but would make the audit's key list read oddly.
if !out.contains(&key) {
out.push(key);
}
}
out
}
/// Can this node's owner be positively bounded — i.e. can we enumerate its
/// sibling legs and be right?
///
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
/// `client.id` alone does **not** bound an owner: that is the measured
/// GStreamer refutation, where one process presented two different
/// `client.id`s for its two legs. So an owner is bounded only by a strong
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
///
/// The case this exists for is v3.4 §12's "module forwarder with neither
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
/// therefore suppressed, its two legs may carry different `client.id`s, and
/// nothing else relates them. Its sibling output leg cannot be found, so
/// 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
///
/// **Round 11 review, finding 1. Verified correct; deliberately not fixed in
/// that round.** 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
/// bounded, and `propagate_unresolved_owner`'s global sweep is triggered by an
/// **un**bounded tainted reader. Concretely:
///
/// 1. A tainted reader's node claims the pipewire-pulse PID while its Client
/// holds a real protected PID `A`. Under `or_else` the node's value won and
/// exception 1 suppressed it, leaving the reader unbounded; under the union
/// it is bounded by `A`.
/// 2. Its process's output leg uses a second Client whose id is **ambiguous**
/// (the observer missed a removal), so no protected PID is available — but
/// the leg claims a bogus `application.process.id` `B`, which bounds it.
/// 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.
///
/// **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.
///
/// 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.
///
/// ⚠️ 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.
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
keys_of(node, ctx)
.iter()
.any(|(key, _)| *key != OwnerKey::ClientId)
}
/// Owner keys computed once per snapshot.
///
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
/// bridge asks for keys once per (tainted member × component member) pair —
/// so recomputing was the hot spot in an otherwise linear pass.
#[derive(Debug, Default)]
pub struct OwnerKeyIndex {
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
Self {
keys: snapshot
.nodes()
.map(|node| (node.serial, keys_of(node, ctx)))
.collect(),
}
}
/// The strongest key these two nodes share directly, if any.
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
return None;
};
// Stored strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*key)
})
}
/// Is `client.id` a usable owner key for this node?
///
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
/// suppressed by exception 2, so recording the session manager's Client
/// as a *member* of a tainted device's sticky owner would smuggle the
/// suppressed key back in: the next recompute would expand that Client
/// to every hardware node on the box — the microphone included — and
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
/// (Codex round 2, finding 1.)
pub fn uses_client_key(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
}
/// The owner keys that are safe to remember *across* connections, for
/// sticky taint: the strong keys plus a usable process id.
///
/// `client.id` is deliberately excluded — it identifies a *connection*,
/// and the whole point of a fingerprint is to survive one process
/// closing a connection and opening another. A live Client member is
/// what covers the same-connection case, precisely.
///
/// These are recyclable strings and numbers, so they are only ever
/// applied while some **serial** member of the owner is still live
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
/// handed to anyone else.
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
self.keys
.get(&serial)
.map(|keys| {
keys.iter()
.filter(|(key, _)| *key != OwnerKey::ClientId)
.map(|(key, value)| Fingerprint(*key, value.clone()))
.collect()
})
.unwrap_or_default()
}
/// Does this node currently present `fingerprint`?
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
self.keys.get(&serial).is_some_and(|keys| {
keys.iter()
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
})
}
/// See [`owner_is_bounded`].
pub fn is_bounded(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
}
}
/// The strongest key two nodes share, or `None` if they share none. Used to
/// *name* the key in a bridge decision; membership itself is transitive and
/// comes from [`OwnerComponents`].
pub fn strongest_shared_key(
a: &NodeSnapshot,
b: &NodeSnapshot,
ctx: &OwnerCtx,
) -> Option<OwnerKey> {
let a_keys = keys_of(a, ctx);
let b_keys = keys_of(b, ctx);
// `keys_of` yields strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*key)
})
}
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Fingerprint(OwnerKey, KeyValue);
/// Nodes partitioned into owner components.
#[derive(Clone, Debug, Default)]
pub struct OwnerComponents {
/// node serial → component index.
of_node: BTreeMap<Serial, usize>,
/// component index → member node serials, ascending.
members: Vec<Vec<Serial>>,
}
impl OwnerComponents {
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
let index: BTreeMap<Serial, usize> =
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
let mut uf = UnionFind::new(serials.len());
// Group by (key, value) and union within each group. Equivalent to
// the pairwise "some key resolves" rule, and O(n log n).
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
for node in snapshot.nodes() {
let slot = index[&node.serial];
for (key, value) in keys_of(node, ctx) {
buckets.entry((key, value)).or_default().push(slot);
}
}
for group in buckets.values() {
for pair in group.windows(2) {
uf.union(pair[0], pair[1]);
}
}
// Compact roots into dense component indices, deterministically.
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
let mut members: Vec<Vec<Serial>> = Vec::new();
let mut of_node = BTreeMap::new();
for (slot, serial) in serials.iter().enumerate() {
let root = uf.find(slot);
let component = *root_to_component.entry(root).or_insert_with(|| {
members.push(Vec::new());
members.len() - 1
});
members[component].push(*serial);
of_node.insert(*serial, component);
}
Self { of_node, members }
}
pub fn component_of(&self, serial: Serial) -> Option<usize> {
self.of_node.get(&serial).copied()
}
/// Member serials of the component containing `serial`, including it.
/// Empty if the node is not in this snapshot.
pub fn members_with(&self, serial: Serial) -> &[Serial] {
match self.component_of(serial) {
Some(component) => &self.members[component],
None => &[],
}
}
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
self.members.iter().map(Vec::as_slice)
}
}
struct UnionFind {
parent: Vec<usize>,
}
impl UnionFind {
fn new(len: usize) -> Self {
Self {
parent: (0..len).collect(),
}
}
fn find(&mut self, mut node: usize) -> usize {
while self.parent[node] != node {
self.parent[node] = self.parent[self.parent[node]];
node = self.parent[node];
}
node
}
fn union(&mut self, a: usize, b: usize) {
let (a, b) = (self.find(a), self.find(b));
if a != b {
// Lowest root wins, so components are deterministic.
let (low, high) = if a < b { (a, b) } else { (b, a) };
self.parent[high] = low;
}
}
}
/// Client objects belonging to an owner component, so sticky taint can be
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
/// entry only once **all** member objects are gone).
pub fn client_serials_of(
snapshot: &GraphSnapshot,
keys: &OwnerKeyIndex,
nodes: &[Serial],
) -> Vec<Serial> {
let mut out: Vec<Serial> = nodes
.iter()
// Only nodes for which `client.id` is a *usable* owner key. See
// `uses_client_key`: recording a device node's shared session-manager
// Client here would defeat exception 2 on the next recompute.
.filter(|serial| keys.uses_client_key(**serial))
.filter_map(|serial| snapshot.node(*serial))
.filter_map(|node| node.props.client_id)
// An ambiguous client id means two Clients claim it and we cannot
// say which one is ours, so remember both: an entry that recorded
// neither could be retired while its owner was still live.
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
.collect();
out.sort_unstable();
out.dedup();
out
}
+355
View File
@@ -0,0 +1,355 @@
//! The plain, owned graph model the taint engine reasons over.
//!
//! **No PipeWire types appear in this file, by design** (impl plan §4,
//! phase 2). The registry observer (phase 3) translates live globals into
//! these structs; every test builds them by hand. Nothing here ever links
//! against libpipewire.
//!
//! Two id-ish things live in this model and confusing them is the bug the
//! whole file is shaped to prevent:
//!
//! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**.
//! This is *identity*. Sticky taint is keyed on it.
//! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is
//! a *lookup key within one snapshot* and nothing else: links name their
//! endpoints with it, nodes name their client with it. It must never
//! outlive the snapshot it was read from (design v3.4 §6.1.3).
use std::collections::BTreeMap;
/// `object.serial` — 64-bit, monotonic, never recycled. Identity.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub struct Serial(pub u64);
/// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup
/// key only; see the module docs.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub struct GlobalId(pub u32);
/// What a node does with audio, parsed from `media.class`.
///
/// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the
/// monitor connection is already a real Link whose output node is the sink
/// itself, so a node-level walk crosses `app → sink → monitor-reader` for
/// free). Ports exist in the model for link creation in phase 6 and for the
/// `port.exclusive` predicate, not for taint.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum MediaRole {
/// `Stream/Output/Audio` — an application playing audio. The only
/// fan-out candidate.
StreamOutput,
/// `Stream/Input/Audio` — an application capturing audio.
StreamInput,
/// `Audio/Sink` — a real or virtual sink.
Sink,
/// `Audio/Source` — a real or virtual source.
Source,
/// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles
/// of these; accepted for v1 as fail-closed over-exclusion
/// (v3.4 §6.1, edge type 2 caveat).
Duplex,
/// Anything else, including video and unparseable/absent `media.class`.
Other,
}
impl MediaRole {
pub fn parse(media_class: Option<&str>) -> Self {
match media_class {
Some("Stream/Output/Audio") => Self::StreamOutput,
Some("Stream/Input/Audio") => Self::StreamInput,
Some("Audio/Sink") => Self::Sink,
Some("Audio/Source") => Self::Source,
Some("Audio/Duplex") => Self::Duplex,
_ => Self::Other,
}
}
/// Can this node *receive* audio? This is the gate on the owner bridge:
/// taint crosses the intra-process hop only when the owner is actually
/// reading tainted audio (v3.4 §6.1.1 — "this client has both an input
/// and an output leg ⇒ exclude the output" is the catastrophic rule
/// that excludes every app with a microphone).
///
/// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that
/// receives the tainted mix, and its re-emitting leg is joined to it by
/// `node.link-group` with no Link between them.
pub fn receives_audio(self) -> bool {
matches!(self, Self::StreamInput | Self::Sink | Self::Duplex)
}
/// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner
/// keys are not allowed to bridge these; see [`super::owner`].
pub fn is_device_role(self) -> bool {
matches!(self, Self::Sink | Self::Source | Self::Duplex)
}
/// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2).
pub fn is_candidate(self) -> bool {
matches!(self, Self::StreamOutput)
}
/// Stable machine-readable code for the audit output. Not the raw
/// `media.class`: `Other` has no single one, and the audit's codes are a
/// contract with the matrix, not with PipeWire.
pub fn code(self) -> &'static str {
match self {
Self::StreamOutput => "stream-output",
Self::StreamInput => "stream-input",
Self::Sink => "sink",
Self::Source => "source",
Self::Duplex => "duplex",
Self::Other => "other",
}
}
}
/// The subset of node properties the engine actually reasons about.
///
/// Deliberately a struct of parsed fields rather than a property bag: the
/// parsing (and its failure modes) belongs at the observer boundary, and a
/// bag invites `props.get("...")` typos that silently read `None` — which
/// on this feature means "not tainted".
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NodeProps {
/// `peerspeak.owned` is present and **exactly**
/// [`super::PEERSPEAK_OWNED_VALUE`] (v3.4 §5.1, tightened by round 10's
/// R10-4 — it is not "present and truthy", and the round-10 review found
/// this doc still saying so). A correctness mechanism, explicitly *not* a
/// security boundary.
///
/// ⚠️ **Ownership carrier 1 of 2, so this being `false` does not mean
/// "not peerspeak's".** Carrier 2 is the [`NodeSnapshot::name`] prefix
/// [`super::PEERSPEAK_OWNED_NODE_PREFIX`], matched as a union in
/// `local_root_reason`. Read that function, not this field, to answer
/// "is this node owned?".
pub peerspeak_owned: bool,
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
/// parse-defensively note and the phase 0a truncation bug.
pub pulse_module_id: Option<u64>,
/// `node.link-group` — owner key 1, and the `echo-cancel-` hazard
/// prefix (v3.4 §5.4 / D3).
pub link_group: Option<String>,
/// `client.id` — owner key 3. A **connection**, not an owner: GStreamer
/// opens one per stream (v3.4 §6.1.2, measured refutation).
pub client_id: Option<GlobalId>,
/// `application.process.id` **on the node** — owner key 4. For
/// module-created streams this is pipewire-pulse's own PID, which is
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
pub process_id: Option<u32>,
/// The stream negotiated an encoded/passthrough format; a second link
/// would refuse or corrupt it (v3.4 §6.2).
pub passthrough: bool,
/// This node is a **passive device node exported by the session
/// manager** — a real sound card's sink or source, not something that
/// forwards audio.
///
/// ⚠️ **A positive high-confidence classification the observer owes, not
/// a raw property** (Codex rounds 23). PipeWire defines `device.id`
/// only as "the Device this node belongs to" and `device.api` as that
/// Device's access API; **neither promises the node passively terminates
/// audio**, so a card-associated filter can satisfy both. Setting this
/// flag *removes* two protections at once — the node's coarse owner keys
/// (`owner` exception 2) and its ability to trip the fail-closed
/// backstop — so a false positive is a leak, not over-exclusion.
///
/// **Phase-3 contract:**
/// - Set `true` only on positively-identified passive hardware
/// terminals: a resolved `device.id` on a real backend
/// (`device.api` present) whose `factory.name` is on an **explicit
/// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`,
/// and the equivalent for other real backends (bluez5, v4l2 for the
/// media case) as phase 3 enumerates them — never a filter, loopback,
/// or `support.null-audio-sink` factory. An allowlist, not a
/// substring or a denylist: an unknown factory is not a device.
/// Measured discriminator on the
/// target box: the five ALSA nodes carry `device.api=alsa` +
/// `factory.name=api.alsa.pcm.*` and share `client.id=42`
/// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes
/// carry neither. (`node.physical` was measured **null** on the ALSA
/// nodes here, so it is *not* a usable discriminator — do not rely on
/// it.)
/// - **Fail closed: unknown ⇒ `false`.** A node that cannot be
/// positively classified keeps its owner keys and can trip the
/// backstop; both are the safe direction.
/// - A node MUST NOT enter a snapshot with this field provisional. If
/// the Device backing a node has not yet been bound, withhold the node
/// and keep the epoch not-ready — otherwise a provisional `false`
/// during not-ready fuses sink and mic on the shared session client
/// and that fusion can persist as sticky over-exclusion (round-3
/// finding 3).
///
/// ⚠️ **A false positive is leak-capable — do not treat it as braced.**
/// I claimed a mis-classified filter could not leak because its legs
/// share a `node.link-group` (strong-key bridge) or trip the unbounded
/// backstop. Codex refuted it (round 4): a filter *without* a shared
/// strong key, marked `session_device=true`, cannot activate the
/// backstop from its reading leg, so a differently-keyed re-emitting leg
/// leaks. Those braces catch *some* shapes, not all. The only real
/// defence is a correct classifier — hence "positive high-confidence"
/// and "fail closed to false" above, without exception.
///
/// What it is for: every real device node shares the session manager's
/// `client.id`, so coarse owner keys must not bridge them — else
/// peerspeak's playback (which taints the default sink every recompute)
/// would reach the microphone. See [`super::owner`] exception 2.
pub session_device: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry
/// detection (v3.4 §6.2, cycle prevention).
pub name: Option<String>,
pub role: MediaRole,
pub props: NodeProps,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PortDirection {
In,
Out,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// Owning node, by snapshot-local id.
pub node: GlobalId,
pub direction: PortDirection,
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
pub exclusive: bool,
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
/// need it at node granularity.
pub monitor: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `link.output.node` — the node audio flows **from**.
pub output_node: GlobalId,
/// `link.input.node` — the node audio flows **to**.
pub input_node: GlobalId,
pub output_port: Option<GlobalId>,
pub input_port: Option<GlobalId>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `pipewire.sec.pid` — for Pulse-emulated clients this is
/// **pipewire-pulse's** PID, identical across every unrelated app
/// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the
/// consistency of this value; the engine only consumes the result.
pub sec_pid: Option<u32>,
}
/// How a snapshot-local id resolves.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum IdLookup {
Unique(Serial),
/// Two live objects in one snapshot claim the same global id — the
/// observer missed a removal, so the recycled id is ambiguous. Every
/// edge touching it is treated as unresolved, i.e. fail closed.
Ambiguous,
}
/// One coherent observation of the graph.
///
/// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity
/// detection cannot be skipped.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GraphSnapshot {
nodes: BTreeMap<Serial, NodeSnapshot>,
ports: BTreeMap<Serial, PortSnapshot>,
links: BTreeMap<Serial, LinkSnapshot>,
clients: BTreeMap<Serial, ClientSnapshot>,
node_ids: BTreeMap<GlobalId, IdLookup>,
client_ids: BTreeMap<GlobalId, IdLookup>,
}
impl GraphSnapshot {
pub fn new(
nodes: Vec<NodeSnapshot>,
ports: Vec<PortSnapshot>,
links: Vec<LinkSnapshot>,
clients: Vec<ClientSnapshot>,
) -> Self {
let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial)));
let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial)));
Self {
nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(),
ports: ports.into_iter().map(|p| (p.serial, p)).collect(),
links: links.into_iter().map(|l| (l.serial, l)).collect(),
clients: clients.into_iter().map(|c| (c.serial, c)).collect(),
node_ids,
client_ids,
}
}
pub fn nodes(&self) -> impl Iterator<Item = &NodeSnapshot> {
self.nodes.values()
}
pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> {
self.nodes.get(&serial)
}
pub fn links(&self) -> impl Iterator<Item = &LinkSnapshot> {
self.links.values()
}
pub fn ports(&self) -> impl Iterator<Item = &PortSnapshot> {
self.ports.values()
}
pub fn clients(&self) -> impl Iterator<Item = &ClientSnapshot> {
self.clients.values()
}
/// Resolve a snapshot-local node id. `None` means "no such node in this
/// snapshot", which for a link endpoint means unresolved ancestry.
pub fn node_by_id(&self, id: GlobalId) -> Option<IdLookup> {
self.node_ids.get(&id).copied()
}
pub fn client_by_id(&self, id: GlobalId) -> Option<IdLookup> {
self.client_ids.get(&id).copied()
}
/// Every node claiming a global id. More than one means the id is
/// [`IdLookup::Ambiguous`] and each claimant must be treated as a
/// possible endpoint of any link naming it.
pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator<Item = &NodeSnapshot> {
self.nodes.values().filter(move |node| node.id == id)
}
/// Every client claiming a global id — same fail-closed reasoning.
pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator<Item = &ClientSnapshot> {
self.clients.values().filter(move |client| client.id == id)
}
/// Ports belonging to a node, by the node's snapshot-local id.
pub fn ports_of(&self, node: GlobalId) -> impl Iterator<Item = &PortSnapshot> {
self.ports.values().filter(move |p| p.node == node)
}
}
fn index_ids(entries: impl Iterator<Item = (GlobalId, Serial)>) -> BTreeMap<GlobalId, IdLookup> {
let mut out: BTreeMap<GlobalId, IdLookup> = BTreeMap::new();
for (id, serial) in entries {
out.entry(id)
.and_modify(|slot| {
if *slot != IdLookup::Unique(serial) {
*slot = IdLookup::Ambiguous;
}
})
.or_insert(IdLookup::Unique(serial));
}
out
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -281,8 +281,10 @@ impl Player {
&[
// No `--untimed`: it ignores audio timestamps and drifts a
// shared video out of sync. Pacing to audio keeps A/V synced.
// Also leave hwdec at the `low-latency` default (software
// decode): forcing `--hwdec=auto` froze some viewers on
// frame 1 while audio kept playing.
"--profile=low-latency",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
+15
View File
@@ -1,5 +1,6 @@
mod cli;
mod common;
mod doctor;
#[cfg(feature = "gui")]
mod gui;
mod host;
@@ -36,6 +37,13 @@ async fn main() -> Result<()> {
}
}
// Diagnostics run before pipewire::init() (they don't need it) and work
// regardless of the `gui` feature, so a headless tester can probe their box.
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
// libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();
@@ -44,6 +52,13 @@ async fn main() -> Result<()> {
return repair::run().await;
}
// Read-only diagnostic: observe the graph, report what the audio-exclusion
// engine concludes, create nothing. Placed before the host/viewer dispatch
// because it is neither — it shares no screen and connects to no peer.
if cli.audit_audio {
return host::audit::run::run_standalone().await;
}
if cli.reconfigure {
return interactive::run_reconfigure().await;
}
+1 -1
View File
@@ -102,7 +102,7 @@ fn print_viewer_banner(url: &str) {
eprintln!("│ Connected to host. Open the stream in your player:");
eprintln!("");
eprintln!(
"│ mpv --profile=low-latency --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
);
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
eprintln!("");
+42
View File
@@ -0,0 +1,42 @@
# Screenshare audio exclusion — ownership tagging wire contract.
#
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
# CONSUMES them as the primary taint root of the exclusion engine. Neither
# repo depends on the other, so this file is the contract: it is committed
# byte-identical in both, and each repo has a test that asserts its own named
# constants (and, on the producer side, the environment a real child Command
# would carry) match these values exactly.
#
# peerspeak/tests/fixtures/ownership-tag-contract.txt
# pixelpass/tests/fixtures/ownership-tag-contract.txt
#
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
# is a cross-repo breaking change: both repos must land in the same session,
# and the phase 5 matrix must be re-run.
#
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
# matches. Round 8 added the second because a property is invisible to the
# PipeWire registry `global` event and readable only via a node bind, so the
# primary taint root must not rest on one observation mechanism alone.
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
#
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
# used to accept any value other than "false"/"0", on the theory that leniency
# over-excludes and is therefore safe. It is not: leniency buys false-positive
# exclusion, and it let any process suppress a rival application's audio from
# the share with a property it did not even have to spell right. Fail-closed
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
# not about parsing.
prop_key=peerspeak.owned
prop_value=1
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
# Only the prefix is matched; the rest of the name is for diagnostics.
node_name_prefix=peerspeak_owned_
node_name_format=peerspeak_owned_<role>_<pid>
node_name_example=peerspeak_owned_mpv_31284