//! 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) -> 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]) -> Vec { 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` 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" ); }