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>
This commit is contained in:
2026-07-24 15:07:10 -04:00
co-authored by Claude Opus 4.8
3 changed files with 686 additions and 0 deletions
+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
View File
@@ -1,3 +1,4 @@
pub mod aec;
pub mod audio;
mod capture;
mod observer;