Native PipeWire clients put no application.process.id on their nodes — only client.id. keys_of read node properties alone, so those nodes had no key 4, were therefore unbounded, and propagate_unresolved_owner excluded them the moment any tainted reader existed anywhere on the machine. Measured: an untagged mpv was eligible alone, and became unresolved-owner the instant peerspeak played audio. Since peerspeak playing audio is the only situation in which this feature runs, that amounted to "native PipeWire apps are never shareable". The tainted reader that armed it was sunshine, which is itself bounded — so this is the bounded-reader arm, not the keyless-reader case §6.1.1 narrates. The pid is one hop away, on the node's Client, already in the snapshot. RISK, and the guard on it: every Pulse-emulated Client carries pipewire-pulse's own PID as sec_pid — measured, 15 unrelated Clients sharing 2528 on this host. An unguarded fallback would fuse all of them into one owner. Exception 1 therefore applies to the fallback exactly as it does to the node's own property, so the fallback strictly *adds* correct bounding rather than trading it. Ambiguous client ids yield no fallback pid: inventing an owner key is the one direction that can reduce taint, so a coin toss is the wrong guess. The client index is threaded through a new OwnerCtx rather than a sixth positional Option<u32>, and evaluate() builds one and shares it, so the components and the key index cannot disagree about who is bounded. Round 10, R10-3. 6 new rows; 3 mutations verified — removing the fallback, dropping the pulse-pid exception (11 rows die), and resolving an ambiguous client id instead of dropping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
961 lines
39 KiB
Rust
961 lines
39 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::PEERSPEAK_OWNED_NODE_PREFIX;
|
|
use crate::host::taint::fixture::{self, Graph, NodeRef};
|
|
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
|
|
|
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
|
|
/// the same constant the engine matches on, so these audit rows report the
|
|
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
|
|
fn owned_name(role: &str, pid: u32) -> String {
|
|
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
|
|
}
|
|
|
|
const AEC_MODULE: u64 = 7;
|
|
const TIMEOUT: Millis = 5_000;
|
|
|
|
fn ready(snapshot: GraphSnapshot) -> Projection {
|
|
Projection {
|
|
snapshot,
|
|
pipewire_pulse_pid: Some(fixture::PULSE_PID),
|
|
graph_ready: true,
|
|
readiness: Readiness::Complete,
|
|
}
|
|
}
|
|
|
|
fn not_ready(snapshot: GraphSnapshot, readiness: Readiness) -> Projection {
|
|
Projection {
|
|
snapshot,
|
|
pipewire_pulse_pid: Some(fixture::PULSE_PID),
|
|
graph_ready: false,
|
|
readiness,
|
|
}
|
|
}
|
|
|
|
fn auditor_off() -> Auditor {
|
|
Auditor::new(AuditConfig {
|
|
aec: AecConfig::Off,
|
|
aec_timeout: TIMEOUT,
|
|
})
|
|
}
|
|
|
|
fn auditor_aec(index: u64) -> Auditor {
|
|
Auditor::new(AuditConfig {
|
|
aec: AecConfig::PulseModule(index),
|
|
aec_timeout: TIMEOUT,
|
|
})
|
|
}
|
|
|
|
/// One graph-triggered recompute at `now`.
|
|
fn observe(auditor: &mut Auditor, projection: &Projection, now: Millis) -> AuditOutcome {
|
|
auditor.observe(projection, EventKind::Graph, now)
|
|
}
|
|
|
|
/// Candidate names split into (eligible, excluded-with-reason), which is how the
|
|
/// §5.1 rows are phrased. Names rather than serials so a failure reads as the
|
|
/// scenario rather than as an integer.
|
|
fn partition(body: &AuditBody) -> (Vec<&str>, Vec<(&str, &str)>) {
|
|
let eligible = body
|
|
.candidates
|
|
.iter()
|
|
.filter(|row| row.eligible)
|
|
.map(|row| row.name.as_deref().unwrap_or("<unnamed>"))
|
|
.collect();
|
|
let excluded = body
|
|
.candidates
|
|
.iter()
|
|
.filter(|row| !row.eligible)
|
|
.map(|row| {
|
|
(
|
|
row.name.as_deref().unwrap_or("<unnamed>"),
|
|
row.reason.unwrap_or("<none>"),
|
|
)
|
|
})
|
|
.collect();
|
|
(eligible, excluded)
|
|
}
|
|
|
|
// ── the §5.1 structural requirement ───────────────────────────────────────
|
|
|
|
/// The record must contain **every** `Stream/Output/Audio` node, not only the
|
|
/// interesting ones. This is the property the whole exact-partition requirement
|
|
/// rests on: if the record could omit a candidate, then asserting a complete
|
|
/// partition over the record would still not constrain the graph.
|
|
#[test]
|
|
fn the_record_carries_the_complete_candidate_universe() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.app_node("game", MediaRole::StreamOutput, 101);
|
|
graph.app_node("recorder", MediaRole::StreamInput, 102);
|
|
graph.device_node("speakers", MediaRole::Sink);
|
|
let projection = ready(graph.build());
|
|
|
|
let outcome = observe(&mut auditor_off(), &projection, 0);
|
|
let (eligible, excluded) = partition(&outcome.record.body);
|
|
|
|
// Both playback streams, neither the capture stream nor the sink. Ordered by
|
|
// serial (creation order), which is what makes the partition assertions in
|
|
// every other row stable rather than dependent on a hash iteration.
|
|
assert_eq!(eligible, vec!["music", "game"]);
|
|
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
|
assert_eq!(outcome.record.body.candidates.len(), 2);
|
|
assert_eq!(outcome.record.body.eligible_count, 2);
|
|
assert_eq!(outcome.record.body.excluded_count, 0);
|
|
}
|
|
|
|
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
|
|
/// an ownership carrier on a non-producer, which means the fix removes an
|
|
/// exclusion — so the only way an operator learns a tag was seen and dropped is
|
|
/// this field. A matrix row that silently grew an impostor would otherwise read
|
|
/// as a clean pass.
|
|
#[test]
|
|
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
|
|
// The bystander is untouched — the point of the fix.
|
|
let (eligible, excluded) = partition(&body);
|
|
assert_eq!(eligible, vec!["music"]);
|
|
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
|
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
|
|
|
|
// ...but the tag is not silent, and the row names the role it appeared on.
|
|
assert_eq!(body.ignored_ownership_tags.len(), 1);
|
|
let row = &body.ignored_ownership_tags[0];
|
|
assert_eq!(row.serial, impostor.serial.0);
|
|
assert_eq!(row.role, "stream-input");
|
|
assert_eq!(
|
|
row.name.as_deref(),
|
|
Some(owned_name("rogue", 4_242).as_str())
|
|
);
|
|
}
|
|
|
|
/// The common path stays quiet: a correctly tagged peerspeak producer is
|
|
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
|
|
/// this, a diagnostic that fired on every normal run would be worthless.
|
|
#[test]
|
|
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
|
|
let mut graph = Graph::new();
|
|
let sink = graph.device_node("speakers", MediaRole::Sink);
|
|
let call = graph.peerspeak_node("call", 200);
|
|
graph.link(call, sink);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
|
|
assert_eq!(body.excluded_count, 1);
|
|
assert!(
|
|
body.ignored_ownership_tags.is_empty(),
|
|
"honoured tag reported as misplaced: {:?}",
|
|
body.ignored_ownership_tags
|
|
);
|
|
}
|
|
|
|
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
|
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
|
/// panic and no invented row.
|
|
#[test]
|
|
fn an_empty_graph_yields_an_empty_partition() {
|
|
let projection = ready(Graph::new().build());
|
|
let outcome = observe(&mut auditor_off(), &projection, 0);
|
|
|
|
assert!(outcome.record.body.candidates.is_empty());
|
|
assert!(outcome.record.body.taint.is_empty());
|
|
assert_eq!(outcome.record.body.eligible_count, 0);
|
|
assert_eq!(outcome.record.body.excluded_count, 0);
|
|
assert!(outcome.record.body.fan_out_permitted);
|
|
}
|
|
|
|
/// `eligible_count + excluded_count` is the candidate count, always. A partition
|
|
/// that does not partition would let a row's two assertions both pass while the
|
|
/// record described no coherent state.
|
|
#[test]
|
|
fn the_counts_always_partition_the_candidates() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.peerspeak_node("peerspeak-playback", 200);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
assert_eq!(
|
|
body.eligible_count + body.excluded_count,
|
|
body.candidates.len()
|
|
);
|
|
assert_eq!(body.eligible().len(), body.eligible_count);
|
|
assert_eq!(body.excluded().len(), body.excluded_count);
|
|
}
|
|
|
|
/// Every excluded row names a reason and every eligible row does not. The
|
|
/// §5.1 rows assert "excluded, with reason code" — a `None` reason on an
|
|
/// excluded row would make that assertion unwritable.
|
|
#[test]
|
|
fn reason_presence_is_exactly_the_exclusion() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.peerspeak_node("peerspeak-playback", 200);
|
|
let projection = ready(graph.build());
|
|
|
|
for row in observe(&mut auditor_off(), &projection, 0)
|
|
.record
|
|
.body
|
|
.candidates
|
|
{
|
|
assert_eq!(
|
|
row.eligible,
|
|
row.reason.is_none(),
|
|
"row {row:?} has eligibility and reason out of step"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── readiness ─────────────────────────────────────────────────────────────
|
|
|
|
/// No decision made from a partial graph is a decision. Note this is asserted on
|
|
/// the *eligible* half too: an implementation that reported nothing at all while
|
|
/// not ready would also be wrong, because the audit must still show what it can
|
|
/// see.
|
|
#[test]
|
|
fn a_not_ready_graph_excludes_every_candidate() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.app_node("game", MediaRole::StreamOutput, 101);
|
|
let projection = not_ready(graph.build(), Readiness::Waiting);
|
|
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
assert!(eligible.is_empty());
|
|
assert_eq!(
|
|
excluded,
|
|
vec![("music", "graph-not-ready"), ("game", "graph-not-ready"),]
|
|
);
|
|
assert!(!body.graph_ready);
|
|
}
|
|
|
|
/// The three ways `graph_ready` can be false are distinguishable in the record.
|
|
/// Collapsing them would make a timed-out observer — a fail-closed *fault* —
|
|
/// indistinguishable from an enumeration that is merely still running.
|
|
#[test]
|
|
fn the_epoch_distinguishes_the_ways_a_graph_can_be_unready() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let snapshot = graph.build();
|
|
|
|
for (readiness, expected) in [
|
|
(Readiness::Waiting, "waiting"),
|
|
(Readiness::TimedOut, "timed-out"),
|
|
// A completed epoch momentarily blocked on a current obligation: the
|
|
// interesting one, because `graph_ready` alone makes it look like a
|
|
// brand-new observer.
|
|
(Readiness::Complete, "complete"),
|
|
] {
|
|
let projection = not_ready(snapshot.clone(), readiness);
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
assert_eq!(body.epoch, expected);
|
|
assert!(!body.graph_ready);
|
|
}
|
|
|
|
let body = observe(&mut auditor_off(), &ready(snapshot), 0).record.body;
|
|
assert_eq!(body.epoch, "complete");
|
|
assert!(body.graph_ready);
|
|
}
|
|
|
|
// ── the fan-out gate (phase 4 → audit) ────────────────────────────────────
|
|
|
|
/// `--aec=off` leaves the gate open: `NotConfigured` is "there is no echo
|
|
/// canceller", not "we failed to find one".
|
|
#[test]
|
|
fn aec_off_leaves_the_gate_open() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
|
assert_eq!(body.aec_state, "not-configured");
|
|
assert!(body.fan_out_permitted);
|
|
assert_eq!(body.gate_reason, None);
|
|
assert_eq!(body.aec_module_id, None);
|
|
assert_eq!(partition(&body).0, vec!["music"]);
|
|
}
|
|
|
|
/// While the configured identity has not been seen, nothing may fan out —
|
|
/// silence over echo — and the record says why in a code, not in prose.
|
|
#[test]
|
|
fn a_validating_gate_excludes_every_engine_eligible_candidate() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.app_node("game", MediaRole::StreamOutput, 101);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
|
|
.record
|
|
.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
assert_eq!(body.aec_state, "validating");
|
|
assert!(!body.fan_out_permitted);
|
|
assert_eq!(body.gate_reason, Some("aec-validating"));
|
|
assert!(eligible.is_empty());
|
|
assert_eq!(
|
|
excluded,
|
|
vec![("music", "aec-validating"), ("game", "aec-validating")]
|
|
);
|
|
}
|
|
|
|
/// A shut gate must not erase the engine's own reason codes. If it did, every
|
|
/// §5.1 row run under a shut gate would report one uniform code and the matrix
|
|
/// would stop constraining the taint engine at all — the record would say
|
|
/// "nothing may fan out" while hiding *which* nodes were tainted and how.
|
|
#[test]
|
|
fn a_shut_gate_preserves_the_engines_own_reasons() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
graph.peerspeak_node("peerspeak-playback", 200);
|
|
let projection = ready(graph.build());
|
|
|
|
let body = observe(&mut auditor_aec(AEC_MODULE), &projection, 0)
|
|
.record
|
|
.body;
|
|
let (_, excluded) = partition(&body);
|
|
|
|
let playback = owned_name("peerspeak-playback", 200);
|
|
assert!(!body.fan_out_permitted);
|
|
assert_eq!(
|
|
excluded,
|
|
vec![
|
|
("music", "aec-validating"),
|
|
// Tagged, so it keeps the reason that actually applies to it.
|
|
(playback.as_str(), "peerspeak-owned"),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// The deadline is armed on the first ready graph, so a slow enumeration reads
|
|
/// as "unknown", not "absent" (the phase-4 user design call). Past it with the
|
|
/// identity never seen, the gate latches shut.
|
|
#[test]
|
|
fn the_gate_fails_closed_after_the_deadline() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let snapshot = graph.build();
|
|
let mut auditor = auditor_aec(AEC_MODULE);
|
|
|
|
// Still enumerating well past the timeout: not a failure, because absence
|
|
// has not been established.
|
|
let waiting = not_ready(snapshot.clone(), Readiness::Waiting);
|
|
let body = observe(&mut auditor, &waiting, TIMEOUT * 3).record.body;
|
|
assert_eq!(body.aec_state, "validating");
|
|
|
|
// Ready arms the deadline; the clock has to advance past it from here.
|
|
let projection = ready(snapshot);
|
|
let body = observe(&mut auditor, &projection, TIMEOUT * 3).record.body;
|
|
assert_eq!(body.aec_state, "validating");
|
|
|
|
let body = observe(&mut auditor, &projection, TIMEOUT * 6 + 1)
|
|
.record
|
|
.body;
|
|
assert_eq!(body.aec_state, "failed");
|
|
assert_eq!(body.gate_reason, Some("aec-failed"));
|
|
assert_eq!(partition(&body).1, vec![("music", "aec-failed")]);
|
|
}
|
|
|
|
// ── §5.1 row 12: the AEC lifecycle ────────────────────────────────────────
|
|
|
|
/// Build the four nodes `module-echo-cancel` creates, all bearing one index:
|
|
/// two `Stream/*` legs plus the virtual sink/source pair (v3.4 §5.2). The
|
|
/// playback leg is the hazard — a `Stream/Output/Audio` wired to the speakers.
|
|
fn aec_nodes(graph: &mut Graph, index: u64) -> Vec<NodeRef> {
|
|
vec![
|
|
graph.module_node("echo-cancel-playback", MediaRole::StreamOutput, index),
|
|
graph.module_node("echo-cancel-capture", MediaRole::StreamInput, index),
|
|
graph.module_node("echo-cancel-sink", MediaRole::Sink, index),
|
|
graph.module_node("echo-cancel-source", MediaRole::Source, index),
|
|
]
|
|
}
|
|
|
|
/// §5.1 row 12: AEC loaded → validated, its playback leg excluded by identity
|
|
/// while everything else stays eligible → unloaded → `Revoked`, gate shut.
|
|
///
|
|
/// The eligible half is the load-bearing assertion in the first phase: an
|
|
/// implementation that excluded the whole graph the moment an AEC appeared would
|
|
/// satisfy "the four nodes are excluded" and still be wrong.
|
|
#[test]
|
|
fn row_12_aec_loaded_then_unloaded_validates_then_revokes() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
|
let mut auditor = auditor_aec(AEC_MODULE);
|
|
|
|
let loaded = ready(graph.build());
|
|
let body = observe(&mut auditor, &loaded, 0).record.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
assert_eq!(body.aec_state, "validated");
|
|
assert!(body.fan_out_permitted);
|
|
assert_eq!(body.aec_module_id, Some(AEC_MODULE));
|
|
assert_eq!(eligible, vec!["music"]);
|
|
assert_eq!(excluded, vec![("echo-cancel-playback", "aec-identity")]);
|
|
|
|
// Every node bearing the index goes away: a real unload.
|
|
let unloaded = ready(graph.build_without(&aec));
|
|
let body = observe(&mut auditor, &unloaded, 1).record.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
assert_eq!(body.aec_state, "revoked");
|
|
assert!(!body.fan_out_permitted);
|
|
assert_eq!(body.gate_reason, Some("aec-revoked"));
|
|
assert_eq!(body.aec_module_id, None);
|
|
assert!(eligible.is_empty());
|
|
assert_eq!(excluded, vec![("music", "aec-revoked")]);
|
|
}
|
|
|
|
/// One leg corking is not a revocation (v3.4 §5.3). Getting this wrong turns an
|
|
/// ordinary cork into a share-wide audio stop, so the audit must report the
|
|
/// identity as still live.
|
|
#[test]
|
|
fn row_12_partial_leg_loss_does_not_revoke() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
|
let mut auditor = auditor_aec(AEC_MODULE);
|
|
|
|
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
|
|
assert_eq!(body.aec_state, "validated");
|
|
|
|
// The capture leg alone disappears; three nodes still bear the index.
|
|
let partial = ready(graph.build_without(&aec[1..2]));
|
|
let body = observe(&mut auditor, &partial, 1).record.body;
|
|
|
|
assert_eq!(body.aec_state, "validated");
|
|
assert!(body.fan_out_permitted);
|
|
assert_eq!(partition(&body).0, vec!["music"]);
|
|
}
|
|
|
|
/// Revocation is sticky terminal: module indices are reused verbatim across an
|
|
/// unload/reload (v3.4 §5.2 correction 3), so a reappearing index must not
|
|
/// resurrect the epoch and alias onto an unrelated module. A genuine reload gets
|
|
/// a fresh validator, never this one.
|
|
#[test]
|
|
fn row_12_a_reused_index_does_not_resurrect_a_revoked_epoch() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let aec = aec_nodes(&mut graph, AEC_MODULE);
|
|
let mut auditor = auditor_aec(AEC_MODULE);
|
|
|
|
observe(&mut auditor, &ready(graph.build()), 0);
|
|
let unloaded = graph.build_without(&aec);
|
|
let body = observe(&mut auditor, &ready(unloaded), 1).record.body;
|
|
assert_eq!(body.aec_state, "revoked");
|
|
|
|
// A second module comes back with the same index — different objects,
|
|
// identical number.
|
|
let mut reloaded = Graph::new();
|
|
reloaded.app_node("music", MediaRole::StreamOutput, 100);
|
|
aec_nodes(&mut reloaded, AEC_MODULE);
|
|
let body = observe(&mut auditor, &ready(reloaded.build()), 2)
|
|
.record
|
|
.body;
|
|
|
|
assert_eq!(body.aec_state, "revoked");
|
|
assert!(!body.fan_out_permitted);
|
|
assert_eq!(body.gate_reason, Some("aec-revoked"));
|
|
}
|
|
|
|
// ── §5.1 rows in fixture form (plumbing, not phase-2 verdicts) ────────────
|
|
|
|
/// §5.1 row 1: a `module-null-sink` + `module-loopback` forwarder. The output
|
|
/// leg is excluded across the **owner bridge** — naming the mechanism, not a
|
|
/// link walk — while an identically-shaped forwarder with no tainted input stays
|
|
/// eligible. The second half is what an exclude-everything build fails.
|
|
#[test]
|
|
fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
|
|
let mut graph = Graph::new();
|
|
// Tainted root: peerspeak's own call playback, feeding a sink the forwarder
|
|
// reads back out.
|
|
let call = graph.peerspeak_node("peerspeak-call", 200);
|
|
let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30);
|
|
graph.link(call, sink);
|
|
let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30);
|
|
let playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30);
|
|
graph.link(sink, capture);
|
|
let _ = playback;
|
|
|
|
// Control: the same shape, fed by nothing tainted.
|
|
let clean_sink = graph.module_node("clean-null-sink", MediaRole::Sink, 31);
|
|
let clean_capture = graph.module_node("clean-loopback-capture", MediaRole::StreamInput, 31);
|
|
let clean_playback = graph.module_node("clean-loopback-playback", MediaRole::StreamOutput, 31);
|
|
graph.link(clean_sink, clean_capture);
|
|
let _ = clean_playback;
|
|
|
|
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
|
.record
|
|
.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
let call_name = owned_name("peerspeak-call", 200);
|
|
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
|
assert_eq!(
|
|
excluded,
|
|
vec![
|
|
(call_name.as_str(), "peerspeak-owned"),
|
|
("tainted-loopback-playback", "tainted-owner-bridge"),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
|
|
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
|
|
/// fusion observable, because fusing all Pulse-created nodes into one owner
|
|
/// would drag the innocent module's output leg down with the tainted one.
|
|
#[test]
|
|
fn row_3_one_tainted_module_does_not_taint_the_other() {
|
|
let mut graph = Graph::new();
|
|
let call = graph.peerspeak_node("peerspeak-call", 200);
|
|
let sink = graph.module_node("null-sink-a", MediaRole::Sink, 40);
|
|
graph.link(call, sink);
|
|
let capture_a = graph.module_node("module-a-capture", MediaRole::StreamInput, 40);
|
|
let playback_a = graph.module_node("module-a-playback", MediaRole::StreamOutput, 40);
|
|
graph.link(sink, capture_a);
|
|
let _ = playback_a;
|
|
|
|
// A second, entirely independent module reading an untainted source.
|
|
let mic = graph.device_node("microphone", MediaRole::Source);
|
|
let capture_b = graph.module_node("module-b-capture", MediaRole::StreamInput, 41);
|
|
let playback_b = graph.module_node("module-b-playback", MediaRole::StreamOutput, 41);
|
|
graph.link(mic, capture_b);
|
|
let _ = playback_b;
|
|
|
|
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
|
.record
|
|
.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
let call_name = owned_name("peerspeak-call", 200);
|
|
assert_eq!(eligible, vec!["module-b-playback"]);
|
|
assert_eq!(
|
|
excluded,
|
|
vec![
|
|
(call_name.as_str(), "peerspeak-owned"),
|
|
("module-a-playback", "tainted-owner-bridge"),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// §5.1 row 7 (cycle prevention, v3.4 §6.2): a forwarder reading *another*
|
|
/// pixelpass host's capture sink must be excluded by its **named output
|
|
/// serial**, or two hosts sharing to each other build an audio cycle.
|
|
#[test]
|
|
fn row_7_a_forwarder_reading_another_hosts_capture_sink_is_excluded() {
|
|
let mut graph = Graph::new();
|
|
// The other host's own client, in *this* graph — a node pointing at a client
|
|
// that does not exist would exercise the unresolved-owner path instead of the
|
|
// capture-sink-name path this row is about.
|
|
let other_client = graph.client(Some(fixture::PULSE_PID));
|
|
let other_sink = graph.node(
|
|
"pixelpass_capture_deadbeef",
|
|
MediaRole::Sink,
|
|
fixture::app(other_client, 300),
|
|
);
|
|
let capture = graph.module_node("cycle-loopback-capture", MediaRole::StreamInput, 50);
|
|
let playback = graph.module_node("cycle-loopback-playback", MediaRole::StreamOutput, 50);
|
|
graph.link(other_sink, capture);
|
|
let _ = playback;
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
|
|
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
|
.record
|
|
.body;
|
|
let (eligible, excluded) = partition(&body);
|
|
|
|
assert_eq!(eligible, vec!["music"]);
|
|
assert_eq!(
|
|
excluded,
|
|
vec![("cycle-loopback-playback", "tainted-owner-bridge")]
|
|
);
|
|
// The sink itself is tainted, by the mechanism that names it.
|
|
let sink_taint = body
|
|
.taint
|
|
.iter()
|
|
.find(|row| row.name.as_deref() == Some("pixelpass_capture_deadbeef"))
|
|
.expect("the other host's capture sink must be tainted");
|
|
assert_eq!(sink_taint.reason, "pixelpass-owned");
|
|
}
|
|
|
|
/// Sticky taint (§5.1 row 10) is reported as sticky, not silently folded into
|
|
/// an ordinary exclusion. The flag is how the audit distinguishes "this is
|
|
/// tainted right now" from "this was tainted and its owner has not fully torn
|
|
/// down" — two different things to be surprised by.
|
|
#[test]
|
|
fn sticky_exclusions_are_flagged_as_sticky() {
|
|
let mut graph = Graph::new();
|
|
let call = graph.peerspeak_node("peerspeak-call", 200);
|
|
let sink = graph.module_node("null-sink", MediaRole::Sink, 60);
|
|
graph.link(call, sink);
|
|
let capture = graph.module_node("loopback-capture", MediaRole::StreamInput, 60);
|
|
graph.module_node("loopback-playback", MediaRole::StreamOutput, 60);
|
|
graph.link(sink, capture);
|
|
|
|
let mut auditor = auditor_off();
|
|
let body = observe(&mut auditor, &ready(graph.build()), 0).record.body;
|
|
let playback = body
|
|
.row_named("loopback-playback")
|
|
.expect("the output leg must be a candidate");
|
|
assert!(!playback.eligible);
|
|
assert!(!playback.sticky, "first sight is not sticky");
|
|
|
|
// The tainted input leg goes away; the output leg lives on.
|
|
let body = observe(&mut auditor, &ready(graph.build_without(&[capture])), 1)
|
|
.record
|
|
.body;
|
|
let playback = body
|
|
.row_named("loopback-playback")
|
|
.expect("the output leg must still be a candidate");
|
|
assert!(!playback.eligible);
|
|
assert!(playback.sticky, "the taint is carried over, and says so");
|
|
}
|
|
|
|
// ── record accounting ─────────────────────────────────────────────────────
|
|
|
|
/// Ticks exist to drive the AEC deadline, not to describe the graph. Emitting an
|
|
/// identical record four times a second would bury the graph events the audit
|
|
/// exists to show — but a tick that *does* change something must still be
|
|
/// emitted, or a `Validating → Failed` transition (which only a tick can cause)
|
|
/// would never appear in the log at all.
|
|
#[test]
|
|
fn an_unchanged_tick_is_suppressed_but_a_changed_one_is_not() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let projection = ready(graph.build());
|
|
let mut auditor = auditor_aec(AEC_MODULE);
|
|
|
|
assert!(auditor.observe(&projection, EventKind::Graph, 0).emit);
|
|
assert!(
|
|
!auditor.observe(&projection, EventKind::Tick, 100).emit,
|
|
"an identical tick record is noise"
|
|
);
|
|
assert!(
|
|
!auditor.observe(&projection, EventKind::Tick, 200).emit,
|
|
"still noise"
|
|
);
|
|
|
|
// The deadline expires on a tick: the state changes, so this one is emitted.
|
|
let outcome = auditor.observe(&projection, EventKind::Tick, TIMEOUT + 1);
|
|
assert!(outcome.emit);
|
|
assert_eq!(outcome.record.body.aec_state, "failed");
|
|
}
|
|
|
|
/// A graph event always emits, even when the decision content is identical — a
|
|
/// suppressed graph event would erase the evidence that the graph changed at all,
|
|
/// and "PipeWire told us something and nothing moved" is itself a finding.
|
|
#[test]
|
|
fn an_unchanged_graph_event_still_emits() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let projection = ready(graph.build());
|
|
let mut auditor = auditor_off();
|
|
|
|
assert!(observe(&mut auditor, &projection, 0).emit);
|
|
assert!(observe(&mut auditor, &projection, 1).emit);
|
|
}
|
|
|
|
/// `seq` counts every recompute, emitted or not, so a gap in the emitted
|
|
/// sequence is visibly a suppression rather than a lost line. Without this, a
|
|
/// reader cannot tell a quiet audit from a broken one.
|
|
#[test]
|
|
fn seq_counts_suppressed_recomputes_too() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let projection = ready(graph.build());
|
|
let mut auditor = auditor_off();
|
|
|
|
assert_eq!(observe(&mut auditor, &projection, 0).record.seq, 1);
|
|
let suppressed = auditor.observe(&projection, EventKind::Tick, 1);
|
|
assert!(!suppressed.emit);
|
|
assert_eq!(suppressed.record.seq, 2);
|
|
assert_eq!(observe(&mut auditor, &projection, 2).record.seq, 3);
|
|
}
|
|
|
|
/// Suppression compares against the last record actually *written*, not the last
|
|
/// one computed. Comparing against the last computed record would let a change
|
|
/// that appears and reverts between two ticks vanish from the log entirely,
|
|
/// leaving a reader with a record that no longer matches the state.
|
|
#[test]
|
|
fn suppression_compares_against_the_last_emitted_record() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
|
let with_music = ready(graph.build());
|
|
let empty = ready(Graph::new().build());
|
|
let mut auditor = auditor_off();
|
|
|
|
assert!(observe(&mut auditor, &with_music, 0).emit);
|
|
// A tick sees a different graph and emits.
|
|
assert!(auditor.observe(&empty, EventKind::Tick, 1).emit);
|
|
// The next tick sees the original graph again — different from what was last
|
|
// written, so it must be emitted.
|
|
assert!(auditor.observe(&with_music, EventKind::Tick, 2).emit);
|
|
// And now it matches the last written record.
|
|
assert!(!auditor.observe(&with_music, EventKind::Tick, 3).emit);
|
|
}
|
|
|
|
/// The trigger and clock are reported verbatim, which is what lets the O5 event
|
|
/// rate be recomputed from the record stream alone rather than trusted from the
|
|
/// summary.
|
|
#[test]
|
|
fn the_record_reports_its_trigger_and_clock() {
|
|
let projection = ready(Graph::new().build());
|
|
let mut auditor = auditor_off();
|
|
|
|
let outcome = auditor.observe(&projection, EventKind::Graph, 42);
|
|
assert_eq!(outcome.record.trigger, "graph");
|
|
assert_eq!(outcome.record.at_ms, 42);
|
|
|
|
let outcome = auditor.observe(&projection, EventKind::Tick, 43);
|
|
assert_eq!(outcome.record.trigger, "tick");
|
|
assert_eq!(outcome.record.at_ms, 43);
|
|
}
|
|
|
|
/// The taint view spans every media role, not just candidates. A candidate's
|
|
/// exclusion is usually explained by taint on a node that is not itself a
|
|
/// candidate — the sink in the middle of a forwarder — and without that the
|
|
/// record shows the verdict but not the evidence.
|
|
#[test]
|
|
fn the_taint_view_covers_non_candidate_roles() {
|
|
let mut graph = Graph::new();
|
|
let call = graph.peerspeak_node("peerspeak-call", 200);
|
|
let sink = graph.module_node("null-sink", MediaRole::Sink, 70);
|
|
graph.link(call, sink);
|
|
|
|
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
|
.record
|
|
.body;
|
|
let tainted: Vec<(&str, &str)> = body
|
|
.taint
|
|
.iter()
|
|
.map(|row| (row.name.as_deref().unwrap_or("?"), row.reason))
|
|
.collect();
|
|
|
|
assert!(
|
|
tainted.contains(&("null-sink", "tainted-upstream")),
|
|
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
|
);
|
|
let call_name = owned_name("peerspeak-call", 200);
|
|
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
|
|
}
|
|
|
|
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
|
/// record would split one record into two unparseable ones — and node names come
|
|
/// from PipeWire properties, which are attacker-adjacent free text.
|
|
#[test]
|
|
fn a_record_serialises_to_exactly_one_line() {
|
|
let mut graph = Graph::new();
|
|
graph.app_node("evil\nname\r\nwith breaks", MediaRole::StreamOutput, 100);
|
|
let projection = ready(graph.build());
|
|
|
|
let outcome = observe(&mut auditor_off(), &projection, 0);
|
|
let json = serde_json::to_string(&outcome.record).expect("a record must serialise");
|
|
assert_eq!(json.lines().count(), 1, "record split across lines: {json}");
|
|
assert!(
|
|
json.contains(r"evil\nname"),
|
|
"the name must survive escaped"
|
|
);
|
|
}
|
|
|
|
// ── O5 metrics ────────────────────────────────────────────────────────────
|
|
|
|
fn sample(kind: EventKind, at_us: u64, gap_us: u64, recompute_us: u64, emit_us: u64) -> Sample {
|
|
Sample {
|
|
at_us,
|
|
gap_us,
|
|
recompute_us,
|
|
emit_us,
|
|
kind,
|
|
}
|
|
}
|
|
|
|
/// Bucket bounds are exclusive upper bounds, so a value exactly on a bound lands
|
|
/// in the next bucket up. Asserted because an off-by-one here silently shifts
|
|
/// the whole distribution the O5 conclusion rests on.
|
|
#[test]
|
|
fn histogram_bounds_are_exclusive_upper_bounds() {
|
|
let mut metrics = Metrics::default();
|
|
for us in [0, 49, 50, 99_999, 100_000, 1_000_000] {
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, us, 0));
|
|
}
|
|
let summary = metrics.summary();
|
|
|
|
assert_eq!(
|
|
summary.recompute_distribution,
|
|
vec![
|
|
("<50us", 2), // 0 and 49
|
|
("<100us", 1), // 50
|
|
("<100ms", 1), // 99_999
|
|
(">=100ms", 2), // 100_000 and 1_000_000
|
|
]
|
|
);
|
|
assert_eq!(summary.recompute_max_us, 1_000_000);
|
|
}
|
|
|
|
/// The maximum is exact, not bucketed. O5 asks for the maximum specifically, and
|
|
/// ">= 100 ms" is not an answer to "how bad does it get?".
|
|
#[test]
|
|
fn the_maximum_is_exact_not_bucketed() {
|
|
let mut metrics = Metrics::default();
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, 137, 0));
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, 4_211, 0));
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, 90, 0));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(summary.recompute_max_us, 4_211);
|
|
assert_eq!(summary.recompute_mean_us, Some((137 + 4_211 + 90) / 3));
|
|
}
|
|
|
|
/// Nearest-rank quantiles over the buckets.
|
|
#[test]
|
|
fn quantiles_use_nearest_rank_over_the_buckets() {
|
|
let mut metrics = Metrics::default();
|
|
// 99 fast samples and one very slow one: the tail must show up at p99 and
|
|
// nowhere earlier, which is the whole reason for reporting p99 at all.
|
|
for _ in 0..99 {
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, 10, 0));
|
|
}
|
|
metrics.record(sample(EventKind::Graph, 0, 1_000, 200_000, 0));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(summary.recompute_p50, Some("<50us"));
|
|
assert_eq!(summary.recompute_p90, Some("<50us"));
|
|
assert_eq!(summary.recompute_p99, Some("<50us"));
|
|
assert_eq!(summary.recompute_max_us, 200_000);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_histogram_reports_no_quantiles_and_no_rate() {
|
|
let summary = Metrics::default().summary();
|
|
assert_eq!(summary.recompute_p50, None);
|
|
assert_eq!(summary.recompute_mean_us, None);
|
|
assert_eq!(summary.graph_events_per_sec, None);
|
|
assert_eq!(summary.busy_fraction, None);
|
|
assert_eq!(summary.recompute_max_us, 0);
|
|
assert!(summary.recompute_distribution.is_empty());
|
|
}
|
|
|
|
/// Ticks are counted separately from graph events. Folding them in would inflate
|
|
/// the measured event rate by a constant 4 Hz and hide the real graph churn —
|
|
/// which is the number O5 is actually about.
|
|
#[test]
|
|
fn ticks_do_not_count_toward_the_graph_event_rate() {
|
|
let mut metrics = Metrics::default();
|
|
// Two graph events one second apart, with ticks in between.
|
|
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
|
for i in 1..4 {
|
|
metrics.record(sample(EventKind::Tick, i * 250_000, 250_000, 100, 0));
|
|
}
|
|
metrics.record(sample(EventKind::Graph, 1_000_000, 250_000, 100, 0));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(summary.graph_events, 2);
|
|
assert_eq!(summary.tick_events, 3);
|
|
// Span runs to the last sample's completion: 1_000_000 + 100 µs.
|
|
assert_eq!(summary.span_us, 1_000_100);
|
|
assert_eq!(summary.graph_events_per_sec, Some(2.0));
|
|
}
|
|
|
|
/// The queueing proxy: an event beginning within the threshold of the previous
|
|
/// sample's completion was almost certainly already waiting. The first sample is
|
|
/// never counted — it has no predecessor to have queued behind, and counting it
|
|
/// would put a phantom backlog in every run.
|
|
#[test]
|
|
fn the_queueing_proxy_counts_back_to_back_events_only() {
|
|
let mut metrics = Metrics::default();
|
|
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
|
metrics.record(sample(EventKind::Graph, 100, QUEUE_THRESHOLD_US, 100, 0));
|
|
metrics.record(sample(
|
|
EventKind::Graph,
|
|
200,
|
|
QUEUE_THRESHOLD_US + 1,
|
|
100,
|
|
0,
|
|
));
|
|
metrics.record(sample(EventKind::Graph, 300, 0, 100, 0));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(
|
|
summary.queued_events, 2,
|
|
"exactly the two within the threshold, never the first sample"
|
|
);
|
|
assert_eq!(summary.queue_threshold_us, QUEUE_THRESHOLD_US);
|
|
}
|
|
|
|
/// Emission cost is tracked separately from recompute cost, and a suppressed
|
|
/// record contributes neither an emitted-record count nor an emit sample —
|
|
/// otherwise the logging distribution would be diluted by every tick that wrote
|
|
/// nothing.
|
|
#[test]
|
|
fn emission_cost_is_tracked_separately_from_recompute() {
|
|
let mut metrics = Metrics::default();
|
|
metrics.record(sample(EventKind::Graph, 0, 0, 300, 80));
|
|
metrics.record(sample(EventKind::Tick, 1_000, 900, 200, 0));
|
|
metrics.record(sample(EventKind::Graph, 2_000, 900, 400, 120));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(summary.emitted_records, 2);
|
|
assert_eq!(summary.emit_max_us, 120);
|
|
assert_eq!(summary.emit_mean_us, Some(100));
|
|
assert_eq!(
|
|
summary.emit_distribution,
|
|
vec![("<100us", 1), ("<250us", 1)]
|
|
);
|
|
// Busy time is recompute *and* logging: 300+80+200+400+120.
|
|
assert_eq!(summary.busy_us, 1_100);
|
|
}
|
|
|
|
/// The busy fraction needs no inference, unlike the queueing proxy, so it is the
|
|
/// number the O5 verdict should lean on.
|
|
#[test]
|
|
fn the_busy_fraction_is_the_share_of_wall_time_spent_working() {
|
|
let mut metrics = Metrics::default();
|
|
metrics.record(sample(EventKind::Graph, 0, 0, 100, 0));
|
|
// Ends at 1_000_000 + 900 → a span of 1_000_900 µs with 1_000 µs of work.
|
|
metrics.record(sample(EventKind::Graph, 1_000_000, 999_900, 900, 0));
|
|
|
|
let summary = metrics.summary();
|
|
assert_eq!(summary.busy_us, 1_000);
|
|
assert_eq!(summary.span_us, 1_000_900);
|
|
assert_eq!(summary.busy_fraction, Some(0.001));
|
|
}
|
|
|
|
/// Bucket labels and bounds must stay parallel, or the distribution mislabels
|
|
/// itself — a silent failure that would misreport every O5 result.
|
|
#[test]
|
|
fn bucket_labels_cover_every_bound_plus_overflow() {
|
|
assert_eq!(
|
|
BUCKET_LABELS.len(),
|
|
super::metrics::BUCKET_BOUNDS_US.len() + 1
|
|
);
|
|
}
|