Author SHA1 Message Date
molluskandClaude Opus 4.8 4a844ac6fa host/aec: address Codex phase-4 review (no merge-blockers; docs + test holes)
Codex adversarial review found no merge-blocking defects. Five
worth-checking items, all triaged for reachability:

- F1 (spurious pre-ready revoke): unreachable — the AEC's four nodes are
  two Stream/* legs + a null-sink-like virtual sink/source, none claiming
  a device.id, so the phase-3 observer never withholds them; index_present
  goes false only on a genuine full unload. Documented why revoke is NOT
  gated on graph_ready, and why gating it would reopen the reused-index
  alias trap (F4) during a hot-reload-under-churn. Pinned with
  revokes_on_empty_even_while_not_ready (mutation-verified: `&& graph_ready`
  on the revoke guard dies here).
- F4 (test relies on observing the empty gap): documented the phase-5/6
  integration contract it rests on (one observe per graph event, no
  coalescing across a module lifetime boundary) and owed the robust fix
  (serial-continuity / observer-generation) to a later hardening round.
- F2 (late positive evidence beats the deadline): intentional and correct
  — a demonstrably-present identity is ground truth. Documented +
  late_positive_evidence_wins_over_expired_deadline (both arms: node-first
  validates, Tick-first fails closed and stays sticky).
- F3 (real P3 coverage hole): strengthened deadline_is_not_armed_until_
  graph_ready to prove the budget starts at first-ready, not construction
  (mutation-verified: a construction-relative deadline now dies).
- F5 (`+7` grammar mismatch): documented the producer contract — peerspeak
  emits bare decimal (pactl returns unsigned decimal), the narrow parser
  is deliberate. Unreachable on the measured stack.

No core logic change. 24 pure aec tests, cargo test --bins green (145 unit
+ 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:07:01 -04:00
molluskandClaude Opus 4.8 b74ed17823 host/aec: phase 4 — AEC identity validation state machine (pure core)
Bounded, read-only state machine that validates peerspeak's live
echo-cancel module identity before the taint engine trusts it, filling
the last ExclusionCtx field (aec_module_id). Design v3.4 §5.2/§5.3,
impl plan §4.

States (v3.4 §5.3 verbatim): NotConfigured / Validating / Validated /
Failed / Revoked. No fan-out while Validating; Failed and Revoked are
sticky terminals so a reused module index (indices ARE reused, §5.2
correction 3) cannot alias a Revoked epoch onto an unrelated reload.
Revocation is loss of the whole identity (every node bearing the index
gone), never one leg corking. The Failed deadline is armed only on the
first graph_ready, so a slow initial enumeration is "unknown" not
"absent" and never times out spuriously.

parse_aec_arg handles --aec=off|pulse-module:<idx> (D5): bare-u64
decimal accepted past u32::MAX, rejecting sign/whitespace/non-digit/
overflow/unknown-form.

22 pure tests (the exit-gate transition matrix), cargo test --bins
green (143 unit + 1 ignored live), clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:51:49 -04:00
8973e5dc19 Merge phase 3: registry observer (pure core + I/O adapter)
Split-seam mutual-review build: pure reducer/classifiers (Claude) + libpipewire
adapter (Codex), each reviewed by the other. Two review rounds closed 3 P1s
(dynamic graph_ready over invisible edges; snd_aloop absent-driver fail-closed;
FIFO lockstep). Exit gate incl. live topology-diff row passes on the host.

Additive/read-only — does not yet replace the audio.rs router (integration
phase). DAG: 0a -> 2 -> 3 done; next is Phase 4 (AEC validation state machine).

Co-Authored-By: Codex (gpt-5.6-sol) <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:01:32 -04:00
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;