Author SHA1 Message Date
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
19 changed files with 6371 additions and 36 deletions
+12
View File
@@ -118,6 +118,18 @@ pub struct Cli {
/// 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)]
+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.
+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));
}
+1 -1
View File
@@ -615,7 +615,7 @@ fn run_router(
/// (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.
fn parse_object_serial(raw: &str) -> Option<u64> {
pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
+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
}
+455
View File
@@ -0,0 +1,455 @@
//! 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,
}
/// 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>,
}
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();
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,
}
}
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,
}
+896
View File
@@ -0,0 +1,896 @@
//! 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::fixture::{self, Graph, NodeRef};
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
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);
}
/// 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);
assert!(!body.fan_out_permitted);
assert_eq!(
excluded,
vec![
("music", "aec-validating"),
// Tagged, so it keeps the reason that actually applies to it.
("peerspeak-playback", "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);
assert_eq!(eligible, vec!["clean-loopback-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "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);
assert_eq!(eligible, vec!["module-b-playback"]);
assert_eq!(
excluded,
vec![
("peerspeak-call", "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:?}"
);
assert!(tainted.contains(&("peerspeak-call", "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
);
}
+9
View File
@@ -1,5 +1,8 @@
pub mod aec;
pub mod audio;
pub mod audit;
mod capture;
mod observer;
mod pipeline;
mod quality;
mod serve;
@@ -75,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
+211
View File
@@ -0,0 +1,211 @@
//! 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",
];
/// 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;
let api_present = device.device_api.is_some() || claim.device_api.is_some();
let is_hardware_pcm = api_present && 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
}
}
+750
View File
@@ -0,0 +1,750 @@
//! 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 all three
/// fail-closed cases at once: no such Device observed, its bind still
/// outstanding, or **two live Devices sharing the recycled id**, where
/// there is no way to tell whose properties these are (v3.4 §6.1.3).
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
let mut found: Option<Serial> = None;
for slot in self.live_ids.get(&id)? {
if let Slot::Device(serial) = slot {
if found.is_some() {
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
}
found = Some(*serial);
}
}
self.devices.get(&found?)?.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
+133 -35
View File
@@ -100,8 +100,11 @@
pub mod owner;
pub mod snapshot;
// `pub` so the phase-5 audit's pure tests can drive the auditor with the same
// graph builder the taint fixtures use — one fixture vocabulary, so an audit
// test and a taint test describing the same topology cannot drift apart.
#[cfg(test)]
mod fixture;
pub mod fixture;
#[cfg(test)]
mod tests;
@@ -378,36 +381,28 @@ pub fn evaluate(
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
seed_sticky(
// Pass 1 — the fail-closed view. Every decision is made from this one, so
// "we could not see" counts as taint.
let (taint, sticky_serials) = compute_taint(
snapshot,
ctx,
&keys,
prior,
&components,
&mut taint,
&mut sticky_serials,
prior,
Uncertainty::FailsClosed,
);
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
let edges = downstream_edges(snapshot, &mut taint);
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
// Pass 2 — the evidence-only view, and the only thing sticky state is
// ever built from (see [`Uncertainty`]).
let (evidence, _) = compute_taint(
snapshot,
ctx,
&keys,
&components,
prior,
Uncertainty::Ignored,
);
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
// 1 and 2, which caught the two halves of this in turn). An object
// missing from an untrustworthy snapshot has not been observed to
@@ -416,10 +411,105 @@ pub fn evaluate(
// *observed* during a not-ready epoch is real — a reader can consume
// and buffer the call and then vanish before readiness — so discarding
// additions was the same defect pointing the other way.
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
let next_sticky = build_sticky(
snapshot,
&keys,
&components,
&evidence,
prior,
ctx.graph_ready,
);
(decisions, next_sticky)
}
/// Whether a pass treats "we could not see" as taint.
///
/// **Both passes exist because stickiness is a claim about history, and
/// uncertainty is not history.** A node tainted only because the graph was
/// mid-enumeration has had nothing observed about it; remembering that as
/// taint forever is over-exclusion with no evidence behind it, and phase 3r's
/// bind-everything observer makes the window it happens in systematically
/// wide (every node is withheld until its bind resolves, so any link observed
/// across that gap raises [`Reason::UnresolvedAncestry`] on its input side).
/// Measured on a live desktop: a hardware sink acquired a permanent sticky
/// taint at every startup, from one link seen while its output node was still
/// unbound.
///
/// Retiring by *reason code* is not enough, because uncertainty launders
/// itself: an unresolved node propagates [`Reason::TaintedUpstream`] to its
/// downstream, and that reason is indistinguishable from real contamination
/// once recorded. So the split is by **provenance** — the sticky pass never
/// raises an uncertainty root at all, and nothing derived from one can reach
/// it. Decisions are unaffected: they are made from the fail-closed pass,
/// which is unchanged.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Uncertainty {
/// Unresolved ancestry and an unbounded tainted reader are taint
/// (v3.4 §6.1, §6.1.1, §6.1.4).
FailsClosed,
/// Only positively observed contamination counts.
Ignored,
}
/// One taint fixpoint over the snapshot. The `uncertainty` mode decides
/// whether absence of evidence is treated as evidence of contamination.
fn compute_taint(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
prior: &StickyState,
uncertainty: Uncertainty,
) -> (BTreeMap<Serial, Reason>, BTreeSet<Serial>) {
let fails_closed = uncertainty == Uncertainty::FailsClosed;
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
if fails_closed {
for serial in ambiguous_id_nodes(snapshot) {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
seed_sticky(
snapshot,
keys,
prior,
components,
&mut taint,
&mut sticky_serials,
);
// Edges are built identically in both passes — receiver status is a
// topological fact and must not depend on the mode, or the owner bridge
// would see two different graphs.
let mut unresolved_input: BTreeSet<Serial> = BTreeSet::new();
let edges = downstream_edges(snapshot, &mut unresolved_input);
if fails_closed {
for serial in unresolved_input {
raise(&mut taint, serial, Reason::UnresolvedAncestry);
}
}
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(keys, components, &edges, &mut taint);
if fails_closed {
changed |= propagate_unresolved_owner(snapshot, keys, &edges, &mut taint);
}
if !changed {
break;
}
}
(taint, sticky_serials)
}
/// Roots that are visible on the node itself.
fn seed_local_roots(
snapshot: &GraphSnapshot,
@@ -430,14 +520,20 @@ fn seed_local_roots(
if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason);
}
// A node whose own global id is ambiguous cannot be the reliable
// endpoint of any link, so its ancestry is unresolvable.
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
raise(taint, node.serial, Reason::UnresolvedAncestry);
}
}
}
/// Nodes whose own global id is ambiguous: they cannot be the reliable
/// endpoint of any link, so their ancestry is unresolvable. Uncertainty, not
/// evidence — see [`Uncertainty`].
fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
snapshot
.nodes()
.filter(|node| snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous))
.map(|node| node.serial)
.collect()
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
return Some(Reason::PeerspeakOwned);
@@ -560,7 +656,7 @@ fn nodes_of_client(
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when
/// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Serial>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
for link in snapshot.links() {
@@ -572,8 +668,10 @@ fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reaso
receivers.insert(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
// Something feeds this node and we cannot say what. Reported
// rather than raised here, because whether "cannot say" is
// taint depends on which pass is running ([`Uncertainty`]).
unresolved_input.insert(to);
receivers.insert(to);
}
(_, Some(IdLookup::Ambiguous)) => {
+131
View File
@@ -820,6 +820,137 @@ fn an_ambiguous_recycled_global_id_fails_closed() {
);
}
// ──────────────────────────────────────────────────────────────────────
// Uncertainty is not history — it never enters sticky state
// (round 9, from a live phase-5 audit run; see `Uncertainty` in mod.rs)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn unresolved_ancestry_does_not_survive_being_resolved() {
// Measured live on a desktop: a link is observed while its output node is
// still unbound, the input side fails closed — correctly — and then that
// fail-closed mark became *sticky*, so a hardware sink stayed excluded for
// the process lifetime even after the node resolved and turned out to be
// an ordinary game. Phase 3r's bind-everything observer widens that window
// to every node, so this must clear.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let client = graph.client_of_app(6000);
let victim = graph.node("victim-in", MediaRole::StreamInput, app(client, 6000));
let sibling = graph.node("victim-out", MediaRole::StreamOutput, app(client, 6000));
graph.link_ids(ghost, victim.id);
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11114);
let c = ctx();
// While the ancestry is genuinely unresolved, the decision is unchanged:
// fail closed, both the victim and its sibling excluded.
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_partition(
&first,
&[("firefox", firefox)],
&[("victim-out", sibling, "tainted-owner-bridge")],
);
assert_tainted(&first, victim, "unresolved-ancestry");
// The node behind that id turns up — nothing tainted, it was simply not
// observed yet. The uncertainty is gone, so nothing may remain of it.
let late_client = graph.client_of_app(7100);
let resolved = graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7100),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_partition(
&second,
&[
("firefox", firefox),
("victim-out", sibling),
("was-unbound", resolved),
],
&[],
);
}
#[test]
fn uncertainty_laundered_into_downstream_taint_is_not_sticky_either() {
// Retiring by reason *code* would not be enough: an unresolved node
// propagates `tainted-upstream`, which is indistinguishable from real
// contamination once recorded. The split has to be by provenance, so a
// node two hops from the uncertainty must clear too.
let mut graph = Graph::new();
let ghost = graph.dangling_id();
let forwarder_client = graph.client_of_app(6100);
let forwarder_in = graph.node(
"fwd-in",
MediaRole::StreamInput,
app(forwarder_client, 6100),
);
let forwarder_out = graph.node(
"fwd-out",
MediaRole::StreamOutput,
app(forwarder_client, 6100),
);
let downstream_client = graph.client_of_app(6200);
let downstream = graph.node("downstream", MediaRole::Sink, app(downstream_client, 6200));
let downstream_leg = graph.node(
"downstream-out",
MediaRole::StreamOutput,
app(downstream_client, 6200),
);
graph.link_ids(ghost, forwarder_in.id);
graph.link(forwarder_out, downstream);
let c = ctx();
let (first, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
assert_tainted(&first, forwarder_in, "unresolved-ancestry");
assert_tainted(&first, downstream, "tainted-upstream");
assert!(
first.candidates[&downstream_leg.serial].reason().is_some(),
"while the ancestry is unresolved the downstream owner is excluded too"
);
let late_client = graph.client_of_app(7200);
graph.node_with_id(
"was-unbound",
MediaRole::StreamOutput,
ghost,
app(late_client, 7200),
);
let (second, _) = evaluate(&graph.build(), &c, &sticky);
assert_eq!(
second.candidates[&downstream_leg.serial].reason(),
None,
"nothing derived from the uncertainty may outlive it"
);
assert_eq!(
second.candidates[&forwarder_out.serial].reason(),
None,
"including the unresolved node's own owner siblings"
);
}
#[test]
fn real_taint_is_still_sticky_when_its_topology_goes_away() {
// The other half of the same rule, stated positively: *evidence* is
// history and must survive. This is the guard on the change above — if
// provenance splitting ever leaks into the evidence path, peerspeak's own
// audio starts escaping.
let (graph, call, rec_in, rec_out, firefox) = sticky_scene();
let c = ctx();
let (_, sticky) = evaluate(&graph.build(), &c, &StickyState::default());
let (second, _) = evaluate(&graph.build_without(&[rec_in]), &c, &sticky);
assert_partition(
&second,
&[("firefox", firefox)],
&[
("call", call, "peerspeak-owned"),
("rec-out", rec_out, "tainted-owner-bridge"),
],
);
}
// ──────────────────────────────────────────────────────────────────────
// Stickiness and lifetime-awareness (v3.4 §6.1.3)
// ──────────────────────────────────────────────────────────────────────
+7
View File
@@ -52,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;
}