Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a844ac6fa | ||
|
|
b74ed17823 | ||
|
|
8973e5dc19 |
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod aec;
|
||||
pub mod audio;
|
||||
mod capture;
|
||||
mod observer;
|
||||
|
||||
Reference in New Issue
Block a user