MEASURED DEFECT, found by the §5.1 row-1 matrix run: the daemon PID was unresolvable on this host, permanently, which switched key 4's suppression off and fused every Pulse-emulated node into a single owner. Row 1's clean control forwarder was excluded, Firefox was excluded across an application.process.id bridge, and the taint reached the sunshine sinks and both sound cards -- the same machine-wide over-exclusion cascade as phase 5's F2, from a new cause. Stage 1 returned the one sec_pid shared by 2+ Clients, reasoning that only the Pulse shim repeats a value. WirePlumber repeats one too: it holds Clients 'WirePlumber' and 'WirePlumber [export]', both sec_pid 1747. Two values repeated, the rule called that ambiguous and returned None, and owner::keys_of's documented fail-closed asymmetry did the rest. The rule was wrong in both directions, so the prefilter is gone rather than patched: a second process holding two Clients defeats it (permanent, not a corner case), and a session where pipewire-pulse holds exactly one Client never repeats anything so the candidate is missed. comm was always the authoritative check; repetition stood in front of it and was a guess about other processes' Client counts. Now: candidates() lists every distinct sec_pid, resolve() picks the unique one whose /proc comm is exactly pipewire-pulse, and several matches still fail closed (a single Option<u32> cannot suppress two daemons -- recorded, not approximated). The adapter probes only PIDs entering the set, and retain_probed_comms bounds the map to live PIDs so a PID that leaves and returns is re-probed instead of answered from a stale comm. Row 1 now passes its exact partition, key named: tainted forwarder leg excluded on node.link-group, clean forwarder leg and Firefox eligible, taint confined to the tainted half. 225 tests green (WirePlumber-pair and single-Client regressions covered), clippy clean.
1541 lines
49 KiB
Rust
1541 lines
49 KiB
Rust
//! Pure exit-gate coverage for the observer core (phases 3 and 3r).
|
|
//!
|
|
//! Five of phase 3's six exit-gate rows live here (the sixth — a live
|
|
//! create/destroy topology diff — needs the daemon and belongs to the
|
|
//! adapter), plus three of phase 3r's four (the fourth is the live
|
|
//! prop-recovery gate, likewise the adapter's). Each test builds the
|
|
//! [`RegEvent`] stream by hand; nothing links PipeWire.
|
|
//!
|
|
//! Carrying the phase-0a lesson: the id/pid/serial tests use **interior**
|
|
//! values, not just 1 and a huge number, so a middle-of-range mistake cannot
|
|
//! hide.
|
|
|
|
use super::classify::{Classification, DeviceClaim, DeviceProps, classify};
|
|
use super::pulse_pid;
|
|
use super::*;
|
|
use crate::host::taint::snapshot::{
|
|
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
|
};
|
|
|
|
// ---- builders -------------------------------------------------------------
|
|
|
|
fn ser(n: u64) -> Serial {
|
|
Serial(n)
|
|
}
|
|
fn gid(n: u32) -> GlobalId {
|
|
GlobalId(n)
|
|
}
|
|
fn model() -> RegistryModel {
|
|
// now=0, a 5 s readiness budget.
|
|
RegistryModel::new(0, 5000)
|
|
}
|
|
|
|
fn no_device() -> DeviceClaim {
|
|
DeviceClaim::default()
|
|
}
|
|
|
|
/// A node-side device claim as the *node's* bound `info` reports it: the
|
|
/// factory name (node-only) plus the `device.api`/`alsa.driver_name` copies
|
|
/// that some PipeWire/WirePlumber pairings make and some do not.
|
|
fn hw_claim(device_id: u32, api: &str, factory: &str) -> DeviceClaim {
|
|
DeviceClaim {
|
|
device_id: Some(gid(device_id)),
|
|
device_api: Some(api.to_string()),
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
}
|
|
}
|
|
|
|
/// The same claim with **neither** value copied onto the node — the common
|
|
/// case on PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13, where only the bound
|
|
/// Device knows.
|
|
fn node_only_claim(device_id: u32, factory: &str) -> DeviceClaim {
|
|
DeviceClaim {
|
|
device_id: Some(gid(device_id)),
|
|
device_api: None,
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: None,
|
|
}
|
|
}
|
|
|
|
/// A real sound card's bound Device props.
|
|
fn alsa_device() -> DeviceProps {
|
|
DeviceProps {
|
|
device_api: Some("alsa".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
}
|
|
}
|
|
|
|
fn device_with(api: Option<&str>, driver: Option<&str>) -> DeviceProps {
|
|
DeviceProps {
|
|
device_api: api.map(str::to_owned),
|
|
alsa_driver_name: driver.map(str::to_owned),
|
|
}
|
|
}
|
|
|
|
fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation {
|
|
NodeObservation {
|
|
name: Some(name.to_string()),
|
|
role,
|
|
props: NodeProps::default(),
|
|
device_claim: claim,
|
|
}
|
|
}
|
|
|
|
/// Announce a node and deliver its bind `info` — the settled state most tests
|
|
/// want. The two steps are driven separately only where the gap itself is
|
|
/// under test.
|
|
fn add_node(m: &mut RegistryModel, serial: u64, id: u32, observation: NodeObservation) {
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(serial),
|
|
id: gid(id),
|
|
});
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(serial),
|
|
observation,
|
|
});
|
|
}
|
|
|
|
/// A `Stream/Output/Audio` node with no backing Device — admitted as soon as
|
|
/// its `info` lands.
|
|
fn add_stream_out(m: &mut RegistryModel, serial: u64, id: u32) {
|
|
add_node(
|
|
m,
|
|
serial,
|
|
id,
|
|
obs(
|
|
&format!("stream-{id}"),
|
|
MediaRole::StreamOutput,
|
|
no_device(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A node backed by a Device (withheld until that Device's `info` lands).
|
|
fn add_device_node(
|
|
m: &mut RegistryModel,
|
|
serial: u64,
|
|
id: u32,
|
|
role: MediaRole,
|
|
claim: DeviceClaim,
|
|
) {
|
|
add_node(m, serial, id, obs(&format!("dev-node-{id}"), role, claim));
|
|
}
|
|
|
|
/// Announce a Device and deliver its bind `info`.
|
|
fn add_device(m: &mut RegistryModel, serial: u64, id: u32, props: DeviceProps) {
|
|
m.apply(RegEvent::DeviceAdded {
|
|
serial: ser(serial),
|
|
id: gid(id),
|
|
});
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(serial),
|
|
props,
|
|
});
|
|
}
|
|
|
|
fn client(serial: u64, id: u32, sec_pid: Option<u32>) -> RegEvent {
|
|
RegEvent::ClientAdded(ClientSnapshot {
|
|
serial: ser(serial),
|
|
id: gid(id),
|
|
sec_pid,
|
|
})
|
|
}
|
|
|
|
fn port(serial: u64, id: u32, node_id: u32, dir: PortDirection) -> RegEvent {
|
|
RegEvent::PortAdded(PortSnapshot {
|
|
serial: ser(serial),
|
|
id: gid(id),
|
|
node: gid(node_id),
|
|
direction: dir,
|
|
exclusive: false,
|
|
monitor: false,
|
|
})
|
|
}
|
|
|
|
fn endpoints(out_node: u32, in_node: u32) -> LinkEndpoints {
|
|
LinkEndpoints {
|
|
output_node: gid(out_node),
|
|
input_node: gid(in_node),
|
|
output_port: None,
|
|
input_port: None,
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// classify() — session_device
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn classify_no_device_is_not_a_device() {
|
|
assert_eq!(classify(&no_device(), None), Classification::NotADevice);
|
|
// A resolved Device is irrelevant with no device_id.
|
|
assert_eq!(
|
|
classify(&no_device(), Some(&alsa_device())),
|
|
Classification::NotADevice
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_unresolved_device_withholds() {
|
|
let claim = hw_claim(42, "alsa", "api.alsa.pcm.sink");
|
|
assert_eq!(
|
|
classify(&claim, None),
|
|
Classification::Withhold { device_id: gid(42) }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_resolved_hardware_pcm_is_session_device() {
|
|
// Only the measured ALSA factories are allowlisted (finding 5: the BlueZ
|
|
// entries were invented and were removed).
|
|
for factory in ["api.alsa.pcm.sink", "api.alsa.pcm.source"] {
|
|
assert_eq!(
|
|
classify(&hw_claim(7, "alsa", factory), Some(&alsa_device())),
|
|
Classification::SessionDevice,
|
|
"factory {factory} should be a session device"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_invented_bluez_factories_are_not_session_devices() {
|
|
// Finding 5: `api.bluez5.pcm.*` is not a real factory name; whatever it is,
|
|
// it is not on the measured allowlist, so it fails closed to false
|
|
// (over-exclusion, safe) rather than being trusted.
|
|
for factory in ["api.bluez5.pcm.sink", "api.bluez5.pcm.source"] {
|
|
let claim = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("bluez5".to_string()),
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: None,
|
|
};
|
|
let device = device_with(Some("bluez5"), None);
|
|
assert_eq!(
|
|
classify(&claim, Some(&device)),
|
|
Classification::NotSessionDevice
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_alsa_without_any_driver_name_fails_closed() {
|
|
// Codex re-review: a missing `alsa.driver_name` must NOT grant
|
|
// session_device — an snd_aloop node whose driver prop was not copied onto
|
|
// the node would otherwise slip through. Absence on BOTH sides fails closed.
|
|
for factory in ["api.alsa.pcm.sink", "api.alsa.pcm.source"] {
|
|
let claim = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("alsa".to_string()),
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: None,
|
|
};
|
|
let device = device_with(Some("alsa"), None);
|
|
assert_eq!(
|
|
classify(&claim, Some(&device)),
|
|
Classification::NotSessionDevice,
|
|
"absent driver on {factory} must fail closed"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_driver_from_the_device_recovers_a_real_card() {
|
|
// Round 8 / v3.5 §6.7 decision 4 — the phase-3 review's owed fix. The node
|
|
// carries neither `device.api` nor `alsa.driver_name` (PipeWire ≥ 1.2.6 +
|
|
// WirePlumber < 0.5.13); the bound Device carries both. Before this, such a
|
|
// card was over-excluded on every one of those installs.
|
|
let claim = node_only_claim(7, "api.alsa.pcm.sink");
|
|
assert_eq!(
|
|
classify(&claim, Some(&alsa_device())),
|
|
Classification::SessionDevice,
|
|
"the Device is authoritative for device.api and alsa.driver_name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_snd_aloop_is_not_a_session_device_from_either_side() {
|
|
// Finding 2: an ALSA loopback presents with an allowlisted factory and
|
|
// device.api=alsa exactly like a real card, but forwards audio through a
|
|
// kernel hop the Link graph cannot see. It must NOT earn session_device —
|
|
// and the denylist is a UNION, so one side naming it is enough even when
|
|
// the other side disagrees (v3.5 §6.7 decision 4, safety-picked direction).
|
|
let factory = "api.alsa.pcm.sink";
|
|
let aloop_both = (
|
|
DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("alsa".to_string()),
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: Some("snd_aloop".to_string()),
|
|
},
|
|
device_with(Some("alsa"), Some("snd_aloop")),
|
|
);
|
|
let aloop_node_only = (
|
|
DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("alsa".to_string()),
|
|
factory_name: Some(factory.to_string()),
|
|
alsa_driver_name: Some("snd_aloop".to_string()),
|
|
},
|
|
// The Device disagrees — the union still denies.
|
|
alsa_device(),
|
|
);
|
|
let aloop_device_only = (
|
|
node_only_claim(7, factory),
|
|
device_with(Some("alsa"), Some("snd_aloop")),
|
|
);
|
|
for (claim, device) in [aloop_both, aloop_node_only, aloop_device_only] {
|
|
assert_eq!(
|
|
classify(&claim, Some(&device)),
|
|
Classification::NotSessionDevice,
|
|
"snd_aloop must fail closed whichever side names it"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_resolved_but_not_hardware_pcm_fails_closed() {
|
|
// A null sink, a loopback, and an unknown factory are all forwarders, not
|
|
// terminals: resolved, but session_device stays false.
|
|
for factory in ["support.null-audio-sink", "api.alsa.pcm.loopback", "wat"] {
|
|
assert_eq!(
|
|
classify(&hw_claim(7, "alsa", factory), Some(&alsa_device())),
|
|
Classification::NotSessionDevice,
|
|
"factory {factory} must not be a session device"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_missing_device_api_everywhere_fails_closed() {
|
|
// Even with an allowlisted factory and a good driver, no `device.api` on
|
|
// either side ⇒ not positively a real-backend terminal.
|
|
let claim = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: None,
|
|
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
};
|
|
let device = device_with(None, Some("snd_hda_intel"));
|
|
assert_eq!(
|
|
classify(&claim, Some(&device)),
|
|
Classification::NotSessionDevice
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_device_api_from_either_side_corroborates() {
|
|
// Presence is a union: whichever side has it, the corroborating signal is
|
|
// satisfied.
|
|
let node_side = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("alsa".to_string()),
|
|
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
};
|
|
assert_eq!(
|
|
classify(&node_side, Some(&device_with(None, None))),
|
|
Classification::SessionDevice
|
|
);
|
|
assert_eq!(
|
|
classify(
|
|
&node_only_claim(7, "api.alsa.pcm.sink"),
|
|
Some(&alsa_device())
|
|
),
|
|
Classification::SessionDevice
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_contradictory_api_fails_closed() {
|
|
// Codex phase-3r review, finding 3. A `device.api` that is merely
|
|
// *present* is not corroboration: `v4l2` under an ALSA PCM factory is a
|
|
// contradiction, and the safe reading of a contradiction is "an
|
|
// observation went wrong", not "close enough".
|
|
let claim = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: None,
|
|
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
};
|
|
assert_eq!(
|
|
classify(
|
|
&claim,
|
|
Some(&device_with(Some("v4l2"), Some("snd_hda_intel")))
|
|
),
|
|
Classification::NotSessionDevice,
|
|
"a non-ALSA api under an ALSA factory must not corroborate"
|
|
);
|
|
|
|
// The two sides disagreeing fails closed for the same reason.
|
|
let disagreeing = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some("bluez5".to_string()),
|
|
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
};
|
|
assert_eq!(
|
|
classify(&disagreeing, Some(&alsa_device())),
|
|
Classification::NotSessionDevice,
|
|
"node and Device naming different APIs must fail closed"
|
|
);
|
|
|
|
// An empty value is not a value.
|
|
let empty = DeviceClaim {
|
|
device_id: Some(gid(7)),
|
|
device_api: Some(String::new()),
|
|
factory_name: Some("api.alsa.pcm.sink".to_string()),
|
|
alsa_driver_name: Some("snd_hda_intel".to_string()),
|
|
};
|
|
assert_eq!(
|
|
classify(&empty, Some(&device_with(None, Some("snd_hda_intel")))),
|
|
Classification::NotSessionDevice
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_allowlist_is_exact_not_substring() {
|
|
// A factory that merely *contains* an allowlisted name must not pass.
|
|
let claim = hw_claim(7, "alsa", "api.alsa.pcm.sink.evil");
|
|
assert_eq!(
|
|
classify(&claim, Some(&alsa_device())),
|
|
Classification::NotSessionDevice
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// pulse_pid — the six-case derivation matrix
|
|
// ==========================================================================
|
|
|
|
fn clients_with(pids: &[Option<u32>]) -> Vec<ClientSnapshot> {
|
|
pids.iter()
|
|
.enumerate()
|
|
.map(|(i, &sec_pid)| ClientSnapshot {
|
|
serial: ser(1000 + i as u64),
|
|
id: gid(200 + i as u32),
|
|
sec_pid,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A `comm` probe of `pipewire-pulse` identifies the daemon regardless of how
|
|
/// many Clients it holds.
|
|
#[test]
|
|
fn pid_resolves_on_comm_not_on_repetition() {
|
|
let clients = clients_with(&[Some(4137), Some(4137), Some(9001)]);
|
|
let pulse = pulse_pid::derive(&clients, |pid| {
|
|
Some(
|
|
if pid == 4137 {
|
|
"pipewire-pulse"
|
|
} else {
|
|
"firefox"
|
|
}
|
|
.to_string(),
|
|
)
|
|
});
|
|
assert_eq!(pulse, Some(4137));
|
|
}
|
|
|
|
/// 🔴 **The round-10 regression, measured on this host and caught by the §5.1
|
|
/// row-1 matrix run.** WirePlumber holds two Clients (`WirePlumber` and
|
|
/// `WirePlumber [export]`) sharing one `sec_pid`, so two values repeat. The old
|
|
/// stage 1 called that ambiguous and returned `None`, which switched key 4's
|
|
/// suppression off and fused every Pulse-emulated node into a single owner —
|
|
/// a machine-wide over-exclusion cascade, on a stock desktop, permanently.
|
|
#[test]
|
|
fn a_second_process_holding_two_clients_does_not_defeat_the_derivation() {
|
|
// 1747 = WirePlumber x2, 2528 = pipewire-pulse x2, plus a native app.
|
|
let clients = clients_with(&[Some(1747), Some(1747), Some(2528), Some(2528), Some(9001)]);
|
|
let pulse = pulse_pid::derive(&clients, |pid| {
|
|
Some(
|
|
match pid {
|
|
1747 => "wireplumber",
|
|
2528 => "pipewire-pulse",
|
|
_ => "firefox",
|
|
}
|
|
.to_string(),
|
|
)
|
|
});
|
|
assert_eq!(
|
|
pulse,
|
|
Some(2528),
|
|
"the WirePlumber pair must not make this ambiguous"
|
|
);
|
|
}
|
|
|
|
/// The other direction the old rule failed in: pipewire-pulse holding exactly
|
|
/// one Client (a session with one Pulse app) repeated nothing, so it was never
|
|
/// even a candidate — same cascade, opposite cause.
|
|
#[test]
|
|
fn a_daemon_holding_a_single_client_is_still_found() {
|
|
let clients = clients_with(&[Some(2528), Some(9001)]);
|
|
let pulse = pulse_pid::derive(&clients, |pid| {
|
|
Some(
|
|
if pid == 2528 {
|
|
"pipewire-pulse"
|
|
} else {
|
|
"kwin_wayland"
|
|
}
|
|
.to_string(),
|
|
)
|
|
});
|
|
assert_eq!(pulse, Some(2528));
|
|
}
|
|
|
|
#[test]
|
|
fn pid_candidates_are_every_distinct_sec_pid() {
|
|
let clients = clients_with(&[Some(4137), Some(4137), Some(9001), None]);
|
|
assert_eq!(
|
|
pulse_pid::candidates(&clients),
|
|
[4137, 9001].into_iter().collect()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_missing_property_leaves_nothing_to_probe() {
|
|
let clients = clients_with(&[None, None, None]);
|
|
assert!(pulse_pid::candidates(&clients).is_empty());
|
|
assert_eq!(pulse_pid::derive(&clients, |_| None), None);
|
|
}
|
|
|
|
/// No Client's `comm` is pipewire-pulse's: nothing to suppress that we can
|
|
/// prove, so `None` — and key 4 stays coarse rather than wrong.
|
|
#[test]
|
|
fn pid_resolve_no_match_is_none() {
|
|
let clients = clients_with(&[Some(4137), Some(9001)]);
|
|
assert_eq!(
|
|
pulse_pid::derive(&clients, |_| Some("firefox".to_string())),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// Two live pipewire-pulse daemons: a single `Option<u32>` cannot suppress
|
|
/// both, so fail closed to over-exclusion rather than pick one and leak the
|
|
/// other's fusion.
|
|
#[test]
|
|
fn pid_resolve_two_daemons_is_none() {
|
|
let clients = clients_with(&[Some(4137), Some(9001)]);
|
|
assert_eq!(
|
|
pulse_pid::derive(&clients, |_| Some("pipewire-pulse".to_string())),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_validate_matches_pulse_comm() {
|
|
assert_eq!(
|
|
pulse_pid::validate(4137, Some("pipewire-pulse")),
|
|
Some(4137)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_validate_proc_missing_is_none() {
|
|
assert_eq!(pulse_pid::validate(4137, None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_validate_comm_mismatch_is_none() {
|
|
assert_eq!(pulse_pid::validate(4137, Some("firefox")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_validate_reuse_named_other_process_is_none() {
|
|
// PID reuse: the repeated sec_pid is now some other process entirely.
|
|
assert_eq!(pulse_pid::validate(4137, Some("systemd")), None);
|
|
// A prefix match must not count either.
|
|
assert_eq!(pulse_pid::validate(4137, Some("pipewire-pulse-x")), None);
|
|
assert_eq!(pulse_pid::validate(4137, Some("pipewire")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn pid_derive_end_to_end_valid() {
|
|
let clients = clients_with(&[Some(4137), Some(4137)]);
|
|
assert_eq!(
|
|
pulse_pid::derive(&clients, |_| Some("pipewire-pulse".to_string())),
|
|
Some(4137)
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — pulse pid through project()
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_pulse_pid_valid_through_projection() {
|
|
let mut m = model();
|
|
m.apply(client(1, 200, Some(4137)));
|
|
m.apply(client(2, 201, Some(4137)));
|
|
m.apply(client(3, 202, Some(9001)));
|
|
assert_eq!(m.pulse_pid_candidates(), [4137, 9001].into_iter().collect());
|
|
m.apply(RegEvent::ProcCommProbed {
|
|
pid: 4137,
|
|
comm: Some("pipewire-pulse".to_string()),
|
|
});
|
|
assert_eq!(m.project().pipewire_pulse_pid, Some(4137));
|
|
}
|
|
|
|
#[test]
|
|
fn model_pulse_pid_none_until_probed() {
|
|
let mut m = model();
|
|
m.apply(client(1, 200, Some(4137)));
|
|
m.apply(client(2, 201, Some(4137)));
|
|
// candidate exists, but no /proc confirmation yet ⇒ fail closed.
|
|
assert_eq!(m.project().pipewire_pulse_pid, None);
|
|
}
|
|
|
|
/// Probed `comm`s are dropped once no Client presents the PID any more.
|
|
///
|
|
/// Two reasons, and the second is the load-bearing one: the map is bounded by
|
|
/// the live Client count in a process that runs for hours, **and** a PID that
|
|
/// leaves and returns is re-probed rather than answered from the `comm` of
|
|
/// whoever held that number before. Pruning cannot change the projection —
|
|
/// `pulse_pid` only reads PIDs in the current candidate set — which is why it
|
|
/// is not an `apply` arm and must not publish.
|
|
#[test]
|
|
fn a_departed_pid_does_not_keep_its_probed_comm() {
|
|
let mut m = model();
|
|
m.apply(client(1, 200, Some(4137)));
|
|
m.apply(RegEvent::ProcCommProbed {
|
|
pid: 4137,
|
|
comm: Some("pipewire-pulse".to_string()),
|
|
});
|
|
assert_eq!(m.project().pipewire_pulse_pid, Some(4137));
|
|
|
|
// The daemon's Client goes away; the adapter prunes to the live set.
|
|
let live = m.pulse_pid_candidates();
|
|
assert!(live.contains(&4137));
|
|
m.retain_probed_comms(&std::collections::BTreeSet::new());
|
|
|
|
// A *different* process now holds 4137 and opens a Client. Without the
|
|
// prune this would answer from the stale `comm` and suppress a real app's
|
|
// owner key.
|
|
m.apply(client(2, 201, Some(4137)));
|
|
assert_eq!(
|
|
m.project().pipewire_pulse_pid,
|
|
None,
|
|
"the stale comm must not survive its PID leaving the graph"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_pulse_pid_none_on_comm_mismatch() {
|
|
let mut m = model();
|
|
m.apply(client(1, 200, Some(4137)));
|
|
m.apply(client(2, 201, Some(4137)));
|
|
m.apply(RegEvent::ProcCommProbed {
|
|
pid: 4137,
|
|
comm: Some("firefox".to_string()),
|
|
});
|
|
assert_eq!(m.project().pipewire_pulse_pid, None);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — add / remove of all four object types
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_adds_all_four_object_types() {
|
|
let mut m = model();
|
|
add_stream_out(&mut m, 100, 50);
|
|
m.apply(port(101, 60, 50, PortDirection::Out));
|
|
m.apply(client(102, 70, Some(4137)));
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(103),
|
|
id: gid(80),
|
|
endpoints: Some(endpoints(50, 55)),
|
|
});
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.nodes().count(), 1);
|
|
assert_eq!(snap.ports().count(), 1);
|
|
assert_eq!(snap.clients().count(), 1);
|
|
assert_eq!(snap.links().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn model_removes_all_four_object_types() {
|
|
let mut m = model();
|
|
add_stream_out(&mut m, 100, 50);
|
|
m.apply(port(101, 60, 50, PortDirection::Out));
|
|
m.apply(client(102, 70, Some(4137)));
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(103),
|
|
id: gid(80),
|
|
endpoints: Some(endpoints(50, 55)),
|
|
});
|
|
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
m.apply(RegEvent::Removed { id: gid(60) });
|
|
m.apply(RegEvent::Removed { id: gid(70) });
|
|
m.apply(RegEvent::Removed { id: gid(80) });
|
|
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.nodes().count(), 0);
|
|
assert_eq!(snap.ports().count(), 0);
|
|
assert_eq!(snap.clients().count(), 0);
|
|
assert_eq!(snap.links().count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn model_remove_of_unknown_id_is_harmless() {
|
|
let mut m = model();
|
|
add_stream_out(&mut m, 100, 50);
|
|
assert_eq!(
|
|
m.apply(RegEvent::Removed { id: gid(999) }),
|
|
Outcome::Suppressed,
|
|
"a removal that changes nothing is not a projection event"
|
|
);
|
|
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — recycled global id, oldest generation first (fail closed)
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_recycled_id_is_ambiguous_until_removal_accounted() {
|
|
let mut m = model();
|
|
// A missed removal: two live nodes claim id 50 (serials 100 then 200).
|
|
add_stream_out(&mut m, 100, 50);
|
|
add_stream_out(&mut m, 200, 50);
|
|
|
|
// The snapshot fails closed: id 50 is ambiguous.
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.node_by_id(gid(50)), Some(IdLookup::Ambiguous));
|
|
assert_eq!(snap.nodes().count(), 2);
|
|
|
|
// One removal accounts for the OLDEST generation (serial 100); the newer
|
|
// node survives and the id is unambiguous again.
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.node_by_id(gid(50)), Some(IdLookup::Unique(ser(200))));
|
|
assert!(snap.node(ser(200)).is_some());
|
|
assert!(snap.node(ser(100)).is_none());
|
|
}
|
|
|
|
// ==========================================================================
|
|
// phase 3r gate row 4 — recycled Node id under churn (the bind is now on the
|
|
// hot path, so a generation mix-up costs a whole node's ownership)
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_recycled_node_id_keeps_generations_distinct() {
|
|
let mut m = model();
|
|
// Generation 1 on id 50, fully bound and tagged.
|
|
let owned = NodeProps {
|
|
peerspeak_owned: true,
|
|
..Default::default()
|
|
};
|
|
add_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
NodeObservation {
|
|
name: Some("gen-1".to_string()),
|
|
role: MediaRole::Sink,
|
|
props: owned,
|
|
device_claim: no_device(),
|
|
},
|
|
);
|
|
// Generation 2 recycles the id before generation 1's removal is accounted
|
|
// (the missed-removal window), and is NOT peerspeak's.
|
|
add_node(
|
|
&mut m,
|
|
200,
|
|
50,
|
|
obs("gen-2", MediaRole::StreamOutput, no_device()),
|
|
);
|
|
|
|
let snap = m.project().snapshot;
|
|
assert!(
|
|
snap.node(ser(100)).unwrap().props.peerspeak_owned,
|
|
"generation 1 keeps its own props"
|
|
);
|
|
assert!(
|
|
!snap.node(ser(200)).unwrap().props.peerspeak_owned,
|
|
"generation 2 must not inherit generation 1's ownership"
|
|
);
|
|
|
|
// The removal retires the oldest generation only.
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
let snap = m.project().snapshot;
|
|
assert!(snap.node(ser(100)).is_none());
|
|
let survivor = snap.node(ser(200)).expect("generation 2 survives");
|
|
assert_eq!(survivor.name.as_deref(), Some("gen-2"));
|
|
assert!(!survivor.props.peerspeak_owned);
|
|
}
|
|
|
|
#[test]
|
|
fn model_repeated_node_churn_on_one_id_leaves_no_residue() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::ServerSynced);
|
|
for generation in 0..8u64 {
|
|
let serial = 500 + generation;
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(serial),
|
|
id: gid(50),
|
|
});
|
|
// graph_ready drops while the bind is outstanding, every cycle.
|
|
assert!(!m.graph_ready(), "unbound node holds readiness");
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(serial),
|
|
observation: obs("churn", MediaRole::StreamOutput, no_device()),
|
|
});
|
|
assert!(m.graph_ready(), "bound node releases readiness");
|
|
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
}
|
|
// Nothing accumulated: a stale generation would show up as a phantom node
|
|
// or a stuck obligation.
|
|
assert!(m.graph_ready());
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — Link endpoint resolution (bind fallback path)
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_link_with_endpoints_appears_immediately() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(103),
|
|
id: gid(80),
|
|
endpoints: Some(endpoints(50, 55)),
|
|
});
|
|
assert_eq!(m.project().snapshot.links().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn model_link_without_endpoints_is_withheld_until_resolved() {
|
|
let mut m = model();
|
|
// The correctness path: the global carried no endpoint props.
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(103),
|
|
id: gid(80),
|
|
endpoints: None,
|
|
});
|
|
// Not in the snapshot yet, and it blocks readiness.
|
|
assert_eq!(m.project().snapshot.links().count(), 0);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(!m.graph_ready(), "pending link must hold readiness");
|
|
|
|
// The bind fallback resolves it.
|
|
m.apply(RegEvent::LinkEndpointsResolved {
|
|
serial: ser(103),
|
|
endpoints: endpoints(50, 55),
|
|
});
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.links().count(), 1);
|
|
let link = snap.links().next().unwrap();
|
|
assert_eq!(link.output_node, gid(50));
|
|
assert_eq!(link.input_node, gid(55));
|
|
assert!(
|
|
m.graph_ready(),
|
|
"resolving the last obligation completes readiness"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_stale_link_resolution_is_ignored() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(103),
|
|
id: gid(80),
|
|
endpoints: None,
|
|
});
|
|
// Link removed before the bind returned.
|
|
m.apply(RegEvent::Removed { id: gid(80) });
|
|
// A late resolution for the gone link must not resurrect it.
|
|
assert_eq!(
|
|
m.apply(RegEvent::LinkEndpointsResolved {
|
|
serial: ser(103),
|
|
endpoints: endpoints(50, 55),
|
|
}),
|
|
Outcome::Suppressed
|
|
);
|
|
assert_eq!(m.project().snapshot.links().count(), 0);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(
|
|
m.graph_ready(),
|
|
"the obligation cleared when the link was removed"
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — readiness epoch
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_readiness_waits_for_sync() {
|
|
let mut m = model();
|
|
add_stream_out(&mut m, 100, 50);
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
assert!(!m.project().graph_ready);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
assert!(m.project().graph_ready);
|
|
}
|
|
|
|
#[test]
|
|
fn model_readiness_does_not_release_with_obligation_outstanding() {
|
|
let mut m = model();
|
|
// A node withheld on an unresolved device is an outstanding obligation.
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
m.apply(RegEvent::ServerSynced);
|
|
// Synced, but the withheld node keeps the epoch shut.
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
assert!(!m.graph_ready());
|
|
|
|
// Announcing the Device is NOT enough — its own bind must land first
|
|
// (v3.5 §6.7 decision 4).
|
|
m.apply(RegEvent::DeviceAdded {
|
|
serial: ser(4200),
|
|
id: gid(42),
|
|
});
|
|
assert_eq!(
|
|
m.readiness(),
|
|
Readiness::Waiting,
|
|
"an unbound Device resolves nothing"
|
|
);
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: alsa_device(),
|
|
});
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_readiness_times_out_fail_closed() {
|
|
let mut m = model();
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
|
|
// The device never resolves; the deadline passes.
|
|
m.apply(RegEvent::Tick { now: 5000 });
|
|
assert_eq!(m.readiness(), Readiness::TimedOut);
|
|
assert!(!m.graph_ready(), "timeout fails closed");
|
|
|
|
// Finding 6: TimedOut must be sticky. Resolving the obligation, syncing
|
|
// again, and ticking further must NOT flip it to Complete — a timed-out
|
|
// observer stays fail-closed for its lifetime.
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
m.apply(RegEvent::ServerSynced);
|
|
m.apply(RegEvent::Tick { now: 6000 });
|
|
assert_eq!(m.readiness(), Readiness::TimedOut, "timeout is sticky");
|
|
assert!(!m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_tick_before_deadline_does_not_time_out() {
|
|
let mut m = model();
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
m.apply(RegEvent::Tick { now: 4999 });
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
}
|
|
|
|
#[test]
|
|
fn model_complete_epoch_is_sticky_but_graph_ready_is_dynamic() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
assert!(m.graph_ready());
|
|
// A node withheld AFTER completion does not revert the sticky EPOCH...
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
assert_eq!(m.readiness(), Readiness::Complete, "epoch stays sticky");
|
|
// ...but graph_ready DOES drop while the obligation is outstanding
|
|
// (Codex finding 1: unresolved ancestry ⇒ fail closed, even post-epoch).
|
|
assert!(
|
|
!m.graph_ready(),
|
|
"an outstanding obligation makes decisions unsafe"
|
|
);
|
|
// A late timeout Tick is inert once Complete.
|
|
m.apply(RegEvent::Tick { now: 100_000 });
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
// Resolving the obligation restores graph_ready.
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_pending_link_drops_graph_ready_after_completion() {
|
|
// Codex finding 1, the leak that mattered: a real Link added post-epoch
|
|
// whose endpoints are still binding is an INVISIBLE edge (absent from the
|
|
// snapshot, not dangling). graph_ready must go false until it resolves,
|
|
// or a candidate can be reported eligible while tainted ancestry it cannot
|
|
// see already carries call audio.
|
|
let mut m = model();
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(m.graph_ready());
|
|
|
|
m.apply(RegEvent::LinkAdded {
|
|
serial: ser(300),
|
|
id: gid(90),
|
|
endpoints: None,
|
|
});
|
|
assert!(!m.graph_ready(), "an unresolved link must gate decisions");
|
|
// The snapshot genuinely omits it, which is exactly why graph_ready must
|
|
// compensate.
|
|
assert_eq!(m.project().snapshot.links().count(), 0);
|
|
assert!(!m.project().graph_ready);
|
|
|
|
m.apply(RegEvent::LinkEndpointsResolved {
|
|
serial: ser(300),
|
|
endpoints: endpoints(50, 55),
|
|
});
|
|
assert!(m.graph_ready(), "resolved ⇒ decisions safe again");
|
|
assert_eq!(m.project().snapshot.links().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn model_withheld_node_removed_clears_obligation() {
|
|
let mut m = model();
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
// The withheld node disappears before its device ever showed up.
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// phase 3r gate row 3 — readiness with node binds
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_unbound_node_is_withheld_and_holds_readiness() {
|
|
// The direct consequence of v3.5 §6.7: a node whose properties have not
|
|
// arrived is an invisible VERTEX. It must not appear in the snapshot with
|
|
// default (untainted, unowned) properties, and no projection may report
|
|
// graph_ready while it is outstanding.
|
|
let mut m = model();
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0, "withheld entirely");
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
assert!(!m.project().graph_ready);
|
|
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: obs("late", MediaRole::StreamOutput, no_device()),
|
|
});
|
|
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
|
assert!(m.project().graph_ready);
|
|
}
|
|
|
|
#[test]
|
|
fn model_unbound_node_after_completion_drops_graph_ready() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(m.graph_ready());
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
assert_eq!(m.readiness(), Readiness::Complete, "epoch stays sticky");
|
|
assert!(!m.graph_ready(), "an unbound node is unresolved ancestry");
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: obs("bound", MediaRole::StreamOutput, no_device()),
|
|
});
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_node_bind_that_never_resolves_times_out_sticky() {
|
|
// The accepted limitation, pinned: an unresolvable bind takes the whole
|
|
// graph down fail-closed rather than quarantining one node.
|
|
let mut m = model();
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
m.apply(RegEvent::ServerSynced);
|
|
m.apply(RegEvent::Tick { now: 5000 });
|
|
assert_eq!(m.readiness(), Readiness::TimedOut);
|
|
assert!(!m.graph_ready());
|
|
// Even a late arrival does not un-stick it.
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: obs("very-late", MediaRole::StreamOutput, no_device()),
|
|
});
|
|
assert_eq!(m.readiness(), Readiness::TimedOut, "timeout is sticky");
|
|
assert!(!m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_unbound_node_removed_clears_its_obligation() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert_eq!(m.readiness(), Readiness::Waiting);
|
|
// A node that vanishes before its bind returns owes nothing.
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
assert_eq!(m.readiness(), Readiness::Complete);
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_node_info_for_an_unknown_node_is_ignored() {
|
|
// A bind callback that lands after the node's removal must not resurrect
|
|
// it — there is no id index behind it any more.
|
|
let mut m = model();
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
m.apply(RegEvent::Removed { id: gid(50) });
|
|
assert_eq!(
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: obs("ghost", MediaRole::StreamOutput, no_device()),
|
|
}),
|
|
Outcome::Suppressed
|
|
);
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
// ==========================================================================
|
|
// phase 3r gate row 2 — lifetime property tracking and the suppression rule
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_node_props_update_is_applied() {
|
|
// `node.link-group` set after node creation — the case a one-shot read
|
|
// would miss forever (v3.5 §6.7 decision 2).
|
|
let mut m = model();
|
|
add_stream_out(&mut m, 100, 50);
|
|
assert_eq!(
|
|
m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.link_group,
|
|
None
|
|
);
|
|
|
|
let grouped = NodeProps {
|
|
link_group: Some("loopback-2528-13".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: NodeObservation {
|
|
name: Some("stream-50".to_string()),
|
|
role: MediaRole::StreamOutput,
|
|
props: grouped,
|
|
device_claim: no_device(),
|
|
},
|
|
}),
|
|
Outcome::Applied
|
|
);
|
|
assert_eq!(
|
|
m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.link_group
|
|
.as_deref(),
|
|
Some("loopback-2528-13")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_identical_node_info_is_suppressed() {
|
|
// PipeWire re-emits `info` for state changes constantly. Same props ⇒ the
|
|
// projection is provably identical ⇒ no event. Anything looser here would
|
|
// break phase 4's no-coalescing contract; anything stricter inflates the
|
|
// O5 event rate with non-events.
|
|
let mut m = model();
|
|
let observation = obs("stream-50", MediaRole::StreamOutput, no_device());
|
|
m.apply(RegEvent::NodeAdded {
|
|
serial: ser(100),
|
|
id: gid(50),
|
|
});
|
|
assert_eq!(
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: observation.clone(),
|
|
}),
|
|
Outcome::Applied,
|
|
"the first info resolves the node"
|
|
);
|
|
let before = m.project();
|
|
assert_eq!(
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation,
|
|
}),
|
|
Outcome::Suppressed
|
|
);
|
|
assert_eq!(m.project(), before, "suppression means literally identical");
|
|
}
|
|
|
|
#[test]
|
|
fn model_node_props_update_can_flip_session_device() {
|
|
// The classification is recomputed from current inputs, not cached at
|
|
// admission: a node that starts as a null sink on a card and is later
|
|
// re-reported with a hardware-PCM factory flips.
|
|
let mut m = model();
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "support.null-audio-sink"),
|
|
);
|
|
assert!(
|
|
!m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.session_device
|
|
);
|
|
|
|
m.apply(RegEvent::NodeInfo {
|
|
serial: ser(100),
|
|
observation: obs(
|
|
"dev-node-50",
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
),
|
|
});
|
|
assert!(
|
|
m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.session_device,
|
|
"re-classified from the updated props"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_device_props_update_reclassifies_its_nodes() {
|
|
// The Device side of the same rule, and the phase-3 owed fix in motion:
|
|
// the Device's first `info` carries no driver name (fails closed), a later
|
|
// one does (the card is recognised).
|
|
let mut m = model();
|
|
m.apply(RegEvent::DeviceAdded {
|
|
serial: ser(4200),
|
|
id: gid(42),
|
|
});
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: device_with(Some("alsa"), None),
|
|
});
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
node_only_claim(42, "api.alsa.pcm.sink"),
|
|
);
|
|
assert!(
|
|
!m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.session_device,
|
|
"no driver anywhere ⇒ fail closed"
|
|
);
|
|
|
|
assert_eq!(
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: alsa_device(),
|
|
}),
|
|
Outcome::Applied
|
|
);
|
|
assert!(
|
|
m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.session_device,
|
|
"the Device's driver name is authoritative"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_identical_device_info_is_suppressed() {
|
|
let mut m = model();
|
|
m.apply(RegEvent::DeviceAdded {
|
|
serial: ser(4200),
|
|
id: gid(42),
|
|
});
|
|
assert_eq!(
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: alsa_device(),
|
|
}),
|
|
Outcome::Applied
|
|
);
|
|
let before = m.project();
|
|
assert_eq!(
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: alsa_device(),
|
|
}),
|
|
Outcome::Suppressed
|
|
);
|
|
assert_eq!(m.project(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn model_device_info_for_an_unknown_device_is_ignored() {
|
|
let mut m = model();
|
|
assert_eq!(
|
|
m.apply(RegEvent::DeviceInfo {
|
|
serial: ser(4200),
|
|
props: alsa_device(),
|
|
}),
|
|
Outcome::Suppressed
|
|
);
|
|
// And it did not resolve anything: a node claiming that id stays withheld.
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// model — device withholding & session_device flag
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn model_device_first_admits_node_immediately() {
|
|
let mut m = model();
|
|
// Device enumerated and bound before the node that references it.
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
let snap = m.project().snapshot;
|
|
let node = snap.node(ser(100)).expect("node admitted immediately");
|
|
assert!(
|
|
node.props.session_device,
|
|
"hardware sink is a session device"
|
|
);
|
|
// No obligation ⇒ a sync completes readiness.
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(m.graph_ready());
|
|
}
|
|
|
|
#[test]
|
|
fn model_withheld_node_admitted_with_correct_session_device() {
|
|
let mut m = model();
|
|
// A real hardware sink and a card-associated filter share client/device
|
|
// ancestry but classify differently once the device resolves.
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
add_device_node(
|
|
&mut m,
|
|
200,
|
|
51,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "support.null-audio-sink"),
|
|
);
|
|
// Both withheld until the device resolves.
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(
|
|
snap.nodes().count(),
|
|
2,
|
|
"both admitted once the device resolved"
|
|
);
|
|
assert!(
|
|
snap.node(ser(100)).unwrap().props.session_device,
|
|
"the real hardware sink is a session device"
|
|
);
|
|
assert!(
|
|
!snap.node(ser(200)).unwrap().props.session_device,
|
|
"the null sink sharing the same device is not"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_withheld_filter_admitted_as_not_session_device() {
|
|
let mut m = model();
|
|
add_device_node(
|
|
&mut m,
|
|
200,
|
|
51,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "support.null-audio-sink"),
|
|
);
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
let snap = m.project().snapshot;
|
|
assert!(
|
|
!snap.node(ser(200)).unwrap().props.session_device,
|
|
"a null sink on a card is not a session device"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_removed_device_withholds_its_nodes_again() {
|
|
// A Device that goes away takes its resolution with it: the node reverts
|
|
// to withheld (fail closed) rather than keeping a stale classification.
|
|
let mut m = model();
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
m.apply(RegEvent::ServerSynced);
|
|
assert!(m.graph_ready());
|
|
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
|
|
|
m.apply(RegEvent::Removed { id: gid(42) });
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0, "withheld again");
|
|
assert!(!m.graph_ready(), "and it is an obligation again");
|
|
}
|
|
|
|
#[test]
|
|
fn model_device_id_shared_with_another_object_type_withholds() {
|
|
// Codex phase-3r review, finding 2 (certain). The ambiguity test has to
|
|
// be "exactly one live global holds this id", not "exactly one live
|
|
// *Device*": a Port recycling the id is the same missed-removal
|
|
// condition, and answering from the older Device leaves the claiming
|
|
// node wearing a stale `session_device = true` — which strips its owner
|
|
// keys and backstop, the difference between over-exclusion and echo.
|
|
let mut m = model();
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
assert!(
|
|
m.project()
|
|
.snapshot
|
|
.node(ser(100))
|
|
.unwrap()
|
|
.props
|
|
.session_device
|
|
);
|
|
|
|
// A Port appears on the recycled id 42.
|
|
m.apply(port(4300, 42, 50, PortDirection::In));
|
|
assert_eq!(
|
|
m.project().snapshot.nodes().count(),
|
|
0,
|
|
"a contested device id resolves nothing"
|
|
);
|
|
assert!(!m.graph_ready(), "and it is an outstanding obligation");
|
|
|
|
// Accounting for the Device's removal leaves the Port holding the id
|
|
// alone — still not a Device, so the node stays withheld.
|
|
m.apply(RegEvent::Removed { id: gid(42) });
|
|
assert_eq!(m.project().snapshot.nodes().count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn model_ambiguous_device_id_withholds_its_nodes() {
|
|
// Two live Devices on one recycled id: there is no way to know whose
|
|
// properties a claiming node should be classified against, so it is
|
|
// withheld (v3.4 §6.1.3, fail closed) rather than guessing a generation.
|
|
let mut m = model();
|
|
add_device(&mut m, 4200, 42, alsa_device());
|
|
add_device_node(
|
|
&mut m,
|
|
100,
|
|
50,
|
|
MediaRole::Sink,
|
|
hw_claim(42, "alsa", "api.alsa.pcm.sink"),
|
|
);
|
|
assert_eq!(m.project().snapshot.nodes().count(), 1);
|
|
|
|
// A second Device recycles id 42 before the first removal is accounted.
|
|
add_device(
|
|
&mut m,
|
|
4201,
|
|
42,
|
|
device_with(Some("alsa"), Some("snd_aloop")),
|
|
);
|
|
assert_eq!(
|
|
m.project().snapshot.nodes().count(),
|
|
0,
|
|
"ambiguous device id ⇒ withheld"
|
|
);
|
|
|
|
// Accounting for the older generation makes the claim unambiguous again —
|
|
// and it now resolves against the *surviving* Device, which is a loopback.
|
|
m.apply(RegEvent::Removed { id: gid(42) });
|
|
let snap = m.project().snapshot;
|
|
assert_eq!(snap.nodes().count(), 1);
|
|
assert!(
|
|
!snap.node(ser(100)).unwrap().props.session_device,
|
|
"resolved against the surviving generation, not the dead one"
|
|
);
|
|
}
|