phase 1: tag every audio node peerspeak owns, on both carriers
Zero behaviour change. This is what makes the screenshare exclusion engine able to see us at all (plan §5.1, impl plan §3): pixelpass must refuse to fan out our own playback, and until now it had no way to recognise it. Two carriers, matched by pixelpass as a union — `peerspeak.owned=1` and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added the second after the phase-5 audit found a node property is invisible to the PipeWire registry `global` event and recoverable only by binding the node; the prefix is announced directly. A union is also the fail-closed direction: a missed tag leaks call audio into a share, a spurious one only over-excludes. Three tagging sites, all three verified live on this host: - native call playback → props on the stream dict - screenshare mpv/VLC → PULSE_PROP + PIPEWIRE_PROPS on the child - notification chimes → same, on pw-play/paplay The literals are a cross-repo wire contract, so they appear once here as named constants and are pinned in a fixture committed byte-identical in both repos (tests/fixtures/ownership-tag-contract.txt). The contract test is black-box: it builds a real child `Command` and reads back the environment it would carry, rather than testing our own formatter. Three live `#[ignore]`d exit-gate tests drive the real call sites and poll `pw-dump` for the resulting node — the plan requires the tag be shown landing on a live node, not just in the env. All three mutation-verified (drop either carrier, or the role, and the matching gate fails). Measured while verifying: mpv, VLC, pw-play and paplay all honour `node.name` from those env vars. The native stream set neither `application.name` nor a description, so a mixer fell back to `node.name` — which the tag turns into an internal identifier. Added an explicit `node.description = "PeerSpeak"` there, which keeps the plan's rule (the prefix must not reach `node.description`) while preserving its intent: mixers stay readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,10 @@ pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||||
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||||
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||||
pub mod ownership;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Ownership tagging for every audio node peerspeak puts on the graph.
|
||||
//!
|
||||
//! Zero behaviour change on its own. Its whole purpose is to make peerspeak's
|
||||
//! own playback *identifiable from outside the process*, so that pixelpass's
|
||||
//! screenshare audio exclusion engine can refuse to fan our own call audio
|
||||
//! back into the stream the far end is already hearing (the echo it exists to
|
||||
//! prevent). See `docs/screenshare-audio-exclusion-plan.md` §5.1 and
|
||||
//! `docs/screenshare-audio-exclusion-impl-plan.md` §3.
|
||||
//!
|
||||
//! # The wire contract
|
||||
//!
|
||||
//! The literals below are a **cross-repo contract**, not local naming.
|
||||
//! pixelpass matches on them and does not depend on this crate (nor this
|
||||
//! crate on it) — the coupling is the PipeWire graph itself. The values are
|
||||
//! committed in both repos as `tests/fixtures/ownership-tag-contract.txt`,
|
||||
//! and [`contract_matches_fixture`](tests::contract_matches_fixture) asserts
|
||||
//! these constants against that file. pixelpass's matching constants are in
|
||||
//! `src/host/taint/mod.rs` (`PEERSPEAK_OWNED_PROP`,
|
||||
//! `PEERSPEAK_OWNED_NODE_PREFIX`), asserted against its own copy of the same
|
||||
//! fixture.
|
||||
//!
|
||||
//! # Why two carriers
|
||||
//!
|
||||
//! A node is owned if **either** carrier matches — a union, which is the
|
||||
//! fail-closed direction (a missed tag means our audio gets shared; a
|
||||
//! spurious tag only over-excludes). Round 8 of the design added the second
|
||||
//! carrier after the phase-5 audit found that a node *property* is invisible
|
||||
//! to the PipeWire registry's `global` event and recoverable only by binding
|
||||
//! the node, while `node.name` is announced directly. The primary taint root
|
||||
//! should not rest on a single observation mechanism.
|
||||
//!
|
||||
//! `node.description` is deliberately left alone, so mixers still show "mpv"
|
||||
//! rather than an internal identifier.
|
||||
//!
|
||||
//! # Known limitation (accepted for v1)
|
||||
//!
|
||||
//! The child environment is inherited by *grandchildren*, so a player that
|
||||
//! spawns its own audio helper tags it too. That over-excludes rather than
|
||||
//! leaks, and is the accepted v1 limitation recorded in plan §5.1.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Carrier 1, key. Set on every peerspeak-owned node. Read by pixelpass only
|
||||
/// after binding the node — see the module docs.
|
||||
pub const OWNED_PROP_KEY: &str = "peerspeak.owned";
|
||||
|
||||
/// Carrier 1, value. pixelpass treats anything other than `false`/`0` as
|
||||
/// truthy (fail-closed), but we always emit exactly this.
|
||||
pub const OWNED_PROP_VALUE: &str = "1";
|
||||
|
||||
/// Carrier 2. The `node.name` prefix pixelpass matches; the remainder of the
|
||||
/// name is diagnostic only. Format: `peerspeak_owned_<role>_<pid>`.
|
||||
pub const OWNED_NODE_NAME_PREFIX: &str = "peerspeak_owned_";
|
||||
|
||||
/// The environment variable through which a PulseAudio-API client (mpv/VLC on
|
||||
/// `--ao=pulse`, `paplay`) receives extra stream properties.
|
||||
pub const PULSE_PROP_ENV: &str = "PULSE_PROP";
|
||||
|
||||
/// The environment variable through which a PipeWire-native client
|
||||
/// (`pw-play`, mpv on `--ao=pipewire`) receives extra stream properties.
|
||||
pub const PIPEWIRE_PROPS_ENV: &str = "PIPEWIRE_PROPS";
|
||||
|
||||
/// Role of the native call-playback stream — the node carrying the far end's
|
||||
/// voice.
|
||||
pub const NATIVE_PLAYBACK_ROLE: &str = "call";
|
||||
|
||||
/// Role of a notification-sound player (`pw-play`/`paplay`/`aplay`).
|
||||
pub const NOTIFICATION_ROLE: &str = "notify";
|
||||
|
||||
/// Build the `node.name` for a node this process owns.
|
||||
///
|
||||
/// The pid is **peerspeak's own**, not the child's: for a spawned player we
|
||||
/// set the name before the fork, so the child's pid does not exist yet — and
|
||||
/// the owning process is the more useful thing to record anyway.
|
||||
pub fn owned_node_name(role: &str) -> String {
|
||||
format!(
|
||||
"{OWNED_NODE_NAME_PREFIX}{}_{}",
|
||||
sanitize_role(role),
|
||||
std::process::id()
|
||||
)
|
||||
}
|
||||
|
||||
/// Keep a role to `[a-z0-9-]`, lowercased, so the generated `node.name` is a
|
||||
/// single shell-safe, whitespace-free token.
|
||||
///
|
||||
/// Not defensive theatre: [`PULSE_PROP_ENV`] is a space-separated
|
||||
/// `key=value` encoding, so a role containing a space would silently split
|
||||
/// into a second bogus property. Every current caller passes a literal, and
|
||||
/// this keeps that true no matter who calls next.
|
||||
fn sanitize_role(role: &str) -> String {
|
||||
let cleaned: String = role
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = cleaned.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
"child".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PULSE_PROP` value carrying both carriers.
|
||||
fn pulse_prop_value(node_name: &str) -> String {
|
||||
format!("{OWNED_PROP_KEY}={OWNED_PROP_VALUE} node.name={node_name}")
|
||||
}
|
||||
|
||||
/// The `PIPEWIRE_PROPS` value carrying both carriers.
|
||||
///
|
||||
/// SPA-JSON; keys are quoted because `peerspeak.owned` contains a dot.
|
||||
fn pipewire_props_value(node_name: &str) -> String {
|
||||
format!("{{ \"{OWNED_PROP_KEY}\" = \"{OWNED_PROP_VALUE}\", \"node.name\" = \"{node_name}\" }}")
|
||||
}
|
||||
|
||||
/// Tag a child process that will play audio, so its resulting node carries
|
||||
/// both ownership carriers.
|
||||
///
|
||||
/// Both environment variables are set unconditionally, because we cannot know
|
||||
/// from here which audio API the child will pick (mpv chooses at runtime, and
|
||||
/// VLC and `paplay` differ from `pw-play`). Setting the one the child ignores
|
||||
/// costs nothing.
|
||||
///
|
||||
/// Any inherited value of either variable is **replaced**, not appended: the
|
||||
/// tag is a correctness mechanism, and a half-merged property string that
|
||||
/// fails to parse would drop it silently.
|
||||
pub fn tag_child(command: &mut Command, role: &str) {
|
||||
let node_name = owned_node_name(role);
|
||||
command.env(PULSE_PROP_ENV, pulse_prop_value(&node_name));
|
||||
command.env(PIPEWIRE_PROPS_ENV, pipewire_props_value(&node_name));
|
||||
}
|
||||
|
||||
/// Live-test support for the phase-1 exit gate, shared by the two modules
|
||||
/// that spawn tagged players (`notify`, `screenshare`).
|
||||
///
|
||||
/// Lives here rather than in either test module because the gate is one
|
||||
/// property — "the carriers reach a live node" — asked of two call sites, and
|
||||
/// two copies of the poll would drift.
|
||||
#[cfg(all(test, not(windows)))]
|
||||
pub mod live_test {
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Poll `pw-dump` for a `Stream/Output/Audio` node whose `node.name`
|
||||
/// starts with `prefix`; returns its name and `peerspeak.owned` value.
|
||||
///
|
||||
/// Polls rather than sleeping once because node creation races the
|
||||
/// player process starting up: a fixed sleep either flakes or is slow.
|
||||
pub fn poll_for_owned_node(
|
||||
prefix: &str,
|
||||
timeout: Duration,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(found) = scan_once(prefix) {
|
||||
return Some(found);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn scan_once(prefix: &str) -> Option<(String, Option<String>)> {
|
||||
let out = Command::new("pw-dump").output().ok()?;
|
||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
for object in dump.as_array()? {
|
||||
let props = &object["info"]["props"];
|
||||
// `continue`, never `?`: most objects in a dump are not nodes and
|
||||
// have no `node.name` at all, and propagating that as `None`
|
||||
// would abandon the scan at the first one.
|
||||
let Some(name) = props["node.name"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
// PipeWire may hand the property back as a JSON string or a
|
||||
// number depending on how the client set it; both are the same
|
||||
// property, and reading only one would make the assertion
|
||||
// accidentally depend on the producer's encoding.
|
||||
let owned = props[super::OWNED_PROP_KEY]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| props[super::OWNED_PROP_KEY].as_i64().map(|n| n.to_string()));
|
||||
return Some((name.to_string(), owned));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A zero-filled 48 kHz stereo s16 WAV of `seconds` length — long enough
|
||||
/// for the poll to catch the node, silent so the gate never makes noise.
|
||||
pub fn silent_wav(seconds: u32) -> Vec<u8> {
|
||||
let data_len = 48_000 * seconds * 4;
|
||||
let mut wav = Vec::with_capacity(44 + data_len as usize);
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
wav.extend_from_slice(&2u16.to_le_bytes()); // stereo
|
||||
wav.extend_from_slice(&48_000u32.to_le_bytes());
|
||||
wav.extend_from_slice(&(48_000u32 * 4).to_le_bytes()); // byte rate
|
||||
wav.extend_from_slice(&4u16.to_le_bytes()); // block align
|
||||
wav.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&data_len.to_le_bytes());
|
||||
wav.resize(44 + data_len as usize, 0);
|
||||
wav
|
||||
}
|
||||
|
||||
/// The `node.name` prefix a node tagged by *this* process with `role`
|
||||
/// must carry. Narrower than [`super::OWNED_NODE_NAME_PREFIX`] on
|
||||
/// purpose: it pins the match to this test run, so a stray tagged node
|
||||
/// left by anything else cannot make a gate pass.
|
||||
pub fn expected_prefix(role: &str) -> String {
|
||||
format!(
|
||||
"{}{role}_{}",
|
||||
super::OWNED_NODE_NAME_PREFIX,
|
||||
std::process::id()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The contract file, byte-identical to pixelpass's copy.
|
||||
const FIXTURE: &str = include_str!("../../tests/fixtures/ownership-tag-contract.txt");
|
||||
|
||||
fn fixture() -> BTreeMap<String, String> {
|
||||
FIXTURE
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(|line| {
|
||||
let (key, value) = line
|
||||
.split_once('=')
|
||||
.unwrap_or_else(|| panic!("fixture line is not key=value: {line:?}"));
|
||||
(key.to_string(), value.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture_get(key: &str) -> String {
|
||||
fixture()
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Half of the cross-repo contract test: our constants are exactly what
|
||||
/// the shared fixture pins. pixelpass runs the mirror of this against its
|
||||
/// own byte-identical copy.
|
||||
#[test]
|
||||
fn contract_matches_fixture() {
|
||||
assert_eq!(OWNED_PROP_KEY, fixture_get("prop_key"));
|
||||
assert_eq!(OWNED_PROP_VALUE, fixture_get("prop_value"));
|
||||
assert_eq!(OWNED_NODE_NAME_PREFIX, fixture_get("node_name_prefix"));
|
||||
}
|
||||
|
||||
/// The fixture's documented format and example must agree with the
|
||||
/// generator, so a future reader can trust the example.
|
||||
#[test]
|
||||
fn fixture_example_is_well_formed() {
|
||||
let example = fixture_get("node_name_example");
|
||||
assert!(example.starts_with(&fixture_get("node_name_prefix")));
|
||||
let format = fixture_get("node_name_format");
|
||||
assert_eq!(format, "peerspeak_owned_<role>_<pid>");
|
||||
// Same shape our generator emits: prefix, role, pid.
|
||||
let generated = owned_node_name("mpv");
|
||||
let suffix = generated
|
||||
.strip_prefix(OWNED_NODE_NAME_PREFIX)
|
||||
.expect("generated name carries the prefix");
|
||||
let (role, pid) = suffix.split_once('_').expect("role_pid suffix");
|
||||
assert_eq!(role, "mpv");
|
||||
assert_eq!(pid.parse::<u32>().unwrap(), std::process::id());
|
||||
}
|
||||
|
||||
/// The black-box half: build a real `Command` exactly as a call site
|
||||
/// does, read back the environment it *would* carry across the fork, and
|
||||
/// assert the property string pixelpass matches on is present in it.
|
||||
///
|
||||
/// Deliberately inspects the `Command`, not our own formatter — a helper
|
||||
/// that returns the right string but is wired into the wrong variable
|
||||
/// would pass a formatter test and fail in the field.
|
||||
#[test]
|
||||
fn tagged_child_command_carries_both_carriers() {
|
||||
let mut command = Command::new("mpv");
|
||||
tag_child(&mut command, "mpv");
|
||||
|
||||
let env: BTreeMap<String, String> = command
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().into_owned(),
|
||||
value
|
||||
.expect("tagging sets values, never removals")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let expected_name = owned_node_name("mpv");
|
||||
let prop = format!("{}={}", fixture_get("prop_key"), fixture_get("prop_value"));
|
||||
|
||||
let pulse = env.get(PULSE_PROP_ENV).expect("PULSE_PROP set");
|
||||
assert!(
|
||||
pulse.contains(&prop),
|
||||
"PULSE_PROP carries carrier 1: {pulse}"
|
||||
);
|
||||
assert!(
|
||||
pulse.contains(&format!("node.name={expected_name}")),
|
||||
"PULSE_PROP carries carrier 2: {pulse}"
|
||||
);
|
||||
|
||||
let pw = env.get(PIPEWIRE_PROPS_ENV).expect("PIPEWIRE_PROPS set");
|
||||
assert!(
|
||||
pw.contains(&format!(
|
||||
"\"{}\" = \"{}\"",
|
||||
fixture_get("prop_key"),
|
||||
fixture_get("prop_value")
|
||||
)),
|
||||
"PIPEWIRE_PROPS carries carrier 1: {pw}"
|
||||
);
|
||||
assert!(
|
||||
pw.contains(&format!("\"node.name\" = \"{expected_name}\"")),
|
||||
"PIPEWIRE_PROPS carries carrier 2: {pw}"
|
||||
);
|
||||
assert!(
|
||||
pw.starts_with('{') && pw.ends_with('}'),
|
||||
"PIPEWIRE_PROPS is a SPA-JSON object: {pw}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_generated_name_matches_the_prefix_pixelpass_looks_for() {
|
||||
for role in ["call", "mpv", "vlc", "notify"] {
|
||||
let name = owned_node_name(role);
|
||||
assert!(
|
||||
name.starts_with(OWNED_NODE_NAME_PREFIX),
|
||||
"{name} must match the prefix"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_is_reduced_to_a_single_safe_token() {
|
||||
// A space would split PULSE_PROP into a bogus second property.
|
||||
assert_eq!(sanitize_role("my player"), "my-player");
|
||||
assert_eq!(sanitize_role("MPV"), "mpv");
|
||||
assert_eq!(sanitize_role("vlc/../.."), "vlc");
|
||||
assert_eq!(sanitize_role(""), "child");
|
||||
assert_eq!(sanitize_role("---"), "child");
|
||||
assert!(!owned_node_name("my player").contains(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulse_prop_pairs_are_whitespace_separated_and_parse_back() {
|
||||
let value = pulse_prop_value(&owned_node_name("notify"));
|
||||
let pairs: BTreeMap<&str, &str> = value
|
||||
.split_whitespace()
|
||||
.map(|pair| pair.split_once('=').expect("key=value"))
|
||||
.collect();
|
||||
assert_eq!(pairs.len(), 2, "exactly two properties: {value}");
|
||||
assert_eq!(pairs.get(OWNED_PROP_KEY), Some(&OWNED_PROP_VALUE));
|
||||
assert!(
|
||||
pairs
|
||||
.get("node.name")
|
||||
.is_some_and(|name| name.starts_with(OWNED_NODE_NAME_PREFIX))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::audio::ownership;
|
||||
use crate::audio::{AudioBackend, AudioError};
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -371,6 +372,11 @@ fn run_playback(
|
||||
mainloop_clone.quit();
|
||||
});
|
||||
|
||||
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
|
||||
// This is the node that carries the far end's voice, so it is the single
|
||||
// most important thing for pixelpass to refuse to fan out: sharing it
|
||||
// would send the call back to the person already speaking on it.
|
||||
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -379,6 +385,19 @@ fn run_playback(
|
||||
// buffer — the real fix is the explicit Buffers param below — but it
|
||||
// expresses the intended quantum for any node that honours it.
|
||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
||||
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
|
||||
// Set explicitly rather than relying on the stream name passed to
|
||||
// `StreamBox::new` below: props win over that name, and this one has
|
||||
// to be exact.
|
||||
*pw::keys::NODE_NAME => owned_node_name.as_str(),
|
||||
// Measured: this stream sets neither `application.name` nor a
|
||||
// description, so a mixer falls back to `node.name` — which the line
|
||||
// above just turned into an internal identifier. The plan's rule is
|
||||
// that the ownership prefix must not reach `node.description`; a
|
||||
// human label there is what keeps that rule's *intent* (mixers stay
|
||||
// readable) true for our own stream, exactly as mpv's own
|
||||
// description does for the spawned players.
|
||||
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
|
||||
};
|
||||
if let Some(target) = target_node {
|
||||
props.insert("node.target", target);
|
||||
@@ -637,6 +656,62 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use std::{sync::mpsc, thread};
|
||||
|
||||
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
|
||||
/// that carries the far end's voice appears on the graph with **both**
|
||||
/// ownership carriers, and still with the `Communication` media role.
|
||||
///
|
||||
/// The third and most important of the three tagged paths — this is the
|
||||
/// node whose audio, if fanned out, would send the call back to whoever
|
||||
/// is speaking on it.
|
||||
///
|
||||
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
fn native_playback_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
|
||||
|
||||
let backend = super::PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
backend
|
||||
.start_playback(rx, None, ring_fill.clone())
|
||||
.expect("playback starts");
|
||||
|
||||
// Keep the ring fed so the node stays live for the whole poll; the
|
||||
// stream is created on connect, but a starved one is not a fair test
|
||||
// of what a real call looks like on the graph.
|
||||
let feeder = thread::spawn(move || {
|
||||
let silence = vec![0i16; 960 * 2];
|
||||
for _ in 0..300 {
|
||||
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
|
||||
&& tx.send(silence.clone()).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
let _ = backend.stop();
|
||||
let _ = feeder.join();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
|
||||
+54
-3
@@ -10,6 +10,8 @@
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use crate::audio::ownership;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -264,12 +266,18 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
let mut command = Command::new(player);
|
||||
command
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.stderr(Stdio::null());
|
||||
// Ownership tag (plan §5.1). A chime is short, but it is still our
|
||||
// audio on the default sink, and an untagged one is an unowned root
|
||||
// the exclusion engine would have to reason about from scratch.
|
||||
// `aplay` talks to ALSA and ignores both variables; harmless.
|
||||
ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
|
||||
let started = command.status();
|
||||
// `status()` errors only if the player binary isn't present; on a real
|
||||
// playback error it still returns (non-zero), so a started player ends
|
||||
// the loop either way — we don't want to double-play through fallbacks.
|
||||
@@ -313,6 +321,49 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
|
||||
/// actually plays produces a live PipeWire node carrying **both**
|
||||
/// ownership carriers.
|
||||
///
|
||||
/// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
|
||||
/// `audio::ownership` proves the environment is built correctly; only a
|
||||
/// live run proves this module *uses* it and that the audio stack honours
|
||||
/// it end to end. The chime is silent (a zero-filled WAV), so running it
|
||||
/// never makes noise.
|
||||
///
|
||||
/// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
#[cfg(not(windows))]
|
||||
fn notification_chime_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
let dir = temp_wav_dir("ownership");
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
set_enabled(true);
|
||||
set_sound_enabled(Sound::PeerJoin, true);
|
||||
play(Sound::PeerJoin, Some(path.to_str().unwrap()));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node too, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_play_truth_table() {
|
||||
// Plays only when BOTH the master and the per-sound flag are on.
|
||||
|
||||
+54
-3
@@ -21,6 +21,8 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::audio::ownership;
|
||||
|
||||
pub mod livesync;
|
||||
|
||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||
@@ -765,20 +767,69 @@ fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child>
|
||||
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||
// fallback from the preferred player to the other one is visible too.
|
||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||
Command::new(bin)
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.kill_on_drop(false);
|
||||
// Ownership tag (plan §5.1): this player is playing the *incoming*
|
||||
// screenshare's audio, so it is exactly what must not be fanned back out
|
||||
// if this machine also starts sharing. The role is the player binary, so
|
||||
// a `pw-dump` during a field test names which one produced the node.
|
||||
ownership::tag_child(command.as_std_mut(), bin);
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
|
||||
/// actually spawns produces a live node carrying **both** ownership
|
||||
/// carriers, tagged with the player's own name as the role.
|
||||
///
|
||||
/// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
|
||||
/// gate does: the plan requires the tag to be shown "landing on a live
|
||||
/// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
|
||||
///
|
||||
/// Live: needs PipeWire, `mpv` and `pw-dump`.
|
||||
/// `cargo test --lib -- --ignored spawned_player`
|
||||
#[tokio::test]
|
||||
#[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
|
||||
async fn spawned_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::live_test;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
let mut child = spawn_player(
|
||||
"mpv",
|
||||
&["--no-video".to_string(), "--really-quiet".to_string()],
|
||||
path.to_str().unwrap(),
|
||||
)
|
||||
.expect("mpv spawns");
|
||||
|
||||
// The role is the player binary, so this also pins that the call site
|
||||
// passes `bin` and not a fixed literal.
|
||||
let prefix = live_test::expected_prefix("mpv");
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
let _ = child.kill().await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||
// A malicious "ticket" that looks like a flag must end up positional,
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Screenshare audio exclusion — ownership tagging wire contract.
|
||||
#
|
||||
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
|
||||
# CONSUMES them as the primary taint root of the exclusion engine. Neither
|
||||
# repo depends on the other, so this file is the contract: it is committed
|
||||
# byte-identical in both, and each repo has a test that asserts its own named
|
||||
# constants (and, on the producer side, the environment a real child Command
|
||||
# would carry) match these values exactly.
|
||||
#
|
||||
# peerspeak/tests/fixtures/ownership-tag-contract.txt
|
||||
# pixelpass/tests/fixtures/ownership-tag-contract.txt
|
||||
#
|
||||
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
|
||||
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
|
||||
# is a cross-repo breaking change: both repos must land in the same session,
|
||||
# and the phase 5 matrix must be re-run.
|
||||
#
|
||||
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
|
||||
# matches. Round 8 added the second because a property is invisible to the
|
||||
# PipeWire registry `global` event and readable only via a node bind, so the
|
||||
# primary taint root must not rest on one observation mechanism alone.
|
||||
|
||||
# Carrier 1 — a node property. The consumer treats any value other than
|
||||
# "false"/"0" as truthy, which is the fail-closed direction; the producer
|
||||
# always emits exactly this value.
|
||||
prop_key=peerspeak.owned
|
||||
prop_value=1
|
||||
|
||||
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
|
||||
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
|
||||
# Only the prefix is matched; the rest of the name is for diagnostics.
|
||||
node_name_prefix=peerspeak_owned_
|
||||
node_name_format=peerspeak_owned_<role>_<pid>
|
||||
node_name_example=peerspeak_owned_mpv_31284
|
||||
Reference in New Issue
Block a user