diff --git a/src/host/aec/mod.rs b/src/host/aec/mod.rs new file mode 100644 index 0000000..a4b2218 --- /dev/null +++ b/src/host/aec/mod.rs @@ -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 == `, 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:` 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:` — 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:` 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 { + 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::() + .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, + 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, +} + +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 { + 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)) + } +} diff --git a/src/host/aec/tests.rs b/src/host/aec/tests.rs new file mode 100644 index 0000000..dd462fb --- /dev/null +++ b/src/host/aec/tests.rs @@ -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) -> 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) -> 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: / > 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)); +} diff --git a/src/host/mod.rs b/src/host/mod.rs index ec59cd3..8f8ca5d 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,3 +1,4 @@ +pub mod aec; pub mod audio; mod capture; mod observer;