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>
897 lines
36 KiB
Rust
897 lines
36 KiB
Rust
//! 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
|
|
);
|
|
}
|