Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa792b9927 | ||
|
|
cc9694c13f | ||
|
|
911f0521f8 | ||
|
|
9ad55c19de | ||
|
|
347462cca7 | ||
|
|
6dd9b2d25a | ||
|
|
58bc8e99e8 | ||
|
|
104a95a6d7 | ||
|
|
d9ef38c1c5 | ||
|
|
1cf6e915c8 | ||
|
|
45464b4af5 | ||
|
|
01c582427b | ||
|
|
9145b2a726 | ||
|
|
919d5bdef2 | ||
|
|
c78eb2dd39 | ||
|
|
91c4dedcb0 | ||
|
|
d462754894 | ||
|
|
90efa51c26 | ||
|
|
b8b8b78b09 | ||
|
|
289016d071 | ||
|
|
4b2b192601 | ||
|
|
993befdedd | ||
|
|
abaf5d9c10 | ||
|
|
67f4ff931e | ||
|
|
295a575b15 | ||
|
|
bf5f2508b8 | ||
|
|
45ca5057f8 | ||
|
|
8b41f64e12 | ||
|
|
1b01847c66 | ||
|
|
0af0124e17 | ||
|
|
e34289fdb5 | ||
|
|
471b8221ff |
@@ -1 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
|
||||||
|
# flake.lock ARE tracked — the lock is what pins the toolchain.
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
|
/.direnv/
|
||||||
|
|||||||
Generated
+39
@@ -2958,6 +2958,33 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-binding"
|
||||||
|
version = "2.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"libc",
|
||||||
|
"libpulse-sys",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libpulse-sys"
|
||||||
|
version = "1.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"pkg-config",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -3539,6 +3566,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -4163,6 +4201,7 @@ dependencies = [
|
|||||||
"iroh",
|
"iroh",
|
||||||
"iroh-tickets",
|
"iroh-tickets",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
"libpulse-binding",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
|
|||||||
+10
@@ -46,6 +46,16 @@ serde_json = "1"
|
|||||||
directories = "5"
|
directories = "5"
|
||||||
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
||||||
pipewire = "0.9"
|
pipewire = "0.9"
|
||||||
|
# `--repair` reads and unloads Pulse modules through libpulse introspection rather
|
||||||
|
# than by parsing `pactl` output. `pa_module_info` carries index, name and the exact
|
||||||
|
# argument in one record, and `pa_context_is_local()` answers whether the server we
|
||||||
|
# actually reached is local — neither of which the text listings can do (an argument
|
||||||
|
# may contain tabs and newlines that the short format cannot escape, the JSON
|
||||||
|
# listing carries no module index at all, and `PULSE_SERVER` is a fallback list, so
|
||||||
|
# it never proved locality). Vetted at 2.30.1: MIT/Apache-2.0, no build script
|
||||||
|
# beyond a pkg-config probe, no network or subprocess use, and all three historical
|
||||||
|
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
|
||||||
|
libpulse-binding = "2.30"
|
||||||
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
|
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
iroh-tickets = "1.0.0"
|
iroh-tickets = "1.0.0"
|
||||||
|
|||||||
Generated
+48
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785989512,
|
||||||
|
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1786076960,
|
||||||
|
"narHash": "sha256-jfR6OhwurCKn1tREyfOcK/Omxf1Q/DzDDFbnEr1mBLs=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "57a23bfaf4f7017267294b161175db1e32eb1c85",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
{
|
||||||
|
description = "PixelPass — P2P screen sharing CLI over iroh";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
# Same channel the hosts run (nixos-config tracks nixos-26.05). The capture
|
||||||
|
# path talks to the live PipeWire daemon and the system PulseAudio server,
|
||||||
|
# so the client libraries here should come from the same release the server
|
||||||
|
# did.
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
# The Rust toolchain is pinned SEPARATELY from the system libraries, so a
|
||||||
|
# nixpkgs bump cannot move the compiler under the lint gate. nixpkgs 26.05
|
||||||
|
# ships 1.95.0; this crate was developed and verified on 1.97.1, and
|
||||||
|
# peerspeak — the sibling project this one is built against — has a clippy
|
||||||
|
# lint that differs between exactly those two versions. Keeping both repos
|
||||||
|
# on one pinned compiler means a check that passes here passes there.
|
||||||
|
#
|
||||||
|
# This is the reproducible alternative to rustup: the same exact-version
|
||||||
|
# control, but recorded in flake.lock, so a fresh clone resolves the
|
||||||
|
# identical toolchain rather than whatever rustup fetches that day.
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, rust-overlay, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Matches what CachyOS shipped (rust 1:1.97.1-1) and what peerspeak pins.
|
||||||
|
# `default` is the rustup "default" profile — rustc, cargo, rust-std,
|
||||||
|
# rustfmt and clippy — so those are NOT listed separately below. No
|
||||||
|
# windows-gnu target here: pixelpass is Linux-only (portal/PipeWire/X11
|
||||||
|
# capture), unlike peerspeak which has a Windows port.
|
||||||
|
rustToolchain = pkgs.rust-bin.stable."1.97.1".default;
|
||||||
|
|
||||||
|
# GStreamer is driven as a SUBPROCESS (gst-launch-1.0 / gst-inspect-1.0),
|
||||||
|
# not linked — there is no gstreamer-sys in Cargo.lock. So these are PATH
|
||||||
|
# dependencies at runtime rather than build inputs, and `deps.rs` refuses
|
||||||
|
# to start a share if any are missing.
|
||||||
|
gstPlugins = with pkgs; [
|
||||||
|
gst_all_1.gstreamer # gst-launch-1.0 / gst-inspect-1.0
|
||||||
|
gst_all_1.gst-plugins-base # videoscale (quality-preset downscale)
|
||||||
|
gst_all_1.gst-plugins-good # pulsesrc, ximagesrc
|
||||||
|
gst_all_1.gst-plugins-bad # h264parse, mpegtsmux, aacparse, vah264enc
|
||||||
|
gst_all_1.gst-plugins-ugly # x264enc (software-encode fallback)
|
||||||
|
gst_all_1.gst-libav # avenc_aac
|
||||||
|
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
|
||||||
|
];
|
||||||
|
|
||||||
|
# Opened with dlopen by the optional `--gui` front end (eframe/egui_glow/
|
||||||
|
# winit/glutin), never linked. Harmless for the default headless build.
|
||||||
|
guiRuntimeLibs = with pkgs; [
|
||||||
|
libGL
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
libx11
|
||||||
|
libxcursor
|
||||||
|
libxrandr
|
||||||
|
libxi
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
nativeBuildInputs =
|
||||||
|
[ rustToolchain ]
|
||||||
|
++ (with pkgs; [
|
||||||
|
# Debian packaging (`cargo deb --no-build`). Build the binary
|
||||||
|
# inside a Debian/Ubuntu distrobox first so it links that distro's
|
||||||
|
# glibc — see the packaging notes in Cargo.toml.
|
||||||
|
cargo-deb
|
||||||
|
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
# pipewire-sys, libspa-sys and libpulse-sys all generate bindings
|
||||||
|
# with bindgen, which needs a real libclang at build time.
|
||||||
|
clang
|
||||||
|
])
|
||||||
|
++ gstPlugins
|
||||||
|
++ [
|
||||||
|
# The rest of what `deps::check_host_binaries` looks for.
|
||||||
|
pkgs.pulseaudio # `pactl` (PipeWire stays the actual audio server)
|
||||||
|
pkgs.mpv # the viewer-side player
|
||||||
|
pkgs.xwininfo # the `--window` click-picker on X11
|
||||||
|
|
||||||
|
# `--doctor` shells out to vainfo to confirm the VA-API H.264
|
||||||
|
# ENCODE entrypoint really exists. Without it the report can only
|
||||||
|
# say "vah264enc and a render node are present" and has to leave
|
||||||
|
# hardware encode unconfirmed — which matters, because a GPU
|
||||||
|
# missing that entrypoint produces no video at all under the
|
||||||
|
# default encoder rather than failing loudly.
|
||||||
|
pkgs.libva-utils
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
pipewire # pipewire-sys + libspa-sys
|
||||||
|
libpulseaudio # libpulse-sys: --repair reads/unloads Pulse modules
|
||||||
|
|
||||||
|
# x11rb is declared `default-features = false` here, which by itself
|
||||||
|
# is pure Rust — but Cargo unifies features across the graph, and
|
||||||
|
# arboard pulls x11rb with its `libxcb` feature on. That drags in
|
||||||
|
# as-raw-xcb-connection and makes the final link need -lxcb. It is a
|
||||||
|
# real link-time dependency of the binary, not an optional extra.
|
||||||
|
libxcb
|
||||||
|
]
|
||||||
|
++ guiRuntimeLibs;
|
||||||
|
|
||||||
|
# bindgen finds libclang through this variable specifically — having
|
||||||
|
# clang on PATH is not sufficient.
|
||||||
|
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||||
|
|
||||||
|
# NixOS keeps every GStreamer plugin in its own store path, so
|
||||||
|
# gst-launch-1.0 discovers them ONLY through this search path. Without
|
||||||
|
# it, pixelpass's `gst-inspect-1.0 --exists pipewiresrc` preflight fails
|
||||||
|
# even though the plugins are installed. Same reasoning as the
|
||||||
|
# GST_PLUGIN_SYSTEM_PATH_1_0 block in nixos-config hosts/darp5.
|
||||||
|
GST_PLUGIN_SYSTEM_PATH_1_0 = pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" gstPlugins;
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath guiRuntimeLibs;
|
||||||
|
|
||||||
|
# Only greet an interactive shell. shellHook also runs under
|
||||||
|
# `nix develop --command …`, where printing this would interleave the
|
||||||
|
# banner with the command's own output (and corrupt it outright for
|
||||||
|
# anything whose stdout is parsed, such as pixelpass's `--output json`).
|
||||||
|
shellHook = ''
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
echo "pixelpass — rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
|
||||||
|
echo " cargo build --release headless build (what peerspeak spawns)"
|
||||||
|
echo " cargo build --release --features gui with the egui front end"
|
||||||
|
echo " cargo test unit + integration tests"
|
||||||
|
echo " ./target/debug/pixelpass --doctor verify this machine can host"
|
||||||
|
echo
|
||||||
|
echo "GStreamer, pactl, mpv and xwininfo are on PATH in this shell, so"
|
||||||
|
echo "capture works here without a system rebuild."
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+12
@@ -105,6 +105,18 @@ pub struct Cli {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub repair: bool,
|
pub repair: bool,
|
||||||
|
|
||||||
|
/// With `--repair`: also clean up modules that carry no ownership token,
|
||||||
|
/// judging them by process id alone.
|
||||||
|
///
|
||||||
|
/// Modules loaded by pixelpass versions before ownership tokens existed cannot
|
||||||
|
/// be attributed to a machine, boot or pid namespace, so `--repair` refuses them
|
||||||
|
/// by default: a process id means different processes in different namespaces,
|
||||||
|
/// and acting on the wrong one unloads a *running* host's audio. Use this only
|
||||||
|
/// on the machine that ran the crashed host, and only when the reported
|
||||||
|
/// candidates look right.
|
||||||
|
#[arg(long, requires = "repair")]
|
||||||
|
pub repair_legacy_untagged: bool,
|
||||||
|
|
||||||
/// Print an environment diagnostic report (display server, capture/encode
|
/// Print an environment diagnostic report (display server, capture/encode
|
||||||
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
|
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
|
||||||
/// then exit. Use this to check a machine can host or view before a real
|
/// then exit. Use this to check a machine can host or view before a real
|
||||||
|
|||||||
+418
-150
@@ -35,25 +35,45 @@ use std::cell::RefCell;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
|
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
|
||||||
|
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
||||||
|
|
||||||
|
/// How long a single `pactl load-module` / `unload-module` may take.
|
||||||
|
///
|
||||||
|
/// A bound, not a calibration: a local Pulse socket answers in milliseconds, and
|
||||||
|
/// this exists only so a wedged server cannot hang teardown forever. It is
|
||||||
|
/// deliberately generous because exceeding it is no longer destructive — the
|
||||||
|
/// ledger records the attempt, and reconciliation finds whatever the server
|
||||||
|
/// actually did.
|
||||||
|
const PACTL_BUDGET: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How many reconcile-then-unload rounds teardown runs.
|
||||||
|
///
|
||||||
|
/// Two, because one round can create exactly one new question: an ambiguous load
|
||||||
|
/// resolves to a module that then needs unloading, and an uncertain unload
|
||||||
|
/// resolves to a module that is either gone or still there. A second round
|
||||||
|
/// settles either. Anything still unresolved after that is left to `--repair`
|
||||||
|
/// rather than looped over.
|
||||||
|
const TEARDOWN_ROUNDS: usize = 2;
|
||||||
|
|
||||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
||||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
||||||
/// prefer [`Routing::shutdown`] explicitly so failures get logged.
|
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can
|
||||||
|
/// reconcile a load whose outcome was never observed.
|
||||||
pub struct Routing {
|
pub struct Routing {
|
||||||
sink_module: Option<u32>,
|
/// Every module this host has loaded, is loading, or must ask the server
|
||||||
/// Shared with the event task so it can `take()` and unload on the
|
/// about. Shared with the event task, which loads and unloads the two
|
||||||
/// first successful route. `Routing::shutdown` unloads whatever
|
/// loopbacks as the routed app comes and goes.
|
||||||
/// remains.
|
///
|
||||||
loopback_module: Arc<Mutex<Option<u32>>>,
|
/// This replaced three `Option<u32>`s. The reason is in [`crate::host::ledger`]:
|
||||||
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
|
/// an `Option` cannot say "a load is in flight", so a cancelled load looked
|
||||||
/// hear the routed app. Shared with the event task, which loads it on the
|
/// exactly like no load at all and its module was left behind.
|
||||||
/// first routed stream and unloads it when the app stops. `None` outside
|
ledger: Arc<ModuleLedger>,
|
||||||
/// app mode and whenever no app is currently routed.
|
|
||||||
local_monitor_module: Arc<Mutex<Option<u32>>>,
|
|
||||||
sink_name: String,
|
sink_name: String,
|
||||||
stream_router: Option<StreamRouter>,
|
stream_router: Option<StreamRouter>,
|
||||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||||
@@ -64,9 +84,18 @@ impl Routing {
|
|||||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
/// also spawn the libpipewire thread that reroutes matching streams.
|
||||||
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
let sink_name = format!("pixelpass_capture_{pid}");
|
let sink_name = repair_plan::sink_name_for(pid);
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
|
||||||
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
|
// Every module this host loads carries an ownership token, minted per
|
||||||
|
// load, so `--repair` can tell whose pid the name refers to instead of
|
||||||
|
// assuming the number means the same thing everywhere. Without it a repair
|
||||||
|
// run in another pid namespace can unload a live host's audio; see
|
||||||
|
// `repair::plan::OwnerToken`. That same per-load nonce is what lets
|
||||||
|
// reconciliation identify a module whose load was interrupted before its
|
||||||
|
// index was ever read.
|
||||||
|
load_module(&ledger, Shape::LegacyCaptureSink, pid)
|
||||||
|
.await
|
||||||
.context("failed to load module-null-sink")?;
|
.context("failed to load module-null-sink")?;
|
||||||
|
|
||||||
// In strict per-app mode we never mirror the default sink: the viewer
|
// In strict per-app mode we never mirror the default sink: the viewer
|
||||||
@@ -78,34 +107,20 @@ impl Routing {
|
|||||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||||
// default of 200ms is enough to be perceptible.
|
// default of 200ms is enough to be perceptible.
|
||||||
let strict_app = opts.app.is_some() && opts.strict_audio;
|
let strict_app = opts.app.is_some() && opts.strict_audio;
|
||||||
let loopback_module = if strict_app {
|
if !strict_app {
|
||||||
None
|
load_module(&ledger, Shape::LoopbackIntoCapture, pid)
|
||||||
} else {
|
.await
|
||||||
Some(
|
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?;
|
||||||
load_module(&[
|
}
|
||||||
"module-loopback",
|
|
||||||
"source=@DEFAULT_SINK@.monitor",
|
|
||||||
&format!("sink={sink_name}"),
|
|
||||||
"latency_msec=20",
|
|
||||||
])
|
|
||||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
sink_module,
|
|
||||||
?loopback_module,
|
|
||||||
strict_app,
|
strict_app,
|
||||||
%sink_name,
|
%sink_name,
|
||||||
"audio routing: null-sink ready (loopback skipped in strict app mode)"
|
"audio routing: null-sink ready (loopback skipped in strict app mode)"
|
||||||
);
|
);
|
||||||
|
|
||||||
let loopback_arc = Arc::new(Mutex::new(loopback_module));
|
|
||||||
let local_monitor_arc = Arc::new(Mutex::new(None));
|
|
||||||
let mut routing = Self {
|
let mut routing = Self {
|
||||||
sink_module: Some(sink_module),
|
ledger: Arc::clone(&ledger),
|
||||||
loopback_module: Arc::clone(&loopback_arc),
|
|
||||||
local_monitor_module: Arc::clone(&local_monitor_arc),
|
|
||||||
sink_name: sink_name.clone(),
|
sink_name: sink_name.clone(),
|
||||||
stream_router: None,
|
stream_router: None,
|
||||||
event_task: None,
|
event_task: None,
|
||||||
@@ -113,22 +128,17 @@ impl Routing {
|
|||||||
|
|
||||||
if let Some(app) = &opts.app {
|
if let Some(app) = &opts.app {
|
||||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
||||||
let loopback_for_task = Arc::clone(&loopback_arc);
|
let ledger_for_task = Arc::clone(&ledger);
|
||||||
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
|
|
||||||
let sink_name_for_task = sink_name.clone();
|
|
||||||
let strict = opts.strict_audio;
|
let strict = opts.strict_audio;
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
use crate::common::output::{self, AppAudioState};
|
use crate::common::output::{self, AppAudioState};
|
||||||
while let Some(ev) = event_rx.recv().await {
|
while let Some(ev) = event_rx.recv().await {
|
||||||
match ev {
|
match ev {
|
||||||
Event::FirstRoutedStream => {
|
Event::FirstRoutedStream => {
|
||||||
let mid = loopback_for_task.lock().unwrap().take();
|
tracing::info!(
|
||||||
if let Some(id) = mid {
|
"audio routing: first stream routed → unloading default-sink loopback"
|
||||||
tracing::info!(
|
);
|
||||||
"audio routing: first stream routed → unloading default-sink loopback"
|
unload_module(&ledger_for_task, Shape::LoopbackIntoCapture).await;
|
||||||
);
|
|
||||||
unload_module(id);
|
|
||||||
}
|
|
||||||
// Mirror the routed app back to the sharer's own
|
// Mirror the routed app back to the sharer's own
|
||||||
// speakers so they hear the content they're sharing.
|
// speakers so they hear the content they're sharing.
|
||||||
// Loaded *after* the default-sink loopback is gone so
|
// Loaded *after* the default-sink loopback is gone so
|
||||||
@@ -136,25 +146,7 @@ impl Routing {
|
|||||||
// sourced from the null-sink monitor — the chosen app
|
// sourced from the null-sink monitor — the chosen app
|
||||||
// only, never the desktop/call — so it can't echo into
|
// only, never the desktop/call — so it can't echo into
|
||||||
// the capture.
|
// the capture.
|
||||||
if local_monitor_for_task.lock().unwrap().is_none() {
|
ensure_loaded(&ledger_for_task, Shape::LoopbackOutOfCapture, pid).await;
|
||||||
match load_module(&[
|
|
||||||
"module-loopback",
|
|
||||||
&format!("source={sink_name_for_task}.monitor"),
|
|
||||||
"sink=@DEFAULT_SINK@",
|
|
||||||
"latency_msec=20",
|
|
||||||
]) {
|
|
||||||
Ok(id) => {
|
|
||||||
tracing::info!(
|
|
||||||
module = id,
|
|
||||||
"audio routing: local monitor loaded (sharer hears the shared app)"
|
|
||||||
);
|
|
||||||
*local_monitor_for_task.lock().unwrap() = Some(id);
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
"audio routing: failed to load local monitor loopback: {e:#}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tell the front-end the chosen app's audio is live.
|
// Tell the front-end the chosen app's audio is live.
|
||||||
output::emit(output::Event::AppAudio {
|
output::emit(output::Event::AppAudio {
|
||||||
state: AppAudioState::Routed,
|
state: AppAudioState::Routed,
|
||||||
@@ -169,13 +161,7 @@ impl Routing {
|
|||||||
// The shared app is gone, so its null-sink is silent:
|
// The shared app is gone, so its null-sink is silent:
|
||||||
// stop mirroring it to the sharer's speakers. Re-loads
|
// stop mirroring it to the sharer's speakers. Re-loads
|
||||||
// on the next FirstRoutedStream if the app resumes.
|
// on the next FirstRoutedStream if the app resumes.
|
||||||
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
|
unload_module(&ledger_for_task, Shape::LoopbackOutOfCapture).await;
|
||||||
tracing::info!(
|
|
||||||
module = id,
|
|
||||||
"audio routing: last routed stream gone → unloading local monitor"
|
|
||||||
);
|
|
||||||
unload_module(id);
|
|
||||||
}
|
|
||||||
if strict {
|
if strict {
|
||||||
// Strict mode: do NOT restore the whole-desktop
|
// Strict mode: do NOT restore the whole-desktop
|
||||||
// loopback. Viewers hear silence until the app
|
// loopback. Viewers hear silence until the app
|
||||||
@@ -188,28 +174,12 @@ impl Routing {
|
|||||||
}
|
}
|
||||||
// Best-effort mode: restore the default-sink loopback
|
// Best-effort mode: restore the default-sink loopback
|
||||||
// so the viewer hears system audio again instead of
|
// so the viewer hears system audio again instead of
|
||||||
// silence.
|
// silence. Already loaded is not an error — the ledger
|
||||||
if loopback_for_task.lock().unwrap().is_some() {
|
// refuses the load and `ensure_loaded` says so quietly.
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"audio routing: last routed stream gone → restoring default-sink loopback"
|
"audio routing: last routed stream gone → restoring default-sink loopback"
|
||||||
);
|
);
|
||||||
match load_module(&[
|
ensure_loaded(&ledger_for_task, Shape::LoopbackIntoCapture, pid).await;
|
||||||
"module-loopback",
|
|
||||||
"source=@DEFAULT_SINK@.monitor",
|
|
||||||
&format!("sink={sink_name_for_task}"),
|
|
||||||
"latency_msec=20",
|
|
||||||
]) {
|
|
||||||
Ok(id) => {
|
|
||||||
*loopback_for_task.lock().unwrap() = Some(id);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"audio routing: failed to re-load loopback: {e:#}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,42 +205,90 @@ impl Routing {
|
|||||||
&self.sink_name
|
&self.sink_name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the stream router (if any), then unload loopback (if still
|
/// Stop the stream router and the event task, settle anything the ledger is
|
||||||
/// loaded), then unload the null-sink. Order matters: PipeWire can
|
/// unsure about, then unload every module in shape order — the loopbacks
|
||||||
/// leave zombie links if you destroy a sink with active inputs.
|
/// before the sink they reference, because PipeWire can leave zombie links if
|
||||||
|
/// a sink is destroyed with active inputs.
|
||||||
///
|
///
|
||||||
/// Every step is a `take()`, so this is idempotent — `Drop` calls it again
|
/// The event task is **awaited, not merely aborted**. Aborting and walking
|
||||||
/// as a backstop and the second run is a no-op.
|
/// away is what left orphans behind: the task's load is an await point now,
|
||||||
fn cleanup(&mut self) {
|
/// so dropping its future marks the slot ambiguous rather than losing the
|
||||||
|
/// module — but only a path that then reconciles can actually clean it up.
|
||||||
|
/// `Drop` cannot await, which is why it is the narrower backstop.
|
||||||
|
pub async fn shutdown(mut self) {
|
||||||
|
if let Some(router) = self.stream_router.take() {
|
||||||
|
// ⚠️ Still an unbounded join: a wedged PipeWire thread parks this
|
||||||
|
// task indefinitely. That is the pre-existing defect S3b exists for.
|
||||||
|
// Nothing here makes it worse, and the ledger is what will make
|
||||||
|
// bounding it safe when it lands.
|
||||||
|
router.shutdown();
|
||||||
|
}
|
||||||
|
if let Some(mut task) = self.event_task.take() {
|
||||||
|
// The router's exit drops the event senders, so the task normally
|
||||||
|
// ends by itself. Abort is the fallback, and it is awaited through
|
||||||
|
// `&mut JoinHandle` so the future is genuinely dropped — and with it
|
||||||
|
// any in-flight permit — before reconciliation reads the ledger.
|
||||||
|
// Dropping the handle instead would *detach* the task, which is how a
|
||||||
|
// load could still land after teardown believed it was finished.
|
||||||
|
if tokio::time::timeout(PACTL_BUDGET, &mut task).await.is_err() {
|
||||||
|
tracing::warn!(
|
||||||
|
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
|
||||||
|
cancelling it"
|
||||||
|
);
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..TEARDOWN_ROUNDS {
|
||||||
|
if let Err(e) = ledger::reconcile_pending(&self.ledger).await {
|
||||||
|
tracing::warn!("audio routing: could not reconcile the module ledger: {e:#}");
|
||||||
|
}
|
||||||
|
let loaded = self.ledger.loaded();
|
||||||
|
if loaded.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for fp in loaded {
|
||||||
|
unload_module(&self.ledger, fp.shape).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.ledger.is_settled() {
|
||||||
|
tracing::warn!(
|
||||||
|
"audio routing: some audio modules could not be accounted for; \
|
||||||
|
`pixelpass --repair` will clean up anything left behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Routing {
|
||||||
|
/// Synchronous backstop for the paths that never reach [`Routing::shutdown`]
|
||||||
|
/// — an error on the way up, or a panic. It cannot await, so it can neither
|
||||||
|
/// wait for the event task nor reconcile; it unloads what the ledger can name
|
||||||
|
/// and says so plainly when something is left unexplained.
|
||||||
|
///
|
||||||
|
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
|
||||||
|
fn drop(&mut self) {
|
||||||
if let Some(router) = self.stream_router.take() {
|
if let Some(router) = self.stream_router.take() {
|
||||||
router.shutdown();
|
router.shutdown();
|
||||||
}
|
}
|
||||||
if let Some(task) = self.event_task.take() {
|
if let Some(task) = self.event_task.take() {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
if let Some(id) = self.loopback_module.lock().unwrap().take() {
|
for fp in self.ledger.loaded() {
|
||||||
unload_module(id);
|
if self.ledger.begin_unload(fp.shape).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let outcome = blocking_unload(fp.id);
|
||||||
|
self.ledger.finish_unload(fp.shape, outcome);
|
||||||
}
|
}
|
||||||
// Unload the local monitor before the null-sink it reads from, so the
|
if !self.ledger.is_settled() {
|
||||||
// sink has no active loopback reader when it's destroyed.
|
tracing::warn!(
|
||||||
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
|
"audio routing: torn down without settling the module ledger; \
|
||||||
unload_module(id);
|
run `pixelpass --repair` to clean up anything left behind"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(id) = self.sink_module.take() {
|
|
||||||
unload_module(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consume the routing and tear it all down now. `Drop` is the backstop;
|
|
||||||
/// the real work lives in [`cleanup`](Self::cleanup).
|
|
||||||
pub fn shutdown(mut self) {
|
|
||||||
self.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Routing {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.cleanup();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,52 +367,178 @@ struct SinkInputProperties {
|
|||||||
// pactl module helpers
|
// pactl module helpers
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn load_module(args: &[&str]) -> Result<u32> {
|
/// Mint an ownership token for one module load.
|
||||||
let output = Command::new("pactl")
|
///
|
||||||
.arg("load-module")
|
/// **Per load, not per session.** The nonce is what makes two loads by the same pid
|
||||||
.args(args)
|
/// render different arguments, which is what lets a fingerprint tell a module from
|
||||||
.output()
|
/// its replacement at the same index. A token minted once and reused for every
|
||||||
.context("failed to run pactl load-module")?;
|
/// reload would be a host-session nonce and would not do that, so the counter is
|
||||||
|
/// bumped on every call and mixed with the clock.
|
||||||
|
fn owner_token(pid: u32) -> Result<repair_plan::OwnerToken> {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static LOADS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
let local = crate::repair::local_identity()?;
|
||||||
|
// A nonce only has to be unlikely to repeat, not unguessable. The counter makes
|
||||||
|
// two loads within the same clock tick distinct; the clock keeps two runs of the
|
||||||
|
// same process distinct.
|
||||||
|
let counter = LOADS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(repair_plan::OwnerToken {
|
||||||
|
machine: local.machine,
|
||||||
|
boot: local.boot,
|
||||||
|
pid_ns: local.pid_ns,
|
||||||
|
nonce: nanos ^ (counter << 48) ^ (u64::from(pid) << 32),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the Pulse module for one [`Shape`] and return its index.
|
||||||
|
///
|
||||||
|
/// Both the module name and its arguments come from the shape itself
|
||||||
|
/// ([`crate::repair::plan::Shape`]) rather than being written out here, so that
|
||||||
|
/// `--repair`'s exact-form matcher and this loader are one source of truth. A
|
||||||
|
/// latency or argument change that moved only one of them would leave repair
|
||||||
|
/// silently unable to recognise the modules this build loads.
|
||||||
|
async fn load_module(ledger: &Arc<ModuleLedger>, shape: Shape, pid: u32) -> Result<Fingerprint> {
|
||||||
|
let owner = owner_token(pid).context("could not build an audio ownership token")?;
|
||||||
|
let permit = ledger
|
||||||
|
.begin_load(shape, pid, owner.clone())
|
||||||
|
.map_err(anyhow::Error::new)
|
||||||
|
.with_context(|| format!("cannot load the {} module", shape.label()))?;
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("pactl");
|
||||||
|
cmd.arg("load-module")
|
||||||
|
.arg(shape.module_name())
|
||||||
|
.args(shape.render_args(pid, Some(&owner)))
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
// Deliberately **not** `select!`ed against a cancellation signal: a completed
|
||||||
|
// load whose index was then dropped on the floor is precisely the defect the
|
||||||
|
// ledger exists to prevent. Cancellation here happens by dropping this whole
|
||||||
|
// future, and the permit's `Drop` turns that into a question reconciliation
|
||||||
|
// can answer, rather than into silence.
|
||||||
|
let output = match tokio::time::timeout(PACTL_BUDGET, cmd.output()).await {
|
||||||
|
Ok(Ok(output)) => output,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
// Spawning or reading failed. Whether the server was ever reached is
|
||||||
|
// not knowable from here, so leave the permit unsettled: an
|
||||||
|
// unnecessary reconcile costs one listing, a missed one costs an
|
||||||
|
// orphan.
|
||||||
|
drop(permit);
|
||||||
|
return Err(e).context("failed to run pactl load-module");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
drop(permit);
|
||||||
|
bail!("pactl load-module did not finish within {PACTL_BUDGET:?}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
bail!(
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
"pactl load-module failed: {}",
|
if output.status.code().is_some() {
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
// pactl exited of its own accord, having reported the server's
|
||||||
);
|
// refusal: nothing was created, so there is nothing to reconcile.
|
||||||
|
permit.abandon();
|
||||||
|
} else {
|
||||||
|
// Killed by a signal, which may have arrived *after* the server
|
||||||
|
// created the module.
|
||||||
|
drop(permit);
|
||||||
|
}
|
||||||
|
bail!("pactl load-module failed: {stderr}");
|
||||||
}
|
}
|
||||||
let id_str = String::from_utf8(output.stdout)
|
|
||||||
.context("pactl returned non-UTF-8")?
|
let id_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
||||||
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
||||||
// back verbatim. Do not widen it.
|
// back verbatim. Do not widen it.
|
||||||
id_str
|
let Ok(index) = id_str.parse::<u32>() else {
|
||||||
.parse::<u32>()
|
// The load may well have succeeded — we simply cannot say which module it
|
||||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
// produced, which is exactly what reconciliation is for.
|
||||||
|
drop(permit);
|
||||||
|
bail!("pactl returned unexpected module ID: {id_str:?}");
|
||||||
|
};
|
||||||
|
let fp = permit.commit(index)?;
|
||||||
|
tracing::info!(
|
||||||
|
module = fp.id,
|
||||||
|
shape = shape.label(),
|
||||||
|
"audio routing: loaded pactl module"
|
||||||
|
);
|
||||||
|
Ok(fp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unload_module(id: u32) {
|
/// Load `shape` unless the slot already holds it.
|
||||||
let result = Command::new("pactl")
|
///
|
||||||
|
/// A busy slot is not a failure on the oscillation path: `FirstRoutedStream` and
|
||||||
|
/// `LastRoutedStreamGone` can both ask for a module that is already in the state
|
||||||
|
/// they want, and the ledger is what decides that rather than a separate flag.
|
||||||
|
async fn ensure_loaded(ledger: &Arc<ModuleLedger>, shape: Shape, pid: u32) {
|
||||||
|
let Err(e) = load_module(ledger, shape, pid).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match e.downcast_ref::<LedgerError>() {
|
||||||
|
Some(LedgerError::Busy { state, .. }) => tracing::debug!(
|
||||||
|
shape = shape.label(),
|
||||||
|
state,
|
||||||
|
"audio routing: nothing to load, the slot is already occupied"
|
||||||
|
),
|
||||||
|
_ => tracing::warn!(
|
||||||
|
"audio routing: failed to load the {} module: {e:#}",
|
||||||
|
shape.label()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unload whatever the ledger holds for `shape`, and record how it went.
|
||||||
|
///
|
||||||
|
/// A no-op for a slot holding nothing. An outcome that cannot be confirmed is
|
||||||
|
/// recorded as uncertain rather than assumed done, so the module keeps being
|
||||||
|
/// named until the server is asked about it.
|
||||||
|
async fn unload_module(ledger: &Arc<ModuleLedger>, shape: Shape) {
|
||||||
|
let Some(fp) = ledger.begin_unload(shape) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut cmd = tokio::process::Command::new("pactl");
|
||||||
|
cmd.arg("unload-module")
|
||||||
|
.arg(fp.id.to_string())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let outcome = match tokio::time::timeout(PACTL_BUDGET, cmd.output()).await {
|
||||||
|
Ok(Ok(output)) if output.status.success() => {
|
||||||
|
tracing::info!(module = fp.id, "audio routing: unloaded pactl module");
|
||||||
|
UnloadOutcome::Confirmed
|
||||||
|
}
|
||||||
|
Ok(Ok(output)) => UnloadOutcome::Uncertain(format!(
|
||||||
|
"pactl unload-module exited {}: {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
)),
|
||||||
|
Ok(Err(e)) => UnloadOutcome::Uncertain(format!("could not run pactl unload-module: {e}")),
|
||||||
|
Err(_) => {
|
||||||
|
UnloadOutcome::Uncertain(format!("pactl unload-module exceeded {PACTL_BUDGET:?}"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ledger.finish_unload(shape, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking unload [`Drop`] uses, since it has no runtime to await on.
|
||||||
|
fn blocking_unload(id: u32) -> UnloadOutcome {
|
||||||
|
match Command::new("pactl")
|
||||||
.arg("unload-module")
|
.arg("unload-module")
|
||||||
.arg(id.to_string())
|
.arg(id.to_string())
|
||||||
.output();
|
.output()
|
||||||
match result {
|
{
|
||||||
Ok(output) if output.status.success() => {
|
Ok(output) if output.status.success() => {
|
||||||
tracing::info!(module = id, "audio routing: unloaded pactl module");
|
tracing::info!(module = id, "audio routing: unloaded pactl module");
|
||||||
|
UnloadOutcome::Confirmed
|
||||||
}
|
}
|
||||||
Ok(output) => {
|
Ok(output) => UnloadOutcome::Uncertain(format!(
|
||||||
tracing::warn!(
|
"pactl unload-module exited {}: {}",
|
||||||
module = id,
|
output.status,
|
||||||
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
"audio routing: pactl unload-module exited non-zero"
|
)),
|
||||||
);
|
Err(e) => UnloadOutcome::Uncertain(format!("could not run pactl unload-module: {e}")),
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
module = id,
|
|
||||||
"audio routing: failed to run pactl unload-module: {e}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +835,130 @@ fn try_flush(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::repair::plan::{ModuleObservation, classify};
|
||||||
|
|
||||||
|
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event
|
||||||
|
/// task — just the null-sink and its default-sink loopback.
|
||||||
|
fn whole_desktop_opts() -> HostOpts {
|
||||||
|
HostOpts {
|
||||||
|
window: false,
|
||||||
|
app: None,
|
||||||
|
strict_audio: false,
|
||||||
|
display_server: None,
|
||||||
|
quality: crate::cli::Quality::Auto,
|
||||||
|
bitrate: None,
|
||||||
|
framerate: None,
|
||||||
|
max_height: None,
|
||||||
|
no_hwencode: false,
|
||||||
|
max_viewers: None,
|
||||||
|
interactive: false,
|
||||||
|
relay: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The module table exactly as `--repair` observes it.
|
||||||
|
fn module_snapshot() -> Vec<(u32, String, String)> {
|
||||||
|
let mut session =
|
||||||
|
crate::repair::introspect::PulseSession::connect().expect("a local Pulse server");
|
||||||
|
session
|
||||||
|
.list_modules()
|
||||||
|
.expect("the server lists its modules")
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.id, m.name, m.args))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A/B against the live graph: routing must leave the module table exactly
|
||||||
|
/// as it found it. The same shape as `--repair`'s field gate, because the
|
||||||
|
/// property is the same one — nothing of ours outlives the session.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
|
||||||
|
async fn live_teardown_leaves_the_module_table_as_it_found_it() {
|
||||||
|
let before = module_snapshot();
|
||||||
|
let routing = Routing::start(&whole_desktop_opts())
|
||||||
|
.await
|
||||||
|
.expect("routing starts");
|
||||||
|
|
||||||
|
let during = module_snapshot();
|
||||||
|
let ours: Vec<_> = during
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !before.iter().any(|b| b.0 == m.0))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
ours.len(),
|
||||||
|
2,
|
||||||
|
"the null-sink and its default-sink loopback must both be loaded"
|
||||||
|
);
|
||||||
|
for (id, name, args) in ours {
|
||||||
|
let fp = classify(&ModuleObservation::new(*id, name, args))
|
||||||
|
.expect("a module we loaded must match one of our canonical forms");
|
||||||
|
assert!(
|
||||||
|
fp.owner.is_some(),
|
||||||
|
"every module we load carries an owner token, or --repair cannot \
|
||||||
|
attribute it to this host's pid namespace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
routing.shutdown().await;
|
||||||
|
assert_eq!(
|
||||||
|
module_snapshot(),
|
||||||
|
before,
|
||||||
|
"teardown must leave the module table byte-identical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The orphan race, staged against a real server: a load cancelled while
|
||||||
|
/// `pactl` is in flight must still be findable and removable.
|
||||||
|
///
|
||||||
|
/// Whether the server got as far as creating the module is genuinely racy,
|
||||||
|
/// and that is the point — the gate does not care which way it went, only
|
||||||
|
/// that the ledger can account for both. With the permit's `Drop` disarmed
|
||||||
|
/// and the module created, the final comparison fails.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
|
||||||
|
async fn live_a_cancelled_load_is_reconciled_not_orphaned() {
|
||||||
|
let before = module_snapshot();
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let pid = std::process::id();
|
||||||
|
|
||||||
|
let ledger_for_task = Arc::clone(&ledger);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let _ = load_module(&ledger_for_task, Shape::LegacyCaptureSink, pid).await;
|
||||||
|
});
|
||||||
|
// Let the task run up to its first await — the spawned `pactl` — so the
|
||||||
|
// abort lands mid-flight rather than before the load ever started, which
|
||||||
|
// would make this gate vacuous.
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
|
||||||
|
// Non-vacuity: the permit is taken *before* `pactl` is spawned, so a
|
||||||
|
// cancelled load must leave a question behind whichever side of the spawn
|
||||||
|
// the abort landed on. Without this the gate could pass while the abort
|
||||||
|
// fired before the load ever began, proving nothing.
|
||||||
|
assert_eq!(
|
||||||
|
ledger.pending().len(),
|
||||||
|
1,
|
||||||
|
"the cancelled load must have left exactly one question behind"
|
||||||
|
);
|
||||||
|
|
||||||
|
ledger::reconcile_pending(&ledger)
|
||||||
|
.await
|
||||||
|
.expect("the ledger reconciles against the server");
|
||||||
|
for fp in ledger.loaded() {
|
||||||
|
unload_module(&ledger, fp.shape).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ledger.is_settled(),
|
||||||
|
"every slot must end in a state we can explain"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
module_snapshot(),
|
||||||
|
before,
|
||||||
|
"a cancelled load must leave nothing behind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_serial_parses_past_u32() {
|
fn object_serial_parses_past_u32() {
|
||||||
|
|||||||
+68
-5
@@ -74,8 +74,9 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::host::aec::{AecConfig, AecState, AecValidator};
|
use crate::host::aec::{AecConfig, AecState, AecValidator};
|
||||||
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
||||||
|
use crate::host::taint::owner::OwnerKey;
|
||||||
use crate::host::taint::snapshot::Serial;
|
use crate::host::taint::snapshot::Serial;
|
||||||
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, StickyState, evaluate};
|
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, Reason, StickyState, evaluate};
|
||||||
|
|
||||||
/// How long the AEC validator may sit in `Validating` after the graph first
|
/// How long the AEC validator may sit in `Validating` after the graph first
|
||||||
/// reports ready before failing closed. Generous relative to the observer's own
|
/// reports ready before failing closed. Generous relative to the observer's own
|
||||||
@@ -181,6 +182,16 @@ pub struct AuditRow {
|
|||||||
pub eligible: bool,
|
pub eligible: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub reason: Option<&'static str>,
|
pub reason: Option<&'static str>,
|
||||||
|
/// The owner key that carried the taint across, when the reason is
|
||||||
|
/// `tainted-owner-bridge` *and* the tainted member shared a key directly.
|
||||||
|
///
|
||||||
|
/// §5.1 row 1 asserts "reason = owner bridge, **naming the key**" — the
|
||||||
|
/// point being that the exclusion is provably the owner bridge on a
|
||||||
|
/// specific key rather than an incidental link walk that happens to reach
|
||||||
|
/// the same verdict. [`Reason::code`] collapses the payload, so without
|
||||||
|
/// this field that row cannot be asserted from the record at all.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owner_key: Option<&'static str>,
|
||||||
/// The exclusion was carried over from a previous snapshot rather than
|
/// The exclusion was carried over from a previous snapshot rather than
|
||||||
/// derived from the current topology (phase-2 stickiness).
|
/// derived from the current topology (phase-2 stickiness).
|
||||||
pub sticky: bool,
|
pub sticky: bool,
|
||||||
@@ -195,9 +206,36 @@ pub struct TaintRow {
|
|||||||
pub serial: u64,
|
pub serial: u64,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub reason: &'static str,
|
pub reason: &'static str,
|
||||||
|
/// As [`AuditRow::owner_key`]. Present here too because the bridge that
|
||||||
|
/// matters for a row's diagnosis is often on a non-candidate node.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub owner_key: Option<&'static str>,
|
||||||
pub sticky: bool,
|
pub sticky: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The owner key a `tainted-owner-bridge` reason resolved on, if it named one.
|
||||||
|
///
|
||||||
|
/// `None` for every other reason, and also for a bridge whose tainted member
|
||||||
|
/// shared no key *directly* — the taint reached it transitively, so there is no
|
||||||
|
/// single key to name and inventing one would be a false diagnosis.
|
||||||
|
fn owner_key_of(reason: Reason) -> Option<&'static str> {
|
||||||
|
match reason {
|
||||||
|
Reason::TaintedOwnerBridge { key } => key.map(OwnerKey::code),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node carrying a peerspeak ownership carrier on a role the engine does not
|
||||||
|
/// honour it on (round 10, R10-1). `role` is the point of the row: it says
|
||||||
|
/// which non-producer role the tag turned up on, which is what distinguishes a
|
||||||
|
/// producer-side bug from an impersonation attempt.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct IgnoredTagRow {
|
||||||
|
pub serial: u64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
/// The decision content of one recompute — everything except which recompute it
|
/// The decision content of one recompute — everything except which recompute it
|
||||||
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||||
/// derived `==` rather than a hand-maintained field comparison that a later
|
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||||
@@ -227,6 +265,16 @@ pub struct AuditBody {
|
|||||||
pub excluded_count: usize,
|
pub excluded_count: usize,
|
||||||
/// Taint across all node roles, ascending by serial.
|
/// Taint across all node roles, ascending by serial.
|
||||||
pub taint: Vec<TaintRow>,
|
pub taint: Vec<TaintRow>,
|
||||||
|
/// Nodes carrying a peerspeak ownership carrier that the engine
|
||||||
|
/// **ignored** because they are not `Stream/Output/Audio` (round 10,
|
||||||
|
/// R10-1). Normally empty; a non-empty list means either peerspeak is
|
||||||
|
/// tagging something it should not, or a process is impersonating the
|
||||||
|
/// tag. Neither is an exclusion, and neither should be silent.
|
||||||
|
///
|
||||||
|
/// Omitted from the JSONL when empty, so it costs nothing on the common
|
||||||
|
/// path and is impossible to miss when it is not.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub ignored_ownership_tags: Vec<IgnoredTagRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuditBody {
|
impl AuditBody {
|
||||||
@@ -403,11 +451,13 @@ fn build_body(
|
|||||||
// engine would have passed — otherwise a shut gate would erase every
|
// engine would have passed — otherwise a shut gate would erase every
|
||||||
// reason code in the record and the matrix would stop constraining
|
// reason code in the record and the matrix would stop constraining
|
||||||
// the engine at all.
|
// the engine at all.
|
||||||
let (eligible, reason, sticky) = match decision.eligibility {
|
let (eligible, reason, owner_key, sticky) = match decision.eligibility {
|
||||||
Eligibility::NotEligible { reason, sticky } => (false, Some(reason.code()), sticky),
|
Eligibility::NotEligible { reason, sticky } => {
|
||||||
|
(false, Some(reason.code()), owner_key_of(reason), sticky)
|
||||||
|
}
|
||||||
Eligibility::Eligible => match gate_reason {
|
Eligibility::Eligible => match gate_reason {
|
||||||
Some(gate) => (false, Some(gate.code()), false),
|
Some(gate) => (false, Some(gate.code()), None, false),
|
||||||
None => (true, None, false),
|
None => (true, None, None, false),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
AuditRow {
|
AuditRow {
|
||||||
@@ -415,6 +465,7 @@ fn build_body(
|
|||||||
name: decision.name.clone(),
|
name: decision.name.clone(),
|
||||||
eligible,
|
eligible,
|
||||||
reason,
|
reason,
|
||||||
|
owner_key,
|
||||||
sticky,
|
sticky,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -429,10 +480,21 @@ fn build_body(
|
|||||||
serial: serial.0,
|
serial: serial.0,
|
||||||
name: node_name(projection, serial),
|
name: node_name(projection, serial),
|
||||||
reason: entry.reason.code(),
|
reason: entry.reason.code(),
|
||||||
|
owner_key: owner_key_of(entry.reason),
|
||||||
sticky: entry.sticky,
|
sticky: entry.sticky,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let ignored_ownership_tags: Vec<IgnoredTagRow> =
|
||||||
|
crate::host::taint::misplaced_ownership_tags(&projection.snapshot)
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| IgnoredTagRow {
|
||||||
|
serial: node.serial.0,
|
||||||
|
name: node.name.clone(),
|
||||||
|
role: node.role.code(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
AuditBody {
|
AuditBody {
|
||||||
graph_ready: projection.graph_ready,
|
graph_ready: projection.graph_ready,
|
||||||
epoch: readiness_code(projection.readiness),
|
epoch: readiness_code(projection.readiness),
|
||||||
@@ -444,6 +506,7 @@ fn build_body(
|
|||||||
eligible_count,
|
eligible_count,
|
||||||
candidates,
|
candidates,
|
||||||
taint,
|
taint,
|
||||||
|
ignored_ownership_tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-4
@@ -22,9 +22,17 @@ use super::metrics::{BUCKET_LABELS, Metrics, QUEUE_THRESHOLD_US, Sample};
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::host::aec::AecConfig;
|
use crate::host::aec::AecConfig;
|
||||||
use crate::host::observer::{EventKind, Readiness};
|
use crate::host::observer::{EventKind, Readiness};
|
||||||
|
use crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX;
|
||||||
use crate::host::taint::fixture::{self, Graph, NodeRef};
|
use crate::host::taint::fixture::{self, Graph, NodeRef};
|
||||||
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
use crate::host::taint::snapshot::{GraphSnapshot, MediaRole};
|
||||||
|
|
||||||
|
/// The `node.name` a [`Graph::peerspeak_node`] fixture produces. Built from
|
||||||
|
/// the same constant the engine matches on, so these audit rows report the
|
||||||
|
/// name shape a live peerspeak node actually has (v3.5 §5.1, carrier 2).
|
||||||
|
fn owned_name(role: &str, pid: u32) -> String {
|
||||||
|
format!("{PEERSPEAK_OWNED_NODE_PREFIX}{role}_{pid}")
|
||||||
|
}
|
||||||
|
|
||||||
const AEC_MODULE: u64 = 7;
|
const AEC_MODULE: u64 = 7;
|
||||||
const TIMEOUT: Millis = 5_000;
|
const TIMEOUT: Millis = 5_000;
|
||||||
|
|
||||||
@@ -117,6 +125,58 @@ fn the_record_carries_the_complete_candidate_universe() {
|
|||||||
assert_eq!(outcome.record.body.excluded_count, 0);
|
assert_eq!(outcome.record.body.excluded_count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **R10-1's diagnostic reaches the record.** The engine deliberately ignores
|
||||||
|
/// an ownership carrier on a non-producer, which means the fix removes an
|
||||||
|
/// exclusion — so the only way an operator learns a tag was seen and dropped is
|
||||||
|
/// this field. A matrix row that silently grew an impostor would otherwise read
|
||||||
|
/// as a clean pass.
|
||||||
|
#[test]
|
||||||
|
fn an_ignored_ownership_tag_is_reported_without_excluding_anything() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
graph.app_node("music", MediaRole::StreamOutput, 100);
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||||
|
let projection = ready(graph.build());
|
||||||
|
|
||||||
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||||
|
|
||||||
|
// The bystander is untouched — the point of the fix.
|
||||||
|
let (eligible, excluded) = partition(&body);
|
||||||
|
assert_eq!(eligible, vec!["music"]);
|
||||||
|
assert!(excluded.is_empty(), "unexpected exclusions: {excluded:?}");
|
||||||
|
assert!(body.taint.is_empty(), "unexpected taint: {:?}", body.taint);
|
||||||
|
|
||||||
|
// ...but the tag is not silent, and the row names the role it appeared on.
|
||||||
|
assert_eq!(body.ignored_ownership_tags.len(), 1);
|
||||||
|
let row = &body.ignored_ownership_tags[0];
|
||||||
|
assert_eq!(row.serial, impostor.serial.0);
|
||||||
|
assert_eq!(row.role, "stream-input");
|
||||||
|
assert_eq!(
|
||||||
|
row.name.as_deref(),
|
||||||
|
Some(owned_name("rogue", 4_242).as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The common path stays quiet: a correctly tagged peerspeak producer is
|
||||||
|
/// honoured as a taint root and is *not* reported as a misplaced tag. Without
|
||||||
|
/// this, a diagnostic that fired on every normal run would be worthless.
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_tagged_producer_is_not_reported_as_misplaced() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("speakers", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("call", 200);
|
||||||
|
graph.link(call, sink);
|
||||||
|
let projection = ready(graph.build());
|
||||||
|
|
||||||
|
let body = observe(&mut auditor_off(), &projection, 0).record.body;
|
||||||
|
|
||||||
|
assert_eq!(body.excluded_count, 1);
|
||||||
|
assert!(
|
||||||
|
body.ignored_ownership_tags.is_empty(),
|
||||||
|
"honoured tag reported as misplaced: {:?}",
|
||||||
|
body.ignored_ownership_tags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
/// The fail-closed default asserted at the boundary (impl plan §4, phase 2's
|
||||||
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
/// "one addition"): nothing in, nothing eligible — and, just as importantly, no
|
||||||
/// panic and no invented row.
|
/// panic and no invented row.
|
||||||
@@ -284,13 +344,14 @@ fn a_shut_gate_preserves_the_engines_own_reasons() {
|
|||||||
.body;
|
.body;
|
||||||
let (_, excluded) = partition(&body);
|
let (_, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let playback = owned_name("peerspeak-playback", 200);
|
||||||
assert!(!body.fan_out_permitted);
|
assert!(!body.fan_out_permitted);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("music", "aec-validating"),
|
("music", "aec-validating"),
|
||||||
// Tagged, so it keeps the reason that actually applies to it.
|
// Tagged, so it keeps the reason that actually applies to it.
|
||||||
("peerspeak-playback", "peerspeak-owned"),
|
(playback.as_str(), "peerspeak-owned"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -457,16 +518,64 @@ fn row_1_owner_bridge_forwarder_with_an_untainted_control() {
|
|||||||
.body;
|
.body;
|
||||||
let (eligible, excluded) = partition(&body);
|
let (eligible, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
assert_eq!(eligible, vec!["clean-loopback-playback"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("peerspeak-call", "peerspeak-owned"),
|
(call_name.as_str(), "peerspeak-owned"),
|
||||||
("tainted-loopback-playback", "tainted-owner-bridge"),
|
("tainted-loopback-playback", "tainted-owner-bridge"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5.1 row 1's other half: the record must **name the key** the bridge
|
||||||
|
/// resolved on, not merely say "owner bridge".
|
||||||
|
///
|
||||||
|
/// Without this the row is unassertable from the record: `Reason::code`
|
||||||
|
/// collapses `TaintedOwnerBridge { key }` to one string, so an exclusion that
|
||||||
|
/// arrived by an incidental link walk and one that arrived across a named owner
|
||||||
|
/// key are indistinguishable — and the row exists precisely to tell them apart.
|
||||||
|
/// The fixture's forwarder legs are joined by `pulse.module.id`, so that is the
|
||||||
|
/// key that must be reported.
|
||||||
|
#[test]
|
||||||
|
fn row_1_names_the_owner_key_the_bridge_resolved_on() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let call = graph.peerspeak_node("peerspeak-call", 200);
|
||||||
|
let sink = graph.module_node("tainted-null-sink", MediaRole::Sink, 30);
|
||||||
|
graph.link(call, sink);
|
||||||
|
let capture = graph.module_node("tainted-loopback-capture", MediaRole::StreamInput, 30);
|
||||||
|
let _playback = graph.module_node("tainted-loopback-playback", MediaRole::StreamOutput, 30);
|
||||||
|
graph.link(sink, capture);
|
||||||
|
|
||||||
|
let body = observe(&mut auditor_off(), &ready(graph.build()), 0)
|
||||||
|
.record
|
||||||
|
.body;
|
||||||
|
|
||||||
|
let playback = body
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.name.as_deref() == Some("tainted-loopback-playback"))
|
||||||
|
.expect("the forwarder's playback leg is a candidate");
|
||||||
|
assert_eq!(playback.reason, Some("tainted-owner-bridge"));
|
||||||
|
assert_eq!(
|
||||||
|
playback.owner_key,
|
||||||
|
Some("pulse.module.id"),
|
||||||
|
"the bridge key must be named in the record, not collapsed into the reason code"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it stays absent everywhere it would be a false diagnosis: the tag
|
||||||
|
// exclusion is not a bridge at all.
|
||||||
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
|
let tagged = body
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.name.as_deref() == Some(call_name.as_str()))
|
||||||
|
.expect("the tagged call playback is a candidate");
|
||||||
|
assert_eq!(tagged.reason, Some("peerspeak-owned"));
|
||||||
|
assert_eq!(tagged.owner_key, None);
|
||||||
|
}
|
||||||
|
|
||||||
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
|
/// §5.1 row 3: two Pulse modules, one tainted input. **The other module's output
|
||||||
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
|
/// must be eligible** — this is the row that makes a wrong pipewire-pulse-PID
|
||||||
/// fusion observable, because fusing all Pulse-created nodes into one owner
|
/// fusion observable, because fusing all Pulse-created nodes into one owner
|
||||||
@@ -494,11 +603,12 @@ fn row_3_one_tainted_module_does_not_taint_the_other() {
|
|||||||
.body;
|
.body;
|
||||||
let (eligible, excluded) = partition(&body);
|
let (eligible, excluded) = partition(&body);
|
||||||
|
|
||||||
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
assert_eq!(eligible, vec!["module-b-playback"]);
|
assert_eq!(eligible, vec!["module-b-playback"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
excluded,
|
excluded,
|
||||||
vec![
|
vec![
|
||||||
("peerspeak-call", "peerspeak-owned"),
|
(call_name.as_str(), "peerspeak-owned"),
|
||||||
("module-a-playback", "tainted-owner-bridge"),
|
("module-a-playback", "tainted-owner-bridge"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -701,7 +811,8 @@ fn the_taint_view_covers_non_candidate_roles() {
|
|||||||
tainted.contains(&("null-sink", "tainted-upstream")),
|
tainted.contains(&("null-sink", "tainted-upstream")),
|
||||||
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
"the sink is not a candidate but its taint is what explains the row: {tainted:?}"
|
||||||
);
|
);
|
||||||
assert!(tainted.contains(&("peerspeak-call", "peerspeak-owned")));
|
let call_name = owned_name("peerspeak-call", 200);
|
||||||
|
assert!(tainted.contains(&(call_name.as_str(), "peerspeak-owned")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
/// A record must serialise to a single line. Newlines inside a JSON Lines
|
||||||
|
|||||||
@@ -0,0 +1,890 @@
|
|||||||
|
//! What this host has put into the Pulse module table — and, where it cannot be
|
||||||
|
//! sure, what it must go and find out before doing anything else.
|
||||||
|
//!
|
||||||
|
//! # The defect this exists for
|
||||||
|
//!
|
||||||
|
//! Module ids used to live in three `Option<u32>`s, two of them shared with the
|
||||||
|
//! event task behind an `Arc<Mutex<…>>`. That representation cannot express *a
|
||||||
|
//! load is in flight*, and the gap is reachable today: teardown calls
|
||||||
|
//! `event_task.abort()` and then reads the ids, but the task's work is a
|
||||||
|
//! synchronous `pactl` call with no await point inside it, so the abort cannot
|
||||||
|
//! land until the load has already returned. Teardown therefore sees `None`,
|
||||||
|
//! unloads the sink, and the still-running task stores the new module's id into a
|
||||||
|
//! mutex nobody will ever read again — an orphan loopback pointing at a sink that
|
||||||
|
//! no longer exists.
|
||||||
|
//!
|
||||||
|
//! A slot is consequently not an `Option<u32>` but a small state machine, and the
|
||||||
|
//! transitions that matter are the ones that admit *we do not know*:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! begin_load commit
|
||||||
|
//! Vacant ───────────────► Loading ───────────────► Loaded
|
||||||
|
//! ▲ │ │ │
|
||||||
|
//! │ abandon │ │ permit dropped │ begin_unload
|
||||||
|
//! └────────────────────┘ │ unsettled ▼
|
||||||
|
//! ▲ ▼ Unloading
|
||||||
|
//! │ Resolution::Nothing Ambiguous ◄──────────────┘
|
||||||
|
//! └───────────────────────► │ ▲ uncertain outcome
|
||||||
|
//! │ │
|
||||||
|
//! Resolution::Conflict▼ └── Resolution::Adopt ──► Loaded
|
||||||
|
//! Poisoned
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Why the permit is affine
|
||||||
|
//!
|
||||||
|
//! [`LoadPermit`] is not `Clone`, is consumed by value to settle, and its [`Drop`]
|
||||||
|
//! marks the slot [`Reconcile::Load`] when it was never settled. That is what makes
|
||||||
|
//! the guarantee structural rather than a discipline: a cancelled task drops its
|
||||||
|
//! locals, so an aborted load *cannot* silently forget a module the server may
|
||||||
|
//! already have created. Two permitted loads for one slot cannot both commit,
|
||||||
|
//! because [`ModuleLedger::begin_load`] issues a permit only for a `Vacant` slot
|
||||||
|
//! and every other state — including the ambiguous one — refuses.
|
||||||
|
//!
|
||||||
|
//! # Why an ambiguous slot blocks the next load
|
||||||
|
//!
|
||||||
|
//! An unresolved load may or may not have created a module carrying our sink name.
|
||||||
|
//! Starting another one on top of it risks two sinks sharing a `node.name`, which
|
||||||
|
//! is measurably not an error the server reports: it accepts both, and
|
||||||
|
//! `pulsesrc device=<name>.monitor` attaches to the *older* one. A second capture
|
||||||
|
//! session would then be silently stolen by the debris of the first. Refusing to
|
||||||
|
//! proceed until the question is answered is the whole point.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
|
||||||
|
use crate::repair::introspect::PulseSession;
|
||||||
|
use crate::repair::plan::{
|
||||||
|
Fingerprint, ModuleObservation, OwnerToken, Shape, classify, recorded_argument,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// PulseAudio's "no such index" — `PA_INVALID_INDEX`, `(uint32_t) -1`.
|
||||||
|
///
|
||||||
|
/// Every Pulse load callback reports failure by handing back this value rather
|
||||||
|
/// than an index. We load through `pactl`, which reports failure by exiting
|
||||||
|
/// non-zero instead, so seeing it on stdout would mean the tool printed a
|
||||||
|
/// sentinel we must not mistake for a module: unloading it would be a request
|
||||||
|
/// about something that cannot exist. Treated as a load whose outcome is unknown,
|
||||||
|
/// never as a successful one.
|
||||||
|
pub const PA_INVALID_INDEX: u32 = u32::MAX;
|
||||||
|
|
||||||
|
/// What an unresolved slot needs looked up before it can be trusted again.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Reconcile {
|
||||||
|
/// A load whose outcome is unknown: `pactl` was killed, timed out, or printed
|
||||||
|
/// something we could not read as an index. The server may or may not have
|
||||||
|
/// created the module, so it is looked for **by owner token** — the nonce is
|
||||||
|
/// minted per load, so it names this attempt and no other.
|
||||||
|
Load {
|
||||||
|
shape: Shape,
|
||||||
|
pid: u32,
|
||||||
|
token: OwnerToken,
|
||||||
|
},
|
||||||
|
/// An unload whose outcome is unknown. The module is looked for by its full
|
||||||
|
/// fingerprint: still present means the unload did not happen, absent means it
|
||||||
|
/// did. An id alone would not do, because Pulse reuses module indices verbatim.
|
||||||
|
Unload { fp: Fingerprint },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One shape's slot in the module table.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SlotState {
|
||||||
|
/// Nothing loaded, and nothing outstanding.
|
||||||
|
Vacant,
|
||||||
|
/// A load is in flight under `permit`. The id is meaningless until it commits.
|
||||||
|
Loading { token: OwnerToken, permit: u64 },
|
||||||
|
/// A module we loaded and can name exactly.
|
||||||
|
Loaded { fp: Fingerprint },
|
||||||
|
/// An unload is in flight. The fingerprint is retained deliberately: an unload
|
||||||
|
/// that times out must not leave the module unrecorded.
|
||||||
|
Unloading { fp: Fingerprint },
|
||||||
|
/// The slot's real state is unknown and must be resolved against the server.
|
||||||
|
Ambiguous(Reconcile),
|
||||||
|
/// Resolution found something we refuse to act on. Terminal.
|
||||||
|
Poisoned { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlotState {
|
||||||
|
/// A short, stable label for logs and refusal messages.
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SlotState::Vacant => "vacant",
|
||||||
|
SlotState::Loading { .. } => "loading",
|
||||||
|
SlotState::Loaded { .. } => "loaded",
|
||||||
|
SlotState::Unloading { .. } => "unloading",
|
||||||
|
SlotState::Ambiguous(_) => "ambiguous",
|
||||||
|
SlotState::Poisoned { .. } => "poisoned",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the ledger refused an operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LedgerError {
|
||||||
|
/// The slot was not free. Carries the state that refused, for the log line.
|
||||||
|
Busy { shape: Shape, state: &'static str },
|
||||||
|
/// The slot is poisoned and will not be used again this session.
|
||||||
|
Poisoned { shape: Shape, reason: String },
|
||||||
|
/// `pactl` reported `PA_INVALID_INDEX` where an index was expected.
|
||||||
|
InvalidIndex { shape: Shape },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LedgerError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
LedgerError::Busy { shape, state } => {
|
||||||
|
write!(f, "the {} slot is {state}, not free", shape.label())
|
||||||
|
}
|
||||||
|
LedgerError::Poisoned { shape, reason } => {
|
||||||
|
write!(f, "the {} slot is poisoned: {reason}", shape.label())
|
||||||
|
}
|
||||||
|
LedgerError::InvalidIndex { shape } => write!(
|
||||||
|
f,
|
||||||
|
"pactl reported PA_INVALID_INDEX for the {} module",
|
||||||
|
shape.label()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for LedgerError {}
|
||||||
|
|
||||||
|
/// The outcome of an attempted unload, as the caller observed it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum UnloadOutcome {
|
||||||
|
/// The server acknowledged the unload.
|
||||||
|
Confirmed,
|
||||||
|
/// The unload may or may not have happened: the command failed, was killed,
|
||||||
|
/// or its result could not be read.
|
||||||
|
Uncertain(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a reconciliation found.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Resolution {
|
||||||
|
/// Exactly one module answers the question. Adopt it.
|
||||||
|
Adopt(Fingerprint),
|
||||||
|
/// No module answers it: the load never happened, or the unload did.
|
||||||
|
Nothing,
|
||||||
|
/// More than one module answers it. Refuse to guess — see [`Resolution`]'s use
|
||||||
|
/// in [`ModuleLedger::apply`], which poisons the slot rather than picking.
|
||||||
|
Conflict(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ledger proper: one slot per [`Shape`], plus the permit counter.
|
||||||
|
///
|
||||||
|
/// Held behind an `Arc` because the permit needs a way back to it from [`Drop`],
|
||||||
|
/// which is what makes a cancelled load impossible to lose.
|
||||||
|
pub struct ModuleLedger {
|
||||||
|
slots: Mutex<BTreeMap<Shape, SlotState>>,
|
||||||
|
next_permit: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleLedger {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
slots: Mutex::new(BTreeMap::new()),
|
||||||
|
next_permit: AtomicU64::new(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current state of one slot. Absent keys read as [`SlotState::Vacant`].
|
||||||
|
///
|
||||||
|
/// Test-only: production code never needs to look a slot up, because every
|
||||||
|
/// decision that depends on one is made *by* the ledger — `begin_load`
|
||||||
|
/// refuses a busy slot and says which state refused, `begin_unload` returns
|
||||||
|
/// nothing for a slot holding nothing. An accessor callers could branch on
|
||||||
|
/// would invite exactly the check-then-act races the permit removes.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn state(&self, shape: Shape) -> SlotState {
|
||||||
|
self.slots
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&shape)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(SlotState::Vacant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take permission to load `shape`, moving the slot to
|
||||||
|
/// [`SlotState::Loading`].
|
||||||
|
///
|
||||||
|
/// Refuses anything but a `Vacant` slot. In particular an *ambiguous* slot
|
||||||
|
/// refuses, so an unresolved load blocks the next one until it is reconciled.
|
||||||
|
pub fn begin_load(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
shape: Shape,
|
||||||
|
pid: u32,
|
||||||
|
token: OwnerToken,
|
||||||
|
) -> Result<LoadPermit, LedgerError> {
|
||||||
|
let mut slots = self.slots.lock().unwrap();
|
||||||
|
let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
|
||||||
|
match current {
|
||||||
|
SlotState::Vacant => {}
|
||||||
|
SlotState::Poisoned { reason } => {
|
||||||
|
return Err(LedgerError::Poisoned { shape, reason });
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(LedgerError::Busy {
|
||||||
|
shape,
|
||||||
|
state: other.label(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
|
||||||
|
slots.insert(
|
||||||
|
shape,
|
||||||
|
SlotState::Loading {
|
||||||
|
token: token.clone(),
|
||||||
|
permit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(slots);
|
||||||
|
Ok(LoadPermit {
|
||||||
|
ledger: Arc::clone(self),
|
||||||
|
shape,
|
||||||
|
pid,
|
||||||
|
token,
|
||||||
|
permit,
|
||||||
|
settled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a `Loaded` slot to `Unloading` and hand back what to unload.
|
||||||
|
///
|
||||||
|
/// `None` for any other state: there is nothing to unload, or the slot is not
|
||||||
|
/// in a condition to be acted on.
|
||||||
|
pub fn begin_unload(&self, shape: Shape) -> Option<Fingerprint> {
|
||||||
|
let mut slots = self.slots.lock().unwrap();
|
||||||
|
let SlotState::Loaded { fp } = slots.get(&shape).cloned()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
slots.insert(shape, SlotState::Unloading { fp: fp.clone() });
|
||||||
|
Some(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record how an unload turned out. An uncertain one becomes ambiguous rather
|
||||||
|
/// than being assumed done — the module is not forgotten either way.
|
||||||
|
pub fn finish_unload(&self, shape: Shape, outcome: UnloadOutcome) {
|
||||||
|
let mut slots = self.slots.lock().unwrap();
|
||||||
|
let Some(SlotState::Unloading { fp }) = slots.get(&shape).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let next = match outcome {
|
||||||
|
UnloadOutcome::Confirmed => SlotState::Vacant,
|
||||||
|
UnloadOutcome::Uncertain(why) => {
|
||||||
|
tracing::warn!(
|
||||||
|
shape = fp.shape.label(),
|
||||||
|
module = fp.id,
|
||||||
|
"audio ledger: unload outcome uncertain ({why}); slot needs reconciling"
|
||||||
|
);
|
||||||
|
SlotState::Ambiguous(Reconcile::Unload { fp })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
slots.insert(shape, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every slot currently holding a module, in [`Shape`] declaration order —
|
||||||
|
/// which is unload order: the loopbacks that reference the capture sink come
|
||||||
|
/// before the sink itself.
|
||||||
|
pub fn loaded(&self) -> Vec<Fingerprint> {
|
||||||
|
self.slots
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter_map(|state| match state {
|
||||||
|
SlotState::Loaded { fp } => Some(fp.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every question outstanding against the server, in shape order.
|
||||||
|
pub fn pending(&self) -> Vec<(Shape, Reconcile)> {
|
||||||
|
self.slots
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(shape, state)| match state {
|
||||||
|
SlotState::Ambiguous(r) => Some((*shape, r.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is every slot in a state we can explain? False while anything is ambiguous
|
||||||
|
/// or poisoned.
|
||||||
|
pub fn is_settled(&self) -> bool {
|
||||||
|
self.slots
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.all(|state| !matches!(state, SlotState::Ambiguous(_) | SlotState::Poisoned { .. }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a reconciliation result to an ambiguous slot.
|
||||||
|
///
|
||||||
|
/// A slot that is no longer ambiguous is left alone: the answer is stale, and
|
||||||
|
/// overwriting a live state with it would be worse than ignoring it.
|
||||||
|
pub fn apply(&self, shape: Shape, resolution: Resolution) {
|
||||||
|
let mut slots = self.slots.lock().unwrap();
|
||||||
|
if !matches!(slots.get(&shape), Some(SlotState::Ambiguous(_))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let next = match resolution {
|
||||||
|
Resolution::Adopt(fp) => {
|
||||||
|
tracing::info!(
|
||||||
|
shape = shape.label(),
|
||||||
|
module = fp.id,
|
||||||
|
"audio ledger: reconciled — adopting the module the server actually has"
|
||||||
|
);
|
||||||
|
SlotState::Loaded { fp }
|
||||||
|
}
|
||||||
|
Resolution::Nothing => {
|
||||||
|
tracing::info!(
|
||||||
|
shape = shape.label(),
|
||||||
|
"audio ledger: reconciled — the server has no such module"
|
||||||
|
);
|
||||||
|
SlotState::Vacant
|
||||||
|
}
|
||||||
|
Resolution::Conflict(n) => {
|
||||||
|
let reason =
|
||||||
|
format!("{n} modules answer to this slot's token; refusing to choose one");
|
||||||
|
tracing::error!(shape = shape.label(), "audio ledger: {reason}");
|
||||||
|
SlotState::Poisoned { reason }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
slots.insert(shape, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission to perform exactly one load, which must be settled by value.
|
||||||
|
///
|
||||||
|
/// Dropping it unsettled is not an error — it is the *reporting* path for a
|
||||||
|
/// cancelled or panicking load, and it marks the slot ambiguous so the module the
|
||||||
|
/// server may have created is looked for rather than forgotten.
|
||||||
|
#[must_use = "an unsettled permit marks the slot ambiguous when dropped"]
|
||||||
|
pub struct LoadPermit {
|
||||||
|
ledger: Arc<ModuleLedger>,
|
||||||
|
shape: Shape,
|
||||||
|
pid: u32,
|
||||||
|
token: OwnerToken,
|
||||||
|
permit: u64,
|
||||||
|
settled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadPermit {
|
||||||
|
/// Record that the server created the module at `index`.
|
||||||
|
///
|
||||||
|
/// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that
|
||||||
|
/// dropping it marks the slot ambiguous — a sentinel where an index belongs
|
||||||
|
/// means the load's outcome is precisely what we do not know.
|
||||||
|
pub fn commit(mut self, index: u32) -> Result<Fingerprint, LedgerError> {
|
||||||
|
if index == PA_INVALID_INDEX {
|
||||||
|
return Err(LedgerError::InvalidIndex { shape: self.shape });
|
||||||
|
}
|
||||||
|
let fp = expected_fingerprint(self.shape, self.pid, &self.token, index);
|
||||||
|
// A permit outlives its slot's `Loading` state only if something else has
|
||||||
|
// already moved the slot on — in which case this answer is stale and the
|
||||||
|
// live state wins.
|
||||||
|
let mut slots = self.ledger.slots.lock().unwrap();
|
||||||
|
match slots.get(&self.shape) {
|
||||||
|
Some(SlotState::Loading { permit, .. }) if *permit == self.permit => {
|
||||||
|
slots.insert(self.shape, SlotState::Loaded { fp: fp.clone() });
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
let state = other.map(SlotState::label).unwrap_or("vacant");
|
||||||
|
tracing::warn!(
|
||||||
|
shape = self.shape.label(),
|
||||||
|
module = index,
|
||||||
|
"audio ledger: a load committed against a slot that is now {state}; \
|
||||||
|
leaving the live state alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(slots);
|
||||||
|
self.settled = true;
|
||||||
|
Ok(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that the server definitively created nothing.
|
||||||
|
///
|
||||||
|
/// Only for a load that failed *cleanly* — `pactl` exiting non-zero of its own
|
||||||
|
/// accord, having reported the server's refusal. A killed or timed-out load is
|
||||||
|
/// not this: drop the permit instead and let the slot go ambiguous.
|
||||||
|
pub fn abandon(mut self) {
|
||||||
|
let mut slots = self.ledger.slots.lock().unwrap();
|
||||||
|
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
|
||||||
|
&& *permit == self.permit
|
||||||
|
{
|
||||||
|
slots.insert(self.shape, SlotState::Vacant);
|
||||||
|
}
|
||||||
|
drop(slots);
|
||||||
|
self.settled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LoadPermit {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.settled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut slots = self.ledger.slots.lock().unwrap();
|
||||||
|
// Only claim the slot if it is still *our* load. Anything else already
|
||||||
|
// moved past this permit.
|
||||||
|
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
|
||||||
|
&& *permit == self.permit
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
shape = self.shape.label(),
|
||||||
|
"audio ledger: a load was cancelled before its outcome was known; \
|
||||||
|
the slot needs reconciling"
|
||||||
|
);
|
||||||
|
slots.insert(
|
||||||
|
self.shape,
|
||||||
|
SlotState::Ambiguous(Reconcile::Load {
|
||||||
|
shape: self.shape,
|
||||||
|
pid: self.pid,
|
||||||
|
token: self.token.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fingerprint a successful load of `shape` for `pid` under `token` must have.
|
||||||
|
///
|
||||||
|
/// Built from the shape's own renderer, exactly as [`classify`] rebuilds it from
|
||||||
|
/// an observation, so an adopted module and a committed one are the same value.
|
||||||
|
fn expected_fingerprint(shape: Shape, pid: u32, token: &OwnerToken, id: u32) -> Fingerprint {
|
||||||
|
Fingerprint {
|
||||||
|
id,
|
||||||
|
module_name: shape.module_name().to_string(),
|
||||||
|
args: recorded_argument(&shape.render_args(pid, Some(token))),
|
||||||
|
pid,
|
||||||
|
shape,
|
||||||
|
owner: Some(token.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer one outstanding question against a snapshot of the module table.
|
||||||
|
///
|
||||||
|
/// Pure: the snapshot is the only input, so every branch is testable without a
|
||||||
|
/// Pulse server.
|
||||||
|
pub fn resolve(reconcile: &Reconcile, observations: &[ModuleObservation]) -> Resolution {
|
||||||
|
let matches: Vec<Fingerprint> = match reconcile {
|
||||||
|
// The nonce is minted per load, so token equality names this attempt and
|
||||||
|
// no other — including a previous load of the same shape by the same pid.
|
||||||
|
Reconcile::Load { shape, token, .. } => observations
|
||||||
|
.iter()
|
||||||
|
.filter_map(classify)
|
||||||
|
.filter(|fp| fp.shape == *shape && fp.owner.as_ref() == Some(token))
|
||||||
|
.collect(),
|
||||||
|
// A full fingerprint match, not an id: Pulse reuses module indices
|
||||||
|
// verbatim, so "something is at that index" is not "our module is".
|
||||||
|
Reconcile::Unload { fp } => observations
|
||||||
|
.iter()
|
||||||
|
.filter(|obs| fp.still_matches(obs))
|
||||||
|
.filter_map(classify)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
match matches.len() {
|
||||||
|
0 => Resolution::Nothing,
|
||||||
|
1 => Resolution::Adopt(matches.into_iter().next().expect("length checked")),
|
||||||
|
n => Resolution::Conflict(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve every outstanding question against one snapshot of the module table.
|
||||||
|
///
|
||||||
|
/// One listing answers all of them, so the slots are reconciled against a single
|
||||||
|
/// server response rather than several that could disagree.
|
||||||
|
///
|
||||||
|
/// ⚠️ The session is deliberately short-lived — connect, list, disconnect — which
|
||||||
|
/// is `--repair`'s pattern and not the long-lived host session round 19 sketched.
|
||||||
|
/// `repair::introspect` documents why: on a request timeout the binding leaks the
|
||||||
|
/// boxed callback until the context disconnects, which is bounded and harmless for
|
||||||
|
/// a session that ends immediately, and is not acceptable for one held open for
|
||||||
|
/// the life of a share. Reconciliation is rare and off the hot path, so paying a
|
||||||
|
/// connection for it costs nothing that matters.
|
||||||
|
pub async fn reconcile_pending(ledger: &Arc<ModuleLedger>) -> Result<()> {
|
||||||
|
let pending = ledger.pending();
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
n = pending.len(),
|
||||||
|
"audio ledger: reconciling unresolved module slots against the server"
|
||||||
|
);
|
||||||
|
let observations = tokio::task::spawn_blocking(|| -> Result<Vec<ModuleObservation>> {
|
||||||
|
let mut session = PulseSession::connect()?;
|
||||||
|
session.list_modules()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("the Pulse listing task failed to run")?
|
||||||
|
.context("could not list Pulse modules to reconcile the audio ledger")?;
|
||||||
|
|
||||||
|
for (shape, reconcile) in pending {
|
||||||
|
ledger.apply(shape, resolve(&reconcile, &observations));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn token(nonce: u64) -> OwnerToken {
|
||||||
|
OwnerToken {
|
||||||
|
machine: "abc123".to_string(),
|
||||||
|
boot: "def456".to_string(),
|
||||||
|
pid_ns: 4_026_531_836,
|
||||||
|
nonce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A module observation as the server would report it for one of our loads.
|
||||||
|
fn observed(id: u32, shape: Shape, pid: u32, token: &OwnerToken) -> ModuleObservation {
|
||||||
|
ModuleObservation::new(
|
||||||
|
id,
|
||||||
|
shape.module_name(),
|
||||||
|
&recorded_argument(&shape.render_args(pid, Some(token))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_committed_load_becomes_loaded() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let permit = ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit");
|
||||||
|
assert!(matches!(
|
||||||
|
ledger.state(Shape::LegacyCaptureSink),
|
||||||
|
SlotState::Loading { .. }
|
||||||
|
));
|
||||||
|
let fp = permit.commit(9).expect("9 is a real index");
|
||||||
|
assert_eq!(fp.id, 9);
|
||||||
|
assert_eq!(
|
||||||
|
ledger.state(Shape::LegacyCaptureSink),
|
||||||
|
SlotState::Loaded { fp }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_permit_unsettled_marks_the_slot_ambiguous() {
|
||||||
|
// The orphan race in one test: a load that is cancelled between spawning
|
||||||
|
// pactl and reading its id must leave the slot asking a question, never
|
||||||
|
// looking empty.
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let permit = ledger
|
||||||
|
.begin_load(Shape::LoopbackOutOfCapture, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit");
|
||||||
|
drop(permit);
|
||||||
|
assert_eq!(
|
||||||
|
ledger.state(Shape::LoopbackOutOfCapture),
|
||||||
|
SlotState::Ambiguous(Reconcile::Load {
|
||||||
|
shape: Shape::LoopbackOutOfCapture,
|
||||||
|
pid: 42,
|
||||||
|
token: token(7),
|
||||||
|
}),
|
||||||
|
"a cancelled load must be remembered as a question, not as a vacancy"
|
||||||
|
);
|
||||||
|
assert!(!ledger.is_settled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abandoning_a_cleanly_failed_load_returns_the_slot_to_vacant() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit")
|
||||||
|
.abandon();
|
||||||
|
assert_eq!(
|
||||||
|
ledger.state(Shape::LoopbackIntoCapture),
|
||||||
|
SlotState::Vacant,
|
||||||
|
"a load the server refused created nothing to reconcile"
|
||||||
|
);
|
||||||
|
assert!(ledger.is_settled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_permit_is_refused_while_a_load_is_in_flight() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let _first = ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit");
|
||||||
|
let second = ledger.begin_load(Shape::LegacyCaptureSink, 42, token(8));
|
||||||
|
assert_eq!(
|
||||||
|
second.err(),
|
||||||
|
Some(LedgerError::Busy {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
state: "loading"
|
||||||
|
}),
|
||||||
|
"two loads for one slot must not both be permitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_slot_refuses_the_next_load_until_it_is_reconciled() {
|
||||||
|
// Why this matters: two sinks may share a node.name, and pulsesrc attaches
|
||||||
|
// to the older one — so loading over unresolved debris silently steals the
|
||||||
|
// next session's capture.
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
drop(
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(8))
|
||||||
|
.err(),
|
||||||
|
Some(LedgerError::Busy {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
state: "ambiguous"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconciling to "nothing was created" frees it again.
|
||||||
|
ledger.apply(Shape::LegacyCaptureSink, Resolution::Nothing);
|
||||||
|
assert!(
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(9))
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_index_is_not_adopted_and_leaves_a_question_behind() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let permit = ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit");
|
||||||
|
assert_eq!(
|
||||||
|
permit.commit(PA_INVALID_INDEX).err(),
|
||||||
|
Some(LedgerError::InvalidIndex {
|
||||||
|
shape: Shape::LegacyCaptureSink
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
ledger.state(Shape::LegacyCaptureSink),
|
||||||
|
SlotState::Ambiguous(_)
|
||||||
|
),
|
||||||
|
"a sentinel where an index belongs is exactly the unknown outcome"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncertain_unload_is_not_forgotten() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
let fp = ledger
|
||||||
|
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit")
|
||||||
|
.commit(3)
|
||||||
|
.expect("3 is a real index");
|
||||||
|
assert_eq!(
|
||||||
|
ledger.begin_unload(Shape::LoopbackIntoCapture),
|
||||||
|
Some(fp.clone())
|
||||||
|
);
|
||||||
|
ledger.finish_unload(
|
||||||
|
Shape::LoopbackIntoCapture,
|
||||||
|
UnloadOutcome::Uncertain("pactl was killed".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ledger.state(Shape::LoopbackIntoCapture),
|
||||||
|
SlotState::Ambiguous(Reconcile::Unload { fp }),
|
||||||
|
"an unload whose outcome is unknown must keep naming the module"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_confirmed_unload_empties_the_slot() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit")
|
||||||
|
.commit(3)
|
||||||
|
.expect("3 is a real index");
|
||||||
|
ledger.begin_unload(Shape::LoopbackIntoCapture);
|
||||||
|
ledger.finish_unload(Shape::LoopbackIntoCapture, UnloadOutcome::Confirmed);
|
||||||
|
assert_eq!(ledger.state(Shape::LoopbackIntoCapture), SlotState::Vacant);
|
||||||
|
assert!(ledger.is_settled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn begin_unload_only_acts_on_a_loaded_slot() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
assert_eq!(ledger.begin_unload(Shape::LegacyCaptureSink), None);
|
||||||
|
let _permit = ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit");
|
||||||
|
assert_eq!(
|
||||||
|
ledger.begin_unload(Shape::LegacyCaptureSink),
|
||||||
|
None,
|
||||||
|
"a load in flight has no id to unload yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_adopts_the_one_module_carrying_our_token() {
|
||||||
|
let mine = token(7);
|
||||||
|
let reconcile = Reconcile::Load {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
pid: 42,
|
||||||
|
token: mine.clone(),
|
||||||
|
};
|
||||||
|
let observations = vec![
|
||||||
|
// A previous load by the same pid and shape: same everything but the
|
||||||
|
// per-load nonce, which is the whole reason the nonce exists.
|
||||||
|
observed(4, Shape::LegacyCaptureSink, 42, &token(6)),
|
||||||
|
observed(5, Shape::LegacyCaptureSink, 42, &mine),
|
||||||
|
// Somebody else's module entirely.
|
||||||
|
ModuleObservation::new(6, "module-null-sink", "sink_name=other"),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&reconcile, &observations),
|
||||||
|
Resolution::Adopt(expected_fingerprint(Shape::LegacyCaptureSink, 42, &mine, 5))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_reports_nothing_when_the_server_never_created_it() {
|
||||||
|
let reconcile = Reconcile::Load {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
pid: 42,
|
||||||
|
token: token(7),
|
||||||
|
};
|
||||||
|
let observations = vec![observed(4, Shape::LegacyCaptureSink, 42, &token(6))];
|
||||||
|
assert_eq!(resolve(&reconcile, &observations), Resolution::Nothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_fails_closed_when_more_than_one_module_answers() {
|
||||||
|
let mine = token(7);
|
||||||
|
let reconcile = Reconcile::Load {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
pid: 42,
|
||||||
|
token: mine.clone(),
|
||||||
|
};
|
||||||
|
// Two modules carrying the same token should be impossible. If it ever
|
||||||
|
// happens, guessing which to keep is how a live sink gets unloaded.
|
||||||
|
let observations = vec![
|
||||||
|
observed(4, Shape::LegacyCaptureSink, 42, &mine),
|
||||||
|
observed(5, Shape::LegacyCaptureSink, 42, &mine),
|
||||||
|
];
|
||||||
|
assert_eq!(resolve(&reconcile, &observations), Resolution::Conflict(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conflict_poisons_the_slot_and_it_stays_poisoned() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
drop(
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit"),
|
||||||
|
);
|
||||||
|
ledger.apply(Shape::LegacyCaptureSink, Resolution::Conflict(2));
|
||||||
|
let SlotState::Poisoned { reason } = ledger.state(Shape::LegacyCaptureSink) else {
|
||||||
|
panic!("a conflict must poison the slot");
|
||||||
|
};
|
||||||
|
assert!(!ledger.is_settled());
|
||||||
|
assert_eq!(
|
||||||
|
ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(8))
|
||||||
|
.err(),
|
||||||
|
Some(LedgerError::Poisoned {
|
||||||
|
shape: Shape::LegacyCaptureSink,
|
||||||
|
reason
|
||||||
|
}),
|
||||||
|
"poisoning is terminal for the session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_finds_a_module_an_uncertain_unload_left_behind() {
|
||||||
|
let mine = token(7);
|
||||||
|
let fp = expected_fingerprint(Shape::LoopbackIntoCapture, 42, &mine, 5);
|
||||||
|
let observations = vec![observed(5, Shape::LoopbackIntoCapture, 42, &mine)];
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&Reconcile::Unload { fp: fp.clone() }, &observations),
|
||||||
|
Resolution::Adopt(fp.clone()),
|
||||||
|
"still present means the unload did not happen"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&Reconcile::Unload { fp }, &[]),
|
||||||
|
Resolution::Nothing,
|
||||||
|
"absent means it did"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unload_reconcile_ignores_a_stranger_at_the_same_index() {
|
||||||
|
// Pulse reuses module indices verbatim, so "something is at index 5" must
|
||||||
|
// not be read as "our module is still at index 5".
|
||||||
|
//
|
||||||
|
// The stranger has to be a module that *classifies* — another pixelpass
|
||||||
|
// host's canonical loopback — or this gate is vacuous: a comparator using
|
||||||
|
// the id alone would still return `Nothing` for junk, because junk is
|
||||||
|
// discarded by `classify` regardless of how the match was made.
|
||||||
|
let fp = expected_fingerprint(Shape::LoopbackIntoCapture, 42, &token(7), 5);
|
||||||
|
let another_hosts = observed(5, Shape::LoopbackIntoCapture, 99, &token(3));
|
||||||
|
assert_eq!(
|
||||||
|
resolve(
|
||||||
|
&Reconcile::Unload { fp: fp.clone() },
|
||||||
|
std::slice::from_ref(&another_hosts)
|
||||||
|
),
|
||||||
|
Resolution::Nothing,
|
||||||
|
"another host's module at our old index is not our module"
|
||||||
|
);
|
||||||
|
// And plain junk at that index is ignored too.
|
||||||
|
let junk = ModuleObservation::new(5, "module-loopback", "source=some_mic sink=theirs");
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&Reconcile::Unload { fp }, std::slice::from_ref(&junk)),
|
||||||
|
Resolution::Nothing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_answer_does_not_overwrite_a_live_slot() {
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
// Nothing is ambiguous, so an answer arriving late is meaningless.
|
||||||
|
let fp = ledger
|
||||||
|
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
|
||||||
|
.expect("a vacant slot issues a permit")
|
||||||
|
.commit(3)
|
||||||
|
.expect("3 is a real index");
|
||||||
|
ledger.apply(Shape::LegacyCaptureSink, Resolution::Nothing);
|
||||||
|
assert_eq!(
|
||||||
|
ledger.state(Shape::LegacyCaptureSink),
|
||||||
|
SlotState::Loaded { fp },
|
||||||
|
"a live state must win over a stale reconciliation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loaded_lists_modules_in_shape_declaration_order() {
|
||||||
|
// Declaration order is unload order: the loopbacks that reference the
|
||||||
|
// capture sink must come before the sink itself.
|
||||||
|
let ledger = ModuleLedger::new();
|
||||||
|
for (shape, id) in [
|
||||||
|
(Shape::LegacyCaptureSink, 1),
|
||||||
|
(Shape::LoopbackIntoCapture, 2),
|
||||||
|
(Shape::LoopbackOutOfCapture, 3),
|
||||||
|
] {
|
||||||
|
ledger
|
||||||
|
.begin_load(shape, 42, token(u64::from(id)))
|
||||||
|
.expect("a vacant slot issues a permit")
|
||||||
|
.commit(id)
|
||||||
|
.expect("a real index");
|
||||||
|
}
|
||||||
|
let order: Vec<Shape> = ledger.loaded().into_iter().map(|fp| fp.shape).collect();
|
||||||
|
assert_eq!(order, crate::repair::plan::ALL_SHAPES.to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
order.last(),
|
||||||
|
Some(&Shape::LegacyCaptureSink),
|
||||||
|
"the sink must be unloaded after everything that references it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod aec;
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
mod capture;
|
mod capture;
|
||||||
|
pub mod ledger;
|
||||||
mod observer;
|
mod observer;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod quality;
|
mod quality;
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ use crate::host::audio::parse_object_serial;
|
|||||||
use crate::host::taint::snapshot::{
|
use crate::host::taint::snapshot::{
|
||||||
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
|
||||||
};
|
};
|
||||||
|
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use pipewire::{self as pw, types::ObjectType};
|
use pipewire::{self as pw, types::ObjectType};
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
@@ -129,7 +130,9 @@ struct LiveGlobal {
|
|||||||
struct ObserverState {
|
struct ObserverState {
|
||||||
model: RegistryModel,
|
model: RegistryModel,
|
||||||
latest: Arc<Mutex<Option<Projection>>>,
|
latest: Arc<Mutex<Option<Projection>>>,
|
||||||
last_candidate: Option<u32>,
|
/// The pulse-PID candidate set as of the last probe, so only PIDs entering
|
||||||
|
/// it are read from `/proc`.
|
||||||
|
last_candidates: BTreeSet<u32>,
|
||||||
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
|
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
|
||||||
sink: Option<Box<dyn ProjectionSink>>,
|
sink: Option<Box<dyn ProjectionSink>>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
@@ -147,7 +150,7 @@ impl ObserverState {
|
|||||||
Self {
|
Self {
|
||||||
model: RegistryModel::new(0, READINESS_TIMEOUT_MILLIS),
|
model: RegistryModel::new(0, READINESS_TIMEOUT_MILLIS),
|
||||||
latest,
|
latest,
|
||||||
last_candidate: None,
|
last_candidates: BTreeSet::new(),
|
||||||
live_globals: BTreeMap::new(),
|
live_globals: BTreeMap::new(),
|
||||||
sink,
|
sink,
|
||||||
started_at,
|
started_at,
|
||||||
@@ -162,10 +165,17 @@ impl ObserverState {
|
|||||||
let event_outcome = self.model.apply(event);
|
let event_outcome = self.model.apply(event);
|
||||||
let mut outcome = event_outcome;
|
let mut outcome = event_outcome;
|
||||||
|
|
||||||
let candidate = self.model.pulse_pid_candidate();
|
// Round 10: a *set* of candidates, because repetition across Clients
|
||||||
if candidate != self.last_candidate {
|
// turned out not to identify pipewire-pulse (see `pulse_pid`'s module
|
||||||
self.last_candidate = candidate;
|
// docs — WirePlumber repeats a PID too, which made the old single
|
||||||
if let Some(pid) = candidate {
|
// candidate permanently ambiguous on this host).
|
||||||
|
let candidates = self.model.pulse_pid_candidates();
|
||||||
|
if candidates != self.last_candidates {
|
||||||
|
// Only PIDs *entering* the set are probed. A PID that left and came
|
||||||
|
// back is "entering" again and so is re-probed, which is what keeps
|
||||||
|
// the PID-reuse guard honest rather than answering from a cached
|
||||||
|
// `comm` for a number that now belongs to someone else.
|
||||||
|
for &pid in candidates.difference(&self.last_candidates) {
|
||||||
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
|
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
.ok()
|
.ok()
|
||||||
.map(|comm| comm.trim_end_matches(['\r', '\n']).to_string());
|
.map(|comm| comm.trim_end_matches(['\r', '\n']).to_string());
|
||||||
@@ -177,6 +187,8 @@ impl ObserverState {
|
|||||||
outcome = Outcome::Applied;
|
outcome = Outcome::Applied;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.model.retain_probed_comms(&candidates);
|
||||||
|
self.last_candidates = candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3.5 §6.7 decision 2: a projection the model proved identical is not
|
// v3.5 §6.7 decision 2: a projection the model proved identical is not
|
||||||
@@ -648,16 +660,44 @@ fn parse_serial(id: u32, kind: &str, raw: Option<&str>) -> Option<Serial> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lenient boolean for PipeWire's own `bool`-ish properties
|
||||||
|
/// (`port.exclusive`, `port.monitor`, `node.passthrough`), whose spelling
|
||||||
|
/// varies by producer. Leniency is the fail-closed direction *for these*:
|
||||||
|
/// each one, when true, causes exclusion.
|
||||||
fn truthy(value: Option<&str>) -> bool {
|
fn truthy(value: Option<&str>) -> bool {
|
||||||
value.is_some_and(|value| value != "false" && value != "0")
|
value.is_some_and(|value| value != "false" && value != "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ownership carrier is matched **exactly**, not leniently (round 10,
|
||||||
|
/// R10-4).
|
||||||
|
///
|
||||||
|
/// It is tempting to reuse [`truthy`] here on the grounds that treating an
|
||||||
|
/// unexpected value as "owned" over-excludes and is therefore safe. That
|
||||||
|
/// argument does not hold: leniency buys false-positive *exclusion*, not
|
||||||
|
/// safety. Under `truthy`, `peerspeak.owned=""` and `peerspeak.owned=false `
|
||||||
|
/// (trailing space) both mean owned, so any process can suppress a rival's
|
||||||
|
/// audio from the share with a property it does not even have to spell right.
|
||||||
|
///
|
||||||
|
/// Fail-closed on this feature is about **ancestry** — an unresolvable graph
|
||||||
|
/// is not eligible — not about parsing. The producer emits exactly
|
||||||
|
/// [`PEERSPEAK_OWNED_VALUE`] at all three of its sites and is pinned to it by
|
||||||
|
/// the shared cross-repo fixture, so there is no real value to be lenient
|
||||||
|
/// about. And a missed tag is not silent: carrier 2 is a union with this one,
|
||||||
|
/// so a garbled property still leaves the `node.name` prefix.
|
||||||
|
fn peerspeak_owned(value: Option<&str>) -> bool {
|
||||||
|
value == Some(PEERSPEAK_OWNED_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
|
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
|
||||||
NodeObservation {
|
NodeObservation {
|
||||||
name: props.get("node.name").map(str::to_string),
|
name: props.get("node.name").map(str::to_string),
|
||||||
role: MediaRole::parse(props.get("media.class")),
|
role: MediaRole::parse(props.get("media.class")),
|
||||||
props: NodeProps {
|
props: NodeProps {
|
||||||
peerspeak_owned: truthy(props.get("peerspeak.owned")),
|
// Carrier 1 only. Carrier 2 (the `node.name` prefix) is matched
|
||||||
|
// in the engine off `NodeObservation::name` above, so each
|
||||||
|
// carrier stays independently testable — see
|
||||||
|
// [`crate::host::taint::PEERSPEAK_OWNED_NODE_PREFIX`].
|
||||||
|
peerspeak_owned: peerspeak_owned(props.get(PEERSPEAK_OWNED_PROP)),
|
||||||
pulse_module_id: props
|
pulse_module_id: props
|
||||||
.get("pulse.module.id")
|
.get("pulse.module.id")
|
||||||
.and_then(|value| value.parse::<u64>().ok()),
|
.and_then(|value| value.parse::<u64>().ok()),
|
||||||
@@ -717,6 +757,165 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// **R10-4.** The ownership carrier is matched exactly; the lenient
|
||||||
|
/// [`truthy`] spelling is wrong for it.
|
||||||
|
///
|
||||||
|
/// Under `truthy`, every value in `denied` below meant "peerspeak owns
|
||||||
|
/// this" — including the empty string and a `false` with a trailing space
|
||||||
|
/// — so any process could suppress a rival application's audio from the
|
||||||
|
/// share with a property it did not have to spell correctly. Leniency here
|
||||||
|
/// buys false-positive exclusion, not safety.
|
||||||
|
#[test]
|
||||||
|
fn the_ownership_carrier_is_matched_exactly_not_leniently() {
|
||||||
|
assert!(peerspeak_owned(Some(PEERSPEAK_OWNED_VALUE)));
|
||||||
|
|
||||||
|
let denied = [
|
||||||
|
None,
|
||||||
|
Some(""),
|
||||||
|
Some("false"),
|
||||||
|
Some("0"),
|
||||||
|
Some("false "),
|
||||||
|
Some("true"),
|
||||||
|
Some("yes"),
|
||||||
|
Some("1 "),
|
||||||
|
Some(" 1"),
|
||||||
|
Some("01"),
|
||||||
|
Some("2"),
|
||||||
|
];
|
||||||
|
for value in denied {
|
||||||
|
assert!(
|
||||||
|
!peerspeak_owned(value),
|
||||||
|
"{value:?} must not read as peerspeak-owned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same property, asserted through the **production wiring** rather
|
||||||
|
/// than the helper.
|
||||||
|
///
|
||||||
|
/// ⚠️ **This is the gate; the one above is a unit test of a private
|
||||||
|
/// function** (round 10 review, finding 4). Mutating
|
||||||
|
/// [`node_observation_from_props`] back to `truthy(props.get(…))` left the
|
||||||
|
/// helper test green, because it calls [`peerspeak_owned`] directly and
|
||||||
|
/// the only live case it shares with production — exact `"1"` — passes
|
||||||
|
/// under both implementations. That is precisely the "a gate satisfiable
|
||||||
|
/// by two sources gates neither" failure that bit the `main.rs` wiring
|
||||||
|
/// guard and phase 3r row 1.
|
||||||
|
///
|
||||||
|
/// So: build a real `pw_properties` dictionary, push it through the same
|
||||||
|
/// function the registry callback calls, and assert the resulting
|
||||||
|
/// [`NodeProps::peerspeak_owned`] for every spelling.
|
||||||
|
#[test]
|
||||||
|
fn the_production_wiring_reads_the_ownership_carrier_exactly() {
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
// (property value, must be read as peerspeak-owned)
|
||||||
|
let spellings = [
|
||||||
|
(Some(PEERSPEAK_OWNED_VALUE), true),
|
||||||
|
(None, false),
|
||||||
|
(Some(""), false),
|
||||||
|
(Some("false"), false),
|
||||||
|
(Some("0"), false),
|
||||||
|
(Some("false "), false),
|
||||||
|
(Some("true"), false),
|
||||||
|
(Some("yes"), false),
|
||||||
|
(Some("1 "), false),
|
||||||
|
(Some(" 1"), false),
|
||||||
|
(Some("01"), false),
|
||||||
|
(Some("2"), false),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (value, expected) in spellings {
|
||||||
|
let mut props = pw::properties::PropertiesBox::new();
|
||||||
|
// A realistic node, so the rest of the parse runs too: this is the
|
||||||
|
// shape peerspeak's own tagged playback arrives in.
|
||||||
|
props.insert("media.class", "Stream/Output/Audio");
|
||||||
|
props.insert("node.name", "probe");
|
||||||
|
props.insert("client.id", "42");
|
||||||
|
if let Some(value) = value {
|
||||||
|
props.insert(PEERSPEAK_OWNED_PROP, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let observation = node_observation_from_props(props.dict());
|
||||||
|
assert_eq!(
|
||||||
|
observation.props.peerspeak_owned, expected,
|
||||||
|
"{PEERSPEAK_OWNED_PROP}={value:?} through the real adapter"
|
||||||
|
);
|
||||||
|
// The surrounding parse must still work, or a green result above
|
||||||
|
// could just mean the whole dictionary was dropped.
|
||||||
|
assert_eq!(observation.role, MediaRole::StreamOutput);
|
||||||
|
assert_eq!(observation.name.as_deref(), Some("probe"));
|
||||||
|
assert_eq!(observation.props.client_id, Some(GlobalId(42)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cross-repo fixture's `prop_value` is the only spelling this
|
||||||
|
/// consumer treats as owned — asserted through the production wiring.
|
||||||
|
///
|
||||||
|
/// The taint module's `ownership_carriers_match_the_cross_repo_fixture`
|
||||||
|
/// proves the two repos agree on the *literal*. That is not the same as
|
||||||
|
/// proving the shipping observer reads it, which is the half the round-10
|
||||||
|
/// review's finding 6 was about: a future producer following the fixture
|
||||||
|
/// needs the file to describe what the code does, and only a test that
|
||||||
|
/// runs the code can keep those two honest.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_value_is_the_only_owned_spelling() {
|
||||||
|
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
|
||||||
|
pw::init();
|
||||||
|
|
||||||
|
let pinned = FIXTURE
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.map(|line| line.split_once('=').expect("fixture line is key=value"));
|
||||||
|
let mut prop_key = None;
|
||||||
|
let mut prop_value = None;
|
||||||
|
for (key, value) in pinned {
|
||||||
|
match key {
|
||||||
|
"prop_key" => prop_key = Some(value),
|
||||||
|
"prop_value" => prop_value = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prop_key = prop_key.expect("fixture defines prop_key");
|
||||||
|
let prop_value = prop_value.expect("fixture defines prop_value");
|
||||||
|
|
||||||
|
let observe = |value: &str| {
|
||||||
|
let mut props = pw::properties::PropertiesBox::new();
|
||||||
|
props.insert("media.class", "Stream/Output/Audio");
|
||||||
|
props.insert(prop_key, value);
|
||||||
|
node_observation_from_props(props.dict())
|
||||||
|
.props
|
||||||
|
.peerspeak_owned
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
observe(prop_value),
|
||||||
|
"the fixture's own {prop_key}={prop_value} must read as owned"
|
||||||
|
);
|
||||||
|
// The spellings the fixture explicitly says are NOT owned.
|
||||||
|
for denied in ["true", "yes", ""] {
|
||||||
|
assert!(
|
||||||
|
!observe(denied),
|
||||||
|
"{prop_key}={denied:?} must not read as owned; the fixture says so"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other three boolean properties keep the lenient spelling, and that
|
||||||
|
/// is deliberate rather than an oversight: each is PipeWire's own, each
|
||||||
|
/// varies by producer, and each causes *exclusion* when true — so reading
|
||||||
|
/// an unrecognised value as true is genuinely the safe direction for them.
|
||||||
|
#[test]
|
||||||
|
fn pipewires_own_boolean_props_stay_lenient() {
|
||||||
|
assert!(truthy(Some("true")));
|
||||||
|
assert!(truthy(Some("1")));
|
||||||
|
assert!(truthy(Some("")));
|
||||||
|
assert!(!truthy(Some("false")));
|
||||||
|
assert!(!truthy(Some("0")));
|
||||||
|
assert!(!truthy(None));
|
||||||
|
}
|
||||||
|
|
||||||
struct PactlModule {
|
struct PactlModule {
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -893,7 +1092,15 @@ mod tests {
|
|||||||
"module-null-sink",
|
"module-null-sink",
|
||||||
&[
|
&[
|
||||||
format!("sink_name={unique}"),
|
format!("sink_name={unique}"),
|
||||||
"sink_properties=peerspeak.owned=true node.passthrough=true".to_string(),
|
// The value peerspeak actually emits, not merely a truthy one:
|
||||||
|
// this row is the live proof that carrier 1 survives the bind,
|
||||||
|
// and the sink's name deliberately does *not* carry the
|
||||||
|
// `peerspeak_owned_` prefix, so carrier 2 cannot stand in for
|
||||||
|
// it here.
|
||||||
|
format!(
|
||||||
|
"sink_properties={PEERSPEAK_OWNED_PROP}={} node.passthrough=true",
|
||||||
|
crate::host::taint::PEERSPEAK_OWNED_VALUE
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let null_sink_id = null_sink.id.expect("null-sink module must have an id");
|
let null_sink_id = null_sink.id.expect("null-sink module must have an id");
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
|||||||
"api.alsa.pcm.source",
|
"api.alsa.pcm.source",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The `device.api` every entry in [`HARDWARE_PCM_FACTORIES`] belongs to.
|
||||||
|
/// A single value rather than a list, because the allowlist is ALSA-only;
|
||||||
|
/// this constant is the thing to change when that stops being true.
|
||||||
|
const HARDWARE_PCM_API: &str = "alsa";
|
||||||
|
|
||||||
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
/// ALSA drivers that expose a hardware-PCM `factory.name` but are **not**
|
||||||
/// passive terminals — audio written in reappears on their capture side
|
/// passive terminals — audio written in reappears on their capture side
|
||||||
/// through a path the PipeWire Link graph cannot see, so classifying them
|
/// through a path the PipeWire Link graph cannot see, so classifying them
|
||||||
@@ -199,8 +204,20 @@ pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classifica
|
|||||||
.flatten()
|
.flatten()
|
||||||
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
||||||
let driver_ok = driver.is_some() && !driver_denied;
|
let driver_ok = driver.is_some() && !driver_denied;
|
||||||
let api_present = device.device_api.is_some() || claim.device_api.is_some();
|
// The API must positively be the one the factory allowlist is written
|
||||||
let is_hardware_pcm = api_present && on_factory_allowlist && driver_ok;
|
// for, not merely present (Codex phase-3r review, finding 3). "Present"
|
||||||
|
// admitted `device.api=v4l2` alongside `factory.name=api.alsa.pcm.sink`
|
||||||
|
// — a contradiction no truthful configuration produces, which is exactly
|
||||||
|
// why it should be read as an observation gone wrong rather than as
|
||||||
|
// corroboration. Disagreement between the two sides fails closed for the
|
||||||
|
// same reason. ⚠️ Tied to [`HARDWARE_PCM_FACTORIES`] being ALSA-only:
|
||||||
|
// adding a BlueZ factory means allowing `bluez5` here too.
|
||||||
|
let api_ok = match (device.device_api.as_deref(), claim.device_api.as_deref()) {
|
||||||
|
(Some(from_device), Some(from_node)) if from_device != from_node => false,
|
||||||
|
(Some(api), _) | (None, Some(api)) => api == HARDWARE_PCM_API,
|
||||||
|
(None, None) => false,
|
||||||
|
};
|
||||||
|
let is_hardware_pcm = api_ok && on_factory_allowlist && driver_ok;
|
||||||
if is_hardware_pcm {
|
if is_hardware_pcm {
|
||||||
Classification::SessionDevice
|
Classification::SessionDevice
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+45
-22
@@ -91,7 +91,7 @@ use crate::host::taint::snapshot::{
|
|||||||
PortSnapshot, Serial,
|
PortSnapshot, Serial,
|
||||||
};
|
};
|
||||||
use classify::{Classification, DeviceClaim, DeviceProps};
|
use classify::{Classification, DeviceClaim, DeviceProps};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
|
|
||||||
/// A monotonic millisecond clock value, supplied by the adapter via
|
/// A monotonic millisecond clock value, supplied by the adapter via
|
||||||
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
|
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
|
||||||
@@ -399,12 +399,25 @@ impl RegistryModel {
|
|||||||
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pulse-PID candidate the adapter should be probing (`None` = no
|
/// The pulse-PID candidates the adapter should be probing — every distinct
|
||||||
/// repeated `sec_pid`, nothing to probe). Exposed so the adapter re-probes
|
/// `sec_pid` on the current Clients. Exposed so the adapter re-probes only
|
||||||
/// only when the candidate changes.
|
/// the PIDs *entering* the set rather than all of them on every event.
|
||||||
pub fn pulse_pid_candidate(&self) -> Option<u32> {
|
pub fn pulse_pid_candidates(&self) -> BTreeSet<u32> {
|
||||||
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
|
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
|
||||||
pulse_pid::candidate(&clients)
|
pulse_pid::candidates(&clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget probed `comm`s for PIDs no Client presents any more.
|
||||||
|
///
|
||||||
|
/// Cannot change the projection — [`Self::pulse_pid`] only ever reads
|
||||||
|
/// `comm`s for PIDs in the current candidate set — so it is deliberately
|
||||||
|
/// not an [`Outcome`]-returning `apply` arm: it must not publish, and it
|
||||||
|
/// must not count as a graph event for O5. Its purpose is to bound the map
|
||||||
|
/// (one entry per live Client PID) in a host process that runs for hours,
|
||||||
|
/// and to guarantee a PID that leaves and returns is re-probed rather than
|
||||||
|
/// answered from a stale `comm`.
|
||||||
|
pub fn retain_probed_comms(&mut self, live: &BTreeSet<u32>) {
|
||||||
|
self.probed_comm.retain(|pid, _| live.contains(pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold one observation into the model. The returned [`Outcome`] tells the
|
/// Fold one observation into the model. The returned [`Outcome`] tells the
|
||||||
@@ -596,21 +609,31 @@ impl RegistryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The bound properties of the Device a node claims by global id, or
|
/// The bound properties of the Device a node claims by global id, or
|
||||||
/// `None` when that claim is unresolved — which covers all three
|
/// `None` when that claim is unresolved — which covers every fail-closed
|
||||||
/// fail-closed cases at once: no such Device observed, its bind still
|
/// case at once: no such Device observed, its bind still outstanding, or
|
||||||
/// outstanding, or **two live Devices sharing the recycled id**, where
|
/// **the id claimed by more than one live global**, where there is no way
|
||||||
/// there is no way to tell whose properties these are (v3.4 §6.1.3).
|
/// to tell whose properties these are (v3.4 §6.1.3).
|
||||||
|
///
|
||||||
|
/// ⚠️ The ambiguity test is "**exactly one** live global holds this id",
|
||||||
|
/// not "exactly one live *Device*" (Codex phase-3r review, finding 2).
|
||||||
|
/// The weaker test looks equivalent and is not: with `[Device, Port]` on
|
||||||
|
/// one id — a missed removal, the same precondition as every other
|
||||||
|
/// recycled-id hazard — it keeps answering with the older Device's
|
||||||
|
/// properties, so a node claiming that id holds a stale
|
||||||
|
/// `session_device = true`. That flag *removes* the node's owner keys and
|
||||||
|
/// its fail-closed backstop, so a forwarder wearing it can put its output
|
||||||
|
/// leg back on the eligible side: echo, from a lookup that was merely
|
||||||
|
/// looking at the wrong object type.
|
||||||
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
|
fn device_props(&self, id: GlobalId) -> Option<&DeviceProps> {
|
||||||
let mut found: Option<Serial> = None;
|
let slots = self.live_ids.get(&id)?;
|
||||||
for slot in self.live_ids.get(&id)? {
|
if slots.len() != 1 {
|
||||||
if let Slot::Device(serial) = slot {
|
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
||||||
if found.is_some() {
|
|
||||||
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
|
||||||
}
|
|
||||||
found = Some(*serial);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.devices.get(&found?)?.props.as_ref()
|
let Slot::Device(serial) = slots.front()? else {
|
||||||
|
// The id is live, but it is not a Device any more.
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
self.devices.get(serial)?.props.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify one node's device claim against the currently resolved
|
/// Classify one node's device claim against the currently resolved
|
||||||
@@ -691,9 +714,9 @@ impl RegistryModel {
|
|||||||
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
||||||
/// the safe answer (key 4 unusable).
|
/// the safe answer (key 4 unusable).
|
||||||
fn pulse_pid(&self) -> Option<u32> {
|
fn pulse_pid(&self) -> Option<u32> {
|
||||||
let candidate = self.pulse_pid_candidate()?;
|
pulse_pid::resolve(&self.pulse_pid_candidates(), |pid| {
|
||||||
let comm = self.probed_comm.get(&candidate).and_then(|c| c.as_deref());
|
self.probed_comm.get(&pid).cloned().flatten()
|
||||||
pulse_pid::validate(candidate, comm)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Project the current state into the taint engine's inputs.
|
/// Project the current state into the taint engine's inputs.
|
||||||
|
|||||||
@@ -11,20 +11,49 @@
|
|||||||
//! The derivation is split into two pure stages so the I/O — reading
|
//! The derivation is split into two pure stages so the I/O — reading
|
||||||
//! `/proc/<pid>/comm` — stays in the adapter:
|
//! `/proc/<pid>/comm` — stays in the adapter:
|
||||||
//!
|
//!
|
||||||
//! 1. [`candidate`] finds the PID that *looks* like pulse from the graph
|
//! 1. [`candidates`] lists the PIDs worth probing from the graph alone: every
|
||||||
//! alone: the `pipewire.sec.pid` value shared across multiple Clients.
|
//! distinct `pipewire.sec.pid` any Client presents.
|
||||||
//! Native PipeWire clients carry their own distinct PID; only the
|
//! 2. [`resolve`] picks the one whose `comm`, as read from `/proc` by the
|
||||||
//! Pulse shim repeats one value, so a repeated value is the signal.
|
//! adapter, is exactly pipewire-pulse's. This is also what closes **PID
|
||||||
//! 2. [`validate`] confirms that candidate against the `comm` the adapter
|
//! reuse**: a recycled PID is rejected because `/proc/<pid>/comm` now names
|
||||||
//! read from `/proc`. This is what closes **PID reuse**: a recycled PID
|
//! a different process.
|
||||||
//! that coincidentally repeats in the graph is rejected because
|
|
||||||
//! `/proc/<pid>/comm` now names a different process.
|
|
||||||
//!
|
//!
|
||||||
//! Any failure at either stage — no repeated value, two repeated values,
|
//! Any failure — no Client carries the property, no `comm` matches, `/proc`
|
||||||
//! the property missing, `/proc` gone, a `comm` mismatch — yields `None`.
|
//! gone, or *several* PIDs claim to be pipewire-pulse — yields `None`.
|
||||||
|
//!
|
||||||
|
//! ## ⚠️ Round 10 (MEASURED): repetition is not the signal
|
||||||
|
//!
|
||||||
|
//! Stage 1 used to return a single candidate: the one `sec_pid` value shared by
|
||||||
|
//! two or more Clients, reasoning that "native PipeWire clients carry their own
|
||||||
|
//! distinct PID; only the Pulse shim repeats one value". **That is false on a
|
||||||
|
//! stock desktop, and the phase-5 §5.1 matrix caught it on row 1.** Measured on
|
||||||
|
//! this host (PipeWire 1.6.8 / WirePlumber 0.5.15): WirePlumber holds *two*
|
||||||
|
//! Clients — `WirePlumber` and `WirePlumber [export]` — both carrying
|
||||||
|
//! `sec_pid` 1747. So two values repeated (1747 and pipewire-pulse's 2528), the
|
||||||
|
//! old rule called that ambiguous and returned `None`, and the consequence was
|
||||||
|
//! not a missing optimisation but a machine-wide over-exclusion cascade: with
|
||||||
|
//! the daemon PID unknown, key 4's suppression never fires, every
|
||||||
|
//! Pulse-emulated node fuses into one owner, and the eligible half of every row
|
||||||
|
//! empties out (see `owner::keys_of`'s fail-closed asymmetry note).
|
||||||
|
//!
|
||||||
|
//! The rule failed in *both* directions, which is why the prefilter is gone
|
||||||
|
//! rather than patched:
|
||||||
|
//!
|
||||||
|
//! - **False ambiguity** — any second process holding two Clients defeats it.
|
||||||
|
//! WirePlumber always does, so this was permanent, not a corner case.
|
||||||
|
//! - **False absence** — a session where pipewire-pulse happens to hold exactly
|
||||||
|
//! one Client (one Pulse app running) never repeats a value at all, so the
|
||||||
|
//! candidate is missed and the same cascade follows.
|
||||||
|
//!
|
||||||
|
//! `comm` was always the authoritative check; repetition was a heuristic
|
||||||
|
//! standing in front of it, and it was wrong. Probing every distinct `sec_pid`
|
||||||
|
//! costs one `/proc` read per *distinct* PID (single digits — bounded by the
|
||||||
|
//! Client count, cached, and re-read only when the candidate set changes),
|
||||||
|
//! which is a cheap price for a signal that does not encode an assumption about
|
||||||
|
//! how many Clients anyone else opens.
|
||||||
|
|
||||||
use crate::host::taint::snapshot::ClientSnapshot;
|
use crate::host::taint::snapshot::ClientSnapshot;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
|
/// The kernel `comm` of the pipewire-pulse process. `comm` is truncated to
|
||||||
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
|
/// 15 bytes by the kernel; `pipewire-pulse` is 14 bytes, so it is exact —
|
||||||
@@ -32,40 +61,19 @@ use std::collections::BTreeMap;
|
|||||||
/// recycled PID belonging to e.g. `pipewire-pulseX`.
|
/// recycled PID belonging to e.g. `pipewire-pulseX`.
|
||||||
const PULSE_COMM: &str = "pipewire-pulse";
|
const PULSE_COMM: &str = "pipewire-pulse";
|
||||||
|
|
||||||
/// Stage 1: the PID that looks like pipewire-pulse from the client graph.
|
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
|
||||||
|
/// the Clients present.
|
||||||
///
|
///
|
||||||
/// Returns `Some(pid)` only when **exactly one** `pipewire.sec.pid` value is
|
/// No filtering, and deliberately so (see the module docs): any rule applied
|
||||||
/// shared by two or more clients. Rationale, matched to the failure matrix:
|
/// here is a guess about other processes' Client counts, while stage 2 has the
|
||||||
///
|
/// kernel's own answer. A `BTreeSet` because the adapter diffs successive
|
||||||
/// - **consistent** — one value repeats, the rest (native clients) are
|
/// candidate sets to decide what to re-probe, and that diff must not depend on
|
||||||
/// distinct ⇒ that value.
|
/// Client iteration order.
|
||||||
/// - **inconsistent** — two or more values each repeat ⇒ we cannot tell which
|
pub fn candidates(clients: &[ClientSnapshot]) -> BTreeSet<u32> {
|
||||||
/// is pulse ⇒ `None`.
|
clients.iter().filter_map(|client| client.sec_pid).collect()
|
||||||
/// - **missing property** — the Pulse clients carry no `sec_pid` ⇒ nothing
|
|
||||||
/// repeats ⇒ `None`.
|
|
||||||
///
|
|
||||||
/// A count threshold of two is deliberate: a single client carrying a PID is
|
|
||||||
/// indistinguishable from a lone native app, and pulse always mints many.
|
|
||||||
pub fn candidate(clients: &[ClientSnapshot]) -> Option<u32> {
|
|
||||||
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
|
|
||||||
for client in clients {
|
|
||||||
if let Some(pid) = client.sec_pid {
|
|
||||||
*counts.entry(pid).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every PID seen on 2+ clients is a pulse candidate. If there is exactly
|
|
||||||
// one such PID we trust it; zero or several ⇒ fail closed.
|
|
||||||
let mut repeated = counts.iter().filter(|&(_, &n)| n >= 2).map(|(&pid, _)| pid);
|
|
||||||
let first = repeated.next()?;
|
|
||||||
if repeated.next().is_some() {
|
|
||||||
// Ambiguous: more than one value repeats.
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(first)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 2: confirm the candidate against the `comm` read from
|
/// Stage 2: confirm one candidate against the `comm` read from
|
||||||
/// `/proc/<candidate>/comm`.
|
/// `/proc/<candidate>/comm`.
|
||||||
///
|
///
|
||||||
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
|
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
|
||||||
@@ -79,11 +87,36 @@ pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two stages composed, for callers that already hold the probed `comm`.
|
/// Stage 2 across the whole candidate set: the *unique* PID whose `comm` is
|
||||||
/// The model keeps them separate (it recomputes the candidate as clients
|
/// pipewire-pulse's.
|
||||||
/// churn, and only re-probes when the candidate *changes*), so this is a
|
///
|
||||||
/// convenience for tests and for the fully-resolved path.
|
/// `None` when none matches (nothing to suppress that we can prove) and also
|
||||||
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
/// when **several** do. Several means either two pipewire-pulse daemons are
|
||||||
let candidate = candidate(clients)?;
|
/// live — a nested or sandboxed session — or a `comm` collision, and a single
|
||||||
validate(candidate, comm_of(candidate).as_deref())
|
/// `Option<u32>` cannot suppress two owners. Failing closed here lands on the
|
||||||
|
/// over-exclusion side, matching the asymmetry `owner::keys_of` already
|
||||||
|
/// documents: broad over-exclusion is annoying, a missed suppression is an
|
||||||
|
/// echo. Suppressing a *set* of daemon PIDs is the real answer if a
|
||||||
|
/// multi-daemon host ever turns up; it is not v1, and it is recorded rather
|
||||||
|
/// than silently approximated.
|
||||||
|
pub fn resolve(candidates: &BTreeSet<u32>, comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
||||||
|
let mut found = None;
|
||||||
|
for &pid in candidates {
|
||||||
|
if validate(pid, comm_of(pid).as_deref()).is_some() {
|
||||||
|
if found.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
found = Some(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both stages composed, for callers that can probe on demand.
|
||||||
|
///
|
||||||
|
/// The model keeps them separate — it recomputes the candidate set as Clients
|
||||||
|
/// churn and only re-probes PIDs entering it — so this is a convenience for
|
||||||
|
/// tests and for the fully-resolved path.
|
||||||
|
pub fn derive(clients: &[ClientSnapshot], comm_of: impl Fn(u32) -> Option<String>) -> Option<u32> {
|
||||||
|
resolve(&candidates(clients), comm_of)
|
||||||
}
|
}
|
||||||
|
|||||||
+212
-13
@@ -346,6 +346,53 @@ fn classify_device_api_from_either_side_corroborates() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn classify_allowlist_is_exact_not_substring() {
|
fn classify_allowlist_is_exact_not_substring() {
|
||||||
// A factory that merely *contains* an allowlisted name must not pass.
|
// A factory that merely *contains* an allowlisted name must not pass.
|
||||||
@@ -371,29 +418,107 @@ fn clients_with(pids: &[Option<u32>]) -> Vec<ClientSnapshot> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `comm` probe of `pipewire-pulse` identifies the daemon regardless of how
|
||||||
|
/// many Clients it holds.
|
||||||
#[test]
|
#[test]
|
||||||
fn pid_candidate_consistent_repeated_value() {
|
fn pid_resolves_on_comm_not_on_repetition() {
|
||||||
let clients = clients_with(&[Some(4137), Some(4137), Some(9001)]);
|
let clients = clients_with(&[Some(4137), Some(4137), Some(9001)]);
|
||||||
assert_eq!(pulse_pid::candidate(&clients), Some(4137));
|
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]
|
#[test]
|
||||||
fn pid_candidate_inconsistent_two_repeats_is_none() {
|
fn pid_candidates_are_every_distinct_sec_pid() {
|
||||||
let clients = clients_with(&[Some(4137), Some(4137), Some(9001), Some(9001)]);
|
let clients = clients_with(&[Some(4137), Some(4137), Some(9001), None]);
|
||||||
assert_eq!(pulse_pid::candidate(&clients), None);
|
assert_eq!(
|
||||||
|
pulse_pid::candidates(&clients),
|
||||||
|
[4137, 9001].into_iter().collect()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pid_candidate_missing_property_is_none() {
|
fn pid_missing_property_leaves_nothing_to_probe() {
|
||||||
let clients = clients_with(&[None, None, None]);
|
let clients = clients_with(&[None, None, None]);
|
||||||
assert_eq!(pulse_pid::candidate(&clients), 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]
|
#[test]
|
||||||
fn pid_candidate_single_occurrence_is_none() {
|
fn pid_resolve_no_match_is_none() {
|
||||||
// One client per pid: nothing repeats, so nothing is pipewire-pulse.
|
|
||||||
let clients = clients_with(&[Some(4137), Some(9001)]);
|
let clients = clients_with(&[Some(4137), Some(9001)]);
|
||||||
assert_eq!(pulse_pid::candidate(&clients), None);
|
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]
|
#[test]
|
||||||
@@ -426,9 +551,8 @@ fn pid_validate_reuse_named_other_process_is_none() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pid_derive_end_to_end_valid() {
|
fn pid_derive_end_to_end_valid() {
|
||||||
let clients = clients_with(&[Some(4137), Some(4137)]);
|
let clients = clients_with(&[Some(4137), Some(4137)]);
|
||||||
let candidate = pulse_pid::candidate(&clients).expect("candidate");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pulse_pid::validate(candidate, Some("pipewire-pulse")),
|
pulse_pid::derive(&clients, |_| Some("pipewire-pulse".to_string())),
|
||||||
Some(4137)
|
Some(4137)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -443,7 +567,7 @@ fn model_pulse_pid_valid_through_projection() {
|
|||||||
m.apply(client(1, 200, Some(4137)));
|
m.apply(client(1, 200, Some(4137)));
|
||||||
m.apply(client(2, 201, Some(4137)));
|
m.apply(client(2, 201, Some(4137)));
|
||||||
m.apply(client(3, 202, Some(9001)));
|
m.apply(client(3, 202, Some(9001)));
|
||||||
assert_eq!(m.pulse_pid_candidate(), Some(4137));
|
assert_eq!(m.pulse_pid_candidates(), [4137, 9001].into_iter().collect());
|
||||||
m.apply(RegEvent::ProcCommProbed {
|
m.apply(RegEvent::ProcCommProbed {
|
||||||
pid: 4137,
|
pid: 4137,
|
||||||
comm: Some("pipewire-pulse".to_string()),
|
comm: Some("pipewire-pulse".to_string()),
|
||||||
@@ -460,6 +584,40 @@ fn model_pulse_pid_none_until_probed() {
|
|||||||
assert_eq!(m.project().pipewire_pulse_pid, None);
|
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]
|
#[test]
|
||||||
fn model_pulse_pid_none_on_comm_mismatch() {
|
fn model_pulse_pid_none_on_comm_mismatch() {
|
||||||
let mut m = model();
|
let mut m = model();
|
||||||
@@ -1300,6 +1458,47 @@ fn model_removed_device_withholds_its_nodes_again() {
|
|||||||
assert!(!m.graph_ready(), "and it is an obligation 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]
|
#[test]
|
||||||
fn model_ambiguous_device_id_withholds_its_nodes() {
|
fn model_ambiguous_device_id_withholds_its_nodes() {
|
||||||
// Two live Devices on one recycled id: there is no way to know whose
|
// Two live Devices on one recycled id: there is no way to know whose
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ impl CaptureHandle {
|
|||||||
let _ = child.start_kill();
|
let _ = child.start_kill();
|
||||||
}
|
}
|
||||||
if let Some(audio) = self.audio.take() {
|
if let Some(audio) = self.audio.take() {
|
||||||
audio.shutdown();
|
audio.shutdown().await;
|
||||||
}
|
}
|
||||||
if let Some(serve) = self.serve.take() {
|
if let Some(serve) = self.serve.take() {
|
||||||
serve.shutdown().await;
|
serve.shutdown().await;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct Graph {
|
|||||||
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
||||||
client_by_app: BTreeMap<u32, GlobalId>,
|
client_by_app: BTreeMap<u32, GlobalId>,
|
||||||
client_by_module: BTreeMap<u64, GlobalId>,
|
client_by_module: BTreeMap<u64, GlobalId>,
|
||||||
|
/// Native (non-Pulse-emulated) clients, whose `pipewire.sec.pid` is the
|
||||||
|
/// app's **own** pid rather than pipewire-pulse's. See
|
||||||
|
/// [`Graph::native_client_node`].
|
||||||
|
native_client_by_app: BTreeMap<u32, GlobalId>,
|
||||||
session_client: Option<GlobalId>,
|
session_client: Option<GlobalId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,35 @@ impl Graph {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A **native PipeWire** client's stream: `client.id` on the node, **no
|
||||||
|
/// `application.process.id`**, and the app's real pid only on the Client
|
||||||
|
/// as `pipewire.sec.pid`.
|
||||||
|
///
|
||||||
|
/// ⚠️ This is what an ordinary app actually looks like when it does not go
|
||||||
|
/// through pipewire-pulse — measured for mpv on its default ao and for
|
||||||
|
/// peerspeak's own playback stream. [`Graph::app_node`] models the
|
||||||
|
/// Pulse-emulated shape, where the pid is on the node and the Client's
|
||||||
|
/// `sec_pid` is the *daemon's*; both shapes are live on this host, and
|
||||||
|
/// only this one exercises key 4's Client fallback (round 10, R10-3).
|
||||||
|
pub fn native_client_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = match self.native_client_by_app.get(&pid) {
|
||||||
|
Some(id) => *id,
|
||||||
|
None => {
|
||||||
|
let id = self.client(Some(pid));
|
||||||
|
self.native_client_by_app.insert(pid, id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.node(
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// An ordinary application stream: its own client, its own PID.
|
/// An ordinary application stream: its own client, its own PID.
|
||||||
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
let client = self.client_of_app(pid);
|
let client = self.client_of_app(pid);
|
||||||
@@ -152,11 +185,43 @@ impl Graph {
|
|||||||
self.node(name, role, app(client, pid))
|
self.node(name, role, app(client, pid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A peerspeak-owned node carrying **both** ownership carriers, as a
|
||||||
|
/// live one does. `name` gets the real `node.name` prefix so the fixture
|
||||||
|
/// cannot pass on the property alone.
|
||||||
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
|
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||||
|
self.node(&name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both ownership carriers on a node of **any** role — an impostor, or a
|
||||||
|
/// producer-side tagging bug. Only [`MediaRole::StreamOutput`] makes it a
|
||||||
|
/// taint root (round 10, R10-1); every other role must be ignored, and
|
||||||
|
/// these are the fixtures that prove it.
|
||||||
|
pub fn peerspeak_tagged_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
let name = format!("{}{name}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||||
|
self.node(&name, role, peerspeak_owned(client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carrier 1 alone: the `peerspeak.owned` property present, the
|
||||||
|
/// `node.name` prefix absent. What the engine sees for a node it had to
|
||||||
|
/// bind to observe (v3.5 §6.7).
|
||||||
|
pub fn peerspeak_node_prop_only(&mut self, name: &str, pid: u32) -> NodeRef {
|
||||||
let client = self.client_of_app(pid);
|
let client = self.client_of_app(pid);
|
||||||
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Carrier 2 alone: the `node.name` prefix present, the property absent
|
||||||
|
/// — indistinguishable from an ordinary app in every other respect.
|
||||||
|
/// This is the case that survives the F1 observation defect, and the
|
||||||
|
/// reason round 8 added a second carrier at all.
|
||||||
|
pub fn peerspeak_node_name_only(&mut self, role: &str, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
let name = format!("{}{role}_{pid}", super::PEERSPEAK_OWNED_NODE_PREFIX);
|
||||||
|
self.node(&name, MediaRole::StreamOutput, app(client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
|
||||||
let id = self.id();
|
let id = self.id();
|
||||||
self.node_with_id(name, role, id, props)
|
self.node_with_id(name, role, id, props)
|
||||||
|
|||||||
+101
-4
@@ -123,6 +123,44 @@ pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
|
|||||||
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
|
||||||
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
|
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
|
||||||
|
|
||||||
|
/// Ownership carrier 1: the node property peerspeak sets on everything it
|
||||||
|
/// plays (v3.5 §5.1). Read at the observer boundary, which is the only place
|
||||||
|
/// that touches raw property names — see [`super::observer`].
|
||||||
|
///
|
||||||
|
/// ⚠️ **Cross-repo wire contract.** peerspeak emits this; it does not depend
|
||||||
|
/// on this crate, nor this crate on it. The values are pinned in
|
||||||
|
/// `tests/fixtures/ownership-tag-contract.txt`, committed byte-identical in
|
||||||
|
/// both repos, and asserted by [`tests::ownership_carriers_match_the_cross_repo_fixture`].
|
||||||
|
/// The producer's matching constants live in peerspeak
|
||||||
|
/// `src/audio/ownership.rs`. Changing either is a both-repos-same-session
|
||||||
|
/// change that invalidates the phase 5 matrix.
|
||||||
|
pub const PEERSPEAK_OWNED_PROP: &str = "peerspeak.owned";
|
||||||
|
|
||||||
|
/// The value peerspeak emits for [`PEERSPEAK_OWNED_PROP`], and the **only**
|
||||||
|
/// value this consumer reads as owned.
|
||||||
|
///
|
||||||
|
/// ⚠️ This doc used to say the opposite — that any truthy value counted, on
|
||||||
|
/// the theory that treating an unexpected value as "owned" is the fail-closed
|
||||||
|
/// direction. R10-4 removed that leniency and the round-10 review caught the
|
||||||
|
/// prose surviving it here and in the shared fixture. The theory is wrong:
|
||||||
|
/// leniency buys false-positive *exclusion*, not safety, and it let any
|
||||||
|
/// process suppress a rival application's audio from the share with a
|
||||||
|
/// property it did not have to spell right. Fail-closed on this feature is
|
||||||
|
/// about **ancestry** — an unresolvable graph is not eligible — not about
|
||||||
|
/// parsing. The matching lives in the observer's `peerspeak_owned`, which is
|
||||||
|
/// deliberately *not* the lenient `truthy` used for PipeWire's own booleans.
|
||||||
|
pub const PEERSPEAK_OWNED_VALUE: &str = "1";
|
||||||
|
|
||||||
|
/// Ownership carrier 2: a `node.name` prefix (v3.5 §5.1, round 8).
|
||||||
|
///
|
||||||
|
/// Matched as a **union** with [`PEERSPEAK_OWNED_PROP`] — either one makes a
|
||||||
|
/// node peerspeak-owned. Two carriers because a property is invisible to the
|
||||||
|
/// registry `global` event and recoverable only by binding the node (v3.5
|
||||||
|
/// §6.7), which is precisely how the phase-5 gate failed; this one is
|
||||||
|
/// announced directly. A union is also the fail-closed direction: a missed
|
||||||
|
/// tag leaks call audio into the share, a spurious one only over-excludes.
|
||||||
|
pub const PEERSPEAK_OWNED_NODE_PREFIX: &str = "peerspeak_owned_";
|
||||||
|
|
||||||
/// Why a node is tainted or excluded. Stable machine-readable codes: this
|
/// Why a node is tainted or excluded. Stable machine-readable codes: this
|
||||||
/// value is the phase 5 audit output, the phase 6 status event, and the
|
/// value is the phase 5 audit output, the phase 6 status event, and the
|
||||||
/// eventual answer to "why isn't this app being shared?".
|
/// eventual answer to "why isn't this app being shared?".
|
||||||
@@ -378,8 +416,13 @@ pub fn evaluate(
|
|||||||
ctx: &ExclusionCtx,
|
ctx: &ExclusionCtx,
|
||||||
prior: &StickyState,
|
prior: &StickyState,
|
||||||
) -> (Decisions, StickyState) {
|
) -> (Decisions, StickyState) {
|
||||||
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
|
// Built once and shared: it carries the Client → `pipewire.sec.pid` index
|
||||||
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
|
// that key 4 falls back to (round 10, R10-3), so the components and the
|
||||||
|
// key index must be derived from the *same* one or they would disagree
|
||||||
|
// about which nodes are bounded.
|
||||||
|
let owner_ctx = owner::OwnerCtx::new(snapshot, ctx.pipewire_pulse_pid);
|
||||||
|
let components = OwnerComponents::build(snapshot, &owner_ctx);
|
||||||
|
let keys = owner::OwnerKeyIndex::build(snapshot, &owner_ctx);
|
||||||
|
|
||||||
// Pass 1 — the fail-closed view. Every decision is made from this one, so
|
// Pass 1 — the fail-closed view. Every decision is made from this one, so
|
||||||
// "we could not see" counts as taint.
|
// "we could not see" counts as taint.
|
||||||
@@ -534,8 +577,59 @@ fn ambiguous_id_nodes(snapshot: &GraphSnapshot) -> BTreeSet<Serial> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does this node carry either ownership carrier? **Tag presence only** — it
|
||||||
|
/// deliberately says nothing about whether the tag is honoured, which is
|
||||||
|
/// `local_root_reason`'s business (round 10 restricts that to producers).
|
||||||
|
/// Split out so the "is it tagged?" and "does the tag count?" questions can
|
||||||
|
/// be tested, and reported, independently.
|
||||||
|
pub fn is_peerspeak_tagged(node: &NodeSnapshot) -> bool {
|
||||||
|
node.props.peerspeak_owned
|
||||||
|
|| node
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|name| name.starts_with(PEERSPEAK_OWNED_NODE_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes carrying an ownership carrier that `local_root_reason` **ignored**
|
||||||
|
/// because the node is not a producer (round 10, R10-1). Ascending by serial.
|
||||||
|
///
|
||||||
|
/// Purely diagnostic — nothing in the engine consumes it. It exists because
|
||||||
|
/// R10-1 turns a formerly load-bearing tag into a no-op, and a silently
|
||||||
|
/// ignored tag has exactly two causes, both of which someone wants to know
|
||||||
|
/// about: peerspeak tagging a node it should not (a producer-side bug this
|
||||||
|
/// would otherwise hide), or another process impersonating the tag (the F2
|
||||||
|
/// attack, now defanged but still worth seeing).
|
||||||
|
pub fn misplaced_ownership_tags(snapshot: &GraphSnapshot) -> Vec<&NodeSnapshot> {
|
||||||
|
let mut tagged: Vec<&NodeSnapshot> = snapshot
|
||||||
|
.nodes()
|
||||||
|
.filter(|node| node.role != MediaRole::StreamOutput && is_peerspeak_tagged(node))
|
||||||
|
.collect();
|
||||||
|
tagged.sort_by_key(|node| node.serial);
|
||||||
|
tagged
|
||||||
|
}
|
||||||
|
|
||||||
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
|
||||||
if node.props.peerspeak_owned {
|
// The two ownership carriers, as a union (v3.5 §5.1). Kept here rather
|
||||||
|
// than folded together at the observer boundary so that the union is a
|
||||||
|
// pure, directly-testable rule: an adapter that collapsed both into the
|
||||||
|
// one `peerspeak_owned` bool would make each carrier untestable alone,
|
||||||
|
// which is exactly how phase 3r's row 1 nearly gated nothing.
|
||||||
|
//
|
||||||
|
// ⚠️ **Producer roles only** (round 10, R10-1). Neither carrier is a
|
||||||
|
// security boundary — both are strings any unprivileged process can put
|
||||||
|
// on its own node — so an unrestricted root is a denial of the whole
|
||||||
|
// feature: an unlinked `Stream/Input/Audio` named `peerspeak_owned_x`
|
||||||
|
// is a tainted *reader* with no owner bound to it, which fails every
|
||||||
|
// candidate closed machine-wide (Codex phase-1 F2, reproduced live).
|
||||||
|
// Restricting the root to `Stream/Output/Audio` costs nothing real —
|
||||||
|
// peerspeak only ever tags playback streams — and the attack needs the
|
||||||
|
// impostor to be a plausible playback node instead, which taints only
|
||||||
|
// its own descendants. The AEC's virtual sink/source is unaffected: it
|
||||||
|
// roots on [`Reason::AecIdentity`] below, by module id, not by this tag.
|
||||||
|
// A tag on a non-producer falls through: ignored for taint, but not
|
||||||
|
// nothing — it is either a peerspeak bug or an impostor, and
|
||||||
|
// [`misplaced_ownership_tags`] surfaces it so neither is silent.
|
||||||
|
if is_peerspeak_tagged(node) && node.role == MediaRole::StreamOutput {
|
||||||
return Some(Reason::PeerspeakOwned);
|
return Some(Reason::PeerspeakOwned);
|
||||||
}
|
}
|
||||||
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
|
||||||
@@ -810,7 +904,10 @@ fn propagate_owner_bridge(
|
|||||||
/// unknown than one we can** (Codex round 3 — the mirror image of the
|
/// unknown than one we can** (Codex round 3 — the mirror image of the
|
||||||
/// round-1 case):
|
/// round-1 case):
|
||||||
///
|
///
|
||||||
/// - A *bounded* tainted reader has a strong key or a usable PID, so its
|
/// - A *bounded* tainted reader has a strong key, or a usable PID **backed by
|
||||||
|
/// a resolved Client** (F11-1 — a node's self-claimed
|
||||||
|
/// `application.process.id` no longer bounds anything on its own; see
|
||||||
|
/// [`owner::owner_is_bounded`]), so its
|
||||||
/// siblings are exactly the output legs sharing that key. Any output leg
|
/// siblings are exactly the output legs sharing that key. Any output leg
|
||||||
/// that is *itself* bounded by a **different** key is provably a different
|
/// that is *itself* bounded by a **different** key is provably a different
|
||||||
/// owner and stays eligible; only unbounded output legs are its possible
|
/// owner and stays eligible; only unbounded output legs are its possible
|
||||||
|
|||||||
+270
-29
@@ -67,10 +67,87 @@
|
|||||||
//! Grouping is **transitive** (union-find). That is the fail-closed
|
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||||||
//! direction: bigger owner components mean more taint, never less.
|
//! direction: bigger owner components mean more taint, never less.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
|
||||||
|
|
||||||
|
/// Everything owner-key derivation needs from outside a single node.
|
||||||
|
///
|
||||||
|
/// Introduced by round 10 (R10-3). Before it, `keys_of` read only node
|
||||||
|
/// properties, and key 4 was therefore available **only** to nodes carrying
|
||||||
|
/// `application.process.id` — which native PipeWire clients do not. mpv on its
|
||||||
|
/// default ao, and peerspeak's own playback stream, expose nothing but
|
||||||
|
/// `client.id`, so both were *unbounded*, and the moment any tainted reader
|
||||||
|
/// existed anywhere, `propagate_unresolved_owner` excluded every one of them.
|
||||||
|
/// Measured: an untagged mpv went from eligible (alone) to `unresolved-owner`
|
||||||
|
/// the instant peerspeak played audio. That is "native-PipeWire apps are never
|
||||||
|
/// shareable", which is not a feature.
|
||||||
|
///
|
||||||
|
/// The missing pid is not missing at all — it is one hop away, on the node's
|
||||||
|
/// **Client**, as `pipewire.sec.pid`, and already in the snapshot.
|
||||||
|
pub struct OwnerCtx {
|
||||||
|
pub pipewire_pulse_pid: Option<u32>,
|
||||||
|
/// `client.id` → that Client's `pipewire.sec.pid`.
|
||||||
|
///
|
||||||
|
/// Clients whose global id is **ambiguous** (two live objects claiming it,
|
||||||
|
/// i.e. the observer missed a removal) are deliberately absent: resolving
|
||||||
|
/// an ambiguous id to a pid would attribute a node to whichever Client won
|
||||||
|
/// a coin toss, and inventing an owner key is the one direction that can
|
||||||
|
/// *reduce* taint. Absent ⇒ unbounded ⇒ fails closed, as before.
|
||||||
|
client_pids: BTreeMap<GlobalId, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerCtx {
|
||||||
|
pub fn new(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
||||||
|
let mut client_pids: BTreeMap<GlobalId, u32> = BTreeMap::new();
|
||||||
|
// ⚠️ Tracked separately from `client_pids`, and that is the point: a
|
||||||
|
// Client with no `sec_pid` still *claims* its id. Detecting duplicates
|
||||||
|
// by looking in the pid map would let a pid-less first claimant leave
|
||||||
|
// no trace, so the next Client claiming the same id would look unique
|
||||||
|
// and its pid would be used — resolving an ambiguous id, which is the
|
||||||
|
// one guess this guard exists to refuse. Pid-less Clients are ordinary
|
||||||
|
// (the session manager's is one).
|
||||||
|
let mut seen: BTreeSet<GlobalId> = BTreeSet::new();
|
||||||
|
for client in snapshot.clients() {
|
||||||
|
if !seen.insert(client.id) {
|
||||||
|
// Two Clients claiming one id: drop it entirely rather than
|
||||||
|
// pick. See the field docs.
|
||||||
|
client_pids.remove(&client.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(pid) = client.sec_pid {
|
||||||
|
client_pids.insert(client.id, pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
pipewire_pulse_pid,
|
||||||
|
client_pids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pipewire.sec.pid` of this node's Client, if it has one and that
|
||||||
|
/// Client's id is unambiguous.
|
||||||
|
fn client_pid(&self, node: &NodeSnapshot) -> Option<u32> {
|
||||||
|
self.client_pids.get(&node.props.client_id?).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this node have **protected provenance** — an unambiguous Client
|
||||||
|
/// yielding `Some(pipewire.sec.pid)`?
|
||||||
|
///
|
||||||
|
/// ⚠️ Read **before** the pipewire-pulse suppression in [`keys_of`], and
|
||||||
|
/// that ordering is the whole rule (F11-1, below). A Pulse-emulated app's
|
||||||
|
/// Client resolves to the daemon's PID; the value is then omitted from the
|
||||||
|
/// bridge keys as too coarse to *group* on, but it is still a protected
|
||||||
|
/// `pipewire.*` answer to "who is this", so the app keeps its provenance.
|
||||||
|
///
|
||||||
|
/// ❌ Not "a unique Client object exists". A unique Client with
|
||||||
|
/// `sec_pid = None` satisfies that and carries no protected identity at
|
||||||
|
/// all, which is exactly the hole [`owner_is_bounded`] closes.
|
||||||
|
fn client_is_resolved(&self, node: &NodeSnapshot) -> bool {
|
||||||
|
self.client_pid(node).is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
|
||||||
/// load-bearing for "report the strongest shared key".
|
/// load-bearing for "report the strongest shared key".
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
@@ -84,6 +161,16 @@ pub enum OwnerKey {
|
|||||||
impl OwnerKey {
|
impl OwnerKey {
|
||||||
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
||||||
/// and the phase 6 status event.
|
/// and the phase 6 status event.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Known imprecision, deliberately not fixed here.** `ProcessId` now
|
||||||
|
/// covers two sources — the node's `application.process.id` and its
|
||||||
|
/// Client's `pipewire.sec.pid` (see [`keys_of`]) — so a bridge reported as
|
||||||
|
/// `application.process.id` may in fact have resolved on the Client's
|
||||||
|
/// protected pid. Pre-existing since R10-3 made the Client a fallback, and
|
||||||
|
/// widened by the review's finding 1 making it a union. Splitting it would
|
||||||
|
/// add a code to a set that is explicitly a stable contract for the audit
|
||||||
|
/// output and the "why isn't this app being shared?" answer, so it wants
|
||||||
|
/// its own decision rather than a drive-by.
|
||||||
pub fn code(self) -> &'static str {
|
pub fn code(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::LinkGroup => "node.link-group",
|
Self::LinkGroup => "node.link-group",
|
||||||
@@ -106,7 +193,7 @@ enum KeyValue {
|
|||||||
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
|
||||||
/// on a device node) is **absent** here — that is the whole mechanism of the
|
/// on a device node) is **absent** here — that is the whole mechanism of the
|
||||||
/// two exceptions.
|
/// two exceptions.
|
||||||
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
|
fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if let Some(group) = &node.props.link_group {
|
if let Some(group) = &node.props.link_group {
|
||||||
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||||||
@@ -122,14 +209,48 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
|||||||
if let Some(client) = node.props.client_id {
|
if let Some(client) = node.props.client_id {
|
||||||
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||||||
}
|
}
|
||||||
if let Some(pid) = node.props.process_id {
|
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
|
||||||
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
|
// union rather than a fallback by the round-10 review, finding 1).
|
||||||
// unknown (`None`): the exception does *not* fire, key 4 applies to
|
//
|
||||||
// everything, and Pulse modules fuse into one owner. That is broad
|
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
|
||||||
// over-exclusion — annoying and safe — which is the direction v3.4
|
// The node's `application.process.id` is client-controlled and optional;
|
||||||
// §6.1.2's failure-mode paragraph asks for.
|
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
|
||||||
if Some(pid) != pipewire_pulse_pid {
|
// one that can carry a soundness argument (the same reason
|
||||||
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
|
// `propagate_unresolved_owner` sweeps everything for an unbounded reader).
|
||||||
|
// Letting the node's value *replace* the Client's meant one process using
|
||||||
|
// two Clients could escape the bridge entirely: its tainted reader reports
|
||||||
|
// a bogus node pid, its output leg omits the node pid and falls back to
|
||||||
|
// the Client's real one, the two legs are bounded by different values, so
|
||||||
|
// they neither bridge nor trip the unbounded sweep — and the output stays
|
||||||
|
// eligible while re-emitting the call. Carrying both values costs nothing
|
||||||
|
// and closes it: a leg that presents *either* value bridges.
|
||||||
|
//
|
||||||
|
// ⚠️ **Exception 1 applies to each value independently, and that is the
|
||||||
|
// whole risk here.** Measured on this host: 15 unrelated Clients share
|
||||||
|
// `sec_pid` 2528, which is pipewire-pulse's own — every Pulse-emulated app
|
||||||
|
// has one. Suppressing it per value is what keeps the union from fusing
|
||||||
|
// all fifteen into a single owner while still keeping each app's real
|
||||||
|
// per-app pid. For the common Pulse shape (node pid = the app's, Client
|
||||||
|
// `sec_pid` = the daemon's) the union therefore reduces to exactly the
|
||||||
|
// node's pid, as before.
|
||||||
|
//
|
||||||
|
// Note the fail-closed asymmetry when the daemon PID is unknown (`None`):
|
||||||
|
// the exception does *not* fire, key 4 applies to everything, and Pulse
|
||||||
|
// modules fuse into one owner. That is broad over-exclusion — annoying and
|
||||||
|
// safe — which is the direction v3.4 §6.1.2's failure-mode paragraph asks
|
||||||
|
// for.
|
||||||
|
for pid in [node.props.process_id, ctx.client_pid(node)]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if Some(pid) == ctx.pipewire_pulse_pid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = (OwnerKey::ProcessId, KeyValue::Num(u64::from(pid)));
|
||||||
|
// The two agree far more often than not; a duplicate entry would be
|
||||||
|
// harmless but would make the audit's key list read oddly.
|
||||||
|
if !out.contains(&key) {
|
||||||
|
out.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -151,10 +272,120 @@ fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKe
|
|||||||
/// nothing else relates them. Its sibling output leg cannot be found, so
|
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||||||
/// the engine must fail closed rather than declare it clean
|
/// the engine must fail closed rather than declare it clean
|
||||||
/// (v3.4 §6.1.1, final paragraph).
|
/// (v3.4 §6.1.1, final paragraph).
|
||||||
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
|
///
|
||||||
keys_of(node, pipewire_pulse_pid)
|
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
|
||||||
.iter()
|
///
|
||||||
.any(|(key, _)| *key != OwnerKey::ClientId)
|
/// **The rule, implemented below:** a strong key (`node.link-group`,
|
||||||
|
/// `pulse.module.id`) bounds an owner on its own; **key 4 bounds an owner only
|
||||||
|
/// when the node's Client resolves** — see [`OwnerCtx::client_is_resolved`].
|
||||||
|
/// A node whose Client cannot be resolved at all is *unbounded*, whatever
|
||||||
|
/// `application.process.id` it puts on itself.
|
||||||
|
///
|
||||||
|
/// The five Client cases, which is the matrix this needed (two of them are
|
||||||
|
/// indistinguishable under the wrong reading of "resolves"):
|
||||||
|
///
|
||||||
|
/// | Client | node's own pid | bounded? | why |
|
||||||
|
/// | --- | --- | --- | --- |
|
||||||
|
/// | **absent** | claimed | **no** | nothing corroborates the claim |
|
||||||
|
/// | **ambiguous** (two Clients, one id) | claimed | **no** | "we do not know who owns this" must not be papered over |
|
||||||
|
/// | **unique but pid-less** | claimed | **no** | a Client object is not an identity; `sec_pid` is |
|
||||||
|
/// | **resolved-native** (`sec_pid` = the app's) | absent | **yes** | protected pid, and it *is* key 4 |
|
||||||
|
/// | **resolved-to-pipewire-pulse** | claimed | **yes** | protected provenance; the daemon pid is suppressed as a *grouping* key only |
|
||||||
|
///
|
||||||
|
/// The last row is what keeps this from being the blunt fix. Applying
|
||||||
|
/// "self-claims are not sound" without the provenance test unbounds every
|
||||||
|
/// Pulse-emulated app — their Client's `sec_pid` is the daemon's and
|
||||||
|
/// suppressed, so the node's own claim is their only per-app identity — which
|
||||||
|
/// re-triggers the §6.1.1 mass over-exclusion the whole design exists to avoid
|
||||||
|
/// and empties the eligible half of the §5.1 matrix.
|
||||||
|
///
|
||||||
|
/// **Cost, measured on the live graph** (2026-07-26): **zero**. The
|
||||||
|
/// before- and after-binaries audited the *same* graph simultaneously — both
|
||||||
|
/// are read-only observers, which is the only way to A/B a partition without
|
||||||
|
/// churn between runs — with a tagged producer feeding the default sink,
|
||||||
|
/// `parec` on its monitor as a real tainted reader (so the sweep was armed,
|
||||||
|
/// not merely present in the code), and Firefox, `aplay` and `pacat` as
|
||||||
|
/// bystanders. **181 records each, the same 14 distinct decision states, none
|
||||||
|
/// exclusive to either side, no `unresolved-owner` on either.** The eligible
|
||||||
|
/// half stayed non-empty throughout: native (`aplay`), Pulse-emulated
|
||||||
|
/// (`pacat`) and Firefox all eligible. O5 is unmoved: identical p50 (15 µs)
|
||||||
|
/// and busy fraction (0.0012), and the after-binary's worst per-record
|
||||||
|
/// recompute was *lower* (217 µs vs 243 µs — noise, same debug build, same
|
||||||
|
/// concurrent load).
|
||||||
|
///
|
||||||
|
/// Why it costs nothing here: every real app on this box is either native
|
||||||
|
/// (Client `sec_pid` = its own pid) or Pulse-emulated (Client `sec_pid` = the
|
||||||
|
/// daemon's), and **both resolve**. Sweeping all 18 live nodes for the
|
||||||
|
/// predicate's inputs directly, the only unresolved-Client nodes were
|
||||||
|
/// `Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose;
|
||||||
|
/// session-manager device nodes are unresolved too (their Client is pid-less)
|
||||||
|
/// but exception 2 already strips key 4 from them. That is the answer the
|
||||||
|
/// deferral was waiting for: the rule bites exactly the anomalous shapes, and
|
||||||
|
/// this host has none.
|
||||||
|
///
|
||||||
|
/// ## The leak it closes (round 11 review, finding 1)
|
||||||
|
///
|
||||||
|
/// Round 10 made key 4 a union of the node's
|
||||||
|
/// `application.process.id` and its Client's `pipewire.sec.pid`, and the claim
|
||||||
|
/// that this was "strictly additive" was too strong: the same key list also
|
||||||
|
/// feeds *this* predicate, so adding a value can move a node from unbounded to
|
||||||
|
/// bounded, and `propagate_unresolved_owner`'s global sweep is triggered by an
|
||||||
|
/// **un**bounded tainted reader. Concretely:
|
||||||
|
///
|
||||||
|
/// 1. A tainted reader's node claims the pipewire-pulse PID while its Client
|
||||||
|
/// holds a real protected PID `A`. Under `or_else` the node's value won and
|
||||||
|
/// exception 1 suppressed it, leaving the reader unbounded; under the union
|
||||||
|
/// it is bounded by `A`.
|
||||||
|
/// 2. Its process's output leg uses a second Client whose id is **ambiguous**
|
||||||
|
/// (the observer missed a removal), so no protected PID is available — but
|
||||||
|
/// the leg claims a bogus `application.process.id` `B`, which bounds it.
|
||||||
|
/// 3. Neither the bridge nor the sweep fires, and the output stays eligible
|
||||||
|
/// while re-emitting the call.
|
||||||
|
///
|
||||||
|
/// Step 2 is now unbounded ⇒ the sweep fires ⇒ the leg is excluded. Note it
|
||||||
|
/// could not leak *yet* when it was filed — `evaluate()` is reached only by the
|
||||||
|
/// dry-run audit, which creates no links — and that is why the fix waited for
|
||||||
|
/// the §5.1 measurement instead of guessing at its cost.
|
||||||
|
///
|
||||||
|
/// ## What this is deliberately NOT
|
||||||
|
///
|
||||||
|
/// It is not a claim that `application.process.id` is now unused: it still
|
||||||
|
/// bridges (a self-claim is fine as *evidence that two legs are related* —
|
||||||
|
/// the fail-closed direction), and a resolved-Client node is still bounded by
|
||||||
|
/// whichever key-4 value survives suppression. Only *boundedness* — the
|
||||||
|
/// permission to say "I can enumerate this owner's other legs, so a
|
||||||
|
/// differently-keyed output is provably someone else" — now demands a
|
||||||
|
/// `pipewire.*` answer to "who is this".
|
||||||
|
///
|
||||||
|
/// ⚠️ Bridging must keep using the **full** union, so boundedness is carried
|
||||||
|
/// separately from the key set in [`OwnerKeyIndex`] rather than being
|
||||||
|
/// re-derived from it.
|
||||||
|
pub fn owner_is_bounded(node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||||
|
bounded_by(&keys_of(node, ctx), node, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`owner_is_bounded`]'s rule, over an already-computed key list.
|
||||||
|
///
|
||||||
|
/// The single implementation: [`OwnerKeyIndex::build`] has the keys in hand and
|
||||||
|
/// must not recompute them, and two copies of a predicate this load-bearing is
|
||||||
|
/// how the two spellings drift apart.
|
||||||
|
fn bounded_by(keys: &[(OwnerKey, KeyValue)], node: &NodeSnapshot, ctx: &OwnerCtx) -> bool {
|
||||||
|
let mut has_process_key = false;
|
||||||
|
for (key, _) in keys {
|
||||||
|
match key {
|
||||||
|
// Strong keys are per-instance and name the sibling set directly.
|
||||||
|
OwnerKey::LinkGroup | OwnerKey::PulseModuleId => return true,
|
||||||
|
OwnerKey::ProcessId => has_process_key = true,
|
||||||
|
// Never: one process can present two `client.id`s (the measured
|
||||||
|
// GStreamer refutation, above).
|
||||||
|
OwnerKey::ClientId => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// F11-1. The key may be the node's own claim, the Client's protected pid,
|
||||||
|
// or both — `keys_of` does not record which, and it does not need to: a
|
||||||
|
// resolved Client is provenance for the node *whatever* value key 4 ends
|
||||||
|
// up carrying, and without one there is no protected identity to stand on.
|
||||||
|
has_process_key && ctx.client_is_resolved(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owner keys computed once per snapshot.
|
/// Owner keys computed once per snapshot.
|
||||||
@@ -165,16 +396,27 @@ pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) ->
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct OwnerKeyIndex {
|
pub struct OwnerKeyIndex {
|
||||||
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
|
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
|
||||||
|
/// Nodes whose owner is positively bounded — see [`owner_is_bounded`].
|
||||||
|
///
|
||||||
|
/// ⚠️ **Stored, not derived from `keys`.** Since F11-1 the predicate needs
|
||||||
|
/// the node's Client as well as its key list, and the two answers are
|
||||||
|
/// deliberately different: the full union still bridges, while a
|
||||||
|
/// self-claimed pid no longer bounds.
|
||||||
|
bounded: BTreeSet<Serial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OwnerKeyIndex {
|
impl OwnerKeyIndex {
|
||||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
Self {
|
let mut keys = BTreeMap::new();
|
||||||
keys: snapshot
|
let mut bounded = BTreeSet::new();
|
||||||
.nodes()
|
for node in snapshot.nodes() {
|
||||||
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
|
let node_keys = keys_of(node, ctx);
|
||||||
.collect(),
|
if bounded_by(&node_keys, node, ctx) {
|
||||||
|
bounded.insert(node.serial);
|
||||||
|
}
|
||||||
|
keys.insert(node.serial, node_keys);
|
||||||
}
|
}
|
||||||
|
Self { keys, bounded }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The strongest key these two nodes share directly, if any.
|
/// The strongest key these two nodes share directly, if any.
|
||||||
@@ -238,11 +480,10 @@ impl OwnerKeyIndex {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See [`owner_is_bounded`].
|
/// See [`owner_is_bounded`]. A node outside this snapshot is unbounded,
|
||||||
|
/// which is the fail-closed answer.
|
||||||
pub fn is_bounded(&self, serial: Serial) -> bool {
|
pub fn is_bounded(&self, serial: Serial) -> bool {
|
||||||
self.keys
|
self.bounded.contains(&serial)
|
||||||
.get(&serial)
|
|
||||||
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +493,10 @@ impl OwnerKeyIndex {
|
|||||||
pub fn strongest_shared_key(
|
pub fn strongest_shared_key(
|
||||||
a: &NodeSnapshot,
|
a: &NodeSnapshot,
|
||||||
b: &NodeSnapshot,
|
b: &NodeSnapshot,
|
||||||
pipewire_pulse_pid: Option<u32>,
|
ctx: &OwnerCtx,
|
||||||
) -> Option<OwnerKey> {
|
) -> Option<OwnerKey> {
|
||||||
let a_keys = keys_of(a, pipewire_pulse_pid);
|
let a_keys = keys_of(a, ctx);
|
||||||
let b_keys = keys_of(b, pipewire_pulse_pid);
|
let b_keys = keys_of(b, ctx);
|
||||||
// `keys_of` yields strongest-first, so the first match is the strongest.
|
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||||||
a_keys.iter().find_map(|(key, value)| {
|
a_keys.iter().find_map(|(key, value)| {
|
||||||
b_keys
|
b_keys
|
||||||
@@ -279,7 +520,7 @@ pub struct OwnerComponents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OwnerComponents {
|
impl OwnerComponents {
|
||||||
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
||||||
let index: BTreeMap<Serial, usize> =
|
let index: BTreeMap<Serial, usize> =
|
||||||
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||||||
@@ -290,7 +531,7 @@ impl OwnerComponents {
|
|||||||
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||||||
for node in snapshot.nodes() {
|
for node in snapshot.nodes() {
|
||||||
let slot = index[&node.serial];
|
let slot = index[&node.serial];
|
||||||
for (key, value) in keys_of(node, pipewire_pulse_pid) {
|
for (key, value) in keys_of(node, ctx) {
|
||||||
buckets.entry((key, value)).or_default().push(slot);
|
buckets.entry((key, value)).or_default().push(slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ impl MediaRole {
|
|||||||
pub fn is_candidate(self) -> bool {
|
pub fn is_candidate(self) -> bool {
|
||||||
matches!(self, Self::StreamOutput)
|
matches!(self, Self::StreamOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stable machine-readable code for the audit output. Not the raw
|
||||||
|
/// `media.class`: `Other` has no single one, and the audit's codes are a
|
||||||
|
/// contract with the matrix, not with PipeWire.
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::StreamOutput => "stream-output",
|
||||||
|
Self::StreamInput => "stream-input",
|
||||||
|
Self::Sink => "sink",
|
||||||
|
Self::Source => "source",
|
||||||
|
Self::Duplex => "duplex",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The subset of node properties the engine actually reasons about.
|
/// The subset of node properties the engine actually reasons about.
|
||||||
@@ -97,8 +111,17 @@ impl MediaRole {
|
|||||||
/// on this feature means "not tainted".
|
/// on this feature means "not tainted".
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct NodeProps {
|
pub struct NodeProps {
|
||||||
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
|
/// `peerspeak.owned` is present and **exactly**
|
||||||
/// mechanism, explicitly *not* a security boundary.
|
/// [`super::PEERSPEAK_OWNED_VALUE`] (v3.4 §5.1, tightened by round 10's
|
||||||
|
/// R10-4 — it is not "present and truthy", and the round-10 review found
|
||||||
|
/// this doc still saying so). A correctness mechanism, explicitly *not* a
|
||||||
|
/// security boundary.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Ownership carrier 1 of 2, so this being `false` does not mean
|
||||||
|
/// "not peerspeak's".** Carrier 2 is the [`NodeSnapshot::name`] prefix
|
||||||
|
/// [`super::PEERSPEAK_OWNED_NODE_PREFIX`], matched as a union in
|
||||||
|
/// `local_root_reason`. Read that function, not this field, to answer
|
||||||
|
/// "is this node owned?".
|
||||||
pub peerspeak_owned: bool,
|
pub peerspeak_owned: bool,
|
||||||
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
||||||
/// parse-defensively note and the phase 0a truncation bug.
|
/// parse-defensively note and the phase 0a truncation bug.
|
||||||
|
|||||||
+759
-4
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
|
use super::fixture::{Graph, NodeRef, PULSE_PID, app};
|
||||||
use super::owner::{OwnerKey, strongest_shared_key};
|
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
|
||||||
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
|
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial};
|
||||||
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
|
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
|
||||||
|
|
||||||
@@ -176,6 +176,217 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
|
|||||||
assert_tainted(&decisions, sink, "tainted-upstream");
|
assert_tainted(&decisions, sink, "tainted-upstream");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each ownership carrier must work **alone** (v3.5 §5.1).
|
||||||
|
///
|
||||||
|
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
|
||||||
|
/// two sources can satisfy gates neither. `peerspeak_tagged_nodes_…` above
|
||||||
|
/// uses nodes carrying both carriers, so it would keep passing if either
|
||||||
|
/// were deleted. These are the rows that actually pin them.
|
||||||
|
#[test]
|
||||||
|
fn either_ownership_carrier_alone_taints_the_node() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
// Carrier 1: the property, on a node whose name says nothing.
|
||||||
|
let prop_only = graph.peerspeak_node_prop_only("some-playback-stream", 7);
|
||||||
|
// Carrier 2: the name prefix, property absent — the F1 case.
|
||||||
|
let name_only = graph.peerspeak_node_name_only("mpv", 31_284);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
for node in [prop_only, name_only, firefox] {
|
||||||
|
graph.link(node, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("firefox", firefox)],
|
||||||
|
&[
|
||||||
|
("prop_only", prop_only, "peerspeak-owned"),
|
||||||
|
("name_only", name_only, "peerspeak-owned"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **R10-1, the F2 fix.** Neither carrier is a security boundary — both are
|
||||||
|
/// strings any unprivileged process can set on its own node — so the tag is
|
||||||
|
/// honoured only on `Stream/Output/Audio`, the one role peerspeak ever tags.
|
||||||
|
///
|
||||||
|
/// Without the restriction, a tagged `Stream/Input/Audio` **with no links at
|
||||||
|
/// all** is a tainted *reader* (`receivers` includes nodes by role, no link
|
||||||
|
/// required), and an unbounded one, so `propagate_unresolved_owner` fails
|
||||||
|
/// every candidate on the machine closed. That is a whole-feature denial from
|
||||||
|
/// an unprivileged process, reproduced live during the phase-1 review.
|
||||||
|
#[test]
|
||||||
|
fn an_ownership_tag_on_a_non_producer_is_not_a_taint_root() {
|
||||||
|
for role in [
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
MediaRole::Sink,
|
||||||
|
MediaRole::Source,
|
||||||
|
MediaRole::Duplex,
|
||||||
|
MediaRole::Other,
|
||||||
|
] {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
graph.link(firefox, sink);
|
||||||
|
// Deliberately unlinked: the F2 shape needs no edges whatsoever.
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", role, 4_242);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_untainted(&decisions, impostor);
|
||||||
|
assert!(
|
||||||
|
decisions.taint.is_empty(),
|
||||||
|
"{role:?} impostor tainted something: {:?}",
|
||||||
|
decisions.taint.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
// The whole point: the eligible half stays non-empty.
|
||||||
|
assert_partition(&decisions, &[("firefox", firefox)], &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The live F2 reproduction, verbatim.** The measured impostor was an
|
||||||
|
/// *unbounded* reader — `client.id` present, `application.process.id` absent
|
||||||
|
/// — which is what turns "one bogus tainted node" into "nothing on this
|
||||||
|
/// machine is shareable": `propagate_unresolved_owner` cannot prove any
|
||||||
|
/// candidate independent of a reader it cannot attribute to an owner.
|
||||||
|
///
|
||||||
|
/// Measured before the fix: `BASELINE eligible=1 excluded=[]` →
|
||||||
|
/// `WITH IMPOSTOR eligible=0 excluded=[firefox → unresolved-owner]`.
|
||||||
|
///
|
||||||
|
/// Distinct from the row above, which uses a *bounded* impostor and so would
|
||||||
|
/// still pass if only the cheap half of the fix were present.
|
||||||
|
#[test]
|
||||||
|
fn an_unbounded_tagged_impostor_cannot_exclude_a_bystander_app() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
let mpv = graph.app_node("mpv", MediaRole::StreamOutput, 31_284);
|
||||||
|
for node in [firefox, mpv] {
|
||||||
|
graph.link(node, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseline = run(&graph, &ctx());
|
||||||
|
assert_partition(&baseline, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||||
|
|
||||||
|
// Both carriers, no pid, no links — everything an unprivileged process
|
||||||
|
// can arrange for itself in one `pw-cli` invocation.
|
||||||
|
let rogue_client = graph.client(Some(PULSE_PID));
|
||||||
|
let impostor = graph.node(
|
||||||
|
&format!("{}rogue_4242", super::PEERSPEAK_OWNED_NODE_PREFIX),
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
NodeProps {
|
||||||
|
peerspeak_owned: true,
|
||||||
|
client_id: Some(rogue_client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_untainted(&decisions, impostor);
|
||||||
|
assert_partition(&decisions, &[("firefox", firefox), ("mpv", mpv)], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tag that R10-1 ignores is still reported, so that neither a peerspeak
|
||||||
|
/// tagging bug nor an impersonation attempt is silent.
|
||||||
|
#[test]
|
||||||
|
fn ignored_ownership_tags_are_surfaced_for_diagnostics() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("call", 7);
|
||||||
|
graph.link(call, sink);
|
||||||
|
let impostor = graph.peerspeak_tagged_node("rogue", MediaRole::StreamInput, 4_242);
|
||||||
|
|
||||||
|
let snapshot = graph.build();
|
||||||
|
let misplaced: Vec<Serial> = super::misplaced_ownership_tags(&snapshot)
|
||||||
|
.iter()
|
||||||
|
.map(|node| node.serial)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Exactly the ignored one: the honoured producer is not "misplaced".
|
||||||
|
assert_eq!(misplaced, vec![impostor.serial]);
|
||||||
|
assert_ne!(impostor.serial, call.serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefix is a **prefix**, not a substring: an unrelated app must not be
|
||||||
|
/// excluded because the literal appears somewhere in its name. Over-exclusion
|
||||||
|
/// is the safe direction, but it is still wrong, and the phase-5 gate now
|
||||||
|
/// asserts exact partitions in both halves.
|
||||||
|
#[test]
|
||||||
|
fn the_owned_prefix_matches_only_at_the_start_of_node_name() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let impostor = graph.app_node(
|
||||||
|
&format!("recorder-of-{}stuff", super::PEERSPEAK_OWNED_NODE_PREFIX),
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
11_114,
|
||||||
|
);
|
||||||
|
graph.link(impostor, sink);
|
||||||
|
|
||||||
|
assert_partition(&run(&graph, &ctx()), &[("impostor", impostor)], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The consumer half of the cross-repo contract test (impl plan §3
|
||||||
|
/// requirement 2). peerspeak runs the mirror of this against a byte-identical
|
||||||
|
/// copy of the same file, and asserts the environment a real child `Command`
|
||||||
|
/// would carry produces exactly these literals.
|
||||||
|
///
|
||||||
|
/// This proves the two repos agree on the *literals*. That pixelpass actually
|
||||||
|
/// *listens* is proven by the two carrier tests above, and against the live
|
||||||
|
/// graph by the phase 5 dry-run.
|
||||||
|
#[test]
|
||||||
|
fn ownership_carriers_match_the_cross_repo_fixture() {
|
||||||
|
const FIXTURE: &str = include_str!("../../../tests/fixtures/ownership-tag-contract.txt");
|
||||||
|
|
||||||
|
let pinned: Vec<(&str, &str)> = FIXTURE
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.map(|line| line.split_once('=').expect("fixture line is key=value"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// ⚠️ Refuse a duplicated key rather than resolving it (Codex phase-1
|
||||||
|
// review, finding 3). This side takes the first match and peerspeak's
|
||||||
|
// took the last, so a duplicate in a byte-identical file could leave both
|
||||||
|
// repos green having selected *different* contracts.
|
||||||
|
for (index, (key, _)) in pinned.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
!pinned[..index].iter().any(|(seen, _)| seen == key),
|
||||||
|
"fixture defines {key:?} twice; the two repos would disagree on which wins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let get = |key: &str| -> &str {
|
||||||
|
pinned
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| *k == key)
|
||||||
|
.unwrap_or_else(|| panic!("fixture has no key {key:?}"))
|
||||||
|
.1
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(super::PEERSPEAK_OWNED_PROP, get("prop_key"));
|
||||||
|
assert_eq!(super::PEERSPEAK_OWNED_NODE_PREFIX, get("node_name_prefix"));
|
||||||
|
// ⚠️ **Equality, and that is now the whole rule**: carrier 1 is matched
|
||||||
|
// exactly, not as "anything but false/0" (round 10, R10-4). This assert
|
||||||
|
// used to be followed by a weaker `value != "false" && value != "0"`
|
||||||
|
// check, which described a leniency that no longer exists — the round-10
|
||||||
|
// review's finding 6, and a real trap: a future producer reading the old
|
||||||
|
// fixture prose could emit "true" and silently lose this carrier.
|
||||||
|
//
|
||||||
|
// That this consumer actually *listens* to the fixture's value, through
|
||||||
|
// the production observer wiring rather than a helper, is asserted by
|
||||||
|
// `observer::adapter::tests::the_fixture_value_is_the_only_owned_spelling`.
|
||||||
|
assert_eq!(super::PEERSPEAK_OWNED_VALUE, get("prop_value"));
|
||||||
|
|
||||||
|
// And the fixture's own worked example must be one this engine excludes,
|
||||||
|
// through carrier 2, exactly as written in the shared file.
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let sink = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let example = graph.app_node(get("node_name_example"), MediaRole::StreamOutput, 31_284);
|
||||||
|
graph.link(example, sink);
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[],
|
||||||
|
&[("example", example, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
|
fn aec_identity_is_exact_equality_and_other_modules_stay_eligible() {
|
||||||
let mut graph = Graph::new();
|
let mut graph = Graph::new();
|
||||||
@@ -507,8 +718,9 @@ fn owner_key_union_falls_through_a_present_but_unequal_key() {
|
|||||||
snapshot.node(b.serial).unwrap(),
|
snapshot.node(b.serial).unwrap(),
|
||||||
);
|
);
|
||||||
assert_ne!(a.props.client_id, b.props.client_id);
|
assert_ne!(a.props.client_id, b.props.client_id);
|
||||||
|
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strongest_shared_key(a, b, Some(PULSE_PID)),
|
strongest_shared_key(a, b, &owner_ctx),
|
||||||
Some(OwnerKey::ProcessId)
|
Some(OwnerKey::ProcessId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,11 +731,12 @@ fn the_strongest_shared_key_wins_when_several_match() {
|
|||||||
let a = graph.group_node("a", MediaRole::StreamInput, "g", 500);
|
let a = graph.group_node("a", MediaRole::StreamInput, "g", 500);
|
||||||
let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500);
|
let b = graph.group_node("b", MediaRole::StreamOutput, "g", 500);
|
||||||
let snapshot = graph.build();
|
let snapshot = graph.build();
|
||||||
|
let owner_ctx = OwnerCtx::new(&snapshot, Some(PULSE_PID));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strongest_shared_key(
|
strongest_shared_key(
|
||||||
snapshot.node(a.serial).unwrap(),
|
snapshot.node(a.serial).unwrap(),
|
||||||
snapshot.node(b.serial).unwrap(),
|
snapshot.node(b.serial).unwrap(),
|
||||||
Some(PULSE_PID)
|
&owner_ctx
|
||||||
),
|
),
|
||||||
Some(OwnerKey::LinkGroup)
|
Some(OwnerKey::LinkGroup)
|
||||||
);
|
);
|
||||||
@@ -560,6 +773,541 @@ fn the_pipewire_pulse_pid_does_not_fuse_unrelated_modules() {
|
|||||||
assert_untainted(&decisions, b_in);
|
assert_untainted(&decisions, b_in);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **R10-3, the fix.** A native PipeWire client puts no
|
||||||
|
/// `application.process.id` on its node — only `client.id` — so before the
|
||||||
|
/// Client fallback it had no key 4, was therefore *unbounded*, and
|
||||||
|
/// `propagate_unresolved_owner` excluded it the moment any tainted reader
|
||||||
|
/// existed anywhere on the machine.
|
||||||
|
///
|
||||||
|
/// Measured live: an untagged mpv was eligible alone, and became
|
||||||
|
/// `unresolved-owner` the instant peerspeak played audio. Since peerspeak
|
||||||
|
/// playing audio is the only situation in which this feature runs at all, that
|
||||||
|
/// amounted to "native-PipeWire apps are never shareable".
|
||||||
|
#[test]
|
||||||
|
fn a_native_client_is_bounded_by_its_clients_sec_pid() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// The tainted reader that arms the unresolved-owner arm. Bounded itself
|
||||||
|
// (a real pid), exactly as the live `sunshine` was — so this is the
|
||||||
|
// bounded-reader arm, not the keyless-reader one.
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// mpv on its default ao: client.id only, pid on the Client.
|
||||||
|
let mpv = graph.native_client_node("mpv", MediaRole::StreamOutput, 31_284);
|
||||||
|
graph.link(mpv, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", mpv)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fallback must bridge a native app's *own* legs, or it has bought
|
||||||
|
/// boundedness without buying correctness: an app that reads the call and
|
||||||
|
/// re-emits it on a second native node would be declared clean.
|
||||||
|
#[test]
|
||||||
|
fn the_sec_pid_fallback_still_bridges_a_native_apps_own_legs() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// One native process, two nodes, no link between them — the forwarder
|
||||||
|
// shape, in the native flavour.
|
||||||
|
let leg_in = graph.native_client_node("forwarder-in", MediaRole::StreamInput, 50_000);
|
||||||
|
let leg_out = graph.native_client_node("forwarder-out", MediaRole::StreamOutput, 50_000);
|
||||||
|
graph.link(hw, leg_in);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
assert_tainted(&decisions, leg_out, "tainted-owner-bridge");
|
||||||
|
assert_partition(
|
||||||
|
&decisions,
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("forwarder-out", leg_out, "tainted-owner-bridge"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The risk the fallback creates, and the guard on it.** Every
|
||||||
|
/// Pulse-emulated Client carries pipewire-pulse's own PID as `sec_pid` —
|
||||||
|
/// measured, 15 unrelated Clients sharing 2528 on this host. An unguarded
|
||||||
|
/// fallback would give all of them key 4 with the *same* value and fuse them
|
||||||
|
/// into one owner, so a single tainted Pulse app would exclude every other
|
||||||
|
/// Pulse app on the machine.
|
||||||
|
///
|
||||||
|
/// Exception 1 therefore applies to the fallback exactly as it does to the
|
||||||
|
/// node's own property. Without that, this row goes red.
|
||||||
|
#[test]
|
||||||
|
fn the_sec_pid_fallback_does_not_fuse_every_pulse_client() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// Three unrelated Pulse-emulated apps, each on its own Client, none
|
||||||
|
// exposing a node-level pid — so each can only reach key 4 through its
|
||||||
|
// Client, whose sec_pid is the daemon's.
|
||||||
|
let pulse_app = |graph: &mut Graph, name: &str, role| {
|
||||||
|
let client = graph.client(Some(PULSE_PID));
|
||||||
|
graph.node(
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// One of them reads the tainted sink; the other two must not care.
|
||||||
|
let reader = pulse_app(&mut graph, "recorder", MediaRole::StreamInput);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
let other_a = pulse_app(&mut graph, "player-a", MediaRole::StreamOutput);
|
||||||
|
let other_b = pulse_app(&mut graph, "player-b", MediaRole::StreamOutput);
|
||||||
|
|
||||||
|
let decisions = run(&graph, &ctx());
|
||||||
|
// They are unbounded (`client.id` alone never bounds an owner), so the
|
||||||
|
// fail-closed arm still excludes them — but as `unresolved-owner`, NOT as
|
||||||
|
// `tainted-owner-bridge`. That distinction is the whole assertion: a
|
||||||
|
// bridge reason here would mean the daemon pid had fused three unrelated
|
||||||
|
// applications into one owner, and unlike fail-closed exclusion, fusion
|
||||||
|
// does not go away when the apps are given real pids
|
||||||
|
// (`distinct_sec_pids_bound_each_native_app_separately` is that half).
|
||||||
|
assert_tainted(&decisions, other_a, "unresolved-owner");
|
||||||
|
assert_tainted(&decisions, other_b, "unresolved-owner");
|
||||||
|
for node in [other_a, other_b] {
|
||||||
|
assert_ne!(
|
||||||
|
decisions.taint.get(&node.serial).map(|e| e.reason.code()),
|
||||||
|
Some("tainted-owner-bridge"),
|
||||||
|
"the daemon pid must not bridge unrelated Pulse clients"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same three apps, given **real per-app** `sec_pid`s: now the fallback
|
||||||
|
/// fires, all three are bounded, and only the one actually reading the call is
|
||||||
|
/// affected. This is the row that proves the guard above suppresses the daemon
|
||||||
|
/// pid *specifically* rather than disabling the fallback outright.
|
||||||
|
#[test]
|
||||||
|
fn distinct_sec_pids_bound_each_native_app_separately() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
let reader = graph.native_client_node("recorder", MediaRole::StreamInput, 6_001);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
let other_a = graph.native_client_node("player-a", MediaRole::StreamOutput, 6_002);
|
||||||
|
let other_b = graph.native_client_node("player-b", MediaRole::StreamOutput, 6_003);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("player-a", other_a), ("player-b", other_b)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An **ambiguous** `client.id` — two live Clients claiming it, meaning the
|
||||||
|
/// observer missed a removal — must not yield a fallback pid. Inventing an
|
||||||
|
/// owner key is the one direction that can *reduce* taint, so resolving the
|
||||||
|
/// ambiguity by coin toss is the wrong kind of guess.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_client_id_yields_no_fallback_pid() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// Two Clients, one id, distinct real pids.
|
||||||
|
let shared_id = graph.client(Some(6_010));
|
||||||
|
graph.client_with_id(shared_id, Some(6_011));
|
||||||
|
let app = graph.node(
|
||||||
|
"native-app",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(shared_id),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
// Unbounded ⇒ fails closed, exactly as before R10-3.
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("native-app", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ambiguity guard must not depend on the *first* Client claiming an id
|
||||||
|
/// having a `sec_pid`.
|
||||||
|
///
|
||||||
|
/// Found by auditing R10-3 rather than by a failing case: the first cut
|
||||||
|
/// detected a duplicate id by looking it up in the pid map, which is only
|
||||||
|
/// populated for Clients that carry a pid at all. A pid-less Client therefore
|
||||||
|
/// left no trace, and the next Client claiming the same id was treated as
|
||||||
|
/// unique — resolving an ambiguous id, which is exactly the guess the guard
|
||||||
|
/// exists to refuse. Pid-less Clients are ordinary here (`device_node`'s
|
||||||
|
/// session client is one), so this is reachable, not theoretical.
|
||||||
|
#[test]
|
||||||
|
fn a_pidless_first_client_still_makes_its_id_ambiguous() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
|
||||||
|
// First claimant has NO sec_pid; second has one.
|
||||||
|
let shared_id = graph.client(None);
|
||||||
|
graph.client_with_id(shared_id, Some(6_011));
|
||||||
|
let app = graph.node(
|
||||||
|
"native-app",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(shared_id),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("native-app", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A process using **two** Clients cannot escape the bridge by presenting a
|
||||||
|
/// bogus pid on one leg and none on the other.
|
||||||
|
///
|
||||||
|
/// ⚠️ **This is the round-10 review's finding 1, and it was a real leak while
|
||||||
|
/// key 4 was `node.or_else(client)`.** The node's `application.process.id` is
|
||||||
|
/// client-controlled; the Client's `pipewire.sec.pid` is protected. Letting
|
||||||
|
/// the node's value *replace* the Client's meant the reader was bounded by
|
||||||
|
/// `12_345` and the output leg by `50_000`, so they shared no key, did not
|
||||||
|
/// bridge, and — both being bounded — neither tripped the unbounded sweep.
|
||||||
|
/// The output stayed eligible while re-emitting the call.
|
||||||
|
///
|
||||||
|
/// Carrying both values fixes it: the two legs share the Client pid.
|
||||||
|
///
|
||||||
|
/// Reachability, stated honestly: `evaluate()` today is reached only by the
|
||||||
|
/// dry-run audit, which creates no links, so this could not echo on this
|
||||||
|
/// branch. It becomes live the moment phase 6 consumes these decisions.
|
||||||
|
#[test]
|
||||||
|
fn one_process_with_two_clients_cannot_split_its_pid_to_escape_the_bridge() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
// One native process, two Clients, one protected pid.
|
||||||
|
let reader_client = graph.client(Some(50_000));
|
||||||
|
let output_client = graph.client(Some(50_000));
|
||||||
|
|
||||||
|
// Its reading leg claims a pid that is not its own.
|
||||||
|
let reader = graph.node(
|
||||||
|
"two-client-reader",
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(reader_client),
|
||||||
|
process_id: Some(12_345),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
|
||||||
|
// Its re-emitting leg claims no pid at all.
|
||||||
|
let output = graph.node(
|
||||||
|
"two-client-output",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(output_client),
|
||||||
|
process_id: None,
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(output, hw);
|
||||||
|
|
||||||
|
// A genuinely unrelated app must survive, or "exclude everything" would
|
||||||
|
// pass this test — the §5.1 eligible-half rule.
|
||||||
|
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||||
|
graph.link(bystander, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("two-client-output", output, "tainted-owner-bridge"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node's own `application.process.id` is used even when its Client's
|
||||||
|
/// `sec_pid` is the daemon's — the single most common shape here, since a
|
||||||
|
/// Pulse-emulated node's pid is the app's while its Client's is
|
||||||
|
/// pipewire-pulse's.
|
||||||
|
///
|
||||||
|
/// ⚠️ Both values are now carried (round-10 review, finding 1), so this is no
|
||||||
|
/// longer "the node's wins" but "exception 1 is applied per value": the
|
||||||
|
/// daemon's `sec_pid` is dropped and the node's real pid is kept, leaving the
|
||||||
|
/// same single key as before.
|
||||||
|
#[test]
|
||||||
|
fn the_nodes_own_process_id_wins_over_its_clients() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
// `app_node` is exactly that shape: node pid 11_114, Client sec_pid
|
||||||
|
// PULSE_PID. If the Client's won, exception 1 would suppress key 4 and
|
||||||
|
// this node would be unbounded.
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 11_114);
|
||||||
|
graph.link(firefox, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("firefox", firefox)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// F11-1 — a self-claimed pid is not provenance: the five Client cases
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The scaffold every F11-1 row needs: peerspeak's call reaching the hardware
|
||||||
|
/// sink, a **bounded** tainted reader, and an ordinary bystander.
|
||||||
|
///
|
||||||
|
/// ⚠️ The reader must be *bounded* (`sunshine` carries a real pid). An
|
||||||
|
/// unbounded tainted reader trips `propagate_unresolved_owner`'s other tier,
|
||||||
|
/// which sweeps **every** output candidate on the box regardless of its own
|
||||||
|
/// keys — the three "unbounded" rows below would then pass without testing
|
||||||
|
/// anything. The bystander is the other half of that guard: it is bounded via
|
||||||
|
/// the ordinary Pulse shape, so an implementation that unbounded everything
|
||||||
|
/// fails every row instead of passing three of them.
|
||||||
|
///
|
||||||
|
/// Returns the graph, the hardware sink to hang nodes off, and the two nodes
|
||||||
|
/// every row must name in its partition.
|
||||||
|
fn armed_with_a_bounded_reader() -> (Graph, NodeRef, NodeRef, NodeRef) {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
let sunshine = graph.app_node("sunshine", MediaRole::StreamInput, 3_838);
|
||||||
|
graph.link(hw, sunshine);
|
||||||
|
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||||
|
graph.link(bystander, hw);
|
||||||
|
(graph, hw, call, bystander)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case 1 of 5 — **Client absent.** A node that names no Client at all has
|
||||||
|
/// nothing but its own word for who owns it, so it cannot be bounded.
|
||||||
|
#[test]
|
||||||
|
fn an_absent_client_leaves_a_self_claimed_pid_unbounded() {
|
||||||
|
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||||
|
let orphan = graph.node(
|
||||||
|
"no-client",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
process_id: Some(70_001),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(orphan, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("no-client", orphan, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case 2 of 5 — **Client ambiguous.** Two live Clients claim the id, so the
|
||||||
|
/// observer missed a removal and we do not know who owns this node. A
|
||||||
|
/// self-claimed pid must not paper over that: this is step 2 of the recorded
|
||||||
|
/// leak path, and before F11-1 the claim bounded the node and spared it.
|
||||||
|
#[test]
|
||||||
|
fn an_ambiguous_client_leaves_a_self_claimed_pid_unbounded() {
|
||||||
|
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||||
|
let shared_id = graph.client(Some(70_010));
|
||||||
|
graph.client_with_id(shared_id, Some(70_011));
|
||||||
|
let app = graph.node(
|
||||||
|
"ambiguous-client",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(shared_id),
|
||||||
|
process_id: Some(70_012),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("ambiguous-client", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case 3 of 5 — **Client unique but pid-less**, and *the row that decides
|
||||||
|
/// which rule is implemented*.
|
||||||
|
///
|
||||||
|
/// A unique Client object exists, so "resolved = a unique Client exists" would
|
||||||
|
/// call this node bounded — leaving the self-claimed-pid hole wide open under a
|
||||||
|
/// rule that looks like it closed it. `sec_pid` is what carries protected
|
||||||
|
/// identity, so `None` means unresolved, and pid-less Clients are ordinary
|
||||||
|
/// (the session manager's is one).
|
||||||
|
///
|
||||||
|
/// A two-case absent/resolved matrix skips this silently. That is why it is
|
||||||
|
/// written out.
|
||||||
|
#[test]
|
||||||
|
fn a_unique_but_pidless_client_leaves_a_self_claimed_pid_unbounded() {
|
||||||
|
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||||
|
let pidless = graph.client(None);
|
||||||
|
let app = graph.node(
|
||||||
|
"pidless-client",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(pidless),
|
||||||
|
process_id: Some(70_020),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(app, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("pidless-client", app, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case 4 of 5 — **Client resolved, native.** `pipewire.sec.pid` is the app's
|
||||||
|
/// own, so provenance and key 4 are the same value and the node is bounded
|
||||||
|
/// without claiming anything itself.
|
||||||
|
#[test]
|
||||||
|
fn a_resolved_native_client_bounds_its_node() {
|
||||||
|
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||||
|
let mpv2 = graph.native_client_node("mpv-native", MediaRole::StreamOutput, 70_030);
|
||||||
|
graph.link(mpv2, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander), ("mpv-native", mpv2)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case 5 of 5 — **Client resolved to pipewire-pulse.** The row that stops
|
||||||
|
/// this rule from being the blunt fix.
|
||||||
|
///
|
||||||
|
/// Every Pulse-emulated app looks like this: the Client's `sec_pid` is the
|
||||||
|
/// daemon's — suppressed as a *grouping* key, because it would fuse fifteen
|
||||||
|
/// unrelated apps — while the node's own `application.process.id` is the app's.
|
||||||
|
/// Provenance is read **before** that suppression, so the app keeps its bound
|
||||||
|
/// and stays eligible. Reading it after would unbound every Pulse app on the
|
||||||
|
/// box and empty the eligible half of the §5.1 matrix, which is the §6.1.1
|
||||||
|
/// catastrophe arriving through the boundedness door.
|
||||||
|
#[test]
|
||||||
|
fn a_client_resolving_to_pipewire_pulse_still_bounds_its_node() {
|
||||||
|
let (mut graph, hw, call, bystander) = armed_with_a_bounded_reader();
|
||||||
|
let firefox = graph.app_node("firefox", MediaRole::StreamOutput, 70_040);
|
||||||
|
graph.link(firefox, hw);
|
||||||
|
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander), ("firefox", firefox)],
|
||||||
|
&[("call", call, "peerspeak-owned")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The recorded leak path, end to end** (round 11 review, finding 1).
|
||||||
|
///
|
||||||
|
/// One process, two Clients. Its reading leg claims the daemon's pid — which
|
||||||
|
/// exception 1 suppresses — while its Client holds a real protected pid `A`, so
|
||||||
|
/// the union bounds the reader by `A` and the *unbounded-reader* tier never
|
||||||
|
/// arms. Its re-emitting leg sits on a second Client whose id is **ambiguous**
|
||||||
|
/// (one of the two claimants even holds `A`, so this is not "the guess would
|
||||||
|
/// have been wrong" — it is "a guess is not evidence"), and claims a pid of its
|
||||||
|
/// own. The two legs share no key, so the bridge does not fire either.
|
||||||
|
///
|
||||||
|
/// Before F11-1 the self-claim bounded the output leg, both tiers stayed quiet,
|
||||||
|
/// and it re-emitted the call while eligible. Now the leg is unbounded, the
|
||||||
|
/// bounded-reader tier sweeps it, and `mpv` shows the sweep is still targeted.
|
||||||
|
#[test]
|
||||||
|
fn a_self_claimed_pid_cannot_spare_an_output_leg_the_bridge_cannot_reach() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let hw = graph.device_node("hw-sink", MediaRole::Sink);
|
||||||
|
let call = graph.peerspeak_node("peerspeak", 7);
|
||||||
|
graph.link(call, hw);
|
||||||
|
|
||||||
|
let reader_client = graph.client(Some(80_000));
|
||||||
|
let reader = graph.node(
|
||||||
|
"forwarder-in",
|
||||||
|
MediaRole::StreamInput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(reader_client),
|
||||||
|
process_id: Some(PULSE_PID),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(hw, reader);
|
||||||
|
|
||||||
|
let ambiguous = graph.client(Some(80_000));
|
||||||
|
graph.client_with_id(ambiguous, Some(80_001));
|
||||||
|
let output = graph.node(
|
||||||
|
"forwarder-out",
|
||||||
|
MediaRole::StreamOutput,
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(ambiguous),
|
||||||
|
process_id: Some(80_002),
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
graph.link(output, hw);
|
||||||
|
|
||||||
|
let bystander = graph.app_node("mpv", MediaRole::StreamOutput, 9_001);
|
||||||
|
graph.link(bystander, hw);
|
||||||
|
|
||||||
|
// `mpv` staying eligible is what proves the reader is bounded: an
|
||||||
|
// unbounded tainted reader sweeps **every** output candidate, `mpv`
|
||||||
|
// included, and this row would then be testing the wrong tier.
|
||||||
|
assert_partition(
|
||||||
|
&run(&graph, &ctx()),
|
||||||
|
&[("mpv", bystander)],
|
||||||
|
&[
|
||||||
|
("call", call, "peerspeak-owned"),
|
||||||
|
("forwarder-out", output, "unresolved-owner"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
|
fn an_unknown_pipewire_pulse_pid_over_excludes_rather_than_leaks() {
|
||||||
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
|
// v3.4 §6.1.2's failure-mode paragraph: if pixelpass cannot identify
|
||||||
@@ -1935,5 +2683,12 @@ fn an_ambiguous_client_id_remembers_every_claimant_for_stickiness() {
|
|||||||
client_members >= 2,
|
client_members >= 2,
|
||||||
"both ambiguous-id clients should be remembered: {sticky:#?}"
|
"both ambiguous-id clients should be remembered: {sticky:#?}"
|
||||||
);
|
);
|
||||||
let _ = (ClientSnapshot { serial: Serial(0), id: GlobalId(0), sec_pid: None }, firefox);
|
let _ = (
|
||||||
|
ClientSnapshot {
|
||||||
|
serial: Serial(0),
|
||||||
|
id: GlobalId(0),
|
||||||
|
sec_pid: None,
|
||||||
|
},
|
||||||
|
firefox,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ async fn main() -> Result<()> {
|
|||||||
pipewire::init();
|
pipewire::init();
|
||||||
|
|
||||||
if cli.repair {
|
if cli.repair {
|
||||||
return repair::run().await;
|
return repair::run(cli.repair_legacy_untagged).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only diagnostic: observe the graph, report what the audio-exclusion
|
// Read-only diagnostic: observe the graph, report what the audio-exclusion
|
||||||
|
|||||||
-236
@@ -1,236 +0,0 @@
|
|||||||
//! `--repair`: clean up null-sinks and loopbacks left behind by a crashed
|
|
||||||
//! pixelpass host. Identifies orphans by the `pixelpass_capture_<pid>`
|
|
||||||
//! name pattern + dead-PID check, then unloads paired loopbacks first
|
|
||||||
//! (mirrors `Routing::shutdown`'s order so PipeWire doesn't leave zombie
|
|
||||||
//! links). Live PIDs — including this process and any other running
|
|
||||||
//! pixelpass — are left alone.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
const SINK_NAME_PREFIX: &str = "pixelpass_capture_";
|
|
||||||
|
|
||||||
pub async fn run() -> Result<()> {
|
|
||||||
let modules = list_modules().context("failed to list pactl modules")?;
|
|
||||||
|
|
||||||
let mut dead_sinks: Vec<OrphanSink> = Vec::new();
|
|
||||||
let mut dead_pids: HashSet<u32> = HashSet::new();
|
|
||||||
let mut live_skipped: u32 = 0;
|
|
||||||
|
|
||||||
for m in &modules {
|
|
||||||
if m.name != "module-null-sink" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(sink_name) = extract_kv(&m.args, "sink_name") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(pid_str) = sink_name.strip_prefix(SINK_NAME_PREFIX) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_pid_alive(pid) {
|
|
||||||
live_skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
dead_pids.insert(pid);
|
|
||||||
dead_sinks.push(OrphanSink {
|
|
||||||
id: m.id,
|
|
||||||
sink_name: sink_name.to_string(),
|
|
||||||
pid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut dead_loopbacks: Vec<u32> = Vec::new();
|
|
||||||
for m in &modules {
|
|
||||||
if m.name != "module-loopback" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A pixelpass loopback references a capture sink either as its
|
|
||||||
// destination (`sink=pixelpass_capture_<pid>` — the default→null
|
|
||||||
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
|
|
||||||
// — the local monitor that lets the sharer hear the app). Match both.
|
|
||||||
let Some(pid) = loopback_capture_pid(&m.args) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if dead_pids.contains(&pid) {
|
|
||||||
dead_loopbacks.push(m.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dead_sinks.is_empty() && dead_loopbacks.is_empty() {
|
|
||||||
if live_skipped > 0 {
|
|
||||||
println!(
|
|
||||||
"[pixelpass] --repair: nothing to clean up ({live_skipped} live pixelpass host(s) left alone)."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!("[pixelpass] --repair: nothing to clean up.");
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut unloaded = 0u32;
|
|
||||||
let mut failed = 0u32;
|
|
||||||
|
|
||||||
for id in &dead_loopbacks {
|
|
||||||
match unload_module(*id) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!("[pixelpass] --repair: unloaded loopback module #{id}");
|
|
||||||
unloaded += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[pixelpass] --repair: failed to unload loopback #{id}: {e:#}");
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for orphan in &dead_sinks {
|
|
||||||
match unload_module(orphan.id) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!(
|
|
||||||
"[pixelpass] --repair: unloaded {} (orphaned from pid {})",
|
|
||||||
orphan.sink_name, orphan.pid
|
|
||||||
);
|
|
||||||
unloaded += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"[pixelpass] --repair: failed to unload {} (#{}): {e:#}",
|
|
||||||
orphan.sink_name, orphan.id
|
|
||||||
);
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if live_skipped > 0 {
|
|
||||||
println!("[pixelpass] --repair: left {live_skipped} live pixelpass host(s) alone.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
|
||||||
}
|
|
||||||
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Module {
|
|
||||||
id: u32,
|
|
||||||
name: String,
|
|
||||||
args: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OrphanSink {
|
|
||||||
id: u32,
|
|
||||||
sink_name: String,
|
|
||||||
pid: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_modules() -> Result<Vec<Module>> {
|
|
||||||
let output = Command::new("pactl")
|
|
||||||
.args(["list", "short", "modules"])
|
|
||||||
.output()
|
|
||||||
.context("failed to run `pactl list short modules`")?;
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"pactl list short modules failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let text = String::from_utf8(output.stdout).context("pactl returned non-UTF-8")?;
|
|
||||||
let mut modules = Vec::new();
|
|
||||||
// `pactl list short modules` is tab-separated, but some modules have
|
|
||||||
// multi-line `{ ... }` argument blocks that wrap onto continuation
|
|
||||||
// lines starting with whitespace. The wrap lines never parse as a
|
|
||||||
// u32 ID, so the simple per-line + parse-id filter is robust.
|
|
||||||
for line in text.lines() {
|
|
||||||
let mut parts = line.splitn(4, '\t');
|
|
||||||
let Some(id_str) = parts.next() else { continue };
|
|
||||||
let Ok(id) = id_str.parse::<u32>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(name) = parts.next() else { continue };
|
|
||||||
let args = parts.next().unwrap_or("").to_string();
|
|
||||||
modules.push(Module {
|
|
||||||
id,
|
|
||||||
name: name.to_string(),
|
|
||||||
args,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(modules)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
|
|
||||||
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
|
|
||||||
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
|
|
||||||
fn loopback_capture_pid(args: &str) -> Option<u32> {
|
|
||||||
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
|
|
||||||
let from_source = extract_kv(args, "source")
|
|
||||||
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
|
|
||||||
.and_then(|rest| rest.strip_suffix(".monitor"));
|
|
||||||
from_sink
|
|
||||||
.or(from_source)
|
|
||||||
.and_then(|pid| pid.parse::<u32>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
|
|
||||||
for token in args.split_whitespace() {
|
|
||||||
if let Some(rest) = token.strip_prefix(key)
|
|
||||||
&& let Some(value) = rest.strip_prefix('=')
|
|
||||||
{
|
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_pid_alive(pid: u32) -> bool {
|
|
||||||
Path::new(&format!("/proc/{pid}")).exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unload_module(id: u32) -> Result<()> {
|
|
||||||
let output = Command::new("pactl")
|
|
||||||
.arg("unload-module")
|
|
||||||
.arg(id.to_string())
|
|
||||||
.output()
|
|
||||||
.context("failed to run pactl unload-module")?;
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"pactl unload-module #{id}: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loopback_pid_matches_default_null_mirror_by_sink() {
|
|
||||||
// The default→null loopback: capture sink is the destination.
|
|
||||||
let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20";
|
|
||||||
assert_eq!(loopback_capture_pid(args), Some(4242));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loopback_pid_matches_local_monitor_by_source() {
|
|
||||||
// The local monitor: capture sink's monitor is the source, and the
|
|
||||||
// destination is the real default sink (not a pixelpass name).
|
|
||||||
let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20";
|
|
||||||
assert_eq!(loopback_capture_pid(args), Some(4242));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loopback_pid_ignores_unrelated_loopback() {
|
|
||||||
assert_eq!(
|
|
||||||
loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
//! Structured Pulse introspection: the observation and destruction layer for
|
||||||
|
//! `--repair`.
|
||||||
|
//!
|
||||||
|
//! # Why this replaced parsing `pactl`
|
||||||
|
//!
|
||||||
|
//! Three separate defects, all of them consequences of reading a human-oriented
|
||||||
|
//! text format rather than the protocol:
|
||||||
|
//!
|
||||||
|
//! 1. **Record boundaries were unprovable.** `pactl list short modules` prints a
|
||||||
|
//! module's argument raw into a tab-and-newline-delimited format with no
|
||||||
|
//! escaping. A *genuine* module whose argument contains a newline — say
|
||||||
|
//! `…latency_msec=20\nremix=false`, and `remix` is a real loopback option —
|
||||||
|
//! renders a first line that reads byte-exactly like one of our canonical
|
||||||
|
//! forms, with the remainder dropped as an unparseable continuation. No index
|
||||||
|
//! is forged, so no duplicate-index check can see it: repair would classify and
|
||||||
|
//! unload a module it had never actually seen in full. A tab in the same
|
||||||
|
//! position instead hides a sink reference, which is worse, because the gate
|
||||||
|
//! that protects a still-referenced sink then cannot see the reference.
|
||||||
|
//! 2. **Index and argument could be mis-paired.** The one listing that carries the
|
||||||
|
//! exact argument (`-f json`) carries **no index** at all on pactl 17, and the
|
||||||
|
//! one that carries the index cannot carry the argument faithfully. Combining
|
||||||
|
//! them by position is unsound whenever module names repeat: another client
|
||||||
|
//! loading one module and unloading another between the two calls leaves the
|
||||||
|
//! counts and names aligned while every argument has shifted by one.
|
||||||
|
//! 3. **Locality was a guess.** `PULSE_SERVER` is a *fallback list*, so
|
||||||
|
//! `unix:/missing tcp:remote:4713` passes any "starts with unix:" test and then
|
||||||
|
//! connects to another machine — where our local pids mean nothing and a live
|
||||||
|
//! remote host's modules look dead.
|
||||||
|
//!
|
||||||
|
//! `pa_module_info` carries index, name and argument together in one structured
|
||||||
|
//! record, so (1) and (2) cannot arise. `pa_context_is_local()` answers (3) about
|
||||||
|
//! the connection that actually got established rather than about a string we
|
||||||
|
//! hoped described it. And because unloading goes back through the *same*
|
||||||
|
//! connection, there is no window in which listing and destruction could disagree
|
||||||
|
//! about which server they are talking to.
|
||||||
|
//!
|
||||||
|
//! # What is deliberately not here
|
||||||
|
//!
|
||||||
|
//! No decisions. This module observes and destroys; every judgement about what may
|
||||||
|
//! be destroyed lives in [`super::plan`], which is pure and needs no Pulse server
|
||||||
|
//! to test. The one policy this layer owns is *refusing to talk to the wrong
|
||||||
|
//! server at all*.
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result, bail};
|
||||||
|
use libpulse_binding::callbacks::ListResult;
|
||||||
|
use libpulse_binding::context::{Context, FlagSet as ContextFlagSet, State as ContextState};
|
||||||
|
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
|
||||||
|
use libpulse_binding::operation::{Operation, State as OperationState};
|
||||||
|
use libpulse_binding::proplist::{Proplist, properties};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::plan::ModuleObservation;
|
||||||
|
|
||||||
|
/// How long to wait for the connection to reach `Ready`. A one-shot CLI must not
|
||||||
|
/// hang on an unresponsive server; failing closed here costs the user a re-run.
|
||||||
|
const CONNECT_BUDGET: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long any single introspection request may take.
|
||||||
|
///
|
||||||
|
/// ⚠️ On timeout the `Operation` wrapper is dropped while still running. In
|
||||||
|
/// libpulse-binding 2.30.1 that only unrefs the C operation — the boxed callback
|
||||||
|
/// and the `Rc`s it captured leak until the context cancels the operation at
|
||||||
|
/// disconnect. That is bounded and harmless *here*, because `--repair` is a
|
||||||
|
/// one-shot process that exits immediately afterwards, and it cannot become a
|
||||||
|
/// use-after-free (the closure owns its clones, and the context clears callbacks
|
||||||
|
/// before the mainloop is touched). **It would not be acceptable in the long-lived
|
||||||
|
/// host**, so this module must not be reused for host-side loading until that
|
||||||
|
/// binding bug is fixed or worked around; `op.cancel()` does not help.
|
||||||
|
const REQUEST_BUDGET: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long to sleep between mainloop iterations while waiting. Non-blocking
|
||||||
|
/// iteration plus a short sleep keeps the deadline enforceable, which
|
||||||
|
/// `iterate(true)` would not.
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(2);
|
||||||
|
|
||||||
|
/// A live, verified-local connection to the Pulse server.
|
||||||
|
///
|
||||||
|
/// Both listing and unloading run through this one connection, so everything
|
||||||
|
/// repair sees and everything it destroys provably belong to the same server.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Field order is load-bearing, and this was not theoretical.** Rust drops
|
||||||
|
/// fields in declaration order, and the context's teardown frees IO events that
|
||||||
|
/// live *in* the mainloop. With `mainloop` declared first, `--repair` did its work
|
||||||
|
/// correctly and then died on the way out:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Assertion '!e->dead' failed at ../pulseaudio/src/pulse/mainloop.c:207,
|
||||||
|
/// function mainloop_io_free(). Aborting.
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// SIGABRT, a core dump, and exit 134 — so a completely successful repair reported
|
||||||
|
/// failure to its caller. This is the same invariant phase 0b's
|
||||||
|
/// `ScreenshareTeardown` exists for, met again one layer down.
|
||||||
|
///
|
||||||
|
/// Rather than leave that resting on where the fields happen to be written, [`Drop`]
|
||||||
|
/// **explicitly** takes and drops the context first, so the ordering survives a
|
||||||
|
/// future reorder of this struct. The declaration order below is still correct, and
|
||||||
|
/// now it is also not load-bearing.
|
||||||
|
pub struct PulseSession {
|
||||||
|
/// `Option` only so that `Drop` can `take()` it and destroy it *before* the
|
||||||
|
/// mainloop. Always `Some` for the whole of the session's usable life.
|
||||||
|
context: Option<Context>,
|
||||||
|
mainloop: Mainloop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PulseSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Disconnect, then destroy the context while the mainloop it registered its
|
||||||
|
// IO events with is still alive. The mainloop then drops after us.
|
||||||
|
//
|
||||||
|
// Nothing is drained afterwards on purpose. An earlier version iterated the
|
||||||
|
// mainloop a few times here to "let teardown settle", which was a ritual
|
||||||
|
// rather than a barrier: a fixed number of non-blocking polls cannot
|
||||||
|
// guarantee that any particular event became ready. It is also unnecessary —
|
||||||
|
// PulseAudio's context unlink cancels outstanding operations and removes the
|
||||||
|
// context's socket machinery synchronously, so by the time `drop(context)`
|
||||||
|
// returns there is no obligation left for the mainloop to service.
|
||||||
|
if let Some(mut context) = self.context.take() {
|
||||||
|
context.disconnect();
|
||||||
|
drop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PulseSession {
|
||||||
|
/// The live context. Infallible in practice: only `Drop` ever clears it, and
|
||||||
|
/// nothing can call this afterwards.
|
||||||
|
fn context(&mut self) -> &mut Context {
|
||||||
|
self.context
|
||||||
|
.as_mut()
|
||||||
|
.expect("the context is only taken during Drop")
|
||||||
|
}
|
||||||
|
/// Connect, wait for readiness, and refuse anything but a local server.
|
||||||
|
pub fn connect() -> Result<Self> {
|
||||||
|
let mut proplist = Proplist::new().context("could not allocate a Pulse proplist")?;
|
||||||
|
// `set_str` fails only on an invalid key, and these keys are constants.
|
||||||
|
let _ = proplist.set_str(properties::APPLICATION_NAME, "pixelpass --repair");
|
||||||
|
let _ = proplist.set_str(properties::APPLICATION_ID, "xyz.pixelpass.repair");
|
||||||
|
|
||||||
|
let mut mainloop = Mainloop::new().context("could not create a Pulse mainloop")?;
|
||||||
|
let mut context = Context::new_with_proplist(&mainloop, "pixelpass --repair", &proplist)
|
||||||
|
.context("could not create a Pulse context")?;
|
||||||
|
context
|
||||||
|
.connect(None, ContextFlagSet::NOFLAGS, None)
|
||||||
|
.context("could not connect to the Pulse server")?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + CONNECT_BUDGET;
|
||||||
|
loop {
|
||||||
|
iterate_once(&mut mainloop)?;
|
||||||
|
match context.get_state() {
|
||||||
|
ContextState::Ready => break,
|
||||||
|
ContextState::Failed => {
|
||||||
|
bail!("the Pulse server refused the connection");
|
||||||
|
}
|
||||||
|
ContextState::Terminated => {
|
||||||
|
bail!("the Pulse connection terminated before it was ready");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
bail!(
|
||||||
|
"the Pulse server did not become ready within {:?}",
|
||||||
|
CONNECT_BUDGET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The connection is established, so this is about the server we actually
|
||||||
|
// reached — not about what a server string appeared to promise. A remote
|
||||||
|
// server's module table belongs to another machine's processes, where our
|
||||||
|
// pids mean nothing, so repair must not touch it.
|
||||||
|
match context.is_local() {
|
||||||
|
Some(true) => {}
|
||||||
|
Some(false) => bail!(
|
||||||
|
"connected to a REMOTE Pulse server; --repair only ever operates on the local \
|
||||||
|
server, because it decides what to unload from local process liveness"
|
||||||
|
),
|
||||||
|
None => bail!(
|
||||||
|
"could not determine whether the Pulse server is local; refusing to unload \
|
||||||
|
anything"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
context: Some(context),
|
||||||
|
mainloop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every loaded module, with its exact argument.
|
||||||
|
pub fn list_modules(&mut self) -> Result<Vec<ModuleObservation>> {
|
||||||
|
// `Rc<RefCell<…>>` because the callback is owned by the C library and may
|
||||||
|
// be invoked many times before the operation completes.
|
||||||
|
let collected: Rc<RefCell<Vec<ModuleObservation>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let failed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
|
||||||
|
let sink = Rc::clone(&collected);
|
||||||
|
let error_flag = Rc::clone(&failed);
|
||||||
|
let op = self
|
||||||
|
.context()
|
||||||
|
.introspect()
|
||||||
|
.get_module_info_list(move |result| match result {
|
||||||
|
ListResult::Item(info) => {
|
||||||
|
// A module with no name is not one we can identify, and an
|
||||||
|
// argumentless module is simply one loaded without arguments.
|
||||||
|
let name = info.name.as_deref().unwrap_or_default();
|
||||||
|
let args = info.argument.as_deref().unwrap_or_default();
|
||||||
|
sink.borrow_mut()
|
||||||
|
.push(ModuleObservation::new(info.index, name, args));
|
||||||
|
}
|
||||||
|
ListResult::End => {}
|
||||||
|
ListResult::Error => *error_flag.borrow_mut() = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.run_to_completion(op, "list modules")?;
|
||||||
|
if *failed.borrow() {
|
||||||
|
bail!("the Pulse server returned an error while listing modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
let modules = collected.borrow().clone();
|
||||||
|
// Impossible per the protocol — an index identifies one module — so this is
|
||||||
|
// a sanity check on external input, not a safety boundary. It fails closed
|
||||||
|
// because an ambiguous index is one we could unload wrongly.
|
||||||
|
for (i, module) in modules.iter().enumerate() {
|
||||||
|
if modules[..i].iter().any(|earlier| earlier.id == module.id) {
|
||||||
|
bail!(
|
||||||
|
"the Pulse server reported module index #{} twice; refusing to unload \
|
||||||
|
anything",
|
||||||
|
module.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(modules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unload one module, over the same connection it was observed on.
|
||||||
|
pub fn unload_module(&mut self, index: u32) -> Result<()> {
|
||||||
|
let succeeded: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
|
||||||
|
let outcome = Rc::clone(&succeeded);
|
||||||
|
let op = self
|
||||||
|
.context()
|
||||||
|
.introspect()
|
||||||
|
.unload_module(index, move |success| *outcome.borrow_mut() = Some(success));
|
||||||
|
|
||||||
|
self.run_to_completion(op, "unload module")?;
|
||||||
|
match *succeeded.borrow() {
|
||||||
|
Some(true) => Ok(()),
|
||||||
|
Some(false) => bail!("the Pulse server rejected unloading module #{index}"),
|
||||||
|
// The operation completed without the callback running, which we cannot
|
||||||
|
// read as success.
|
||||||
|
None => bail!("no result was reported for unloading module #{index}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the mainloop until `op` finishes, or the budget expires.
|
||||||
|
fn run_to_completion<T: ?Sized>(&mut self, op: Operation<T>, what: &str) -> Result<()> {
|
||||||
|
let deadline = Instant::now() + REQUEST_BUDGET;
|
||||||
|
loop {
|
||||||
|
iterate_once(&mut self.mainloop)?;
|
||||||
|
match op.get_state() {
|
||||||
|
OperationState::Done => return Ok(()),
|
||||||
|
OperationState::Cancelled => {
|
||||||
|
bail!("the Pulse server cancelled the request to {what}");
|
||||||
|
}
|
||||||
|
OperationState::Running => {
|
||||||
|
// A connection that dies mid-request would otherwise be waited
|
||||||
|
// out to the full budget.
|
||||||
|
match self.context().get_state() {
|
||||||
|
ContextState::Ready => {}
|
||||||
|
state => {
|
||||||
|
bail!("the Pulse connection became {state:?} while trying to {what}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
bail!("the Pulse server did not {what} within {REQUEST_BUDGET:?}");
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One non-blocking mainloop iteration, with quit and error surfaced as errors.
|
||||||
|
fn iterate_once(mainloop: &mut Mainloop) -> Result<()> {
|
||||||
|
match mainloop.iterate(false) {
|
||||||
|
IterateResult::Success(_) => Ok(()),
|
||||||
|
IterateResult::Quit(code) => {
|
||||||
|
bail!("the Pulse mainloop quit unexpectedly (code {})", code.0)
|
||||||
|
}
|
||||||
|
IterateResult::Err(e) => Err(e).context("the Pulse mainloop failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
//! `--repair`: clean up the Pulse modules left behind by a crashed pixelpass
|
||||||
|
//! host.
|
||||||
|
//!
|
||||||
|
//! All of the judgement lives in [`plan`], which is pure. What remains here is
|
||||||
|
//! I/O plus the two rules that cannot be expressed in a plan:
|
||||||
|
//!
|
||||||
|
//! - **Re-verify immediately before destroying anything.** Pulse module indices
|
||||||
|
//! are reused verbatim, and a host can die (or come back) between the snapshot
|
||||||
|
//! and the unload, so the plan is treated as evidence that expires — never as a
|
||||||
|
//! licence.
|
||||||
|
//! - **Gate the sink on what is still attached to it**, not on the plan's ordering
|
||||||
|
//! having succeeded. An unload can fail or be skipped, and a loopback can appear
|
||||||
|
//! after the plan was made.
|
||||||
|
//!
|
||||||
|
//! Repair never touches native PipeWire nodes. Since phase 0c the capture sink is
|
||||||
|
//! connection-owned and removes itself when its host dies, so there is nothing
|
||||||
|
//! there for repair to do and no safe way for it to help.
|
||||||
|
//!
|
||||||
|
//! # Where the observations come from
|
||||||
|
//!
|
||||||
|
//! Structured Pulse introspection over one verified-local connection — see
|
||||||
|
//! [`introspect`], which also documents the three defects that parsing `pactl`'s
|
||||||
|
//! text output turned out to have. Listing *and* unloading both go through that
|
||||||
|
//! same connection.
|
||||||
|
|
||||||
|
pub mod introspect;
|
||||||
|
pub mod plan;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use introspect::PulseSession;
|
||||||
|
use plan::{Fingerprint, Liveness, Shape};
|
||||||
|
|
||||||
|
pub async fn run(clean_untagged: bool) -> Result<()> {
|
||||||
|
let liveness = LivenessProbe::new();
|
||||||
|
if let Some(reason) = liveness.degraded_reason() {
|
||||||
|
// Scoped deliberately: modules carrying a token that matches this machine,
|
||||||
|
// boot and pid namespace are still cleaned, because the token establishes
|
||||||
|
// what these signals can only guess at. Saying "refusing to unload
|
||||||
|
// anything" here would be false in exactly the container-recovery case the
|
||||||
|
// token was added for.
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: cannot independently determine process liveness \
|
||||||
|
({reason}); modules WITHOUT an ownership token will be left alone."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = local_identity().context("could not establish this process's own identity")?;
|
||||||
|
let policy = plan::Policy {
|
||||||
|
local,
|
||||||
|
untagged: if clean_untagged {
|
||||||
|
plan::UntaggedPolicy::CleanByPidAlone
|
||||||
|
} else {
|
||||||
|
plan::UntaggedPolicy::Refuse
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if clean_untagged {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: --repair-legacy-untagged given; untagged modules will be \
|
||||||
|
judged by process id ALONE. That is only safe on the machine and in the pid \
|
||||||
|
namespace that ran the crashed host."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pulse = PulseSession::connect().context("could not observe the Pulse module table")?;
|
||||||
|
let modules = pulse
|
||||||
|
.list_modules()
|
||||||
|
.context("could not list Pulse modules")?;
|
||||||
|
|
||||||
|
// Say so loudly when something names our sinks but matches no shape we know:
|
||||||
|
// that is either a third party using our names, or a newer pixelpass whose
|
||||||
|
// modules this build cannot recognise. The second is how repair would go
|
||||||
|
// silently blind, so it never gets inferred from a clean exit.
|
||||||
|
let unrecognised = plan::unrecognised_pixelpass_modules(&modules);
|
||||||
|
if !unrecognised.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: {} module(s) name a pixelpass capture sink but do not match \
|
||||||
|
any shape this build knows; they are being LEFT ALONE:",
|
||||||
|
unrecognised.len()
|
||||||
|
);
|
||||||
|
for obs in &unrecognised {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: #{} {} {}",
|
||||||
|
obs.id, obs.name, obs.args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let planned = plan::plan(&modules, &policy, |pid, attribution| {
|
||||||
|
liveness_for(&liveness, attribution, pid)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ours by shape, but carrying no proof of whose pid they name. Never unloaded by
|
||||||
|
// default — listed, so an explicit legacy run has something to look at first.
|
||||||
|
if !planned.untagged.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: {} module(s) are pixelpass's but carry no ownership token, so \
|
||||||
|
the process id in their name cannot be attributed to this machine or pid namespace. \
|
||||||
|
LEFT ALONE. Re-run with --repair-legacy-untagged to clean them by pid alone:",
|
||||||
|
planned.untagged.len()
|
||||||
|
);
|
||||||
|
for fp in &planned.untagged {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: #{} {} (claims pid {})",
|
||||||
|
fp.id,
|
||||||
|
fp.shape.label(),
|
||||||
|
fp.pid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tokened, but the token belongs to another machine, boot or namespace.
|
||||||
|
if !planned.foreign.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: {} module(s) belong to another machine, boot or pid namespace; \
|
||||||
|
their process ids mean nothing here. LEFT ALONE:",
|
||||||
|
planned.foreign.len()
|
||||||
|
);
|
||||||
|
for fp in &planned.foreign {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: #{} {} (claims pid {})",
|
||||||
|
fp.id,
|
||||||
|
fp.shape.label(),
|
||||||
|
fp.pid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if planned.is_empty() {
|
||||||
|
let mut held = Vec::new();
|
||||||
|
if !planned.live_pids.is_empty() {
|
||||||
|
held.push(format!(
|
||||||
|
"{} live pixelpass host(s)",
|
||||||
|
planned.live_pids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !planned.unknown_pids.is_empty() {
|
||||||
|
held.push(format!(
|
||||||
|
"{} pid(s) of undeterminable liveness",
|
||||||
|
planned.unknown_pids.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if held.is_empty() {
|
||||||
|
println!("[pixelpass] --repair: nothing to clean up.");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"[pixelpass] --repair: nothing to clean up ({} left alone).",
|
||||||
|
held.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut unloaded = 0u32;
|
||||||
|
let mut skipped = 0u32;
|
||||||
|
let mut failed = 0u32;
|
||||||
|
|
||||||
|
for fp in &planned.unload {
|
||||||
|
// ORDER MATTERS, and it is the opposite of what reads naturally.
|
||||||
|
//
|
||||||
|
// Liveness is asked FIRST, and the fresh snapshot is taken AFTER it. The
|
||||||
|
// tempting order — verify the module, then check liveness, then unload —
|
||||||
|
// leaves the dangerous window wide open: between `kill` returning ESRCH and
|
||||||
|
// the unload, this process can be descheduled long enough for the planned
|
||||||
|
// module to vanish, a new host to inherit both the pid and the module index,
|
||||||
|
// and its differently-nonced arguments to occupy that index. Nothing would
|
||||||
|
// re-read those arguments, so the reused index gets unloaded.
|
||||||
|
//
|
||||||
|
// Asking liveness first and re-verifying the fingerprint after it means a
|
||||||
|
// replacement arriving in that window is caught by the argument comparison,
|
||||||
|
// and only the irreducible snapshot-to-unload interval remains.
|
||||||
|
match attributed_liveness(&liveness, fp) {
|
||||||
|
Liveness::Dead => {}
|
||||||
|
Liveness::Alive => {
|
||||||
|
println!(
|
||||||
|
"[pixelpass] --repair: pid {} is alive again; leaving module #{} alone",
|
||||||
|
fp.pid, fp.id
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Liveness::Unknown => {
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: pid {}'s liveness became undeterminable; \
|
||||||
|
leaving module #{} alone",
|
||||||
|
fp.pid, fp.id
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh snapshot per action, taken after the liveness answer. Deliberately
|
||||||
|
// not hoisted out of the loop: each unload changes the module table, and the
|
||||||
|
// point is to decide against the table as it is *now*.
|
||||||
|
let current = pulse
|
||||||
|
.list_modules()
|
||||||
|
.context("could not re-list Pulse modules")?;
|
||||||
|
let Some(obs) = current.iter().find(|m| m.id == fp.id) else {
|
||||||
|
println!(
|
||||||
|
"[pixelpass] --repair: module #{} is already gone; skipping",
|
||||||
|
fp.id
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !fp.still_matches(obs) {
|
||||||
|
// The index now names something else, or the same module's
|
||||||
|
// arguments changed. Either way we no longer know what we would be
|
||||||
|
// destroying, so we do not destroy it.
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: module #{} no longer matches what was planned \
|
||||||
|
(index reused?); refusing to unload it",
|
||||||
|
fp.id
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The sink goes last in the plan, but "last" is not the same as "nothing
|
||||||
|
// is attached any more": a loopback unload may have failed or been
|
||||||
|
// skipped, or a new one may have arrived since. Ask the fresh snapshot.
|
||||||
|
if fp.shape == Shape::LegacyCaptureSink
|
||||||
|
&& let Some(holder) = plan::sink_still_referenced(¤t, fp.pid, fp.id)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[pixelpass] --repair: module #{} (capture sink for pid {}) is still referenced \
|
||||||
|
by module #{}; leaving the sink loaded",
|
||||||
|
fp.id, fp.pid, holder
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match pulse.unload_module(fp.id) {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("[pixelpass] --repair: {}", describe(fp));
|
||||||
|
unloaded += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[pixelpass] --repair: failed to unload #{}: {e:#}", fp.id);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !planned.live_pids.is_empty() {
|
||||||
|
println!(
|
||||||
|
"[pixelpass] --repair: left {} live pixelpass host(s) alone.",
|
||||||
|
planned.live_pids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !planned.unknown_pids.is_empty() {
|
||||||
|
println!(
|
||||||
|
"[pixelpass] --repair: left {} pid(s) alone whose liveness could not be determined.",
|
||||||
|
planned.unknown_pids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
// Deliberately not "changed under us": a skip can also mean the module is
|
||||||
|
// still referenced, or its owner's liveness stopped being decidable. The
|
||||||
|
// per-module reason was printed above.
|
||||||
|
println!("[pixelpass] --repair: skipped {skipped} module(s) (reasons above).");
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
bail!("--repair: {failed} module(s) failed to unload (see errors above)");
|
||||||
|
}
|
||||||
|
println!("[pixelpass] --repair: cleaned up {unloaded} module(s).");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(fp: &Fingerprint) -> String {
|
||||||
|
format!(
|
||||||
|
"unloaded {} #{} (orphaned from pid {})",
|
||||||
|
fp.shape.label(),
|
||||||
|
fp.id,
|
||||||
|
fp.pid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask liveness with the module's *attribution* in hand.
|
||||||
|
///
|
||||||
|
/// A module whose token matches this machine, boot and pid namespace has already
|
||||||
|
/// proven that its pid is a number meaningful here — that is the token's entire
|
||||||
|
/// job. Running such a module through the probe's degradation checks would defeat
|
||||||
|
/// it in exactly the situation it exists for: a host crashing inside a container
|
||||||
|
/// leaves a token that matches perfectly, while a container marker or a multi-entry
|
||||||
|
/// `NSpid` makes the probe answer `Unknown` for everything, so token-qualified
|
||||||
|
/// repair would do nothing precisely where it is now safe.
|
||||||
|
///
|
||||||
|
/// The degradation signals therefore guard only the *untagged* path, where a bare
|
||||||
|
/// pid is all there is and those signals are the only protection left.
|
||||||
|
fn liveness_for(probe: &LivenessProbe, attribution: plan::Attribution, pid: u32) -> Liveness {
|
||||||
|
match attribution {
|
||||||
|
plan::Attribution::Tokened => probe.of_attributed(pid),
|
||||||
|
plan::Attribution::Untagged => probe.of(pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same rule, for a fingerprint at execution time.
|
||||||
|
fn attributed_liveness(probe: &LivenessProbe, fp: &Fingerprint) -> Liveness {
|
||||||
|
let attribution = match fp.owner {
|
||||||
|
Some(_) => plan::Attribution::Tokened,
|
||||||
|
None => plan::Attribution::Untagged,
|
||||||
|
};
|
||||||
|
liveness_for(probe, attribution, fp.pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Identity
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// This process's machine, boot and pid-namespace identity.
|
||||||
|
///
|
||||||
|
/// Read from the kernel and the system, never guessed: without all three, a token
|
||||||
|
/// cannot be compared and no module can be attributed. Dashes are stripped so every
|
||||||
|
/// component is safe inside a single unquoted Pulse property value.
|
||||||
|
pub fn local_identity() -> Result<plan::LocalIdentity> {
|
||||||
|
let machine = read_identity_file("/etc/machine-id")
|
||||||
|
.or_else(|_| read_identity_file("/var/lib/dbus/machine-id"))
|
||||||
|
.context("could not read a machine id")?;
|
||||||
|
let boot = read_identity_file("/proc/sys/kernel/random/boot_id")
|
||||||
|
.context("could not read the boot id")?;
|
||||||
|
let pid_ns = pid_namespace_id().context("could not read this process's pid namespace")?;
|
||||||
|
Ok(plan::LocalIdentity {
|
||||||
|
machine,
|
||||||
|
boot,
|
||||||
|
pid_ns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_identity_file(path: &str) -> Result<String> {
|
||||||
|
let raw = std::fs::read_to_string(path).with_context(|| format!("could not read {path}"))?;
|
||||||
|
let cleaned: String = raw
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect();
|
||||||
|
if cleaned.is_empty() {
|
||||||
|
bail!("{path} held no usable identity");
|
||||||
|
}
|
||||||
|
Ok(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inode of `/proc/self/ns/pid` — the kernel's identity for a pid namespace.
|
||||||
|
///
|
||||||
|
/// This is the value that makes a pid meaningful: two processes in different pid
|
||||||
|
/// namespaces can hold the same number, and only this distinguishes them.
|
||||||
|
fn pid_namespace_id() -> Result<u64> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let meta =
|
||||||
|
std::fs::metadata("/proc/self/ns/pid").context("could not stat /proc/self/ns/pid")?;
|
||||||
|
Ok(meta.ino())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Liveness
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Answers "is this pid still around", and knows when it must refuse to answer.
|
||||||
|
///
|
||||||
|
/// **An error is not an absence.** `Path::exists()` folds permission errors and a
|
||||||
|
/// missing `/proc` into `false`, which here would read as "dead, go ahead and
|
||||||
|
/// unload". Liveness is asked with `kill(pid, 0)` instead, where `EPERM` *proves*
|
||||||
|
/// existence.
|
||||||
|
///
|
||||||
|
/// # The limit of what this can prove, stated rather than papered over
|
||||||
|
///
|
||||||
|
/// A pid can be alive and invisible. Inside a pid namespace — a container, a
|
||||||
|
/// distrobox — `/proc/self` is perfectly visible while every process in the
|
||||||
|
/// *parent* namespace is not, and `hidepid` has the same self-visible,
|
||||||
|
/// others-invisible shape. Repair in such a place can reach the host's Pulse
|
||||||
|
/// socket, see a live host's modules, get `ESRCH` for its pid and unload a running
|
||||||
|
/// host's audio.
|
||||||
|
///
|
||||||
|
/// The signals below are **negative** ones: they detect *some* cases where pid
|
||||||
|
/// numbers cannot be trusted, and every one of them fails closed. What they cannot
|
||||||
|
/// do is prove the converse. `NSpid` reports this process's pid in each namespace
|
||||||
|
/// that its procfs can see, and its leftmost value is relative to the pid namespace
|
||||||
|
/// that mounted that procfs — so a nested namespace with its own `/proc` reports a
|
||||||
|
/// single entry quite legitimately. `NSpid > 1` therefore means "definitely
|
||||||
|
/// nested", while `NSpid == 1` means only "not detectably nested".
|
||||||
|
///
|
||||||
|
/// Closing that properly needs the module itself to carry an owner token (machine
|
||||||
|
/// and boot identity plus pid-namespace identity) written at load time, with
|
||||||
|
/// token-less modules treated as `Unknown`. That changes what pixelpass writes into
|
||||||
|
/// the graph and how far back `--repair` can clean up, so it is a design decision
|
||||||
|
/// recorded in the impl plan rather than guessed at here.
|
||||||
|
struct LivenessProbe {
|
||||||
|
/// `None` when no signal says pid numbers are untrustworthy; `Some(reason)`
|
||||||
|
/// when every answer must be [`Liveness::Unknown`].
|
||||||
|
degraded: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LivenessProbe {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
degraded: Self::detect_degradation(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_degradation() -> Option<String> {
|
||||||
|
// Locality is deliberately NOT checked here. `PULSE_SERVER` is a fallback
|
||||||
|
// *list*, so `unix:/missing tcp:remote:4713` starts with "unix:" and still
|
||||||
|
// connects to another machine, and a remote server can be selected by client
|
||||||
|
// configuration with the variable unset entirely. The authoritative answer
|
||||||
|
// comes from `pa_context_is_local()` on the connection that actually got
|
||||||
|
// established — see `introspect::PulseSession::connect`.
|
||||||
|
match std::fs::read_to_string("/proc/self/status") {
|
||||||
|
Ok(status) => {
|
||||||
|
let nspid = status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("NSpid:"))
|
||||||
|
.map(|rest| rest.split_whitespace().count());
|
||||||
|
match nspid {
|
||||||
|
Some(n) if n > 1 => {
|
||||||
|
return Some(format!(
|
||||||
|
"this process is in a nested pid namespace (NSpid has {n} entries), \
|
||||||
|
so pids in module names may belong to processes it cannot see"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// NB: a single entry is not proof of the initial namespace — see
|
||||||
|
// the type's doc comment. It only means nothing detected it.
|
||||||
|
// A kernel too old to report NSpid cannot rule nesting out.
|
||||||
|
None => {
|
||||||
|
return Some(
|
||||||
|
"/proc/self/status does not report NSpid, so pid-namespace identity \
|
||||||
|
cannot be established"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Some(format!("/proc/self/status could not be read: {e}")),
|
||||||
|
}
|
||||||
|
// Belt and braces: container runtimes that leave a marker.
|
||||||
|
for marker in ["/run/.containerenv", "/.dockerenv"] {
|
||||||
|
if Path::new(marker).try_exists().unwrap_or(false) {
|
||||||
|
return Some(format!("{marker} exists, so this is a container"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn degraded_reason(&self) -> Option<&str> {
|
||||||
|
self.degraded.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness for a pid this process has **no** independent reason to trust —
|
||||||
|
/// an untagged module. Here the degradation signals are the only protection.
|
||||||
|
fn of(&self, pid: u32) -> Liveness {
|
||||||
|
if self.degraded.is_some() {
|
||||||
|
return Liveness::Unknown;
|
||||||
|
}
|
||||||
|
self.of_attributed(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liveness for a pid already proven to belong to this machine, boot and pid
|
||||||
|
/// namespace by an [`plan::OwnerToken`].
|
||||||
|
///
|
||||||
|
/// The degradation checks are deliberately skipped: they exist to guess at
|
||||||
|
/// whether a bare pid is meaningful, and here that is not a guess any more.
|
||||||
|
fn of_attributed(&self, pid: u32) -> Liveness {
|
||||||
|
// `kill(0, …)` signals our whole process group and a negative pid signals
|
||||||
|
// another group, so neither may ever reach `kill`. Neither is a pid we
|
||||||
|
// could have written into a sink name anyway.
|
||||||
|
if pid == 0 || pid > i32::MAX as u32 {
|
||||||
|
return Liveness::Unknown;
|
||||||
|
}
|
||||||
|
match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None) {
|
||||||
|
Ok(()) => Liveness::Alive,
|
||||||
|
// The process exists; we merely may not signal it.
|
||||||
|
Err(nix::errno::Errno::EPERM) => Liveness::Alive,
|
||||||
|
Err(nix::errno::Errno::ESRCH) => Liveness::Dead,
|
||||||
|
Err(_) => Liveness::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The `pactl`-text parser that used to live here is gone, and so are its
|
||||||
|
// tests: `introspect` gets index, name and argument as structured fields, so
|
||||||
|
// there is no format left to mis-parse. What replaced those tests is the live
|
||||||
|
// field gate, since the remaining risk is in talking to the server, which no
|
||||||
|
// unit test can exercise. The decisions all live in `plan`, which is pure and
|
||||||
|
// tested there.
|
||||||
|
|
||||||
|
/// The probe must never say `Dead` when it cannot see the whole pid space, and
|
||||||
|
/// must never ask `kill` about a pid that would signal something other than one
|
||||||
|
/// process.
|
||||||
|
#[test]
|
||||||
|
fn a_degraded_probe_never_reports_dead() {
|
||||||
|
let degraded = LivenessProbe {
|
||||||
|
degraded: Some("test".to_string()),
|
||||||
|
};
|
||||||
|
assert_eq!(degraded.of(1), Liveness::Unknown);
|
||||||
|
assert_eq!(degraded.of(u32::MAX), Liveness::Unknown);
|
||||||
|
|
||||||
|
let probe = LivenessProbe::new();
|
||||||
|
// Our own pid is alive by construction — unless this test itself runs
|
||||||
|
// somewhere the probe must abstain, which is exactly the other branch.
|
||||||
|
let me = std::process::id();
|
||||||
|
match probe.degraded_reason() {
|
||||||
|
None => assert_eq!(probe.of(me), Liveness::Alive),
|
||||||
|
Some(_) => assert_eq!(probe.of(me), Liveness::Unknown),
|
||||||
|
}
|
||||||
|
// `kill(0, …)` would signal our whole process group, and a pid past
|
||||||
|
// `i32::MAX` cannot be expressed to `kill` at all.
|
||||||
|
assert_eq!(probe.of(0), Liveness::Unknown);
|
||||||
|
assert_eq!(probe.of(u32::MAX), Liveness::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token proves the pid is meaningful here, so the probe's namespace
|
||||||
|
/// guesswork must not veto it. Without this, a host crashing inside a
|
||||||
|
/// container leaves a perfectly matching token while a container marker makes
|
||||||
|
/// every answer `Unknown` — and token-qualified repair does nothing in exactly
|
||||||
|
/// the situation the token was built for.
|
||||||
|
///
|
||||||
|
/// This runs against a *degraded* probe deliberately: on an ordinary desktop
|
||||||
|
/// the two paths agree, so a test using the real probe's state would pass
|
||||||
|
/// whether or not the distinction exists.
|
||||||
|
#[test]
|
||||||
|
fn a_token_beats_the_degradation_signals_but_a_bare_pid_does_not() {
|
||||||
|
let degraded = LivenessProbe {
|
||||||
|
degraded: Some("pretending to be in a container".to_string()),
|
||||||
|
};
|
||||||
|
let me = std::process::id();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
degraded.of_attributed(me),
|
||||||
|
Liveness::Alive,
|
||||||
|
"an attributed pid must still be answered when the probe is degraded"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
degraded.of(me),
|
||||||
|
Liveness::Unknown,
|
||||||
|
"a bare pid must not be, since the signals are all it has"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the routing between them, which is what the caller actually uses.
|
||||||
|
assert_eq!(
|
||||||
|
liveness_for(°raded, plan::Attribution::Tokened, me),
|
||||||
|
Liveness::Alive
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
liveness_for(°raded, plan::Attribution::Untagged, me),
|
||||||
|
Liveness::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1470
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# 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, matched EXACTLY: `prop_value` below is the
|
||||||
|
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
|
||||||
|
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
|
||||||
|
#
|
||||||
|
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
|
||||||
|
# used to accept any value other than "false"/"0", on the theory that leniency
|
||||||
|
# over-excludes and is therefore safe. It is not: leniency buys false-positive
|
||||||
|
# exclusion, and it let any process suppress a rival application's audio from
|
||||||
|
# the share with a property it did not even have to spell right. Fail-closed
|
||||||
|
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
|
||||||
|
# not about parsing.
|
||||||
|
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