Compare commits
58
Commits
b0ff20fe3f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
64f98990c8 | ||
|
|
306b601490 | ||
|
|
b3d71724ae | ||
|
|
a1ac7ea8d5 | ||
|
|
bbf6744444 | ||
|
|
e0fe47d5a9 | ||
|
|
4a844ac6fa | ||
|
|
b74ed17823 | ||
|
|
8973e5dc19 | ||
|
|
f90bee63f7 | ||
|
|
557c1030a7 | ||
|
|
66a0dd54df | ||
|
|
8206864a43 | ||
|
|
ab597c332d | ||
|
|
279903e56e | ||
|
|
65fde92628 | ||
|
|
2183084ec8 | ||
|
|
f35bab0379 | ||
|
|
31084edcfa | ||
|
|
a46c4cd20c | ||
|
|
6ead1fe9f8 | ||
|
|
d54e2b99fc | ||
|
|
87de5213fe | ||
|
|
9b6c8bb5c3 | ||
|
|
40604c716c | ||
|
|
3b92bcbe52 | ||
|
|
b6240c17c5 | ||
|
|
c1b21b32c7 |
@@ -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
+624
-592
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -32,7 +32,7 @@ name = "pixelpass"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iroh = "1.0.0-rc.0"
|
iroh = "1.0.2"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -46,9 +46,19 @@ 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-rc.0"
|
iroh-tickets = "1.0.0"
|
||||||
dialoguer = { version = "0.12", default-features = false }
|
dialoguer = { version = "0.12", default-features = false }
|
||||||
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
|
||||||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Working:
|
|||||||
- Audio capture of the default sink's monitor, with optional per-app
|
- Audio capture of the default sink's monitor, with optional per-app
|
||||||
routing (`--app <name>`)
|
routing (`--app <name>`)
|
||||||
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
|
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
|
||||||
|
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
|
||||||
|
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
|
||||||
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
|
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
|
||||||
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
|
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
|
||||||
- Headless mode for scripts (`pixelpass <ticket>`)
|
- Headless mode for scripts (`pixelpass <ticket>`)
|
||||||
@@ -135,6 +137,35 @@ sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
|
|||||||
If the viewer is running on battery, set the CPU governor to performance
|
If the viewer is running on battery, set the CPU governor to performance
|
||||||
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
|
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
`pixelpass --doctor` prints a one-shot report of everything the above
|
||||||
|
requirements cover and exits — run it on any machine before a real session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pixelpass --doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
It checks, and prints a `✓ / ! / ✗` line for each:
|
||||||
|
|
||||||
|
- **display server** — Wayland vs. X11 (autodetected), the raw session env
|
||||||
|
vars, and the X server's vendor/version (so an xlibre server is visible)
|
||||||
|
- **capture** — the GStreamer tools plus the source element for your backend
|
||||||
|
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
|
||||||
|
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
|
||||||
|
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
|
||||||
|
whether the software `x264enc` fallback is available. This is the usual
|
||||||
|
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
|
||||||
|
produces no video under the default encoder — the report tells you to host
|
||||||
|
with `--no-hwencode`
|
||||||
|
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
|
||||||
|
- **viewer** — whether `mpv` or `vlc` is installed
|
||||||
|
- **network** — binds a real endpoint and checks a relay is reachable
|
||||||
|
|
||||||
|
Each failing line includes a distro-aware install hint, and the closing summary
|
||||||
|
says whether the machine can host and how. The exit code is non-zero if any
|
||||||
|
hard requirement is missing, so it can gate a script or CI.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+32
@@ -105,11 +105,43 @@ 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
|
||||||
|
/// 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
|
||||||
|
/// session — especially to confirm hardware H.264 encode works, since a GPU
|
||||||
|
/// without it silently produces no video under the default encoder.
|
||||||
|
#[arg(long)]
|
||||||
|
pub doctor: bool,
|
||||||
|
|
||||||
/// Re-run the bandwidth pre-flight test, save the result, then exit.
|
/// Re-run the bandwidth pre-flight test, save the result, then exit.
|
||||||
/// Use this if your connection has changed (new ISP, moved house, etc.)
|
/// Use this if your connection has changed (new ISP, moved house, etc.)
|
||||||
/// or if the previously saved test result is stale.
|
/// or if the previously saved test result is stale.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub reconfigure: bool,
|
pub reconfigure: bool,
|
||||||
|
|
||||||
|
/// Run the read-only audio-exclusion dry-run audit against the live
|
||||||
|
/// PipeWire graph, then exit on ctrl-c. Emits one JSON object per line to
|
||||||
|
/// stderr (or to `PIXELPASS_AUDIO_AUDIT_FILE`) describing which audio
|
||||||
|
/// streams would be eligible for a screen share and why the rest would not.
|
||||||
|
/// Creates no links and changes no routing.
|
||||||
|
///
|
||||||
|
/// Hidden: this is development instrumentation for the screen-share audio
|
||||||
|
/// exclusion work (impl plan phase 5), not a user-facing feature, and the
|
||||||
|
/// record schema is free to change until phase 6 fixes it.
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
pub audit_audio: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ValueEnum, Clone, Copy, Debug)]
|
#[derive(ValueEnum, Clone, Copy, Debug)]
|
||||||
|
|||||||
+15
-10
@@ -56,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn require_gst_element(name: &str) -> Result<()> {
|
fn require_gst_element(name: &str) -> Result<()> {
|
||||||
let ok = Command::new("gst-inspect-1.0")
|
if !gst_element_exists(name) {
|
||||||
.args(["--exists", name])
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !ok {
|
|
||||||
bail!(
|
bail!(
|
||||||
"GStreamer element `{name}` not available.\n{}",
|
"GStreamer element `{name}` not available.\n{}",
|
||||||
install_hint_for_gst_element(name)
|
install_hint_for_gst_element(name)
|
||||||
@@ -70,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn which(bin: &str) -> Option<PathBuf> {
|
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
|
||||||
|
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
|
||||||
|
pub(crate) fn gst_element_exists(name: &str) -> bool {
|
||||||
|
Command::new("gst-inspect-1.0")
|
||||||
|
.args(["--exists", name])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
|
||||||
let path = std::env::var_os("PATH")?;
|
let path = std::env::var_os("PATH")?;
|
||||||
for dir in std::env::split_paths(&path) {
|
for dir in std::env::split_paths(&path) {
|
||||||
let candidate = dir.join(bin);
|
let candidate = dir.join(bin);
|
||||||
@@ -81,7 +86,7 @@ fn which(bin: &str) -> Option<PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_hint_for_bin(bin: &str) -> String {
|
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
|
||||||
let distro = detect_distro();
|
let distro = detect_distro();
|
||||||
let pkg = match bin {
|
let pkg = match bin {
|
||||||
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
|
||||||
@@ -113,7 +118,7 @@ fn install_hint_for_bin(bin: &str) -> String {
|
|||||||
install_command(&distro, pkg)
|
install_command(&distro, pkg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_hint_for_gst_element(name: &str) -> String {
|
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
|
||||||
let distro = detect_distro();
|
let distro = detect_distro();
|
||||||
let pkg = match name {
|
let pkg = match name {
|
||||||
"pipewiresrc" => match distro.as_deref() {
|
"pipewiresrc" => match distro.as_deref() {
|
||||||
@@ -210,7 +215,7 @@ fn install_command(distro: &Option<String>, pkg: &str) -> String {
|
|||||||
format!("Install hint: {cmd}")
|
format!("Install hint: {cmd}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_distro() -> Option<String> {
|
pub(crate) fn detect_distro() -> Option<String> {
|
||||||
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
|
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||||
for line in contents.lines() {
|
for line in contents.lines() {
|
||||||
if let Some(rest) = line.strip_prefix("ID=") {
|
if let Some(rest) = line.strip_prefix("ID=") {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tokio::signal::unix::{Signal, SignalKind};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when
|
||||||
|
/// something other than a human at a terminal asks them to (`timeout`, a test
|
||||||
|
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
|
||||||
|
pub fn terminate_stream() -> Result<Signal> {
|
||||||
|
tokio::signal::unix::signal(SignalKind::terminate())
|
||||||
|
.context("could not install a SIGTERM handler")
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a ctrl-c handler that triggers the returned token.
|
/// Install a ctrl-c handler that triggers the returned token.
|
||||||
///
|
///
|
||||||
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
|
/// The first ctrl-c cancels gracefully; a second ctrl-c terminates the process.
|
||||||
|
|||||||
+648
@@ -0,0 +1,648 @@
|
|||||||
|
//! `pixelpass doctor` — environment diagnostics.
|
||||||
|
//!
|
||||||
|
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
|
||||||
|
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
|
||||||
|
//! a GPU/driver with no working VA-API H.264 encoder, so the default
|
||||||
|
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
|
||||||
|
//! `doctor` probes all of that up front and prints one actionable report, so a
|
||||||
|
//! remote tester can read it over a call instead of us guessing from logs. It
|
||||||
|
//! also validates any X11/Wayland test environment we stand up.
|
||||||
|
//!
|
||||||
|
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
|
||||||
|
//! missing dependency, doctor runs *every* check and reports them together — a
|
||||||
|
//! diagnostic wants the whole picture, not the first failure.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::common::deps;
|
||||||
|
use crate::common::display::DisplayServer;
|
||||||
|
use crate::common::endpoint;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Status {
|
||||||
|
/// Working as needed.
|
||||||
|
Ok,
|
||||||
|
/// Degraded but not fatal (e.g. a fallback path is available).
|
||||||
|
Warn,
|
||||||
|
/// Screen-sharing will not work until this is fixed.
|
||||||
|
Fail,
|
||||||
|
/// Neutral fact, no judgement.
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Status {
|
||||||
|
fn icon(self) -> char {
|
||||||
|
match self {
|
||||||
|
Self::Ok => '✓',
|
||||||
|
Self::Warn => '!',
|
||||||
|
Self::Fail => '✗',
|
||||||
|
Self::Info => '·',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line in the report: a status, a short label, a detail, and an optional
|
||||||
|
/// remediation hint printed on its own indented line.
|
||||||
|
pub struct Check {
|
||||||
|
pub status: Status,
|
||||||
|
pub label: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Check {
|
||||||
|
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
status,
|
||||||
|
label: label.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::Ok, label, detail)
|
||||||
|
}
|
||||||
|
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::Warn, label, detail)
|
||||||
|
}
|
||||||
|
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::Fail, label, detail)
|
||||||
|
}
|
||||||
|
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self::new(Status::Info, label, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||||
|
self.hint = Some(hint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tally of the non-trivial statuses across every section.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub fails: usize,
|
||||||
|
pub warns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named group of checks, printed under a header.
|
||||||
|
struct Section {
|
||||||
|
name: &'static str,
|
||||||
|
checks: Vec<Check>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all diagnostics and print the report. Always prints; the process exit
|
||||||
|
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
|
||||||
|
/// and CI can gate on it while a human still sees everything.
|
||||||
|
pub async fn run(relay: Option<String>) -> Result<()> {
|
||||||
|
let display = DisplayServer::detect();
|
||||||
|
|
||||||
|
let sections = vec![
|
||||||
|
system_section(display),
|
||||||
|
capture_section(display),
|
||||||
|
encode_section(),
|
||||||
|
mux_audio_section(),
|
||||||
|
viewer_section(),
|
||||||
|
network_section(relay.as_deref()).await,
|
||||||
|
];
|
||||||
|
|
||||||
|
print_report(§ions);
|
||||||
|
|
||||||
|
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
|
||||||
|
print_summary(summary, §ions);
|
||||||
|
|
||||||
|
if summary.fails > 0 {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sections ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn system_section(display: DisplayServer) -> Section {
|
||||||
|
let mut checks = vec![
|
||||||
|
Check::info(
|
||||||
|
"pixelpass",
|
||||||
|
format!("{} (gui: {})", env!("CARGO_PKG_VERSION"), gui_built()),
|
||||||
|
),
|
||||||
|
Check::info("distro", distro_detail()),
|
||||||
|
display_check(display),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Probe the actual X server when one is reachable — this is where an xlibre
|
||||||
|
// vs. Xorg difference (the thing we most want to see on a tester's box)
|
||||||
|
// shows up. Skip it on a pure Wayland session with no X at all.
|
||||||
|
if display == DisplayServer::X11 || std::env::var_os("DISPLAY").is_some() {
|
||||||
|
checks.push(x_server_check());
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
name: "System",
|
||||||
|
checks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_section(display: DisplayServer) -> Section {
|
||||||
|
let mut checks = vec![
|
||||||
|
bin_check("gst-launch-1.0", "gstreamer tools"),
|
||||||
|
bin_check("gst-inspect-1.0", "gstreamer tools"),
|
||||||
|
];
|
||||||
|
|
||||||
|
match display {
|
||||||
|
DisplayServer::Wayland => {
|
||||||
|
checks.push(gst_check("pipewiresrc", "Wayland capture"));
|
||||||
|
}
|
||||||
|
DisplayServer::X11 => {
|
||||||
|
checks.push(gst_check("ximagesrc", "X11 capture"));
|
||||||
|
checks.push(match deps::which("xwininfo") {
|
||||||
|
Some(p) => Check::ok("window picker", p.display().to_string())
|
||||||
|
.with_hint("needed only for `--window` (share a single window)"),
|
||||||
|
None => Check::info("window picker", "xwininfo not found")
|
||||||
|
.with_hint("optional — only `--window` needs it"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DisplayServer::Unknown => {
|
||||||
|
checks.push(
|
||||||
|
Check::info("capture backend", "unknown — cannot probe a source element")
|
||||||
|
.with_hint("force one with `--display-server x11|wayland` when hosting"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
name: "Capture (host)",
|
||||||
|
checks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_section() -> Section {
|
||||||
|
Section {
|
||||||
|
name: "Encode",
|
||||||
|
checks: vec![hardware_encode_check(), software_encode_check()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The load-bearing check for the common "viewer can't connect" report: the
|
||||||
|
/// default host pipeline uses `vah264enc`, which needs both the GStreamer VA
|
||||||
|
/// plugin *and* a GPU/driver that actually exposes an H.264 encode entrypoint.
|
||||||
|
/// A box with the plugin but no encode entrypoint (or no render node) produces
|
||||||
|
/// no video — the exact silent failure `--no-hwencode` works around.
|
||||||
|
fn hardware_encode_check() -> Check {
|
||||||
|
if !deps::gst_element_exists("vah264enc") {
|
||||||
|
return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
|
||||||
|
"{} — or just host with `--no-hwencode` (software x264)",
|
||||||
|
deps::install_hint_for_gst_element("vah264enc")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_render_node() {
|
||||||
|
return Check::warn(
|
||||||
|
"hardware H.264",
|
||||||
|
"vah264enc present, but no DRM render node (/dev/dri/renderD*)",
|
||||||
|
)
|
||||||
|
.with_hint("GPU encode is unavailable here — host with `--no-hwencode`");
|
||||||
|
}
|
||||||
|
|
||||||
|
match vainfo_output() {
|
||||||
|
Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
|
||||||
|
"hardware H.264",
|
||||||
|
"VA-API H.264 encode available (vah264enc)",
|
||||||
|
),
|
||||||
|
Some(_) => Check::warn(
|
||||||
|
"hardware H.264",
|
||||||
|
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
|
||||||
|
)
|
||||||
|
.with_hint("this GPU/driver can't hardware-encode H.264 — host with `--no-hwencode`"),
|
||||||
|
None => Check::info(
|
||||||
|
"hardware H.264",
|
||||||
|
"vah264enc + render node present; couldn't confirm the VA-API encode entrypoint",
|
||||||
|
)
|
||||||
|
.with_hint("install `vainfo` (libva-utils) to verify, or just test a real host session"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn software_encode_check() -> Check {
|
||||||
|
if deps::gst_element_exists("x264enc") {
|
||||||
|
Check::ok("software H.264", "x264enc available (`--no-hwencode`)")
|
||||||
|
} else {
|
||||||
|
Check::warn("software H.264", "x264enc not installed").with_hint(format!(
|
||||||
|
"{} — the fallback for GPUs without VA-API H.264 encode",
|
||||||
|
deps::install_hint_for_gst_element("x264enc")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mux_audio_section() -> Section {
|
||||||
|
// These live in plugins-bad/-good/-libav and plugins-base; all are required
|
||||||
|
// for either backend, so a miss here is a hard Fail.
|
||||||
|
let tail = [
|
||||||
|
"h264parse",
|
||||||
|
"mpegtsmux",
|
||||||
|
"aacparse",
|
||||||
|
"avenc_aac",
|
||||||
|
"pulsesrc",
|
||||||
|
"videoscale",
|
||||||
|
];
|
||||||
|
let missing: Vec<&str> = tail
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|e| !deps::gst_element_exists(e))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tail_check = if missing.is_empty() {
|
||||||
|
Check::ok("mux + audio tail", tail.join(", "))
|
||||||
|
} else {
|
||||||
|
Check::fail(
|
||||||
|
"mux + audio tail",
|
||||||
|
format!("missing: {}", missing.join(", ")),
|
||||||
|
)
|
||||||
|
.with_hint(deps::install_hint_for_gst_element(missing[0]))
|
||||||
|
};
|
||||||
|
|
||||||
|
Section {
|
||||||
|
name: "Mux / audio",
|
||||||
|
checks: vec![tail_check, bin_check("pactl", "pactl")],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewer_section() -> Section {
|
||||||
|
let mpv = deps::which("mpv");
|
||||||
|
let vlc = deps::which("vlc");
|
||||||
|
|
||||||
|
let check = match (mpv, vlc) {
|
||||||
|
(Some(p), _) => Check::ok("player", format!("mpv ({})", p.display())),
|
||||||
|
(None, Some(p)) => Check::ok("player", format!("vlc ({})", p.display()))
|
||||||
|
.with_hint("mpv is the recommended player; vlc needs the dvb + ffmpeg plugins"),
|
||||||
|
(None, None) => Check::warn("player", "neither mpv nor vlc found")
|
||||||
|
.with_hint("a viewer needs one of them; the GUI launches mpv by default"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Section {
|
||||||
|
name: "Viewer",
|
||||||
|
checks: vec![check],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a real video-plane endpoint and wait briefly for a relay, mirroring
|
||||||
|
/// what a host does. Directly relevant to "couldn't connect": if this machine
|
||||||
|
/// can't reach a relay, hole-punching to a peer is unlikely to work either.
|
||||||
|
async fn network_section(relay: Option<&str>) -> Section {
|
||||||
|
let check = match endpoint::bind(relay).await {
|
||||||
|
Ok(ep) => {
|
||||||
|
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
|
||||||
|
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
|
||||||
|
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
|
||||||
|
// without calling close" error into the middle of the report.
|
||||||
|
ep.close().await;
|
||||||
|
|
||||||
|
if online && relay_count > 0 {
|
||||||
|
Check::ok("relay", format!("home relay reachable{where_}"))
|
||||||
|
} else if online {
|
||||||
|
Check::warn(
|
||||||
|
"relay",
|
||||||
|
format!("endpoint online but no relay address{where_}"),
|
||||||
|
)
|
||||||
|
.with_hint(
|
||||||
|
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
|
||||||
|
"check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
Section {
|
||||||
|
name: "Network",
|
||||||
|
checks: vec![check],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small check builders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn bin_check(bin: &str, label: &str) -> Check {
|
||||||
|
match deps::which(bin) {
|
||||||
|
Some(p) => Check::ok(label, format!("{bin} ({})", p.display())),
|
||||||
|
None => Check::fail(label, format!("{bin} not found on PATH"))
|
||||||
|
.with_hint(deps::install_hint_for_bin(bin)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gst_check(element: &str, label: &str) -> Check {
|
||||||
|
if deps::gst_element_exists(element) {
|
||||||
|
Check::ok(label, element.to_string())
|
||||||
|
} else {
|
||||||
|
Check::fail(
|
||||||
|
label,
|
||||||
|
format!("GStreamer element `{element}` not available"),
|
||||||
|
)
|
||||||
|
.with_hint(deps::install_hint_for_gst_element(element))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_check(display: DisplayServer) -> Check {
|
||||||
|
let env = display_env_summary();
|
||||||
|
match display {
|
||||||
|
DisplayServer::Wayland => Check::ok("display server", format!("Wayland ({env})")),
|
||||||
|
DisplayServer::X11 => Check::ok("display server", format!("X11 ({env})")),
|
||||||
|
DisplayServer::Unknown => Check::fail("display server", format!("undetected ({env})"))
|
||||||
|
.with_hint(
|
||||||
|
"no WAYLAND_DISPLAY/DISPLAY/XDG_SESSION_TYPE — capture can't start; \
|
||||||
|
run inside a graphical session or pass `--display-server`",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to the X server and report its vendor + version. This is how an
|
||||||
|
/// xlibre server distinguishes itself from stock Xorg (vendor string / release
|
||||||
|
/// number), which is exactly what we want to see on a tester's machine.
|
||||||
|
fn x_server_check() -> Check {
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
match x11rb::connect(None) {
|
||||||
|
Ok((conn, _screen)) => {
|
||||||
|
let setup = conn.setup();
|
||||||
|
let vendor = String::from_utf8_lossy(&setup.vendor);
|
||||||
|
let detail = format!(
|
||||||
|
"vendor \"{}\", protocol {}.{}, release {}",
|
||||||
|
vendor.trim(),
|
||||||
|
setup.protocol_major_version,
|
||||||
|
setup.protocol_minor_version,
|
||||||
|
setup.release_number,
|
||||||
|
);
|
||||||
|
let label = "X server";
|
||||||
|
if vendor.to_lowercase().contains("xlibre") {
|
||||||
|
Check::info(label, format!("XLibre — {detail}"))
|
||||||
|
} else {
|
||||||
|
Check::info(label, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => Check::info("X server", "DISPLAY set but the X server is unreachable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── environment helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn gui_built() -> &'static str {
|
||||||
|
if cfg!(feature = "gui") { "yes" } else { "no" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distro_detail() -> String {
|
||||||
|
let id = deps::detect_distro();
|
||||||
|
let pretty = os_release_field("PRETTY_NAME");
|
||||||
|
match (id, pretty) {
|
||||||
|
(Some(id), Some(p)) => format!("{id} ({p})"),
|
||||||
|
(Some(id), None) => id,
|
||||||
|
(None, Some(p)) => p,
|
||||||
|
(None, None) => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_release_field(key: &str) -> Option<String> {
|
||||||
|
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||||
|
for line in contents.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
|
||||||
|
return Some(rest.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_env_summary() -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for var in [
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"DISPLAY",
|
||||||
|
"XDG_SESSION_TYPE",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
] {
|
||||||
|
if let Some(v) = std::env::var_os(var) {
|
||||||
|
parts.push(format!("{var}={}", v.to_string_lossy()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"no display env vars set".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_render_node() -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vainfo_output() -> Option<String> {
|
||||||
|
deps::which("vainfo")?;
|
||||||
|
let out = std::process::Command::new("vainfo").output().ok()?;
|
||||||
|
// vainfo prints its profile/entrypoint table to stdout; some builds also
|
||||||
|
// spill driver banners to stderr. Concatenate both so parsing is robust.
|
||||||
|
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||||
|
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure: does a `vainfo` dump advertise an H.264 *encode* entrypoint? vainfo
|
||||||
|
/// lists one `VAProfile… : VAEntrypoint…` pair per line; hardware H.264 encode
|
||||||
|
/// is any `VAProfileH264*` profile paired with an `EncSlice`/`EncSliceLP`
|
||||||
|
/// entrypoint. VLD-only H.264 (decode) does not count.
|
||||||
|
fn vainfo_has_h264_encode(output: &str) -> bool {
|
||||||
|
output.lines().any(|line| {
|
||||||
|
line.contains("VAProfileH264")
|
||||||
|
&& (line.contains("VAEntrypointEncSlice") || line.contains("VAEntrypointEncSliceLP"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reporting ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn print_report(sections: &[Section]) {
|
||||||
|
println!("pixelpass doctor\n");
|
||||||
|
for section in sections {
|
||||||
|
println!("{}", section.name);
|
||||||
|
for check in §ion.checks {
|
||||||
|
println!(
|
||||||
|
" {} {:<16} {}",
|
||||||
|
check.status.icon(),
|
||||||
|
check.label,
|
||||||
|
check.detail
|
||||||
|
);
|
||||||
|
if let Some(hint) = &check.hint {
|
||||||
|
println!(" → {hint}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize<'a>(checks: impl Iterator<Item = &'a Check>) -> Summary {
|
||||||
|
let mut summary = Summary::default();
|
||||||
|
for check in checks {
|
||||||
|
match check.status {
|
||||||
|
Status::Fail => summary.fails += 1,
|
||||||
|
Status::Warn => summary.warns += 1,
|
||||||
|
Status::Ok | Status::Info => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(summary: Summary, sections: &[Section]) {
|
||||||
|
let hosting = hosting_verdict(sections);
|
||||||
|
let counts = match (summary.fails, summary.warns) {
|
||||||
|
(0, 0) => "all checks passed".to_string(),
|
||||||
|
(0, w) => format!("{w} warning{}", plural(w)),
|
||||||
|
(f, 0) => format!("{f} failure{}", plural(f)),
|
||||||
|
(f, w) => format!("{f} failure{}, {w} warning{}", plural(f), plural(w)),
|
||||||
|
};
|
||||||
|
println!("Summary: {counts}. {hosting}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plural(n: usize) -> &'static str {
|
||||||
|
if n == 1 { "" } else { "s" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line verdict on whether this box can host, and how. Reads the actual
|
||||||
|
/// encode + capture checks rather than the raw tally so the advice is specific.
|
||||||
|
fn hosting_verdict(sections: &[Section]) -> String {
|
||||||
|
let find = |section: &str, label: &str| -> Option<Status> {
|
||||||
|
sections
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == section)?
|
||||||
|
.checks
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.label == label)
|
||||||
|
.map(|c| c.status)
|
||||||
|
};
|
||||||
|
|
||||||
|
let hw = find("Encode", "hardware H.264");
|
||||||
|
let sw_ok = find("Encode", "software H.264") == Some(Status::Ok);
|
||||||
|
let capture_broken = sections
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == "Capture (host)")
|
||||||
|
.map(|s| s.checks.iter().any(|c| c.status == Status::Fail))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if capture_broken {
|
||||||
|
"Hosting will fail: the capture backend is incomplete (see Capture above).".to_string()
|
||||||
|
} else if hw == Some(Status::Ok) {
|
||||||
|
"Hosting will work (hardware H.264 encode).".to_string()
|
||||||
|
} else if hw == Some(Status::Info) && sw_ok {
|
||||||
|
// Plugin + render node present but VA-API unverified (no vainfo): the
|
||||||
|
// default encoder is likely fine; `--no-hwencode` is the safe fallback.
|
||||||
|
"Hosting should work (hardware H.264 likely; `--no-hwencode` is the fallback).".to_string()
|
||||||
|
} else if sw_ok {
|
||||||
|
"Hosting should work with `--no-hwencode` (software H.264 encode).".to_string()
|
||||||
|
} else {
|
||||||
|
"Hosting may fail: no working H.264 encoder found (see Encode above).".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vainfo_detects_h264_encode_entrypoint() {
|
||||||
|
// Realistic AMD/RADV-style dump: H.264 has both decode (VLD) and encode.
|
||||||
|
let dump = "\
|
||||||
|
VAProfileH264Main : VAEntrypointVLD
|
||||||
|
VAProfileH264Main : VAEntrypointEncSlice
|
||||||
|
VAProfileH264High : VAEntrypointVLD
|
||||||
|
VAProfileHEVCMain : VAEntrypointEncSlice";
|
||||||
|
assert!(vainfo_has_h264_encode(dump));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vainfo_low_power_encode_counts() {
|
||||||
|
let dump = "VAProfileH264ConstrainedBaseline: VAEntrypointEncSliceLP";
|
||||||
|
assert!(vainfo_has_h264_encode(dump));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vainfo_decode_only_h264_is_not_encode() {
|
||||||
|
// Decode-only H.264 (VLD) plus HEVC encode must NOT be read as H.264
|
||||||
|
// encode — this is exactly the "default encoder fails" case.
|
||||||
|
let dump = "\
|
||||||
|
VAProfileH264Main : VAEntrypointVLD
|
||||||
|
VAProfileH264High : VAEntrypointVLD
|
||||||
|
VAProfileHEVCMain : VAEntrypointEncSlice";
|
||||||
|
assert!(!vainfo_has_h264_encode(dump));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vainfo_empty_is_not_encode() {
|
||||||
|
assert!(!vainfo_has_h264_encode(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_counts_fails_and_warns_only() {
|
||||||
|
let checks = [
|
||||||
|
Check::ok("a", "x"),
|
||||||
|
Check::info("b", "x"),
|
||||||
|
Check::warn("c", "x"),
|
||||||
|
Check::warn("d", "x"),
|
||||||
|
Check::fail("e", "x"),
|
||||||
|
];
|
||||||
|
let summary = summarize(checks.iter());
|
||||||
|
assert_eq!(summary, Summary { fails: 1, warns: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hosting_verdict_prefers_hardware_then_software() {
|
||||||
|
let hw = vec![Section {
|
||||||
|
name: "Encode",
|
||||||
|
checks: vec![
|
||||||
|
Check::ok("hardware H.264", "ok"),
|
||||||
|
Check::ok("software H.264", "ok"),
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
assert!(hosting_verdict(&hw).contains("hardware"));
|
||||||
|
|
||||||
|
let sw = vec![Section {
|
||||||
|
name: "Encode",
|
||||||
|
checks: vec![
|
||||||
|
Check::warn("hardware H.264", "no"),
|
||||||
|
Check::ok("software H.264", "ok"),
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
assert!(sw_verdict_uses_no_hwencode(&hosting_verdict(&sw)));
|
||||||
|
|
||||||
|
let none = vec![Section {
|
||||||
|
name: "Encode",
|
||||||
|
checks: vec![
|
||||||
|
Check::warn("hardware H.264", "no"),
|
||||||
|
Check::warn("software H.264", "no"),
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
assert!(hosting_verdict(&none).contains("may fail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sw_verdict_uses_no_hwencode(v: &str) -> bool {
|
||||||
|
v.contains("--no-hwencode")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_failure_dominates_verdict() {
|
||||||
|
let sections = vec![
|
||||||
|
Section {
|
||||||
|
name: "Capture (host)",
|
||||||
|
checks: vec![Check::fail("X11 capture", "missing")],
|
||||||
|
},
|
||||||
|
Section {
|
||||||
|
name: "Encode",
|
||||||
|
checks: vec![Check::ok("hardware H.264", "ok")],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert!(hosting_verdict(§ions).contains("capture"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//! Phase 4 — the AEC identity validation state machine (impl plan §4, design
|
||||||
|
//! v3.4 §5.2/§5.3).
|
||||||
|
//!
|
||||||
|
//! peerspeak's echo canceller (`module-echo-cancel`) creates four graph nodes
|
||||||
|
//! that all carry `pulse.module.id == <the index pactl returned>`, and the
|
||||||
|
//! playback leg among them is a `Stream/Output/Audio` node wired straight to
|
||||||
|
//! the speakers — a fan-out candidate that would copy the whole remote call
|
||||||
|
//! into the share unless it is excluded (v3.4 §5.2, measured ≈desktop level).
|
||||||
|
//! The taint engine (phase 2) already excludes it *given* the module index in
|
||||||
|
//! [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx); this
|
||||||
|
//! module is what decides, at runtime and fail-closed, whether that index may
|
||||||
|
//! be trusted and handed over.
|
||||||
|
//!
|
||||||
|
//! **Why a state machine and not a one-shot check (v3.4 §5.3).** The identity
|
||||||
|
//! is an *observed correlation on PipeWire 1.6.8*, not a documented contract,
|
||||||
|
//! and a start-time enumeration races in both directions: peerspeak's
|
||||||
|
//! `enable()` returns before the playback hazard leg is even in the graph, and
|
||||||
|
//! pixelpass's capture spawns lazily on the first viewer, at a moment peerspeak
|
||||||
|
//! does not control. So validation is a bounded epoch, and the identity can be
|
||||||
|
//! *lost* mid-share (the module unloads) as well as *gained*.
|
||||||
|
//!
|
||||||
|
//! **The two traps this is shaped around:**
|
||||||
|
//!
|
||||||
|
//! - **Revocation is loss of the whole module identity, not one leg corking**
|
||||||
|
//! (v3.4 §5.3). Each [`AecValidator::observe`] rescans the snapshot for *any*
|
||||||
|
//! node bearing the index; [`AecState::Validated`] drops to
|
||||||
|
//! [`AecState::Revoked`] only when that set becomes **empty**. A single leg
|
||||||
|
//! corking or relinking (still ≥1 present) stays `Validated` — getting this
|
||||||
|
//! wrong turns a normal cork into a spurious share-wide audio stop.
|
||||||
|
//! - **Module indices are reused verbatim across unload/reload** (v3.4 §5.2
|
||||||
|
//! correction 3 — both a reload's module index *and* its `node.link-group`
|
||||||
|
//! came back byte-identical, and node ids were recycled *and reassigned
|
||||||
|
//! across legs*). So [`AecState::Failed`] and [`AecState::Revoked`] are
|
||||||
|
//! **sticky terminal**: a later node reappearing with the same index does
|
||||||
|
//! **not** un-revoke and alias onto the new module. A genuine reload gets a
|
||||||
|
//! *fresh* [`AecValidator`] (peerspeak re-tells pixelpass the index on every
|
||||||
|
//! load), never a resurrected one.
|
||||||
|
//!
|
||||||
|
//! **Scope.** This is the validation state machine + `--aec` parsing only.
|
||||||
|
//! Foreign / second-AEC detection (a non-owned `echo-cancel-*` group, v3.4
|
||||||
|
//! §5.4 / D3) and the `foreign_aec_warning`/`aec_failed`/`aec_revoked` status
|
||||||
|
//! *events* are phase 6's, which reads this machine's [`AecState`]. Wiring the
|
||||||
|
//! parsed [`AecConfig`] out of the CLI and calling [`AecValidator::observe`]
|
||||||
|
//! in the recompute loop is integration (phases 5/8). The node-side
|
||||||
|
//! `pulse.module.id` parse (JSON-number-vs-string, u64-not-u32) is phase 3's
|
||||||
|
//! adapter; this module consumes the already-parsed
|
||||||
|
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
|
||||||
|
|
||||||
|
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use crate::host::observer::Millis;
|
||||||
|
use crate::host::taint::snapshot::GraphSnapshot;
|
||||||
|
|
||||||
|
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AecConfig {
|
||||||
|
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
|
||||||
|
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
|
||||||
|
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
|
||||||
|
Off,
|
||||||
|
/// `--aec=pulse-module:<idx>` — validate this live module index before
|
||||||
|
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
|
||||||
|
PulseModule(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
|
||||||
|
/// there is no fail-closed *default* index, because a wrong index would exclude
|
||||||
|
/// the wrong node (or nothing), so a malformed value must not silently become
|
||||||
|
/// "no AEC".
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AecParseError {
|
||||||
|
/// The value was empty.
|
||||||
|
Empty,
|
||||||
|
/// Not `off` and not `pulse-module:...`.
|
||||||
|
UnknownForm,
|
||||||
|
/// `pulse-module:` with nothing after the colon.
|
||||||
|
MissingIndex,
|
||||||
|
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
|
||||||
|
/// `> u64::MAX`).
|
||||||
|
InvalidIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
|
||||||
|
///
|
||||||
|
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
|
||||||
|
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
|
||||||
|
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
|
||||||
|
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
|
||||||
|
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
|
||||||
|
/// case-sensitive: the argument is machine-generated by peerspeak from
|
||||||
|
/// `EchoCancelGuard::module_index`, not typed by a user.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
|
||||||
|
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
|
||||||
|
/// `pactl load-module` returns an unsigned decimal, so the stored index is
|
||||||
|
/// already canonical and no reachable value is rejected; if peerspeak ever
|
||||||
|
/// changes how it formats the index it must canonicalize (`value.to_string()`),
|
||||||
|
/// not widen this parser — the narrow grammar is the point.
|
||||||
|
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(AecParseError::Empty);
|
||||||
|
}
|
||||||
|
if value == "off" {
|
||||||
|
return Ok(AecConfig::Off);
|
||||||
|
}
|
||||||
|
if let Some(index) = value.strip_prefix("pulse-module:") {
|
||||||
|
if index.is_empty() {
|
||||||
|
return Err(AecParseError::MissingIndex);
|
||||||
|
}
|
||||||
|
// A bare decimal only: reject a leading sign (Rust's `u64` parser
|
||||||
|
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
|
||||||
|
// before letting the parser catch overflow. Leading zeros are harmless.
|
||||||
|
if !index.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return Err(AecParseError::InvalidIndex);
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
.parse::<u64>()
|
||||||
|
.map(AecConfig::PulseModule)
|
||||||
|
.map_err(|_| AecParseError::InvalidIndex);
|
||||||
|
}
|
||||||
|
Err(AecParseError::UnknownForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validation epoch (v3.4 §5.3, verbatim).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AecState {
|
||||||
|
/// `--aec=off` — no AEC identity, fan-out proceeds with no exclusion.
|
||||||
|
/// Terminal.
|
||||||
|
NotConfigured,
|
||||||
|
/// Waiting for the first node bearing the index. **No fan-out occurs here**
|
||||||
|
/// — silence is the safe direction. Ends at `Validated` on first sight, or
|
||||||
|
/// `Failed` once the graph is fully enumerated and the bounded deadline
|
||||||
|
/// passes with the index never seen.
|
||||||
|
Validating,
|
||||||
|
/// The index was observed live. Fan-out is permitted, excluding that
|
||||||
|
/// identity transitively (phase 2 / v3.4 §6.1).
|
||||||
|
Validated,
|
||||||
|
/// The deadline expired with the index never observed. **Fail closed** — no
|
||||||
|
/// fan-out; the caller reports a capability failure rather than sharing.
|
||||||
|
/// Sticky terminal.
|
||||||
|
Failed,
|
||||||
|
/// The whole module identity disappeared mid-share (every node bearing the
|
||||||
|
/// index gone). **Stop fan-out now** and drop the owned link proxies; do
|
||||||
|
/// not keep the numeric index and hope, because it is reused. Sticky
|
||||||
|
/// terminal — see the module header's second trap.
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bounded, read-only AEC identity validator. Fold the live graph in with
|
||||||
|
/// [`AecValidator::observe`] once per recompute; read the result with
|
||||||
|
/// [`AecValidator::state`], [`AecValidator::fan_out_permitted`], and
|
||||||
|
/// [`AecValidator::validated_module_id`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AecValidator {
|
||||||
|
/// The index to validate. `None` iff [`AecConfig::Off`] (state stays
|
||||||
|
/// [`AecState::NotConfigured`] forever).
|
||||||
|
target: Option<u64>,
|
||||||
|
state: AecState,
|
||||||
|
/// The `Validating → Failed` budget, applied *after* the deadline is armed.
|
||||||
|
timeout: Millis,
|
||||||
|
/// The absolute `Failed` deadline, armed the first time the graph reports
|
||||||
|
/// ready (the "registry sync barrier" of v3.4 §5.3) and never re-armed —
|
||||||
|
/// `graph_ready` is dynamic and can flap, but the epoch budget must not
|
||||||
|
/// restart. `None` until then: while the initial enumeration is still in
|
||||||
|
/// flight, a not-yet-seen index is *unknown*, not *absent*, so it must not
|
||||||
|
/// time out to `Failed`.
|
||||||
|
deadline: Option<Millis>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AecValidator {
|
||||||
|
/// `timeout` is the `Validating → Failed` budget, counted from the moment
|
||||||
|
/// the graph first becomes ready (not from construction). An `Off` config
|
||||||
|
/// starts (and stays) [`AecState::NotConfigured`].
|
||||||
|
pub fn new(config: AecConfig, timeout: Millis) -> Self {
|
||||||
|
match config {
|
||||||
|
AecConfig::Off => Self {
|
||||||
|
target: None,
|
||||||
|
state: AecState::NotConfigured,
|
||||||
|
timeout,
|
||||||
|
deadline: None,
|
||||||
|
},
|
||||||
|
AecConfig::PulseModule(index) => Self {
|
||||||
|
target: Some(index),
|
||||||
|
state: AecState::Validating,
|
||||||
|
timeout,
|
||||||
|
deadline: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> AecState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validated index to place in
|
||||||
|
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
|
||||||
|
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
|
||||||
|
/// including `NotConfigured` (no AEC ⇒ nothing to exclude) and the
|
||||||
|
/// fail-closed states (whose `None` must be paired with
|
||||||
|
/// [`Self::fan_out_permitted`] `== false`, i.e. no fan-out at all — *not*
|
||||||
|
/// a fan-out that merely skips AEC exclusion).
|
||||||
|
pub fn validated_module_id(&self) -> Option<u64> {
|
||||||
|
match self.state {
|
||||||
|
AecState::Validated => self.target,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether fan-out may proceed at all right now. True only in
|
||||||
|
/// [`AecState::NotConfigured`] (fan out, no exclusion) and
|
||||||
|
/// [`AecState::Validated`] (fan out, excluding the identity). `Validating`,
|
||||||
|
/// `Failed` and `Revoked` all forbid it — silence over echo.
|
||||||
|
pub fn fan_out_permitted(&self) -> bool {
|
||||||
|
matches!(self.state, AecState::NotConfigured | AecState::Validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one recompute's view of the graph into the machine.
|
||||||
|
///
|
||||||
|
/// `graph_ready` is the observer's dynamic readiness
|
||||||
|
/// ([`Projection::graph_ready`](crate::host::observer::Projection)); `now`
|
||||||
|
/// is a monotonic millisecond clock. Positive evidence (a node bearing the
|
||||||
|
/// index) is authoritative and validates regardless of `graph_ready` —
|
||||||
|
/// seeing the node *is* seeing it — but the `Failed` deadline only begins
|
||||||
|
/// once `graph_ready` has first become true, so a slow initial enumeration
|
||||||
|
/// can never masquerade as a genuinely-absent module.
|
||||||
|
pub fn observe(&mut self, snapshot: &GraphSnapshot, graph_ready: bool, now: Millis) {
|
||||||
|
// `Off` (NotConfigured) and both sticky terminals are no-ops: there is
|
||||||
|
// nothing to look for, and a reappearing reused index must not revive a
|
||||||
|
// Failed/Revoked epoch (v3.4 §5.2 correction 3).
|
||||||
|
let Some(target) = self.target else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.state {
|
||||||
|
AecState::Validating => {
|
||||||
|
// Presence is checked *before* the deadline on purpose: a
|
||||||
|
// demonstrably-present identity validates regardless of the
|
||||||
|
// clock, even if the node is first seen just past the deadline
|
||||||
|
// (Codex phase-4 review, finding 2). The deadline only bounds
|
||||||
|
// the wait for an identity that is never seen — seeing it, late
|
||||||
|
// or not, is ground truth that the module exists, and excluding
|
||||||
|
// a real echo leg is always the safe answer. (A `Failed` can
|
||||||
|
// still pre-empt this when a `Tick`-only observation crosses the
|
||||||
|
// deadline first; that only makes the machine *more* fail-closed,
|
||||||
|
// never less.)
|
||||||
|
if self.index_present(snapshot, target) {
|
||||||
|
self.state = AecState::Validated;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Arm the deadline once, on the first ready graph.
|
||||||
|
if self.deadline.is_none() && graph_ready {
|
||||||
|
self.deadline = Some(now.saturating_add(self.timeout));
|
||||||
|
}
|
||||||
|
if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||||
|
self.state = AecState::Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AecState::Validated => {
|
||||||
|
// Revocation is the whole identity gone (no node bears the
|
||||||
|
// index), not one leg corking — see the module header.
|
||||||
|
//
|
||||||
|
// ⚠️ **Deliberately NOT gated on `graph_ready`** (Codex
|
||||||
|
// phase-4 review, findings 1 + 4). Two forces pull opposite
|
||||||
|
// ways and this is the resolution:
|
||||||
|
//
|
||||||
|
// - Gating revoke on readiness would avoid a *spurious* revoke
|
||||||
|
// from a transient empty snapshot seen while the module is
|
||||||
|
// still live. But for the AEC that transient does not exist:
|
||||||
|
// its four nodes are two `Stream/*` legs plus a null-sink-like
|
||||||
|
// virtual sink/source, none of which claim a `device.id`, so
|
||||||
|
// the phase-3 observer never *withholds* them
|
||||||
|
// (`observer::classify` withholds only device-claiming nodes).
|
||||||
|
// `index_present` therefore goes false only on a genuine
|
||||||
|
// `global_remove` of every leg — a real unload — and a real
|
||||||
|
// unload *should* revoke.
|
||||||
|
// - Worse, gating on readiness would REOPEN the reused-index
|
||||||
|
// alias trap: if an unload+reload (indices recycle, §5.2
|
||||||
|
// correction 3) both complete inside one not-ready churn
|
||||||
|
// window, the ready snapshot would already show the *new*
|
||||||
|
// module's node and we would never observe the empty gap —
|
||||||
|
// silently aliasing onto an unrelated module. Revoking the
|
||||||
|
// instant the gap appears, ready or not, is what closes it.
|
||||||
|
//
|
||||||
|
// This correctness rests on the phase-5/6 integration contract:
|
||||||
|
// **one `observe` per graph event, no coalescing across a module
|
||||||
|
// lifetime boundary.** Under coalescing, the empty gap between an
|
||||||
|
// old unload and a reused-index reload can be skipped. The
|
||||||
|
// robust fix that would not depend on that contract is a
|
||||||
|
// serial-continuity / observer-generation signal (the AEC nodes'
|
||||||
|
// `object.serial`s are fresh across a reload even when the index
|
||||||
|
// is not) — owed to a later hardening round, not built here.
|
||||||
|
if !self.index_present(snapshot, target) {
|
||||||
|
self.state = AecState::Revoked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AecState::NotConfigured | AecState::Failed | AecState::Revoked => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any node in the snapshot bears the target module index. The same
|
||||||
|
/// exact-`u64`-equality predicate the taint engine roots on
|
||||||
|
/// (`taint/mod.rs`), kept here so "is the identity live?" has one
|
||||||
|
/// definition.
|
||||||
|
fn index_present(&self, snapshot: &GraphSnapshot, target: u64) -> bool {
|
||||||
|
snapshot
|
||||||
|
.nodes()
|
||||||
|
.any(|node| node.props.pulse_module_id == Some(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! Phase 4 exit gate (impl plan §4): a fake-clock / event-sequence transition
|
||||||
|
//! matrix, because these are timing semantics a live poke cannot cover.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::host::taint::snapshot::{
|
||||||
|
GlobalId, GraphSnapshot, MediaRole, NodeProps, NodeSnapshot, Serial,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A `Stream/Output/Audio` node carrying `pulse.module.id == module` (or none).
|
||||||
|
/// Only the fields the validator reads matter; the rest take their defaults.
|
||||||
|
fn node(serial: u64, module: Option<u64>) -> NodeSnapshot {
|
||||||
|
NodeSnapshot {
|
||||||
|
serial: Serial(serial),
|
||||||
|
id: GlobalId(serial as u32),
|
||||||
|
name: None,
|
||||||
|
role: MediaRole::StreamOutput,
|
||||||
|
props: NodeProps {
|
||||||
|
pulse_module_id: module,
|
||||||
|
..NodeProps::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot holding exactly the given nodes (no ports/links/clients — the
|
||||||
|
/// validator reads only nodes).
|
||||||
|
fn snapshot(nodes: Vec<NodeSnapshot>) -> GraphSnapshot {
|
||||||
|
GraphSnapshot::new(nodes, vec![], vec![], vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty() -> GraphSnapshot {
|
||||||
|
snapshot(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDX: u64 = 536_870_919; // 0x20000007 — a real pipewire-pulse module index.
|
||||||
|
const TIMEOUT: Millis = 2_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsing (D5): off / pulse-module:<idx> / > u32::MAX / absent / malformed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_off() {
|
||||||
|
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_pulse_module_index() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_aec_arg("pulse-module:536870919"),
|
||||||
|
Ok(AecConfig::PulseModule(536_870_919)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_index_beyond_u32() {
|
||||||
|
// v3.4 §5.2: compare as u64, never u32. A value one past u32::MAX must
|
||||||
|
// round-trip, not truncate or reject.
|
||||||
|
let big = u64::from(u32::MAX) + 1;
|
||||||
|
assert_eq!(
|
||||||
|
parse_aec_arg(&format!("pulse-module:{big}")),
|
||||||
|
Ok(AecConfig::PulseModule(big)),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
|
||||||
|
Ok(AecConfig::PulseModule(u64::MAX)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty() {
|
||||||
|
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_form() {
|
||||||
|
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
|
||||||
|
assert_eq!(parse_aec_arg("module:5"), Err(AecParseError::UnknownForm));
|
||||||
|
assert_eq!(parse_aec_arg("536870919"), Err(AecParseError::UnknownForm));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_index() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_aec_arg("pulse-module:"),
|
||||||
|
Err(AecParseError::MissingIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_index() {
|
||||||
|
for bad in [
|
||||||
|
"pulse-module:-1", // sign
|
||||||
|
"pulse-module:+7", // sign
|
||||||
|
"pulse-module: 7", // leading whitespace
|
||||||
|
"pulse-module:7 ", // trailing whitespace
|
||||||
|
"pulse-module:0x7", // hex
|
||||||
|
"pulse-module:7.0", // non-integer
|
||||||
|
"pulse-module:abc", // non-numeric
|
||||||
|
"pulse-module:18446744073709551616", // u64::MAX + 1 (overflow)
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_aec_arg(bad),
|
||||||
|
Err(AecParseError::InvalidIndex),
|
||||||
|
"{bad} should be InvalidIndex",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NotConfigured (--aec=off): benign, terminal, fan-out with no exclusion.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_is_not_configured_and_permits_fan_out_with_no_identity() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::Off, TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::NotConfigured);
|
||||||
|
assert!(v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
|
||||||
|
// Even a snapshot full of module nodes never moves it off NotConfigured.
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 10_000);
|
||||||
|
assert_eq!(v.state(), AecState::NotConfigured);
|
||||||
|
assert!(v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row: Validating → Validated on first matching node; no fan-out before.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validating_forbids_fan_out_and_exposes_no_identity() {
|
||||||
|
let v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validating_to_validated_on_first_matching_node() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
// A node with a *different* index does not validate.
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
|
||||||
|
v.observe(&snapshot(vec![node(2, Some(IDX))]), true, 100);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert!(v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positive_evidence_validates_even_before_graph_ready() {
|
||||||
|
// Seeing the node is authoritative; readiness only gates the Failed clock.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), false, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validated_index_is_compared_beyond_u32() {
|
||||||
|
let big = u64::from(u32::MAX) + 7;
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(big), TIMEOUT);
|
||||||
|
// A node whose id equals `big` only in its low 32 bits must not match.
|
||||||
|
v.observe(
|
||||||
|
&snapshot(vec![node(1, Some(big & u64::from(u32::MAX)))]),
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
|
||||||
|
v.observe(&snapshot(vec![node(2, Some(big))]), true, 1);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert_eq!(v.validated_module_id(), Some(big));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row: Validating → Failed on deadline expiry; and the deadline is armed only
|
||||||
|
// once the graph is ready (the registry sync barrier).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validating_to_failed_on_deadline_expiry() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&empty(), true, 0); // arms deadline at 0 + 2000
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
|
||||||
|
v.observe(&empty(), true, TIMEOUT - 1);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
|
||||||
|
v.observe(&empty(), true, TIMEOUT); // now >= deadline
|
||||||
|
assert_eq!(v.state(), AecState::Failed);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_is_not_armed_until_graph_ready() {
|
||||||
|
// The whole point of arming-on-ready: a slow initial enumeration is
|
||||||
|
// "unknown", not "absent", and must never time out to Failed.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
// Long past the would-be deadline, but the graph has never been ready.
|
||||||
|
v.observe(&empty(), false, 10 * TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
// Still no Failed even much later, as long as ready stays false.
|
||||||
|
v.observe(&empty(), false, 100 * TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
|
||||||
|
// And when readiness finally arrives, the FULL budget starts *there*, not
|
||||||
|
// relative to construction (Codex phase-4 review, finding 3): a mutant that
|
||||||
|
// armed a construction-relative deadline would fail immediately here.
|
||||||
|
let late = 200_000;
|
||||||
|
v.observe(&empty(), true, late); // first ready → arm at `late`
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
v.observe(&empty(), true, late + TIMEOUT - 1);
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
v.observe(&empty(), true, late + TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_positive_evidence_wins_over_expired_deadline() {
|
||||||
|
// A node first seen just past the deadline still validates: the deadline
|
||||||
|
// only bounds the wait for an identity that is never seen, and a
|
||||||
|
// demonstrably-present module is ground truth (Codex phase-4 review,
|
||||||
|
// finding 2). Reachable only when the first post-deadline observation
|
||||||
|
// carries the node with no intervening Tick-only observation.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&empty(), true, 0); // arm deadline at 2000
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||||
|
|
||||||
|
// Whereas a Tick-only observation that crosses the deadline first pre-empts
|
||||||
|
// it to Failed (stickily), even if the node then shows up — fail-closed.
|
||||||
|
let mut w = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
w.observe(&empty(), true, 0);
|
||||||
|
w.observe(&empty(), true, TIMEOUT); // Tick-only crosses the line first
|
||||||
|
assert_eq!(w.state(), AecState::Failed);
|
||||||
|
w.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||||
|
assert_eq!(w.state(), AecState::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revokes_on_empty_even_while_not_ready() {
|
||||||
|
// Revocation is deliberately NOT gated on graph_ready (Codex phase-4 review,
|
||||||
|
// findings 1 + 4): the instant every node bearing the index is gone we
|
||||||
|
// revoke, ready or not, because gating on readiness would let an
|
||||||
|
// unload+reload that reused the index inside one not-ready churn window
|
||||||
|
// silently alias onto the new module. A mutant adding `&& graph_ready` to
|
||||||
|
// the revoke guard survives every other test but dies here.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
v.observe(&empty(), false, 10); // identity gone during not-ready churn
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_armed_once_survives_ready_flapping() {
|
||||||
|
// graph_ready is dynamic (it drops back to false while a Link is binding).
|
||||||
|
// The epoch budget must be armed on the *first* ready and not restarted.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&empty(), true, 1_000); // arm at 1000 → deadline 3000
|
||||||
|
v.observe(&empty(), false, 2_000); // ready flaps off; must not disarm
|
||||||
|
assert_eq!(v.state(), AecState::Validating);
|
||||||
|
// At the original deadline it fails, even though ready is false now — the
|
||||||
|
// budget did not restart from the flap.
|
||||||
|
v.observe(&empty(), false, 3_000);
|
||||||
|
assert_eq!(v.state(), AecState::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_is_sticky_even_if_the_index_reappears() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&empty(), true, 0);
|
||||||
|
v.observe(&empty(), true, TIMEOUT);
|
||||||
|
assert_eq!(v.state(), AecState::Failed);
|
||||||
|
|
||||||
|
// A node bearing the index shows up late — must not resurrect the epoch.
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, TIMEOUT + 1);
|
||||||
|
assert_eq!(v.state(), AecState::Failed);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row: partial-node disappearance ⇒ stays Validated; all gone ⇒ Revoked.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_leg_disappearance_stays_validated() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
// The module's four nodes all carry the index.
|
||||||
|
let four = snapshot(vec![
|
||||||
|
node(1, Some(IDX)),
|
||||||
|
node(2, Some(IDX)),
|
||||||
|
node(3, Some(IDX)),
|
||||||
|
node(4, Some(IDX)),
|
||||||
|
]);
|
||||||
|
v.observe(&four, true, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
|
||||||
|
// Three legs cork/relink away; one still bears the index → still Validated.
|
||||||
|
v.observe(&snapshot(vec![node(4, Some(IDX))]), true, 10);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_nodes_gone_revokes() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
|
||||||
|
// The whole identity unloads: no node bears the index any more.
|
||||||
|
v.observe(&empty(), true, 10);
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_stops_fan_out_and_exposes_no_identity() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
v.observe(&empty(), true, 10);
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_that_merely_changes_index_revokes() {
|
||||||
|
// Not a disappearance in the id sense, but the *identity* is gone: no node
|
||||||
|
// bears our index any more, even though a same-serial node lingers.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX + 1))]), true, 10);
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row: a retained stale index does not alias onto a reloaded module — indices
|
||||||
|
// ARE reused (v3.4 §5.2 correction 3). This is the sharpest safety property.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_index_does_not_alias_onto_a_reloaded_module() {
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
v.observe(&empty(), true, 10);
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
|
||||||
|
// A *different* module later reloads and pactl hands it the very same
|
||||||
|
// index (measured: 536870919 came back verbatim). A resurrecting machine
|
||||||
|
// would silently start excluding this unrelated module's node. Ours must
|
||||||
|
// stay Revoked and fail closed; a real reload gets a fresh validator.
|
||||||
|
v.observe(&snapshot(vec![node(99, Some(IDX))]), true, 20);
|
||||||
|
assert_eq!(v.state(), AecState::Revoked);
|
||||||
|
assert!(!v.fan_out_permitted());
|
||||||
|
assert_eq!(v.validated_module_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_validator_re_validates_the_reused_index() {
|
||||||
|
// The counterpart: because peerspeak re-tells pixelpass the index on every
|
||||||
|
// load, the correct response to a reload is a new machine, which validates
|
||||||
|
// the reused index cleanly — proving stickiness costs nothing legitimate.
|
||||||
|
let mut v = AecValidator::new(AecConfig::PulseModule(IDX), TIMEOUT);
|
||||||
|
v.observe(&snapshot(vec![node(1, Some(IDX))]), true, 0);
|
||||||
|
assert_eq!(v.state(), AecState::Validated);
|
||||||
|
assert_eq!(v.validated_module_id(), Some(IDX));
|
||||||
|
}
|
||||||
+164
-28
@@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
|
use crate::repair::plan::{self as repair_plan, Shape};
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -64,9 +65,14 @@ 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 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`.
|
||||||
|
let sink_module = load_module(Shape::LegacyCaptureSink, pid)
|
||||||
.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
|
||||||
@@ -82,12 +88,7 @@ impl Routing {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(
|
Some(
|
||||||
load_module(&[
|
load_module(Shape::LoopbackIntoCapture, pid)
|
||||||
"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)")?,
|
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -115,7 +116,6 @@ impl Routing {
|
|||||||
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 loopback_for_task = Arc::clone(&loopback_arc);
|
||||||
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
|
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};
|
||||||
@@ -137,12 +137,7 @@ impl Routing {
|
|||||||
// 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() {
|
if local_monitor_for_task.lock().unwrap().is_none() {
|
||||||
match load_module(&[
|
match load_module(Shape::LoopbackOutOfCapture, pid) {
|
||||||
"module-loopback",
|
|
||||||
&format!("source={sink_name_for_task}.monitor"),
|
|
||||||
"sink=@DEFAULT_SINK@",
|
|
||||||
"latency_msec=20",
|
|
||||||
]) {
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
module = id,
|
module = id,
|
||||||
@@ -195,12 +190,7 @@ impl Routing {
|
|||||||
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(&[
|
match load_module(Shape::LoopbackIntoCapture, pid) {
|
||||||
"module-loopback",
|
|
||||||
"source=@DEFAULT_SINK@.monitor",
|
|
||||||
&format!("sink={sink_name_for_task}"),
|
|
||||||
"latency_msec=20",
|
|
||||||
]) {
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
*loopback_for_task.lock().unwrap() = Some(id);
|
*loopback_for_task.lock().unwrap() = Some(id);
|
||||||
}
|
}
|
||||||
@@ -349,10 +339,47 @@ struct SinkInputProperties {
|
|||||||
// pactl module helpers
|
// pactl module helpers
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn load_module(args: &[&str]) -> Result<u32> {
|
/// Mint an ownership token for one module load.
|
||||||
|
///
|
||||||
|
/// **Per load, not per session.** The nonce is what makes two loads by the same pid
|
||||||
|
/// render different arguments, which is what lets a fingerprint tell a module from
|
||||||
|
/// its replacement at the same index. A token minted once and reused for every
|
||||||
|
/// 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.
|
||||||
|
fn load_module(shape: Shape, pid: u32) -> Result<u32> {
|
||||||
|
let owner = owner_token(pid).context("could not build an audio ownership token")?;
|
||||||
let output = Command::new("pactl")
|
let output = Command::new("pactl")
|
||||||
.arg("load-module")
|
.arg("load-module")
|
||||||
.args(args)
|
.arg(shape.module_name())
|
||||||
|
.args(shape.render_args(pid, Some(&owner)))
|
||||||
.output()
|
.output()
|
||||||
.context("failed to run pactl load-module")?;
|
.context("failed to run pactl load-module")?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -365,6 +392,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
|
|||||||
.context("pactl returned non-UTF-8")?
|
.context("pactl returned non-UTF-8")?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
||||||
|
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
||||||
|
// back verbatim. Do not widen it.
|
||||||
id_str
|
id_str
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
||||||
@@ -532,14 +562,22 @@ fn run_router(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||||
if let Some(serial) = props
|
match props.get("object.serial").and_then(parse_object_serial) {
|
||||||
.get("object.serial")
|
Some(serial) => {
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
{
|
|
||||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||||
}
|
}
|
||||||
|
// Never silently: without a serial `try_flush` can
|
||||||
|
// never route anything, so the whole app-filter mode
|
||||||
|
// is dead and the only symptom is missing audio.
|
||||||
|
None => tracing::warn!(
|
||||||
|
node_id = obj.id,
|
||||||
|
serial = props.get("object.serial").unwrap_or("<absent>"),
|
||||||
|
"audio routing: pixelpass sink has no usable object.serial; \
|
||||||
|
stream rerouting disabled"
|
||||||
|
),
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||||
@@ -591,8 +629,30 @@ fn run_router(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a PipeWire `object.serial` property value.
|
||||||
|
///
|
||||||
|
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||||
|
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
|
||||||
|
/// (those are `u32` and get recycled — the serial exists precisely so
|
||||||
|
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
|
||||||
|
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
|
||||||
|
/// sink is never registered and no stream is ever routed.
|
||||||
|
///
|
||||||
|
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
|
||||||
|
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
|
||||||
|
/// property we do not understand and must not guess at. Leading zeroes
|
||||||
|
/// are accepted — they are unambiguous and parse to the same value.
|
||||||
|
pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
|
||||||
|
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
raw.parse::<u64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
struct RouterState {
|
struct RouterState {
|
||||||
sink_serial: Option<u32>,
|
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
||||||
|
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
||||||
|
sink_serial: Option<u64>,
|
||||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||||
routed_node_ids: Vec<u32>,
|
routed_node_ids: Vec<u32>,
|
||||||
pending: Vec<u32>,
|
pending: Vec<u32>,
|
||||||
@@ -654,3 +714,79 @@ fn try_flush(
|
|||||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_parses_past_u32() {
|
||||||
|
// The regression this fix exists for: a serial one past `u32::MAX`
|
||||||
|
// used to parse as `None` and silently disable rerouting.
|
||||||
|
let beyond = u64::from(u32::MAX) + 1;
|
||||||
|
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial(&u64::MAX.to_string()),
|
||||||
|
Some(u64::MAX),
|
||||||
|
"the full 64-bit range must round-trip"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_accepts_ordinary_serials() {
|
||||||
|
// Without this the valid cases are only 1, 10 and 20 digits long, and
|
||||||
|
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
|
||||||
|
// survives the whole suite while rejecting every serial a freshly
|
||||||
|
// started daemon actually hands out. (Codex, round 1.)
|
||||||
|
for serial in 0_u64..=1024 {
|
||||||
|
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
|
||||||
|
}
|
||||||
|
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial("007"),
|
||||||
|
Some(7),
|
||||||
|
"leading zeroes are fine"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_boundary_values() {
|
||||||
|
assert_eq!(parse_object_serial("0"), Some(0));
|
||||||
|
assert_eq!(parse_object_serial("1"), Some(1));
|
||||||
|
let max32 = u64::from(u32::MAX);
|
||||||
|
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
|
||||||
|
assert_eq!(
|
||||||
|
parse_object_serial(&(max32 - 1).to_string()),
|
||||||
|
Some(max32 - 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_round_trips_through_the_metadata_string() {
|
||||||
|
// `try_flush` writes the serial back out as a decimal string for
|
||||||
|
// `target.object`; widening must not introduce a formatting change.
|
||||||
|
for raw in ["0", "4294967296", "18446744073709551615"] {
|
||||||
|
let parsed = parse_object_serial(raw).expect("valid serial");
|
||||||
|
assert_eq!(parsed.to_string(), raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_serial_rejects_malformed() {
|
||||||
|
for raw in [
|
||||||
|
"",
|
||||||
|
" 12",
|
||||||
|
"12 ",
|
||||||
|
"+12",
|
||||||
|
"-1",
|
||||||
|
"1.0",
|
||||||
|
"0x10",
|
||||||
|
"12a",
|
||||||
|
"abc",
|
||||||
|
// u64::MAX + 1 — overflow must be rejected, not wrapped.
|
||||||
|
"18446744073709551616",
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! O5 measurement, pure (impl plan §5.2).
|
||||||
|
//!
|
||||||
|
//! v3.4 §6.4 asserts "a full recompute per graph event is fine for v1". The
|
||||||
|
//! impl plan closes O5 by refusing to let that rest on a node count: what has
|
||||||
|
//! to be recorded is the **graph-event rate**, the **recompute duration
|
||||||
|
//! distribution and maximum**, and **whether events queue behind recompute or
|
||||||
|
//! logging**.
|
||||||
|
//!
|
||||||
|
//! Everything here is arithmetic over samples the caller supplies. The clock
|
||||||
|
//! reads live at the I/O edge ([`super::sink`]), which is what keeps the
|
||||||
|
//! statistics unit-testable: a test feeds a hand-written sample sequence and
|
||||||
|
//! asserts the summary exactly, with no timing flake.
|
||||||
|
//!
|
||||||
|
//! **The queueing measure is a proxy, and a one-directional one.** libpipewire
|
||||||
|
//! dispatches registry callbacks serially on its own loop thread and exposes no
|
||||||
|
//! queue depth, so nothing here can read a backlog directly. What it can see is
|
||||||
|
//! that the observer thread was *continuously busy*: if an event begins being
|
||||||
|
//! handled within [`QUEUE_THRESHOLD_US`] of the previous sample's completion,
|
||||||
|
//! it was almost certainly already waiting while that recompute ran. That makes
|
||||||
|
//! [`Summary::queued_events`] a **lower bound** — a genuine backlog always shows
|
||||||
|
//! up in it, but a burst that happens to arrive exactly as the loop goes idle is
|
||||||
|
//! counted as un-queued. Combined with [`Summary::busy_fraction`] (which needs
|
||||||
|
//! no inference at all) it is enough to answer O5 in the direction that matters:
|
||||||
|
//! a low busy fraction with zero queued events is headroom, and anything else is
|
||||||
|
//! a number to argue about rather than an assumption to inherit.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::host::observer::EventKind;
|
||||||
|
|
||||||
|
/// An event beginning this close behind the previous sample's completion is
|
||||||
|
/// counted as having queued. Deliberately tight: the cost of being wrong in the
|
||||||
|
/// generous direction is a metric that overstates backlog and sends a later
|
||||||
|
/// round chasing a non-problem.
|
||||||
|
pub const QUEUE_THRESHOLD_US: u64 = 100;
|
||||||
|
|
||||||
|
/// Upper bounds of the duration histogram, microseconds. A twelfth (overflow)
|
||||||
|
/// bucket catches everything at or above the last bound. Log-ish spacing: the
|
||||||
|
/// interesting question is which order of magnitude a recompute lands in, not
|
||||||
|
/// its exact microsecond.
|
||||||
|
pub const BUCKET_BOUNDS_US: [u64; 11] = [
|
||||||
|
50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Human labels for the histogram buckets, parallel to [`BUCKET_BOUNDS_US`]
|
||||||
|
/// plus the overflow bucket.
|
||||||
|
pub const BUCKET_LABELS: [&str; 12] = [
|
||||||
|
"<50us", "<100us", "<250us", "<500us", "<1ms", "<2.5ms", "<5ms", "<10ms", "<25ms", "<50ms",
|
||||||
|
"<100ms", ">=100ms",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A bucketed duration distribution with exact count, sum and maximum.
|
||||||
|
///
|
||||||
|
/// Bounded memory by construction — the audit runs for as long as a share does,
|
||||||
|
/// and keeping every sample to compute an exact percentile would grow without
|
||||||
|
/// limit. The maximum, which is the number O5 actually cares about, is kept
|
||||||
|
/// exactly; percentiles are reported as the bucket they fall in.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Histogram {
|
||||||
|
buckets: [u64; 12],
|
||||||
|
count: u64,
|
||||||
|
sum_us: u64,
|
||||||
|
max_us: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Histogram {
|
||||||
|
pub fn record(&mut self, us: u64) {
|
||||||
|
let index = BUCKET_BOUNDS_US
|
||||||
|
.iter()
|
||||||
|
.position(|&bound| us < bound)
|
||||||
|
.unwrap_or(BUCKET_BOUNDS_US.len());
|
||||||
|
self.buckets[index] += 1;
|
||||||
|
self.count += 1;
|
||||||
|
self.sum_us = self.sum_us.saturating_add(us);
|
||||||
|
self.max_us = self.max_us.max(us);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> u64 {
|
||||||
|
self.count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_us(&self) -> u64 {
|
||||||
|
self.max_us
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sum_us(&self) -> u64 {
|
||||||
|
self.sum_us
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mean_us(&self) -> Option<u64> {
|
||||||
|
(self.count > 0).then(|| self.sum_us / self.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label of the bucket the `q`-quantile falls in (`q` in `0.0..=1.0`),
|
||||||
|
/// or `None` when nothing has been recorded.
|
||||||
|
///
|
||||||
|
/// Uses the *nearest-rank* definition: the bucket containing the
|
||||||
|
/// `ceil(q · count)`-th sample in ascending order. Reported as a bucket
|
||||||
|
/// rather than a number because interpolating inside a bucket would invent
|
||||||
|
/// precision the histogram does not have.
|
||||||
|
pub fn quantile_bucket(&self, q: f64) -> Option<&'static str> {
|
||||||
|
if self.count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let q = q.clamp(0.0, 1.0);
|
||||||
|
// Rank is 1-based; q = 0 still names the bucket holding the smallest
|
||||||
|
// sample rather than degenerating to "no samples".
|
||||||
|
let rank = ((q * self.count as f64).ceil() as u64).max(1);
|
||||||
|
let mut cumulative = 0u64;
|
||||||
|
for (index, &n) in self.buckets.iter().enumerate() {
|
||||||
|
cumulative += n;
|
||||||
|
if cumulative >= rank {
|
||||||
|
return Some(BUCKET_LABELS[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unreachable while `count` is the sum of the buckets, but returning the
|
||||||
|
// top bucket is the fail-loud answer rather than a panic in a metric.
|
||||||
|
Some(BUCKET_LABELS[BUCKET_LABELS.len() - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-empty buckets as `(label, count)`, ascending. Empty buckets are
|
||||||
|
/// dropped so a summary line stays readable.
|
||||||
|
pub fn distribution(&self) -> Vec<(&'static str, u64)> {
|
||||||
|
self.buckets
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(_, &n)| n > 0)
|
||||||
|
.map(|(index, &n)| (BUCKET_LABELS[index], n))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One handled event, as timed by the I/O edge.
|
||||||
|
///
|
||||||
|
/// Ticks are the AEC validator's clock, not graph changes, so [`Metrics`] counts
|
||||||
|
/// them separately — folding them into the event rate would inflate it by a
|
||||||
|
/// constant 4 Hz and hide the real graph churn.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Sample {
|
||||||
|
/// Monotonic microseconds (since observer start) at which handling began.
|
||||||
|
pub at_us: u64,
|
||||||
|
/// Microseconds between the previous sample's completion and `at_us`. Zero
|
||||||
|
/// for the first sample.
|
||||||
|
pub gap_us: u64,
|
||||||
|
/// Time spent in the AEC observe + taint recompute.
|
||||||
|
pub recompute_us: u64,
|
||||||
|
/// Time spent serialising and writing the record, zero when nothing was
|
||||||
|
/// emitted. Separate from `recompute_us` because O5 asks about queueing
|
||||||
|
/// behind recompute **or logging** — and if logging turns out to dominate,
|
||||||
|
/// that is a fixable problem of a different kind.
|
||||||
|
pub emit_us: u64,
|
||||||
|
pub kind: EventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling O5 state. Fold samples in with [`Metrics::record`]; read with
|
||||||
|
/// [`Metrics::summary`].
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Metrics {
|
||||||
|
graph_events: u64,
|
||||||
|
tick_events: u64,
|
||||||
|
emitted_records: u64,
|
||||||
|
recompute: Histogram,
|
||||||
|
emit: Histogram,
|
||||||
|
busy_us: u64,
|
||||||
|
queued_events: u64,
|
||||||
|
first_event_us: Option<u64>,
|
||||||
|
last_completion_us: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
pub fn record(&mut self, sample: Sample) {
|
||||||
|
match sample.kind {
|
||||||
|
EventKind::Graph => self.graph_events += 1,
|
||||||
|
EventKind::Tick => self.tick_events += 1,
|
||||||
|
}
|
||||||
|
self.recompute.record(sample.recompute_us);
|
||||||
|
if sample.emit_us > 0 {
|
||||||
|
self.emitted_records += 1;
|
||||||
|
self.emit.record(sample.emit_us);
|
||||||
|
}
|
||||||
|
self.busy_us = self
|
||||||
|
.busy_us
|
||||||
|
.saturating_add(sample.recompute_us)
|
||||||
|
.saturating_add(sample.emit_us);
|
||||||
|
|
||||||
|
// The first sample has no predecessor to have queued behind.
|
||||||
|
if self.first_event_us.is_some() && sample.gap_us <= QUEUE_THRESHOLD_US {
|
||||||
|
self.queued_events += 1;
|
||||||
|
}
|
||||||
|
self.first_event_us.get_or_insert(sample.at_us);
|
||||||
|
self.last_completion_us = sample
|
||||||
|
.at_us
|
||||||
|
.saturating_add(sample.recompute_us)
|
||||||
|
.saturating_add(sample.emit_us);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summary(&self) -> Summary {
|
||||||
|
let span_us = self
|
||||||
|
.first_event_us
|
||||||
|
.map(|first| self.last_completion_us.saturating_sub(first))
|
||||||
|
.unwrap_or(0);
|
||||||
|
// A rate needs a span to divide by; one event in zero elapsed time has
|
||||||
|
// no rate, and reporting a made-up one is worse than reporting none.
|
||||||
|
let graph_events_per_sec = (span_us > 0)
|
||||||
|
.then(|| self.graph_events as f64 * 1_000_000.0 / span_us as f64)
|
||||||
|
.map(round_2);
|
||||||
|
let busy_fraction = (span_us > 0).then(|| round_4(self.busy_us as f64 / span_us as f64));
|
||||||
|
|
||||||
|
Summary {
|
||||||
|
graph_events: self.graph_events,
|
||||||
|
tick_events: self.tick_events,
|
||||||
|
emitted_records: self.emitted_records,
|
||||||
|
span_us,
|
||||||
|
graph_events_per_sec,
|
||||||
|
recompute_max_us: self.recompute.max_us(),
|
||||||
|
recompute_mean_us: self.recompute.mean_us(),
|
||||||
|
recompute_p50: self.recompute.quantile_bucket(0.50),
|
||||||
|
recompute_p90: self.recompute.quantile_bucket(0.90),
|
||||||
|
recompute_p99: self.recompute.quantile_bucket(0.99),
|
||||||
|
recompute_distribution: self.recompute.distribution(),
|
||||||
|
emit_max_us: self.emit.max_us(),
|
||||||
|
emit_mean_us: self.emit.mean_us(),
|
||||||
|
emit_distribution: self.emit.distribution(),
|
||||||
|
busy_us: self.busy_us,
|
||||||
|
busy_fraction,
|
||||||
|
queued_events: self.queued_events,
|
||||||
|
queue_threshold_us: QUEUE_THRESHOLD_US,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The O5 answer, as emitted.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub graph_events: u64,
|
||||||
|
pub tick_events: u64,
|
||||||
|
pub emitted_records: u64,
|
||||||
|
/// First event to last completion, microseconds.
|
||||||
|
pub span_us: u64,
|
||||||
|
pub graph_events_per_sec: Option<f64>,
|
||||||
|
pub recompute_max_us: u64,
|
||||||
|
pub recompute_mean_us: Option<u64>,
|
||||||
|
pub recompute_p50: Option<&'static str>,
|
||||||
|
pub recompute_p90: Option<&'static str>,
|
||||||
|
pub recompute_p99: Option<&'static str>,
|
||||||
|
pub recompute_distribution: Vec<(&'static str, u64)>,
|
||||||
|
pub emit_max_us: u64,
|
||||||
|
pub emit_mean_us: Option<u64>,
|
||||||
|
pub emit_distribution: Vec<(&'static str, u64)>,
|
||||||
|
/// Total observer-thread time spent recomputing and logging.
|
||||||
|
pub busy_us: u64,
|
||||||
|
/// `busy_us / span_us` — the share of wall time the observer thread could
|
||||||
|
/// not be servicing PipeWire. Needs no inference, unlike `queued_events`.
|
||||||
|
pub busy_fraction: Option<f64>,
|
||||||
|
/// Events that began within `queue_threshold_us` of the previous sample's
|
||||||
|
/// completion — a **lower bound** on backlog, see the module header.
|
||||||
|
pub queued_events: u64,
|
||||||
|
pub queue_threshold_us: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the JSON readable: a rate to two decimals and a fraction to four are
|
||||||
|
/// well past the precision any of this is good to.
|
||||||
|
fn round_2(value: f64) -> f64 {
|
||||||
|
(value * 100.0).round() / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_4(value: f64) -> f64 {
|
||||||
|
(value * 10_000.0).round() / 10_000.0
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
//! Phase 5 — dry-run audit mode 🚦 (impl plan §5).
|
||||||
|
//!
|
||||||
|
//! **This phase adds no capability. Its entire purpose is to be wrong loudly
|
||||||
|
//! and safely.** It runs phases 2–4 against the *live* graph on every graph
|
||||||
|
//! event and reports what they conclude. It creates no links, loads no modules,
|
||||||
|
//! and changes no routing — the only thing it produces is a line of JSON.
|
||||||
|
//!
|
||||||
|
//! Why this is the gate the plan marks 🚦: the defects that matter here are
|
||||||
|
//! graph-*reasoning* defects. The 57 phase-2 fixture tests prove the engine
|
||||||
|
//! matches my model of PipeWire; only a live run proves my model matches
|
||||||
|
//! PipeWire. A wrong answer at this phase costs a log line. The same wrong
|
||||||
|
//! answer in phase 6 costs an echo — the sharer's own voice, copied back into
|
||||||
|
//! the share, which is the failure this whole design exists to prevent.
|
||||||
|
//!
|
||||||
|
//! ## The one structural requirement (§5.1)
|
||||||
|
//!
|
||||||
|
//! Every emitted record carries the **complete candidate universe partitioned
|
||||||
|
//! into exact eligible and excluded sets**, with a stable reason code on each
|
||||||
|
//! excluded row — never a spot check on named nodes. Checking only the nodes a
|
||||||
|
//! row names constrains nothing about the rest, and it lets the degenerate
|
||||||
|
//! "exclude everything" implementation pass: that build is silent, produces no
|
||||||
|
//! echo, and satisfies any assertion phrased purely as *this must be excluded*.
|
||||||
|
//! Asserting the eligible half of each row is what fails it. That requirement is
|
||||||
|
//! also the plan's answer to open question O7 (over-exclusion needs no separate
|
||||||
|
//! gate — it is subsumed by this one).
|
||||||
|
//!
|
||||||
|
//! ## What is deliberately *not* here
|
||||||
|
//!
|
||||||
|
//! - **No link creation, and no code path that could reach one.** The auditor
|
||||||
|
//! consumes a [`Projection`] and returns a record. It has no handle to
|
||||||
|
//! anything mutable.
|
||||||
|
//! - **No stdout.** Records go to stderr as JSON Lines
|
||||||
|
//! ([`sink`]) because peerspeak parses pixelpass's stdout event stream
|
||||||
|
//! (`screenshare/mod.rs:92`); a stray line there corrupts it.
|
||||||
|
//! - **No `--aec` CLI flag.** That surface is phase 7's mode selector. The audit
|
||||||
|
//! takes its AEC identity from `PIXELPASS_AUDIO_AUDIT_AEC` through the
|
||||||
|
//! *same* [`parse_aec_arg`] the real flag will use, so the parser and the
|
||||||
|
//! validator are both exercised without committing to a public interface
|
||||||
|
//! before it is designed.
|
||||||
|
//!
|
||||||
|
//! ## Fan-out gating vs. taint (read before interpreting a record)
|
||||||
|
//!
|
||||||
|
//! Two independent things can exclude a candidate and the record keeps them
|
||||||
|
//! distinguishable:
|
||||||
|
//!
|
||||||
|
//! - The **taint engine** (phase 2) excludes individual nodes with its own
|
||||||
|
//! reason codes — `peerspeak-owned`, `aec-identity`, `tainted-upstream`, …
|
||||||
|
//! - The **AEC validator** (phase 4) can forbid fan-out *entirely*, regardless
|
||||||
|
//! of taint, whenever the configured identity is unvalidated, failed or
|
||||||
|
//! revoked. Silence over echo.
|
||||||
|
//!
|
||||||
|
//! When the gate is shut, a candidate the engine would have called eligible is
|
||||||
|
//! reported excluded with an audit-level reason ([`GateReason`]); a candidate
|
||||||
|
//! the engine excluded on its own keeps *its* reason, because that names the
|
||||||
|
//! mechanism that actually applies to it. `fan_out_permitted` on the record
|
||||||
|
//! carries the gate state, so the two cases are always tellable apart.
|
||||||
|
//!
|
||||||
|
//! **Consequence for the §5.1 matrix:** every row whose point is the
|
||||||
|
//! eligible/excluded partition must run with `PIXELPASS_AUDIO_AUDIT_AEC=off`
|
||||||
|
//! (state `NotConfigured`, gate open). Row 12 — the AEC lifecycle row — is the
|
||||||
|
//! one that runs with a real `pulse-module:<idx>`, and the gate slamming shut is
|
||||||
|
//! precisely what it asserts.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // Trigger paths are wired by `sink` + `run`; rows are read by tests.
|
||||||
|
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod run;
|
||||||
|
pub mod sink;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::host::aec::{AecConfig, AecState, AecValidator};
|
||||||
|
use crate::host::observer::{EventKind, Millis, Projection, Readiness};
|
||||||
|
use crate::host::taint::owner::OwnerKey;
|
||||||
|
use crate::host::taint::snapshot::Serial;
|
||||||
|
use crate::host::taint::{Decisions, Eligibility, ExclusionCtx, Reason, StickyState, evaluate};
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// 2 s readiness budget: in the audit a `Failed` is a diagnostic, and timing out
|
||||||
|
/// early would report an absent module that was merely slow to appear.
|
||||||
|
pub const AEC_VALIDATION_TIMEOUT_MILLIS: Millis = 5_000;
|
||||||
|
|
||||||
|
/// Everything the auditor needs beyond the live graph.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuditConfig {
|
||||||
|
/// The AEC identity to validate, as parsed from
|
||||||
|
/// `PIXELPASS_AUDIO_AUDIT_AEC`. Defaults to [`AecConfig::Off`] — an audit
|
||||||
|
/// run is not a share, so "there is no echo canceller in play" is the
|
||||||
|
/// honest default, and it is what leaves the fan-out gate open for the
|
||||||
|
/// partition rows.
|
||||||
|
pub aec: AecConfig,
|
||||||
|
pub aec_timeout: Millis,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuditConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
aec: AecConfig::Off,
|
||||||
|
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An audit-level exclusion: the AEC validator has shut the fan-out gate. These
|
||||||
|
/// codes are disjoint from the taint engine's
|
||||||
|
/// [`Reason::code`](crate::host::taint::Reason::code) values, so a reader never
|
||||||
|
/// has to know which layer produced a code to interpret it.
|
||||||
|
// The shared `Aec` prefix is the point: `GateReason::Validating` and
|
||||||
|
// `AecState::Validating` would be one careless glob import away from being
|
||||||
|
// confused, and these three are the *audit's* view of that machine, not the
|
||||||
|
// machine itself.
|
||||||
|
#[allow(clippy::enum_variant_names)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GateReason {
|
||||||
|
/// The configured AEC identity has not been seen yet. Not an error — the
|
||||||
|
/// module may still be loading — but no fan-out happens meanwhile.
|
||||||
|
AecValidating,
|
||||||
|
/// The deadline passed with the identity never observed.
|
||||||
|
AecFailed,
|
||||||
|
/// The whole identity disappeared mid-run: every node bearing the index is
|
||||||
|
/// gone (v3.4 §5.3).
|
||||||
|
AecRevoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GateReason {
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AecValidating => "aec-validating",
|
||||||
|
Self::AecFailed => "aec-failed",
|
||||||
|
Self::AecRevoked => "aec-revoked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate reason implied by a validator state, or `None` when fan-out is
|
||||||
|
/// permitted. Mirrors [`AecValidator::fan_out_permitted`] — kept as one
|
||||||
|
/// `match` over the same enum so the two cannot drift: every state that
|
||||||
|
/// permits fan-out maps to `None` and every state that forbids it maps to a
|
||||||
|
/// code.
|
||||||
|
pub fn from_state(state: AecState) -> Option<Self> {
|
||||||
|
match state {
|
||||||
|
AecState::NotConfigured | AecState::Validated => None,
|
||||||
|
AecState::Validating => Some(Self::AecValidating),
|
||||||
|
AecState::Failed => Some(Self::AecFailed),
|
||||||
|
AecState::Revoked => Some(Self::AecRevoked),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable string for an [`AecState`], for the record's `aec_state` field.
|
||||||
|
///
|
||||||
|
/// Defined here rather than on [`AecState`] to keep the merged phase-4 module
|
||||||
|
/// untouched by a reporting concern.
|
||||||
|
fn aec_state_code(state: AecState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
AecState::NotConfigured => "not-configured",
|
||||||
|
AecState::Validating => "validating",
|
||||||
|
AecState::Validated => "validated",
|
||||||
|
AecState::Failed => "failed",
|
||||||
|
AecState::Revoked => "revoked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable string for the observer's readiness epoch.
|
||||||
|
fn readiness_code(readiness: Readiness) -> &'static str {
|
||||||
|
match readiness {
|
||||||
|
Readiness::Waiting => "waiting",
|
||||||
|
Readiness::Complete => "complete",
|
||||||
|
Readiness::TimedOut => "timed-out",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One candidate node's effective answer. `reason` is `None` exactly when
|
||||||
|
/// `eligible` is true.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditRow {
|
||||||
|
pub serial: u64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub eligible: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
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
|
||||||
|
/// derived from the current topology (phase-2 stickiness).
|
||||||
|
pub sticky: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tainted node of *any* media role, not just fan-out candidates. Candidates
|
||||||
|
/// already appear in [`AuditBody::candidates`]; this is the diagnostic view —
|
||||||
|
/// when a candidate's exclusion is a surprise, the taint that reached it is the
|
||||||
|
/// next question, and it usually sits on a node that is not itself a candidate.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct TaintRow {
|
||||||
|
pub serial: u64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// was. Split out from [`AuditRecord`] so "did anything actually change?" is a
|
||||||
|
/// derived `==` rather than a hand-maintained field comparison that a later
|
||||||
|
/// field addition could silently fall out of.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditBody {
|
||||||
|
/// The observer's dynamic readiness. False ⇒ every candidate is excluded
|
||||||
|
/// `graph-not-ready`; no decision from a partial graph is a decision.
|
||||||
|
pub graph_ready: bool,
|
||||||
|
/// The sticky readiness epoch, which distinguishes the three ways
|
||||||
|
/// `graph_ready` can be false (see [`Projection::readiness`]).
|
||||||
|
pub epoch: &'static str,
|
||||||
|
pub aec_state: &'static str,
|
||||||
|
/// The index handed to the taint engine — `Some` only while `Validated`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aec_module_id: Option<u64>,
|
||||||
|
/// Whether the AEC validator permits fan-out at all right now.
|
||||||
|
pub fan_out_permitted: bool,
|
||||||
|
/// The audit-level reason fan-out is forbidden, when it is.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gate_reason: Option<&'static str>,
|
||||||
|
/// **The complete candidate universe**, ascending by serial — every
|
||||||
|
/// `Stream/Output/Audio` node in the snapshot, partitioned. §5.1's exact
|
||||||
|
/// partition is `candidates`, not a subset of it.
|
||||||
|
pub candidates: Vec<AuditRow>,
|
||||||
|
pub eligible_count: usize,
|
||||||
|
pub excluded_count: usize,
|
||||||
|
/// Taint across all node roles, ascending by serial.
|
||||||
|
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 {
|
||||||
|
/// Serials of eligible candidates, ascending — the half of the partition an
|
||||||
|
/// exclude-everything build fails.
|
||||||
|
pub fn eligible(&self) -> Vec<u64> {
|
||||||
|
self.candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.eligible)
|
||||||
|
.map(|row| row.serial)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(serial, reason code)` for excluded candidates, ascending.
|
||||||
|
pub fn excluded(&self) -> Vec<(u64, &'static str)> {
|
||||||
|
self.candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|row| !row.eligible)
|
||||||
|
.map(|row| (row.serial, row.reason.unwrap_or("?")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The eligible candidate with this name, if any. Convenience for the
|
||||||
|
/// matrix rows, which name nodes rather than serials.
|
||||||
|
pub fn row_named(&self, name: &str) -> Option<&AuditRow> {
|
||||||
|
self.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|row| row.name.as_deref() == Some(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recompute, as emitted.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AuditRecord {
|
||||||
|
/// Monotonic per-run counter over *every* recompute, emitted or suppressed,
|
||||||
|
/// so a gap in the emitted sequence is visibly a suppression rather than a
|
||||||
|
/// lost line.
|
||||||
|
pub seq: u64,
|
||||||
|
pub trigger: &'static str,
|
||||||
|
/// Observer-clock milliseconds at which this recompute ran.
|
||||||
|
pub at_ms: Millis,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub body: AuditBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one [`Auditor::observe`] produced.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuditOutcome {
|
||||||
|
pub record: AuditRecord,
|
||||||
|
/// Whether the record should be written. See [`Auditor::observe`].
|
||||||
|
pub emit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dry-run auditor: phases 2–4 folded together over a live projection.
|
||||||
|
///
|
||||||
|
/// Read-only by construction — it borrows a [`Projection`] and owns only the
|
||||||
|
/// state phases 2 and 4 thread explicitly ([`StickyState`], [`AecValidator`]).
|
||||||
|
/// There is no field here through which a link could be created.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Auditor {
|
||||||
|
validator: AecValidator,
|
||||||
|
sticky: StickyState,
|
||||||
|
seq: u64,
|
||||||
|
/// The body of the last record actually written, for change suppression.
|
||||||
|
last_emitted: Option<AuditBody>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Auditor {
|
||||||
|
pub fn new(config: AuditConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
validator: AecValidator::new(config.aec, config.aec_timeout),
|
||||||
|
sticky: StickyState::default(),
|
||||||
|
seq: 0,
|
||||||
|
last_emitted: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn aec_state(&self) -> AecState {
|
||||||
|
self.validator.state()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sticky(&self) -> &StickyState {
|
||||||
|
&self.sticky
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one projection into the audit.
|
||||||
|
///
|
||||||
|
/// **Called once per applied registry event — never on a coalesced batch.**
|
||||||
|
/// That is not a performance preference, it is the phase-4 integration
|
||||||
|
/// contract (`aec/mod.rs`, the `Validated` arm): revocation is detected by
|
||||||
|
/// observing the *empty gap* between a module unload and the next reload,
|
||||||
|
/// and module indices are reused verbatim (v3.4 §5.2 correction 3). Coalesce
|
||||||
|
/// across that gap and a fresh module silently inherits a dead module's
|
||||||
|
/// validated identity. [`sink`] is what upholds this, by running the
|
||||||
|
/// recompute inline on the observer thread rather than polling
|
||||||
|
/// [`RegistryObserverHandle::latest`](crate::host::observer::adapter::RegistryObserverHandle::latest),
|
||||||
|
/// which coalesces by nature.
|
||||||
|
///
|
||||||
|
/// `emit` is true for every graph-triggered recompute, and for a
|
||||||
|
/// tick-triggered one only when the decision content changed. Ticks arrive
|
||||||
|
/// at a constant 4 Hz purely to drive the AEC deadline; emitting an
|
||||||
|
/// identical record four times a second would bury the graph events the
|
||||||
|
/// audit exists to show. `seq` still advances on suppressed records, so
|
||||||
|
/// nothing about the run is silently unaccounted for.
|
||||||
|
pub fn observe(
|
||||||
|
&mut self,
|
||||||
|
projection: &Projection,
|
||||||
|
kind: EventKind,
|
||||||
|
now: Millis,
|
||||||
|
) -> AuditOutcome {
|
||||||
|
self.seq += 1;
|
||||||
|
|
||||||
|
// Phase 4 first: its verdict is an *input* to phase 2 via
|
||||||
|
// `ExclusionCtx::aec_module_id`, so observing the graph in the other
|
||||||
|
// order would evaluate taint against the previous recompute's identity.
|
||||||
|
self.validator
|
||||||
|
.observe(&projection.snapshot, projection.graph_ready, now);
|
||||||
|
let aec_state = self.validator.state();
|
||||||
|
let gate_reason = GateReason::from_state(aec_state);
|
||||||
|
|
||||||
|
let ctx = ExclusionCtx {
|
||||||
|
aec_module_id: self.validator.validated_module_id(),
|
||||||
|
pipewire_pulse_pid: projection.pipewire_pulse_pid,
|
||||||
|
// The audit creates nothing, so it owns nothing. Another host's
|
||||||
|
// capture sink is still caught — by the `pixelpass_capture_*` name
|
||||||
|
// prefix (v3.4 §6.2), which is what §5.1 row 7 exercises — so an
|
||||||
|
// empty set costs the matrix nothing.
|
||||||
|
pixelpass_owned: Default::default(),
|
||||||
|
graph_ready: projection.graph_ready,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decisions, sticky) = evaluate(&projection.snapshot, &ctx, &self.sticky);
|
||||||
|
self.sticky = sticky;
|
||||||
|
|
||||||
|
let body = build_body(
|
||||||
|
projection,
|
||||||
|
&decisions,
|
||||||
|
aec_state,
|
||||||
|
self.validator.validated_module_id(),
|
||||||
|
gate_reason,
|
||||||
|
);
|
||||||
|
|
||||||
|
let emit = kind == EventKind::Graph || self.last_emitted.as_ref() != Some(&body);
|
||||||
|
if emit {
|
||||||
|
self.last_emitted = Some(body.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditOutcome {
|
||||||
|
record: AuditRecord {
|
||||||
|
seq: self.seq,
|
||||||
|
trigger: kind.code(),
|
||||||
|
at_ms: now,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
emit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_body(
|
||||||
|
projection: &Projection,
|
||||||
|
decisions: &Decisions,
|
||||||
|
aec_state: AecState,
|
||||||
|
aec_module_id: Option<u64>,
|
||||||
|
gate_reason: Option<GateReason>,
|
||||||
|
) -> AuditBody {
|
||||||
|
let candidates: Vec<AuditRow> = decisions
|
||||||
|
.candidates
|
||||||
|
.values()
|
||||||
|
.map(|decision| {
|
||||||
|
// The engine's own reason wins when it has one: it names the
|
||||||
|
// mechanism that actually excluded *this* node, which is what the
|
||||||
|
// §5.1 rows assert. The gate reason applies only to candidates the
|
||||||
|
// engine would have passed — otherwise a shut gate would erase every
|
||||||
|
// reason code in the record and the matrix would stop constraining
|
||||||
|
// the engine at all.
|
||||||
|
let (eligible, reason, owner_key, sticky) = match decision.eligibility {
|
||||||
|
Eligibility::NotEligible { reason, sticky } => {
|
||||||
|
(false, Some(reason.code()), owner_key_of(reason), sticky)
|
||||||
|
}
|
||||||
|
Eligibility::Eligible => match gate_reason {
|
||||||
|
Some(gate) => (false, Some(gate.code()), None, false),
|
||||||
|
None => (true, None, None, false),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
AuditRow {
|
||||||
|
serial: decision.serial.0,
|
||||||
|
name: decision.name.clone(),
|
||||||
|
eligible,
|
||||||
|
reason,
|
||||||
|
owner_key,
|
||||||
|
sticky,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let eligible_count = candidates.iter().filter(|row| row.eligible).count();
|
||||||
|
|
||||||
|
let taint: Vec<TaintRow> = decisions
|
||||||
|
.taint
|
||||||
|
.iter()
|
||||||
|
.map(|(&serial, entry)| TaintRow {
|
||||||
|
serial: serial.0,
|
||||||
|
name: node_name(projection, serial),
|
||||||
|
reason: entry.reason.code(),
|
||||||
|
owner_key: owner_key_of(entry.reason),
|
||||||
|
sticky: entry.sticky,
|
||||||
|
})
|
||||||
|
.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 {
|
||||||
|
graph_ready: projection.graph_ready,
|
||||||
|
epoch: readiness_code(projection.readiness),
|
||||||
|
aec_state: aec_state_code(aec_state),
|
||||||
|
aec_module_id,
|
||||||
|
fan_out_permitted: gate_reason.is_none(),
|
||||||
|
gate_reason: gate_reason.map(GateReason::code),
|
||||||
|
excluded_count: candidates.len() - eligible_count,
|
||||||
|
eligible_count,
|
||||||
|
candidates,
|
||||||
|
taint,
|
||||||
|
ignored_ownership_tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_name(projection: &Projection, serial: Serial) -> Option<String> {
|
||||||
|
projection
|
||||||
|
.snapshot
|
||||||
|
.node(serial)
|
||||||
|
.and_then(|node| node.name.clone())
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Triggering the dry-run audit: environment parsing and the two entry points.
|
||||||
|
//!
|
||||||
|
//! The impl plan §5 specifies a **hidden trigger**, `PIXELPASS_AUDIO_AUDIT=1`.
|
||||||
|
//! It is honoured in two places, which answer two different questions:
|
||||||
|
//!
|
||||||
|
//! - **Inside a real `pixelpass host` run** ([`spawn_if_enabled`]) — proves the
|
||||||
|
//! audit works in the code path phase 6 will actually mutate. This is the
|
||||||
|
//! plan-literal reading of the trigger.
|
||||||
|
//! - **Standalone** ([`run_standalone`], behind the hidden `--audit-audio`
|
||||||
|
//! flag) — observer plus auditor and nothing else: no iroh endpoint, no
|
||||||
|
//! display-server detection, no capture pipeline, no ticket. This is what
|
||||||
|
//! drives the §5.1 matrix, because a row that fails should fail for a reason
|
||||||
|
//! about *audio*, not because a relay was unreachable.
|
||||||
|
//!
|
||||||
|
//! Both paths run the same [`AuditSink`] over the same observer, so neither is a
|
||||||
|
//! simulation of the other.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
use super::sink::AuditSink;
|
||||||
|
use super::{AEC_VALIDATION_TIMEOUT_MILLIS, AuditConfig};
|
||||||
|
use crate::common::signal;
|
||||||
|
use crate::host::aec::{AecConfig, AecParseError, parse_aec_arg};
|
||||||
|
use crate::host::observer::adapter::RegistryObserverHandle;
|
||||||
|
|
||||||
|
/// The hidden trigger (impl plan §5). Exactly `1` enables the audit; anything
|
||||||
|
/// else, including `true` or `yes`, does not.
|
||||||
|
///
|
||||||
|
/// Deliberately strict. This variable can only arrive by someone typing it, and
|
||||||
|
/// a value that *looks* enabling but is not would produce a silent no-op — the
|
||||||
|
/// single most annoying failure mode for a diagnostic tool. A mistyped value
|
||||||
|
/// gets a warning (see [`enabled`]) rather than silence.
|
||||||
|
pub const AUDIT_ENV: &str = "PIXELPASS_AUDIO_AUDIT";
|
||||||
|
|
||||||
|
/// The AEC identity for the audit, in the `--aec` grammar (`off` or
|
||||||
|
/// `pulse-module:<idx>`). Absent ⇒ `off`.
|
||||||
|
pub const AUDIT_AEC_ENV: &str = "PIXELPASS_AUDIO_AUDIT_AEC";
|
||||||
|
|
||||||
|
/// Redirect the JSON Lines stream to this file instead of stderr.
|
||||||
|
pub const AUDIT_FILE_ENV: &str = "PIXELPASS_AUDIO_AUDIT_FILE";
|
||||||
|
|
||||||
|
/// Whether the hidden trigger is set.
|
||||||
|
pub fn enabled() -> bool {
|
||||||
|
match std::env::var(AUDIT_ENV) {
|
||||||
|
Ok(value) if value == "1" => true,
|
||||||
|
Ok(value) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"{AUDIT_ENV}={value:?} is not `1`; the audio audit stays off. \
|
||||||
|
Set {AUDIT_ENV}=1 to enable it."
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the audit configuration from the environment.
|
||||||
|
///
|
||||||
|
/// A malformed `PIXELPASS_AUDIO_AUDIT_AEC` is **fatal**, matching the phase-4
|
||||||
|
/// rule that a bad `--aec` value must not silently become "no AEC": there is no
|
||||||
|
/// fail-closed default index, so a wrong or dropped one would exclude the wrong
|
||||||
|
/// node (or nothing at all) and the audit would confidently report a partition
|
||||||
|
/// computed against an identity nobody asked for.
|
||||||
|
pub fn config_from_env() -> Result<AuditConfig> {
|
||||||
|
let aec = match std::env::var(AUDIT_AEC_ENV) {
|
||||||
|
Ok(raw) => parse_aec_arg(&raw).map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"{AUDIT_AEC_ENV}={raw:?} is not a valid AEC argument ({}). \
|
||||||
|
Expected `off` or `pulse-module:<index>`, where the index is a bare decimal.",
|
||||||
|
describe(e)
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
Err(std::env::VarError::NotPresent) => AecConfig::Off,
|
||||||
|
Err(e) => bail!("{AUDIT_AEC_ENV} is not readable: {e}"),
|
||||||
|
};
|
||||||
|
Ok(AuditConfig {
|
||||||
|
aec,
|
||||||
|
aec_timeout: AEC_VALIDATION_TIMEOUT_MILLIS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(error: AecParseError) -> &'static str {
|
||||||
|
match error {
|
||||||
|
AecParseError::Empty => "the value was empty",
|
||||||
|
AecParseError::UnknownForm => "not `off` and not `pulse-module:...`",
|
||||||
|
AecParseError::MissingIndex => "`pulse-module:` with no index after the colon",
|
||||||
|
AecParseError::InvalidIndex => {
|
||||||
|
"the index was not a bare decimal (no sign, whitespace, or non-digits) that fits in u64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the JSON Lines go. Stderr unless `PIXELPASS_AUDIO_AUDIT_FILE` names a
|
||||||
|
/// file, which is appended to rather than truncated — a matrix run that restarts
|
||||||
|
/// the process mid-scenario should not lose the rows it already recorded.
|
||||||
|
fn writer_from_env() -> Result<Box<dyn Write + Send>> {
|
||||||
|
match std::env::var(AUDIT_FILE_ENV) {
|
||||||
|
Ok(path) if !path.is_empty() => {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.with_context(|| format!("{AUDIT_FILE_ENV}={path:?} could not be opened"))?;
|
||||||
|
tracing::info!("audio audit: writing records to {path}");
|
||||||
|
Ok(Box::new(file))
|
||||||
|
}
|
||||||
|
_ => Ok(Box::new(std::io::stderr())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the sink and spawn the observer behind it.
|
||||||
|
fn spawn_audit() -> Result<RegistryObserverHandle> {
|
||||||
|
let config = config_from_env()?;
|
||||||
|
let sink = AuditSink::new(config, writer_from_env()?);
|
||||||
|
tracing::info!(
|
||||||
|
aec = ?config.aec,
|
||||||
|
"audio audit: dry run active — decisions are logged, no links are created"
|
||||||
|
);
|
||||||
|
RegistryObserverHandle::spawn_with_sink(Some(Box::new(sink)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the audit if the hidden trigger is set, for a `pixelpass host` run.
|
||||||
|
///
|
||||||
|
/// The returned handle must be held for the lifetime of the run: dropping it
|
||||||
|
/// stops the observer thread and flushes the final O5 summary.
|
||||||
|
///
|
||||||
|
/// Returns `Err` only when the trigger *was* set and starting failed — a
|
||||||
|
/// misconfigured audit is worth failing the run over, because the alternative is
|
||||||
|
/// a host that silently is not being audited while its operator believes it is.
|
||||||
|
pub fn spawn_if_enabled() -> Result<Option<RegistryObserverHandle>> {
|
||||||
|
if !enabled() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
spawn_audit().map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standalone audit: run the observer and the auditor, and nothing else,
|
||||||
|
/// until ctrl-c.
|
||||||
|
///
|
||||||
|
/// Does not consult [`AUDIT_ENV`] — reaching this function required passing the
|
||||||
|
/// hidden `--audit-audio` flag, which is already an explicit request. The
|
||||||
|
/// environment still supplies the AEC identity and the output file.
|
||||||
|
pub async fn run_standalone() -> Result<()> {
|
||||||
|
let cancel = signal::install_ctrl_c();
|
||||||
|
let handle = spawn_audit()?;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"pixelpass audio audit (dry run): observing the live PipeWire graph.\n\
|
||||||
|
No links are created and no routing changes. Ctrl-C to stop."
|
||||||
|
);
|
||||||
|
|
||||||
|
// SIGTERM as well as ctrl-c, because this mode is driven by scripts as much
|
||||||
|
// as by hand — `timeout`, a matrix harness, and systemd all send SIGTERM,
|
||||||
|
// and the default disposition would kill the process before the sink's
|
||||||
|
// `Drop` writes the final O5 summary. Losing that summary is losing the
|
||||||
|
// whole §5.2 measurement for that run.
|
||||||
|
let mut sigterm = signal::terminate_stream()?;
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.cancelled() => {}
|
||||||
|
_ = sigterm.recv() => tracing::info!("SIGTERM received, shutting down"),
|
||||||
|
}
|
||||||
|
// Explicit rather than incidental: this drop stops the PipeWire thread,
|
||||||
|
// which drops the sink, which writes the final metrics line. Letting it fall
|
||||||
|
// out of scope would do the same thing, but the ordering is the point.
|
||||||
|
drop(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! The audit's I/O edge: timing, JSON Lines emission, O5 accounting.
|
||||||
|
//!
|
||||||
|
//! Everything impure about phase 5 lives here, and it is deliberately thin —
|
||||||
|
//! read the clock, call [`Auditor::observe`], write a line, fold a
|
||||||
|
//! [`metrics::Sample`]. The decisions are all upstream in the pure core, which
|
||||||
|
//! is why the matrix can be argued about in unit tests rather than only in front
|
||||||
|
//! of a live daemon.
|
||||||
|
//!
|
||||||
|
//! ## Why this runs on the observer thread
|
||||||
|
//!
|
||||||
|
//! [`AuditSink`] is a [`ProjectionSink`], invoked inline from the PipeWire
|
||||||
|
//! observer thread once per applied registry event. The obvious alternative —
|
||||||
|
//! a consumer task polling
|
||||||
|
//! [`RegistryObserverHandle::latest`](super::super::observer::adapter::RegistryObserverHandle::latest)
|
||||||
|
//! — was rejected: polling **coalesces**, and phase 4's revocation logic
|
||||||
|
//! detects a module unload by observing the *empty gap* before the next module
|
||||||
|
//! appears. Module indices are reused verbatim across an unload/reload (v3.4
|
||||||
|
//! §5.2 correction 3), so a poller that misses the gap silently aliases a fresh
|
||||||
|
//! module onto a dead module's validated identity. Running inline is what makes
|
||||||
|
//! "one `observe` per graph event, no coalescing" — the contract phase 4
|
||||||
|
//! documents as owed — actually true.
|
||||||
|
//!
|
||||||
|
//! The cost of that choice is that recompute and logging happen on the thread
|
||||||
|
//! servicing PipeWire, which is precisely the risk O5 asks about. That is not an
|
||||||
|
//! accident: this arrangement puts the cost exactly where the measurement can
|
||||||
|
//! see it. See [`metrics`].
|
||||||
|
//!
|
||||||
|
//! ## Output contract
|
||||||
|
//!
|
||||||
|
//! One JSON object per line, to **stderr** by default, each tagged with a `kind`
|
||||||
|
//! discriminator (`"audit"` or `"metrics"`). Never stdout: peerspeak parses
|
||||||
|
//! pixelpass's stdout event stream, and the impl plan §5 is explicit that
|
||||||
|
//! unstructured output must not go there. `PIXELPASS_AUDIO_AUDIT_FILE`
|
||||||
|
//! redirects the records to a file instead, which is how the §5.1 matrix is
|
||||||
|
//! driven — it separates the audit stream from interleaved `tracing` output
|
||||||
|
//! without needing either side to change format.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::metrics::{self, Metrics, Summary};
|
||||||
|
use super::{AuditConfig, AuditRecord, Auditor};
|
||||||
|
use crate::host::observer::adapter::ProjectionSink;
|
||||||
|
use crate::host::observer::{EventKind, Millis, Projection};
|
||||||
|
|
||||||
|
/// Emit a rolling metrics line every this many ticks. Ticks are 250 ms, so this
|
||||||
|
/// is every 10 s — often enough that a run killed abruptly still leaves a
|
||||||
|
/// usable O5 record, rare enough that it does not crowd out the audit records.
|
||||||
|
const SUMMARY_INTERVAL_TICKS: u64 = 40;
|
||||||
|
|
||||||
|
/// The live audit: pure auditor + clock + writer.
|
||||||
|
pub struct AuditSink {
|
||||||
|
auditor: Auditor,
|
||||||
|
metrics: Metrics,
|
||||||
|
writer: Box<dyn Write + Send>,
|
||||||
|
/// Set once the first sample has completed, so the first event is not
|
||||||
|
/// counted as having queued behind a predecessor that does not exist.
|
||||||
|
last_completion_us: Option<u64>,
|
||||||
|
ticks_since_summary: u64,
|
||||||
|
/// Wall-clock origin for the microsecond timings. Only used for durations,
|
||||||
|
/// never for the AEC deadline — that runs on the observer's own clock,
|
||||||
|
/// handed in as `now_us`, so the validator and the readiness epoch cannot
|
||||||
|
/// disagree about what time it is.
|
||||||
|
epoch: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditSink {
|
||||||
|
pub fn new(config: AuditConfig, writer: Box<dyn Write + Send>) -> Self {
|
||||||
|
Self {
|
||||||
|
auditor: Auditor::new(config),
|
||||||
|
metrics: Metrics::default(),
|
||||||
|
writer,
|
||||||
|
last_completion_us: None,
|
||||||
|
ticks_since_summary: 0,
|
||||||
|
epoch: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elapsed_us(&self) -> u64 {
|
||||||
|
u64::try_from(self.epoch.elapsed().as_micros()).unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one line. Failures are logged once per occurrence and otherwise
|
||||||
|
/// ignored: a broken stderr must not take down the observer thread, and the
|
||||||
|
/// audit is diagnostic — losing a line is a worse audit, not a worse share.
|
||||||
|
fn write_line<T: Serialize>(&mut self, line: &T) {
|
||||||
|
match serde_json::to_string(line) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Err(e) = writeln!(self.writer, "{json}") {
|
||||||
|
tracing::warn!("audit: failed to write record: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("audit: failed to serialise record: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_summary(&mut self, at_ms: Millis) {
|
||||||
|
let summary = self.metrics.summary();
|
||||||
|
self.write_line(&MetricsLine {
|
||||||
|
kind: "metrics",
|
||||||
|
at_ms,
|
||||||
|
summary: &summary,
|
||||||
|
});
|
||||||
|
let _ = self.writer.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectionSink for AuditSink {
|
||||||
|
fn on_projection(&mut self, projection: &Projection, kind: EventKind, now_us: u64) {
|
||||||
|
let at_us = self.elapsed_us();
|
||||||
|
let gap_us = self
|
||||||
|
.last_completion_us
|
||||||
|
.map(|previous| at_us.saturating_sub(previous))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let recompute_start = self.elapsed_us();
|
||||||
|
let outcome = self.auditor.observe(projection, kind, now_us / 1_000);
|
||||||
|
let recompute_us = self.elapsed_us().saturating_sub(recompute_start);
|
||||||
|
|
||||||
|
let emit_us = if outcome.emit {
|
||||||
|
let emit_start = self.elapsed_us();
|
||||||
|
self.write_line(&AuditLine {
|
||||||
|
kind: "audit",
|
||||||
|
recompute_us,
|
||||||
|
record: &outcome.record,
|
||||||
|
});
|
||||||
|
// Flushed per record so a run ended with SIGKILL (or a matrix row
|
||||||
|
// that reads the file while the process is still up) still shows
|
||||||
|
// every decision made before that instant. The cost is measured, not
|
||||||
|
// assumed — it is inside `emit_us`.
|
||||||
|
let _ = self.writer.flush();
|
||||||
|
self.elapsed_us().saturating_sub(emit_start).max(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
self.metrics.record(metrics::Sample {
|
||||||
|
at_us,
|
||||||
|
gap_us,
|
||||||
|
recompute_us,
|
||||||
|
emit_us,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
self.last_completion_us = Some(self.elapsed_us());
|
||||||
|
|
||||||
|
if kind == EventKind::Tick {
|
||||||
|
self.ticks_since_summary += 1;
|
||||||
|
if self.ticks_since_summary >= SUMMARY_INTERVAL_TICKS {
|
||||||
|
self.ticks_since_summary = 0;
|
||||||
|
self.write_summary(now_us / 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AuditSink {
|
||||||
|
/// The final O5 record. The observer thread drops its sink when the main
|
||||||
|
/// loop quits, so an ordinary ctrl-c leaves a complete summary behind
|
||||||
|
/// without the runner having to ask for one.
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let at_ms = self.elapsed_us() / 1_000;
|
||||||
|
self.write_summary(at_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuditLine<'a> {
|
||||||
|
kind: &'static str,
|
||||||
|
/// This record's own recompute cost, so a surprising row can be correlated
|
||||||
|
/// with a cost spike without cross-referencing the periodic summary.
|
||||||
|
recompute_us: u64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
record: &'a AuditRecord,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MetricsLine<'a> {
|
||||||
|
kind: &'static str,
|
||||||
|
at_ms: Millis,
|
||||||
|
#[serde(flatten)]
|
||||||
|
summary: &'a Summary,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
|||||||
|
pub mod aec;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
pub mod audit;
|
||||||
mod capture;
|
mod capture;
|
||||||
|
mod observer;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod quality;
|
mod quality;
|
||||||
mod serve;
|
mod serve;
|
||||||
|
pub mod taint;
|
||||||
mod wayland;
|
mod wayland;
|
||||||
mod x11;
|
mod x11;
|
||||||
|
|
||||||
@@ -74,6 +78,12 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
|
|
||||||
let cancel = signal::install_ctrl_c();
|
let cancel = signal::install_ctrl_c();
|
||||||
|
|
||||||
|
// Phase 5 dry-run audit, off unless `PIXELPASS_AUDIO_AUDIT=1`. Read-only:
|
||||||
|
// it observes the graph and logs what phases 2–4 conclude, creating no
|
||||||
|
// links. Bound to a name so the handle lives as long as the run — dropping
|
||||||
|
// it stops the observer thread and flushes the final O5 summary.
|
||||||
|
let _audio_audit = audit::run::spawn_if_enabled()?;
|
||||||
|
|
||||||
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
let endpoint = endpoint::bind(opts.relay.as_deref()).await?;
|
||||||
|
|
||||||
// Relay-only ticket: wait for the home relay to connect, then keep only
|
// Relay-only ticket: wait for the home relay to connect, then keep only
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
|||||||
|
//! The `session_device` classifier — pure, no PipeWire.
|
||||||
|
//!
|
||||||
|
//! `NodeProps::session_device` (see [`super::super::taint::snapshot`]) is a
|
||||||
|
//! **positive high-confidence** claim that a node is a passive hardware
|
||||||
|
//! terminal: a real sound card's sink or source that terminates audio rather
|
||||||
|
//! than forwarding it. Setting it *removes* two protections at once — the
|
||||||
|
//! node's coarse owner keys and its ability to trip the fail-closed backstop
|
||||||
|
//! — so a false positive is a **leak**, and the whole classifier is shaped so
|
||||||
|
//! that anything less than a positive identification resolves to `false`.
|
||||||
|
//!
|
||||||
|
//! The observer (phase 3) owes this classification; the adapter must never
|
||||||
|
//! stuff a raw property through. Two facts from the design (v3.4 §6.1.1,
|
||||||
|
//! Codex rounds 2–4) drive the shape here:
|
||||||
|
//!
|
||||||
|
//! - `device.id` / `device.api` describe *which* Device a node belongs to and
|
||||||
|
//! *how* that Device is reached — **neither promises the node passively
|
||||||
|
//! terminates audio.** A filter chain associated with a card satisfies
|
||||||
|
//! both. So the discriminator is `factory.name` on an **allowlist** of
|
||||||
|
//! real hardware-PCM factories, never a substring or a denylist: an unknown
|
||||||
|
//! factory is not a device.
|
||||||
|
//! - The backing Device must actually have been **bound and resolved**. A node
|
||||||
|
//! that claims a `device.id` whose Device's properties we do not hold is
|
||||||
|
//! **withheld**, not admitted with a provisional `false` — a provisional
|
||||||
|
//! `false` during the not-ready window fuses sink and mic on the shared
|
||||||
|
//! session client and that fusion can persist as sticky over-exclusion
|
||||||
|
//! (round-3 finding 3).
|
||||||
|
//!
|
||||||
|
//! **Round 8 (v3.5 §6.7 decision 4): the Device is the authority on
|
||||||
|
//! `device.api` and `alsa.driver_name`.** Both are absent from the Node
|
||||||
|
//! *global* and both are present on the **bound Device**'s `info` props
|
||||||
|
//! (measured 2026-07-25). Reading them from the Device closes the phase-3
|
||||||
|
//! review's owed fix: on PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13 the driver
|
||||||
|
//! name is not copied onto the node, and the fail-closed "absent driver ⇒ not
|
||||||
|
//! a session device" rule would over-exclude real sound cards. `factory.name`
|
||||||
|
//! exists only on the node, which is why the node bind is required regardless.
|
||||||
|
|
||||||
|
use crate::host::taint::snapshot::GlobalId;
|
||||||
|
|
||||||
|
/// Factory names that positively identify a passive hardware-PCM terminal.
|
||||||
|
///
|
||||||
|
/// **An allowlist, deliberately.** Membership *removes* protections, so the
|
||||||
|
/// safe error direction is to leave a genuine-but-unlisted device off the
|
||||||
|
/// list (it merely keeps its owner keys — over-exclusion, no echo). Adding a
|
||||||
|
/// backend here is a security-relevant change and wants the same measurement
|
||||||
|
/// the ALSA entries got (snapshot.rs `session_device` contract: the target
|
||||||
|
/// box's five ALSA nodes carry `factory.name=api.alsa.pcm.{sink,source}`; the
|
||||||
|
/// three `support.null-audio-sink` nodes carry neither).
|
||||||
|
///
|
||||||
|
/// `support.null-audio-sink`, `*.loopback`, and any filter factory are
|
||||||
|
/// intentionally **absent**: those forward audio, which is exactly the shape
|
||||||
|
/// this feature must be able to exclude.
|
||||||
|
///
|
||||||
|
/// ⚠️ **ALSA only, and only these two, because they are the only factories
|
||||||
|
/// measured on the target box.** BlueZ was previously listed here as
|
||||||
|
/// `api.bluez5.pcm.{sink,source}` — those are invented; the real BlueZ
|
||||||
|
/// terminals are `api.bluez5.media.{sink,source}` with profile aliases
|
||||||
|
/// (Codex phase-3 review, finding 5). Rather than allowlist an unmeasured
|
||||||
|
/// guess, BlueZ is left off entirely: a real Bluetooth sink then keeps its
|
||||||
|
/// owner keys (over-exclusion — safe). Add BlueZ back only with a *measured*
|
||||||
|
/// factory name and a fixture.
|
||||||
|
const HARDWARE_PCM_FACTORIES: &[&str] = &[
|
||||||
|
// ALSA — measured on the target box.
|
||||||
|
"api.alsa.pcm.sink",
|
||||||
|
"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**
|
||||||
|
/// passive terminals — audio written in reappears on their capture side
|
||||||
|
/// through a path the PipeWire Link graph cannot see, so classifying them
|
||||||
|
/// `session_device` (which drops owner keys and the fail-closed backstop)
|
||||||
|
/// would let tainted audio loop back untainted (Codex phase-3 review,
|
||||||
|
/// finding 2). `factory.name` alone cannot distinguish these from a real
|
||||||
|
/// card — `snd_aloop` presents as `api.alsa.pcm.{sink,source}` exactly like
|
||||||
|
/// `snd_hda_intel` — so a real ALSA terminal must present an `alsa.driver_name`
|
||||||
|
/// that is **present and not on this denylist**; a missing driver fails closed
|
||||||
|
/// (see [`classify`]). `snd_dummy` is intentionally absent: it is virtual but
|
||||||
|
/// does not couple playback to capture, so it is not a loopback hazard.
|
||||||
|
const NON_TERMINAL_ALSA_DRIVERS: &[&str] = &["snd_aloop"];
|
||||||
|
|
||||||
|
/// The node-side properties the classifier reads, exactly as the adapter
|
||||||
|
/// parsed them off the **bound Node's `info`** (never off the registry
|
||||||
|
/// global — v3.5 §6.7). Kept separate from
|
||||||
|
/// [`super::super::taint::snapshot::NodeProps`] because these feed the
|
||||||
|
/// *decision* whose output is the `session_device` field — they are inputs,
|
||||||
|
/// not part of the graph the engine reasons over.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DeviceClaim {
|
||||||
|
/// `device.id` — the Device this node belongs to, if any. Absent on
|
||||||
|
/// `Stream/*` nodes, which is exactly why their absence means "not a
|
||||||
|
/// device", not "unknown".
|
||||||
|
pub device_id: Option<GlobalId>,
|
||||||
|
/// `device.api` **as copied onto the node**, when it is — the access API
|
||||||
|
/// of that Device (e.g. `alsa`, `bluez5`). Its mere presence is **not**
|
||||||
|
/// sufficient (a card-associated filter has it too); required only as a
|
||||||
|
/// corroborating signal alongside the factory allowlist. The
|
||||||
|
/// authoritative copy is [`DeviceProps::device_api`]; this is the
|
||||||
|
/// fallback.
|
||||||
|
pub device_api: Option<String>,
|
||||||
|
/// `factory.name` — the discriminator. Only an allowlisted hardware-PCM
|
||||||
|
/// factory earns `session_device`.
|
||||||
|
pub factory_name: Option<String>,
|
||||||
|
/// `alsa.driver_name` — the kernel driver behind an ALSA node (e.g.
|
||||||
|
/// `snd_hda_intel`, `snd_usb_audio`, `snd_aloop`). Needed because the
|
||||||
|
/// factory allowlist cannot tell a real card from a loopback driver that
|
||||||
|
/// shares the same factory. `session_device` requires this to be
|
||||||
|
/// **present and not** on [`NON_TERMINAL_ALSA_DRIVERS`]; a driver on the
|
||||||
|
/// denylist, or an absent value, both fail closed (see [`classify`]).
|
||||||
|
/// Frequently absent here — PipeWire ≥ 1.2.6 with WirePlumber < 0.5.13
|
||||||
|
/// does not copy `alsa.*` onto the node — which is why the authoritative
|
||||||
|
/// copy is [`DeviceProps::alsa_driver_name`] and this is only the
|
||||||
|
/// fallback.
|
||||||
|
pub alsa_driver_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The **bound Device's** `info` properties — the authoritative half of the
|
||||||
|
/// `session_device` decision (v3.5 §6.7 decision 4).
|
||||||
|
///
|
||||||
|
/// Absent from the Device *registry global* exactly as the node's properties
|
||||||
|
/// are absent from the Node global; both are recovered by binding. A node
|
||||||
|
/// claiming a `device.id` is withheld until this struct exists for that
|
||||||
|
/// Device (see [`Classification::Withhold`]).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DeviceProps {
|
||||||
|
/// `device.api` on the Device — `alsa`, `bluez5`, `v4l2`, …
|
||||||
|
pub device_api: Option<String>,
|
||||||
|
/// `alsa.driver_name` on the Device — the kernel driver behind the card,
|
||||||
|
/// authoritative regardless of whether the session manager copied it onto
|
||||||
|
/// the node.
|
||||||
|
pub alsa_driver_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of classifying one node's device claim.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Classification {
|
||||||
|
/// No `device.id` — a `Stream/*` node. Admit with `session_device=false`.
|
||||||
|
NotADevice,
|
||||||
|
/// A `device.id` is claimed but the backing Device's properties are not
|
||||||
|
/// held: never observed, its bind still outstanding, or its global id
|
||||||
|
/// ambiguously shared by two live Devices. **Withhold the node and keep
|
||||||
|
/// the readiness epoch not-ready**; re-classify when the Device resolves.
|
||||||
|
Withhold { device_id: GlobalId },
|
||||||
|
/// Positively a passive hardware terminal. Admit with
|
||||||
|
/// `session_device=true`.
|
||||||
|
SessionDevice,
|
||||||
|
/// Backed by a *resolved* Device but not a hardware-PCM terminal — a
|
||||||
|
/// filter or virtual node on a card, an unknown factory, or a Device with
|
||||||
|
/// no `device.api`. Admit with `session_device=false` (fail closed).
|
||||||
|
NotSessionDevice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a node's device claim against its backing Device.
|
||||||
|
///
|
||||||
|
/// `device` is the bound Device's properties, and `None` means the claim is
|
||||||
|
/// **unresolved** — never observed, bind outstanding, or an ambiguous
|
||||||
|
/// recycled id. It is only consulted when a `device_id` is present. Pure: the
|
||||||
|
/// model looks the Device up, and the I/O of *binding* it lives in the
|
||||||
|
/// adapter.
|
||||||
|
///
|
||||||
|
/// Where the two sides disagree the rule is deliberately asymmetric, and
|
||||||
|
/// safety picks the direction (v3.5 §6.7 decision 4):
|
||||||
|
///
|
||||||
|
/// - **Presence: the Device wins, the node is the fallback.** That is what
|
||||||
|
/// recovers a real card whose node was never given `alsa.driver_name`.
|
||||||
|
/// - **The denylist is a union.** If *either* side names a non-terminal
|
||||||
|
/// driver the node is not a session device. A disagreement here is not
|
||||||
|
/// expected on any measured configuration, and treating it as "the Device
|
||||||
|
/// says it is fine" would be the one reading that can leak.
|
||||||
|
pub fn classify(claim: &DeviceClaim, device: Option<&DeviceProps>) -> Classification {
|
||||||
|
let Some(device_id) = claim.device_id else {
|
||||||
|
// No backing Device: a stream. Not withheld, not a device.
|
||||||
|
return Classification::NotADevice;
|
||||||
|
};
|
||||||
|
let Some(device) = device else {
|
||||||
|
// Backed by a Device we have not resolved — the one case that blocks
|
||||||
|
// readiness. A provisional answer here is the leak the contract
|
||||||
|
// forbids.
|
||||||
|
return Classification::Withhold { device_id };
|
||||||
|
};
|
||||||
|
let on_factory_allowlist = claim
|
||||||
|
.factory_name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|f| HARDWARE_PCM_FACTORIES.contains(&f));
|
||||||
|
// A **present, non-denied** ALSA driver is required — absence fails closed
|
||||||
|
// (Codex phase-3 re-review). The factory allowlist cannot tell a real card
|
||||||
|
// from `snd_aloop`, which presents the same `api.alsa.pcm.*` factory, so a
|
||||||
|
// *missing* value must not be read as "not a loopback". Round 8 makes the
|
||||||
|
// bound Device the primary source, so a real card is no longer
|
||||||
|
// over-excluded merely because the session manager did not copy `alsa.*`
|
||||||
|
// onto its node.
|
||||||
|
let driver = device
|
||||||
|
.alsa_driver_name
|
||||||
|
.as_deref()
|
||||||
|
.or(claim.alsa_driver_name.as_deref());
|
||||||
|
let driver_denied = [
|
||||||
|
device.alsa_driver_name.as_deref(),
|
||||||
|
claim.alsa_driver_name.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|d| NON_TERMINAL_ALSA_DRIVERS.contains(&d));
|
||||||
|
let driver_ok = driver.is_some() && !driver_denied;
|
||||||
|
// The API must positively be the one the factory allowlist is written
|
||||||
|
// 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 {
|
||||||
|
Classification::SessionDevice
|
||||||
|
} else {
|
||||||
|
// Resolved, but not positively a terminal: fail closed to false so
|
||||||
|
// the node keeps its owner keys and its backstop.
|
||||||
|
Classification::NotSessionDevice
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
//! The registry observer's **pure core** (impl plan §4, phases 3 and 3r).
|
||||||
|
//!
|
||||||
|
//! This is my half of the phase-3 split: a reducer that folds a stream of
|
||||||
|
//! typed [`RegEvent`]s into a live model of the PipeWire graph and projects
|
||||||
|
//! the [`GraphSnapshot`] + context the taint engine (phase 2) consumes. **No
|
||||||
|
//! PipeWire types appear here** — the I/O adapter (Codex's half) translates
|
||||||
|
//! live registry callbacks, binds, `/proc` reads, and the `core.sync`/`done`
|
||||||
|
//! round-trip into these events and feeds them in. Every test in this module
|
||||||
|
//! builds the event stream by hand.
|
||||||
|
//!
|
||||||
|
//! ## 🔴 Round 8 (v3.5 §6.7): the global is an INDEX, not a source of truth
|
||||||
|
//!
|
||||||
|
//! Phase 3 shipped reading node properties off the registry `global` event.
|
||||||
|
//! The registry announces only a fixed 13-key subset for a Node, and **eight
|
||||||
|
//! properties this feature depends on are never among them** — they read as
|
||||||
|
//! absent rather than failing, so the engine was silently, permanently
|
||||||
|
//! starved of both its primary taint root and every strong owner key (the
|
||||||
|
//! phase-5 gate failure, F1/F2). The rule that replaces it:
|
||||||
|
//!
|
||||||
|
//! > A node's properties come from a **bind**, never from the global. The
|
||||||
|
//! > global tells us an object exists, its id and its serial. Everything
|
||||||
|
//! > else — including `node.name` and `media.class`, so there is exactly one
|
||||||
|
//! > source — arrives on [`RegEvent::NodeInfo`]. Same for `Device`
|
||||||
|
//! > ([`RegEvent::DeviceInfo`]).
|
||||||
|
//!
|
||||||
|
//! Consequences visible in this file: a Node is admitted to the snapshot
|
||||||
|
//! **only** once its `info` has arrived (until then it is withheld and is a
|
||||||
|
//! readiness obligation); a Device resolves a node's claim only once *its*
|
||||||
|
//! `info` has arrived; and `info` may fire again for the lifetime of the
|
||||||
|
//! object, so [`RegEvent::NodeInfo`] is both the first resolution and every
|
||||||
|
//! later property change (v3.5 §6.7 decisions 1–4).
|
||||||
|
//!
|
||||||
|
//! Three things this core is shaped to get right, each an exit-gate row:
|
||||||
|
//!
|
||||||
|
//! - **Removal by recycled id.** `global_remove` names only a 32-bit global
|
||||||
|
//! id, and those recycle. The model keeps an insertion-ordered index per id
|
||||||
|
//! so a removal accounts for the *oldest* generation first, and the
|
||||||
|
//! snapshot projection treats any id still claimed by two live objects as
|
||||||
|
//! [`IdLookup::Ambiguous`] — fail closed (v3.4 §6.1.3). Everything the
|
||||||
|
//! model *owns* is keyed by never-recycled `object.serial`; ids are only
|
||||||
|
//! ever a lookup.
|
||||||
|
//! - **The readiness epoch.** `graph_ready` is false until the initial graph
|
||||||
|
//! is fully observed: the server has synced **and** no binds/withheld nodes
|
||||||
|
//! remain outstanding. A bounded timeout makes it fail closed. It gates
|
||||||
|
//! sticky *retirement* only; withholding after completion is per-object.
|
||||||
|
//! - **Withholding on unresolved input.** A node with no `info` yet, or one
|
||||||
|
//! claiming a `device.id` whose Device we have not resolved, is held out of
|
||||||
|
//! the snapshot entirely rather than admitted with provisional ownership
|
||||||
|
//! (see [`classify`]).
|
||||||
|
//!
|
||||||
|
//! **Three accepted limitations, all low-reachability, owed to a later
|
||||||
|
//! hardening round:**
|
||||||
|
//!
|
||||||
|
//! - *A Link dropped for a missing `object.serial`/props is unrepresented.*
|
||||||
|
//! The adapter drops such a global before it reaches [`RegistryModel`], so
|
||||||
|
//! readiness can reach `Complete` while permanently omitting that Link — an
|
||||||
|
//! invisible edge that could hide tainted ancestry. **Not reachable in
|
||||||
|
//! practice:** PipeWire's native protocol defines `object.serial` as the
|
||||||
|
//! unique identity every global carries, so a Link without one requires a
|
||||||
|
//! protocol/server failure, not ordinary churn. (The live gate is
|
||||||
|
//! consistent with this but does not *prove* it — it only counts Links the
|
||||||
|
//! strict parser already admitted.) A full fix needs a pure
|
||||||
|
//! "required-observation-failed" token that holds readiness false; deferred
|
||||||
|
//! rather than built for a case that does not occur.
|
||||||
|
//! - *Removal generation ordering assumes no removal is silently lost.* On a
|
||||||
|
//! recycled id with two live claimants, [`Self::on_removed`] retires the
|
||||||
|
//! oldest generation first; if the *first* generation's removal was never
|
||||||
|
//! delivered, a later removal is misattributed. PipeWire's registry does not
|
||||||
|
//! silently drop `global_remove`, so this needs callback loss to trigger.
|
||||||
|
//! The snapshot treats the two-claimant window as [`IdLookup::Ambiguous`]
|
||||||
|
//! (fail closed) meanwhile.
|
||||||
|
//! - *An unresolvable bind takes the whole graph down, not just its node*
|
||||||
|
//! (v3.5 §6.7 decision 3). A node whose `info` never arrives keeps
|
||||||
|
//! readiness false until the deadline, then sticky-[`Readiness::TimedOut`]
|
||||||
|
//! — no fan-out at all, identical to a never-resolving Link bind. Per-node
|
||||||
|
//! quarantine (that node ineligible **and** taint-bearing, the rest of the
|
||||||
|
//! graph still working) is strictly better and is deferred because it is a
|
||||||
|
//! new concept in the *pure engine*, not a fix to the observer.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // Wired by the phase-3 adapter (Codex's half) and consumed by later phases.
|
||||||
|
|
||||||
|
pub mod adapter;
|
||||||
|
pub mod classify;
|
||||||
|
pub mod pulse_pid;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
use crate::host::taint::snapshot::{
|
||||||
|
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||||
|
PortSnapshot, Serial,
|
||||||
|
};
|
||||||
|
use classify::{Classification, DeviceClaim, DeviceProps};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
|
|
||||||
|
/// A monotonic millisecond clock value, supplied by the adapter via
|
||||||
|
/// [`RegEvent::Tick`]. Kept as a bare integer rather than
|
||||||
|
/// [`std::time::Instant`] so the readiness timeout is deterministic in tests.
|
||||||
|
pub type Millis = u64;
|
||||||
|
|
||||||
|
/// A Node's **bound `info` properties** — the sole source of node properties
|
||||||
|
/// (v3.5 §6.7), delivered by [`RegEvent::NodeInfo`].
|
||||||
|
///
|
||||||
|
/// This carries no identity: the serial names the node on the event and the
|
||||||
|
/// global id was recorded by [`RegEvent::NodeAdded`], so the adapter cannot
|
||||||
|
/// contradict the index it already published. `session_device` inside
|
||||||
|
/// [`NodeObservation::props`] is left at its `false` default; the model
|
||||||
|
/// overwrites it from the [`classify`] result at projection time, once the
|
||||||
|
/// backing Device (if any) is resolved.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct NodeObservation {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: MediaRole,
|
||||||
|
pub props: NodeProps,
|
||||||
|
pub device_claim: DeviceClaim,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The four endpoint references a Link carries. Node endpoints are required —
|
||||||
|
/// a Link with unknown nodes is useless — so this whole struct is what the
|
||||||
|
/// adapter must resolve (from the global's props if present, else by binding
|
||||||
|
/// `LinkInfoRef`, the correctness path) before a Link enters the snapshot.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LinkEndpoints {
|
||||||
|
pub output_node: GlobalId,
|
||||||
|
pub input_node: GlobalId,
|
||||||
|
pub output_port: Option<GlobalId>,
|
||||||
|
pub input_port: Option<GlobalId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed observation of the live graph. The adapter produces these; the
|
||||||
|
/// model consumes them in [`RegistryModel::apply`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RegEvent {
|
||||||
|
/// A Node global appeared. **Index only** — the global's properties are a
|
||||||
|
/// filtered subset and are not read (v3.5 §6.7). The node is withheld
|
||||||
|
/// from the snapshot and is a readiness obligation until its
|
||||||
|
/// [`RegEvent::NodeInfo`] arrives.
|
||||||
|
NodeAdded { serial: Serial, id: GlobalId },
|
||||||
|
/// A bound Node's `info` properties. **Both** the first resolution and
|
||||||
|
/// every later `PROPS` change for the node's lifetime — the model tells
|
||||||
|
/// them apart, so the adapter holds no per-node "have I seen info yet?"
|
||||||
|
/// state to get wrong. An `info` for a serial we do not hold (a node
|
||||||
|
/// already removed) is ignored.
|
||||||
|
NodeInfo {
|
||||||
|
serial: Serial,
|
||||||
|
observation: NodeObservation,
|
||||||
|
},
|
||||||
|
/// A Port global appeared.
|
||||||
|
PortAdded(PortSnapshot),
|
||||||
|
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
|
||||||
|
ClientAdded(ClientSnapshot),
|
||||||
|
/// A Device global appeared. Index only, exactly as for a Node: it does
|
||||||
|
/// not resolve anything until [`RegEvent::DeviceInfo`] arrives.
|
||||||
|
DeviceAdded { serial: Serial, id: GlobalId },
|
||||||
|
/// A bound Device's `info` properties — the **authoritative** source of
|
||||||
|
/// `device.api` and `alsa.driver_name` (v3.5 §6.7 decision 4). Resolves
|
||||||
|
/// every node withheld on this Device's id.
|
||||||
|
DeviceInfo { serial: Serial, props: DeviceProps },
|
||||||
|
/// A Link global appeared. `endpoints` is `Some` when the global carried
|
||||||
|
/// them (the optimisation) and `None` when the adapter must bind to learn
|
||||||
|
/// them (the correctness path) — the latter is an outstanding obligation
|
||||||
|
/// until a matching [`RegEvent::LinkEndpointsResolved`] arrives.
|
||||||
|
///
|
||||||
|
/// Unlike Nodes and Devices, Link endpoint props **are** announced on the
|
||||||
|
/// global (measured, phase-5 results F1), so this asymmetry is real and
|
||||||
|
/// deliberate.
|
||||||
|
LinkAdded {
|
||||||
|
serial: Serial,
|
||||||
|
id: GlobalId,
|
||||||
|
endpoints: Option<LinkEndpoints>,
|
||||||
|
},
|
||||||
|
/// The bind-`LinkInfoRef` fallback resolved a Link's endpoints.
|
||||||
|
LinkEndpointsResolved {
|
||||||
|
serial: Serial,
|
||||||
|
endpoints: LinkEndpoints,
|
||||||
|
},
|
||||||
|
/// The adapter read `/proc/<pid>/comm` (`None` = the read failed / the
|
||||||
|
/// process is gone). Validates the pulse-PID candidate.
|
||||||
|
ProcCommProbed { pid: u32, comm: Option<String> },
|
||||||
|
/// Any global was removed. Only its 32-bit id is known.
|
||||||
|
Removed { id: GlobalId },
|
||||||
|
/// A `core.sync()` issued after the initial enumeration completed its
|
||||||
|
/// round-trip (`done`). One half of readiness; the other is that no
|
||||||
|
/// binds/withheld nodes are still outstanding.
|
||||||
|
ServerSynced,
|
||||||
|
/// A monotonic clock sample. Drives the readiness timeout only.
|
||||||
|
Tick { now: Millis },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of observation drove a projection.
|
||||||
|
///
|
||||||
|
/// Derived from the event itself ([`RegEvent::kind`]) rather than passed
|
||||||
|
/// alongside it, so a consumer's view of "was this a real graph change?" cannot
|
||||||
|
/// disagree with what the model was actually fed. The distinction matters to the
|
||||||
|
/// phase-5 audit twice over: ticks arrive at a constant rate and would inflate
|
||||||
|
/// any measured graph-event rate, and a record that is identical to the previous
|
||||||
|
/// one is worth suppressing on a tick but never on a graph event.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EventKind {
|
||||||
|
/// A registry observation: an add, a removal, a bind resolution, a `/proc`
|
||||||
|
/// probe, or the server sync.
|
||||||
|
Graph,
|
||||||
|
/// The periodic clock sample. Carries no graph information; it exists so the
|
||||||
|
/// readiness timeout and the AEC validation deadline have a clock.
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventKind {
|
||||||
|
pub fn code(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Graph => "graph",
|
||||||
|
Self::Tick => "tick",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegEvent {
|
||||||
|
pub fn kind(&self) -> EventKind {
|
||||||
|
match self {
|
||||||
|
Self::Tick { .. } => EventKind::Tick,
|
||||||
|
_ => EventKind::Graph,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an applied event could have changed the projection.
|
||||||
|
///
|
||||||
|
/// The suppression rule of v3.5 §6.7 decision 2, in the one place that can
|
||||||
|
/// enforce it: **a property update may be dropped only when the resulting
|
||||||
|
/// [`Projection`] is identical to the current one.** The projection is a pure
|
||||||
|
/// function of model state, so "state provably unchanged" *is* "projection
|
||||||
|
/// identical" — which is what [`Outcome::Suppressed`] means and why the check
|
||||||
|
/// is a cheap field comparison rather than building and diffing two snapshots.
|
||||||
|
///
|
||||||
|
/// Anything looser (dropping updates that do change state) breaks phase 4's
|
||||||
|
/// no-coalescing contract, which needs to see the empty gap between an AEC
|
||||||
|
/// module unload and a reload that reuses the index. Anything stricter
|
||||||
|
/// (publishing on every `info`, including the state-only changes PipeWire
|
||||||
|
/// emits constantly) inflates the O5 event rate with non-events.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// Model state may have changed; the caller must publish the projection.
|
||||||
|
Applied,
|
||||||
|
/// Model state provably did not change; publishing is optional and the
|
||||||
|
/// adapter skips it.
|
||||||
|
Suppressed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which slot in the id index a live object occupies. `global_remove` gives
|
||||||
|
/// only the id, so the index remembers what each id currently holds. Every
|
||||||
|
/// slot names its object by never-recycled serial.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Slot {
|
||||||
|
Node(Serial),
|
||||||
|
Port(Serial),
|
||||||
|
Link(Serial),
|
||||||
|
Client(Serial),
|
||||||
|
Device(Serial),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The readiness epoch. A one-time transition out of [`Readiness::Waiting`];
|
||||||
|
/// both terminal states are sticky (a completed graph is not un-completed by
|
||||||
|
/// later per-object withholding, and a timed-out observer stays fail-closed
|
||||||
|
/// for its lifetime).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Readiness {
|
||||||
|
/// The initial enumeration is still in flight.
|
||||||
|
Waiting,
|
||||||
|
/// The initial enumeration finished at least once (server synced with no
|
||||||
|
/// obligations then outstanding). **Sticky** — later per-object
|
||||||
|
/// withholding does not revert it. Note this is *not* the same as
|
||||||
|
/// [`RegistryModel::graph_ready`], which additionally requires no *current*
|
||||||
|
/// obligation (Codex finding 1); `Complete` only records that the epoch
|
||||||
|
/// was reached.
|
||||||
|
Complete,
|
||||||
|
/// The bounded deadline passed with obligations outstanding.
|
||||||
|
/// `graph_ready` stays false — fail closed.
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure handoff to the taint engine: a coherent [`GraphSnapshot`] plus the
|
||||||
|
/// two context fields phase 3 owns. The caller merges these into
|
||||||
|
/// [`crate::host::taint::ExclusionCtx`] alongside `aec_module_id` (phase 4)
|
||||||
|
/// and `pixelpass_owned` (pixelpass's own tracking).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Projection {
|
||||||
|
pub snapshot: GraphSnapshot,
|
||||||
|
pub pipewire_pulse_pid: Option<u32>,
|
||||||
|
pub graph_ready: bool,
|
||||||
|
/// The sticky readiness epoch behind `graph_ready`. Carried so a consumer
|
||||||
|
/// can tell the three not-ready causes apart — enumeration still in flight
|
||||||
|
/// ([`Readiness::Waiting`]), a fail-closed timeout ([`Readiness::TimedOut`]),
|
||||||
|
/// or a completed epoch momentarily blocked on a current obligation
|
||||||
|
/// ([`Readiness::Complete`] with `graph_ready == false`). `graph_ready`
|
||||||
|
/// alone collapses all three into "no". The phase-5 audit reports it as the
|
||||||
|
/// epoch column; nothing gates on it.
|
||||||
|
pub readiness: Readiness,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live Node: its global id (for link endpoint lookup) plus its bound
|
||||||
|
/// properties once they arrive.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct NodeEntry {
|
||||||
|
id: GlobalId,
|
||||||
|
/// `None` while the bind is outstanding — withheld from the snapshot and
|
||||||
|
/// an outstanding readiness obligation (v3.5 §6.7 decision 3).
|
||||||
|
obs: Option<NodeObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live Device: its global id plus its bound properties once they arrive.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct DeviceEntry {
|
||||||
|
id: GlobalId,
|
||||||
|
/// `None` while the bind is outstanding. A node claiming this Device
|
||||||
|
/// stays withheld until it is `Some` — the Device's `device.api` and
|
||||||
|
/// `alsa.driver_name` are the authoritative inputs to `session_device`
|
||||||
|
/// (v3.5 §6.7 decision 4), so classifying without them would be the same
|
||||||
|
/// provisional answer the contract forbids.
|
||||||
|
props: Option<DeviceProps>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live model. Folds [`RegEvent`]s; project with [`RegistryModel::project`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RegistryModel {
|
||||||
|
/// **Every** live Node, keyed by serial — admitted or withheld. Admission
|
||||||
|
/// is decided at projection time from the entry's own state, so there is
|
||||||
|
/// no admitted/withheld pair of maps to drift apart.
|
||||||
|
nodes: BTreeMap<Serial, NodeEntry>,
|
||||||
|
/// Every live Device, keyed by serial.
|
||||||
|
devices: BTreeMap<Serial, DeviceEntry>,
|
||||||
|
ports: BTreeMap<Serial, PortSnapshot>,
|
||||||
|
links: BTreeMap<Serial, LinkSnapshot>,
|
||||||
|
clients: BTreeMap<Serial, ClientSnapshot>,
|
||||||
|
|
||||||
|
/// Links whose endpoints the adapter is still binding; the id is kept so
|
||||||
|
/// removal and resolution can find them.
|
||||||
|
pending_links: BTreeMap<Serial, GlobalId>,
|
||||||
|
|
||||||
|
/// Insertion-ordered holders of each live global id. `global_remove`
|
||||||
|
/// accounts for the oldest generation first (v3.4 §6.1.3).
|
||||||
|
live_ids: BTreeMap<GlobalId, VecDeque<Slot>>,
|
||||||
|
|
||||||
|
/// `/proc/<pid>/comm` reads keyed by pid, for pulse-PID validation.
|
||||||
|
probed_comm: BTreeMap<u32, Option<String>>,
|
||||||
|
|
||||||
|
server_synced: bool,
|
||||||
|
readiness: Readiness,
|
||||||
|
deadline: Millis,
|
||||||
|
last_now: Millis,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistryModel {
|
||||||
|
/// `now` seeds the clock; `timeout` is the readiness budget. The deadline
|
||||||
|
/// is `now + timeout`; a [`RegEvent::Tick`] at or past it while still
|
||||||
|
/// [`Readiness::Waiting`] fails the epoch closed.
|
||||||
|
pub fn new(now: Millis, timeout: Millis) -> Self {
|
||||||
|
Self {
|
||||||
|
nodes: BTreeMap::new(),
|
||||||
|
devices: BTreeMap::new(),
|
||||||
|
ports: BTreeMap::new(),
|
||||||
|
links: BTreeMap::new(),
|
||||||
|
clients: BTreeMap::new(),
|
||||||
|
pending_links: BTreeMap::new(),
|
||||||
|
live_ids: BTreeMap::new(),
|
||||||
|
probed_comm: BTreeMap::new(),
|
||||||
|
server_synced: false,
|
||||||
|
readiness: Readiness::Waiting,
|
||||||
|
deadline: now.saturating_add(timeout),
|
||||||
|
last_now: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn readiness(&self) -> Readiness {
|
||||||
|
self.readiness
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the graph is trustworthy enough to make eligibility and sticky
|
||||||
|
/// **retirement** decisions right now.
|
||||||
|
///
|
||||||
|
/// This is **dynamic**, not the sticky [`Readiness::Complete`] flag: it is
|
||||||
|
/// true only when the initial enumeration has completed **and** there are
|
||||||
|
/// no current obligations outstanding (a node whose bind is outstanding, a
|
||||||
|
/// node withheld on an unresolved Device, or a Link still being bound).
|
||||||
|
/// The distinction is the fix for Codex phase-3 review finding 1: a Link
|
||||||
|
/// whose endpoints are still resolving is an **invisible edge** — it is
|
||||||
|
/// absent from the snapshot, not merely dangling — so a decision made
|
||||||
|
/// while one exists can miss real tainted ancestry and wrongly report a
|
||||||
|
/// candidate eligible. Unresolved ancestry ⇒ fail closed is the governing
|
||||||
|
/// invariant (v3.4 §6.1), and round 8 adds the far more common case: an
|
||||||
|
/// unbound node is an invisible *vertex*, which hides everything the edge
|
||||||
|
/// case hides and its ownership besides.
|
||||||
|
///
|
||||||
|
/// [`Readiness::Complete`] stays sticky (it records that the initial
|
||||||
|
/// enumeration happened, for logging and to distinguish "not started" from
|
||||||
|
/// "momentarily churning"); `graph_ready` layers the dynamic obligation
|
||||||
|
/// check on top. Downstream (phase 6) may debounce the brief blips a
|
||||||
|
/// normal bind causes; the observer's job is to report the truth.
|
||||||
|
pub fn graph_ready(&self) -> bool {
|
||||||
|
matches!(self.readiness, Readiness::Complete) && !self.obligations_outstanding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pulse-PID candidates the adapter should be probing — every distinct
|
||||||
|
/// `sec_pid` on the current Clients. Exposed so the adapter re-probes only
|
||||||
|
/// the PIDs *entering* the set rather than all of them on every event.
|
||||||
|
pub fn pulse_pid_candidates(&self) -> BTreeSet<u32> {
|
||||||
|
let clients: Vec<ClientSnapshot> = self.clients.values().cloned().collect();
|
||||||
|
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
|
||||||
|
/// caller whether the projection can have changed; see [`Outcome`] for why
|
||||||
|
/// that is the only sound place to enforce the suppression rule.
|
||||||
|
pub fn apply(&mut self, event: RegEvent) -> Outcome {
|
||||||
|
match event {
|
||||||
|
RegEvent::NodeAdded { serial, id } => {
|
||||||
|
self.push_id(id, Slot::Node(serial));
|
||||||
|
self.nodes.insert(serial, NodeEntry { id, obs: None });
|
||||||
|
// A node awaiting its bind is a fresh obligation, so this can
|
||||||
|
// only ever *hold* readiness, never complete it — but the
|
||||||
|
// re-check is cheap and keeps the invariant local.
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::NodeInfo {
|
||||||
|
serial,
|
||||||
|
observation,
|
||||||
|
} => self.on_node_info(serial, observation),
|
||||||
|
RegEvent::PortAdded(port) => {
|
||||||
|
self.push_id(port.id, Slot::Port(port.serial));
|
||||||
|
self.ports.insert(port.serial, port);
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::ClientAdded(client) => {
|
||||||
|
self.push_id(client.id, Slot::Client(client.serial));
|
||||||
|
self.clients.insert(client.serial, client);
|
||||||
|
// A new client can change the pulse candidate; the adapter
|
||||||
|
// learns that via `pulse_pid_candidate`. No readiness effect.
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::DeviceAdded { serial, id } => {
|
||||||
|
self.push_id(id, Slot::Device(serial));
|
||||||
|
self.devices.insert(serial, DeviceEntry { id, props: None });
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::DeviceInfo { serial, props } => self.on_device_info(serial, props),
|
||||||
|
RegEvent::LinkAdded {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
endpoints,
|
||||||
|
} => {
|
||||||
|
self.on_link_added(serial, id, endpoints);
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
RegEvent::LinkEndpointsResolved { serial, endpoints } => {
|
||||||
|
self.on_link_resolved(serial, endpoints)
|
||||||
|
}
|
||||||
|
RegEvent::ProcCommProbed { pid, comm } => {
|
||||||
|
let previous = self.probed_comm.insert(pid, comm.clone());
|
||||||
|
if previous.as_ref() == Some(&comm) {
|
||||||
|
Outcome::Suppressed
|
||||||
|
} else {
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegEvent::Removed { id } => self.on_removed(id),
|
||||||
|
RegEvent::ServerSynced => {
|
||||||
|
let already = self.server_synced;
|
||||||
|
self.server_synced = true;
|
||||||
|
self.maybe_complete();
|
||||||
|
if already {
|
||||||
|
Outcome::Suppressed
|
||||||
|
} else {
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegEvent::Tick { now } => {
|
||||||
|
self.last_now = now;
|
||||||
|
self.maybe_timeout(now);
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First resolution *and* every later property change (v3.5 §6.7
|
||||||
|
/// decision 2). The model distinguishes them by what it already holds, so
|
||||||
|
/// the adapter can forward every `info` callback unconditionally.
|
||||||
|
fn on_node_info(&mut self, serial: Serial, observation: NodeObservation) -> Outcome {
|
||||||
|
let Some(entry) = self.nodes.get_mut(&serial) else {
|
||||||
|
// A late `info` for a node already removed. Re-inserting it here
|
||||||
|
// would resurrect a dead node with no id index behind it.
|
||||||
|
tracing::debug!(serial = serial.0, "observer: node info for an unknown node");
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
};
|
||||||
|
if entry.obs.as_ref() == Some(&observation) {
|
||||||
|
// The state-only `info` callbacks PipeWire emits constantly: same
|
||||||
|
// properties, so the projection is provably identical.
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
}
|
||||||
|
entry.obs = Some(observation);
|
||||||
|
// The first `info` retires this node's obligation, which can be the
|
||||||
|
// last one outstanding.
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
|
||||||
|
let Some(entry) = self.devices.get_mut(&serial) else {
|
||||||
|
tracing::debug!(
|
||||||
|
serial = serial.0,
|
||||||
|
"observer: device info for an unknown device"
|
||||||
|
);
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
};
|
||||||
|
if entry.props.as_ref() == Some(&props) {
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
}
|
||||||
|
entry.props = Some(props);
|
||||||
|
// Resolving a Device admits every node that was withheld on it —
|
||||||
|
// which happens at projection time; here it can only retire
|
||||||
|
// obligations.
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_link_added(&mut self, serial: Serial, id: GlobalId, endpoints: Option<LinkEndpoints>) {
|
||||||
|
self.push_id(id, Slot::Link(serial));
|
||||||
|
match endpoints {
|
||||||
|
Some(e) => {
|
||||||
|
self.links.insert(serial, link_snapshot(serial, id, e));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Correctness path: withhold the Link until the bind fallback
|
||||||
|
// resolves it. Counts as an outstanding obligation.
|
||||||
|
self.pending_links.insert(serial, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.maybe_complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_link_resolved(&mut self, serial: Serial, endpoints: LinkEndpoints) -> Outcome {
|
||||||
|
// `remove` also guards against a stale resolution for a Link already
|
||||||
|
// gone: unknown serial ⇒ ignore.
|
||||||
|
if let Some(id) = self.pending_links.remove(&serial) {
|
||||||
|
self.links
|
||||||
|
.insert(serial, link_snapshot(serial, id, endpoints));
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
} else {
|
||||||
|
Outcome::Suppressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_removed(&mut self, id: GlobalId) -> Outcome {
|
||||||
|
let Some(queue) = self.live_ids.get_mut(&id) else {
|
||||||
|
tracing::warn!(global_id = id.0, "observer: remove for an id we never saw");
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
};
|
||||||
|
// Oldest generation first — the id may be shared during a
|
||||||
|
// missed-removal window.
|
||||||
|
let slot = queue.pop_front();
|
||||||
|
if queue.is_empty() {
|
||||||
|
self.live_ids.remove(&id);
|
||||||
|
}
|
||||||
|
match slot {
|
||||||
|
Some(Slot::Node(serial)) => {
|
||||||
|
self.nodes.remove(&serial);
|
||||||
|
}
|
||||||
|
Some(Slot::Port(serial)) => {
|
||||||
|
self.ports.remove(&serial);
|
||||||
|
}
|
||||||
|
Some(Slot::Link(serial)) => {
|
||||||
|
self.links.remove(&serial);
|
||||||
|
self.pending_links.remove(&serial);
|
||||||
|
}
|
||||||
|
Some(Slot::Client(serial)) => {
|
||||||
|
self.clients.remove(&serial);
|
||||||
|
}
|
||||||
|
Some(Slot::Device(serial)) => {
|
||||||
|
self.devices.remove(&serial);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(global_id = id.0, "observer: empty id slot on remove");
|
||||||
|
return Outcome::Suppressed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A removal can drain the last obligation (an unbound node, a node
|
||||||
|
// withheld on a Device, or a pending link vanished before it
|
||||||
|
// resolved).
|
||||||
|
self.maybe_complete();
|
||||||
|
Outcome::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_id(&mut self, id: GlobalId, slot: Slot) {
|
||||||
|
self.live_ids.entry(id).or_default().push_back(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bound properties of the Device a node claims by global id, or
|
||||||
|
/// `None` when that claim is unresolved — which covers every fail-closed
|
||||||
|
/// case at once: no such Device observed, its bind still outstanding, or
|
||||||
|
/// **the id claimed by more than one live global**, where there is no way
|
||||||
|
/// 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> {
|
||||||
|
let slots = self.live_ids.get(&id)?;
|
||||||
|
if slots.len() != 1 {
|
||||||
|
return None; // Ambiguous ⇒ unresolved ⇒ withheld.
|
||||||
|
}
|
||||||
|
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
|
||||||
|
/// Devices. Recomputed per projection rather than cached at admission:
|
||||||
|
/// the inputs (this node's props, its Device's props) both change over an
|
||||||
|
/// object's lifetime now, and a cached classification is exactly the kind
|
||||||
|
/// of stale provisional answer §6.1.3 forbids.
|
||||||
|
fn classification(&self, obs: &NodeObservation) -> Classification {
|
||||||
|
let device = obs
|
||||||
|
.device_claim
|
||||||
|
.device_id
|
||||||
|
.and_then(|id| self.device_props(id));
|
||||||
|
classify::classify(&obs.device_claim, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every obligation that must clear before the initial graph is trusted:
|
||||||
|
/// no node awaiting its bind, no node withheld on an unresolved Device,
|
||||||
|
/// no Link awaiting its bind.
|
||||||
|
fn obligations_outstanding(&self) -> bool {
|
||||||
|
if !self.pending_links.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.nodes.values().any(|entry| match &entry.obs {
|
||||||
|
None => true,
|
||||||
|
Some(obs) => matches!(self.classification(obs), Classification::Withhold { .. }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion needs no clock — only the sync flag and an empty obligation
|
||||||
|
/// set — so it may fire on any mutating event. Sticky once reached.
|
||||||
|
fn maybe_complete(&mut self) {
|
||||||
|
if self.readiness != Readiness::Waiting {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.server_synced && !self.obligations_outstanding() {
|
||||||
|
self.readiness = Readiness::Complete;
|
||||||
|
tracing::info!("observer: readiness epoch reached (synced + no obligations)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the timeout consults the clock.
|
||||||
|
fn maybe_timeout(&mut self, now: Millis) {
|
||||||
|
if self.readiness != Readiness::Waiting {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if now >= self.deadline {
|
||||||
|
self.readiness = Readiness::TimedOut;
|
||||||
|
tracing::warn!(
|
||||||
|
unbound_nodes = self.unbound_node_count(),
|
||||||
|
withheld = self.withheld_node_count(),
|
||||||
|
pending_links = self.pending_links.len(),
|
||||||
|
"observer: readiness epoch timed out with obligations outstanding — fail closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes whose bind has not delivered `info` yet — diagnostics only.
|
||||||
|
fn unbound_node_count(&self) -> usize {
|
||||||
|
self.nodes
|
||||||
|
.values()
|
||||||
|
.filter(|entry| entry.obs.is_none())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nodes held out on an unresolved Device — diagnostics only.
|
||||||
|
fn withheld_node_count(&self) -> usize {
|
||||||
|
self.nodes
|
||||||
|
.values()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry.obs.as_ref().is_some_and(|obs| {
|
||||||
|
matches!(self.classification(obs), Classification::Withhold { .. })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pipewire-pulse's PID from the current clients, validated against the
|
||||||
|
/// probed `comm`. `None` whenever anything is ambiguous or unconfirmed —
|
||||||
|
/// the safe answer (key 4 unusable).
|
||||||
|
fn pulse_pid(&self) -> Option<u32> {
|
||||||
|
pulse_pid::resolve(&self.pulse_pid_candidates(), |pid| {
|
||||||
|
self.probed_comm.get(&pid).cloned().flatten()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project the current state into the taint engine's inputs.
|
||||||
|
///
|
||||||
|
/// A node enters the snapshot only if its bind has delivered `info`
|
||||||
|
/// **and** its device claim classifies terminally; anything else is
|
||||||
|
/// withheld (and is already holding `graph_ready` false).
|
||||||
|
pub fn project(&self) -> Projection {
|
||||||
|
let nodes: Vec<NodeSnapshot> = self
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(&serial, entry)| {
|
||||||
|
let obs = entry.obs.as_ref()?;
|
||||||
|
let session_device = match self.classification(obs) {
|
||||||
|
Classification::Withhold { .. } => return None,
|
||||||
|
Classification::SessionDevice => true,
|
||||||
|
Classification::NotADevice | Classification::NotSessionDevice => false,
|
||||||
|
};
|
||||||
|
let mut props = obs.props.clone();
|
||||||
|
props.session_device = session_device;
|
||||||
|
Some(NodeSnapshot {
|
||||||
|
serial,
|
||||||
|
id: entry.id,
|
||||||
|
name: obs.name.clone(),
|
||||||
|
role: obs.role,
|
||||||
|
props,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let snapshot = GraphSnapshot::new(
|
||||||
|
nodes,
|
||||||
|
self.ports.values().cloned().collect(),
|
||||||
|
self.links.values().cloned().collect(),
|
||||||
|
self.clients.values().cloned().collect(),
|
||||||
|
);
|
||||||
|
Projection {
|
||||||
|
snapshot,
|
||||||
|
pipewire_pulse_pid: self.pulse_pid(),
|
||||||
|
graph_ready: self.graph_ready(),
|
||||||
|
readiness: self.readiness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_snapshot(serial: Serial, id: GlobalId, e: LinkEndpoints) -> LinkSnapshot {
|
||||||
|
LinkSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
output_node: e.output_node,
|
||||||
|
input_node: e.input_node,
|
||||||
|
output_port: e.output_port,
|
||||||
|
input_port: e.input_port,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Deriving pipewire-pulse's own PID — pure, no PipeWire and no `/proc` I/O.
|
||||||
|
//!
|
||||||
|
//! The owner bridge's key 4 is `application.process.id`. For a stream created
|
||||||
|
//! by a **Pulse-emulated** client that PID is *pipewire-pulse's own*, shared
|
||||||
|
//! verbatim across every unrelated Pulse app, so bridging on it would fuse
|
||||||
|
//! every Pulse module into one tainted owner (design v3.4 §5.2 correction 5,
|
||||||
|
//! §6.1.2). The engine therefore needs to know that one PID so it can refuse
|
||||||
|
//! to bridge on it — and **every** way of deriving it can fail, in which case
|
||||||
|
//! the safe answer is `None`: key 4 becomes unusable (coarser, never wrong).
|
||||||
|
//!
|
||||||
|
//! The derivation is split into two pure stages so the I/O — reading
|
||||||
|
//! `/proc/<pid>/comm` — stays in the adapter:
|
||||||
|
//!
|
||||||
|
//! 1. [`candidates`] lists the PIDs worth probing from the graph alone: every
|
||||||
|
//! distinct `pipewire.sec.pid` any Client presents.
|
||||||
|
//! 2. [`resolve`] picks the one whose `comm`, as read from `/proc` by the
|
||||||
|
//! adapter, is exactly pipewire-pulse's. This is also what closes **PID
|
||||||
|
//! reuse**: a recycled PID is rejected because `/proc/<pid>/comm` now names
|
||||||
|
//! a different process.
|
||||||
|
//!
|
||||||
|
//! Any failure — no Client carries the property, no `comm` matches, `/proc`
|
||||||
|
//! 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 std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// 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 —
|
||||||
|
/// and exact is the only safe match, since a prefix match would accept a
|
||||||
|
/// recycled PID belonging to e.g. `pipewire-pulseX`.
|
||||||
|
const PULSE_COMM: &str = "pipewire-pulse";
|
||||||
|
|
||||||
|
/// Stage 1: every PID worth probing — the distinct `pipewire.sec.pid` values
|
||||||
|
/// the Clients present.
|
||||||
|
///
|
||||||
|
/// No filtering, and deliberately so (see the module docs): any rule applied
|
||||||
|
/// 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
|
||||||
|
/// candidate sets to decide what to re-probe, and that diff must not depend on
|
||||||
|
/// Client iteration order.
|
||||||
|
pub fn candidates(clients: &[ClientSnapshot]) -> BTreeSet<u32> {
|
||||||
|
clients.iter().filter_map(|client| client.sec_pid).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 2: confirm one candidate against the `comm` read from
|
||||||
|
/// `/proc/<candidate>/comm`.
|
||||||
|
///
|
||||||
|
/// `comm` is `None` when the adapter's read failed — the `/proc` entry is
|
||||||
|
/// gone (the process exited between derivation and probe) — which is itself a
|
||||||
|
/// reason to fail closed. A present-but-different `comm` is the **PID reuse**
|
||||||
|
/// guard: the number is live but now belongs to someone else.
|
||||||
|
pub fn validate(candidate: u32, comm: Option<&str>) -> Option<u32> {
|
||||||
|
match comm {
|
||||||
|
Some(PULSE_COMM) => Some(candidate),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 2 across the whole candidate set: the *unique* PID whose `comm` is
|
||||||
|
/// pipewire-pulse's.
|
||||||
|
///
|
||||||
|
/// `None` when none matches (nothing to suppress that we can prove) and also
|
||||||
|
/// when **several** do. Several means either two pipewire-pulse daemons are
|
||||||
|
/// live — a nested or sandboxed session — or a `comm` collision, and a single
|
||||||
|
/// `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)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -189,9 +189,32 @@ fn build_args(
|
|||||||
"!".into(),
|
"!".into(),
|
||||||
"queue".into(),
|
"queue".into(),
|
||||||
"!".into(),
|
"!".into(),
|
||||||
|
];
|
||||||
|
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
|
||||||
|
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
|
||||||
|
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
|
||||||
|
// Each tee branch has its own queue so the disk sink can't backpressure the
|
||||||
|
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
|
||||||
|
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
|
||||||
|
let path = dump.to_string_lossy().into_owned();
|
||||||
|
args.extend([
|
||||||
|
"tee".into(),
|
||||||
|
"name=dbgtee".into(),
|
||||||
|
"!".into(),
|
||||||
|
"queue".into(),
|
||||||
|
"!".into(),
|
||||||
"fdsink".into(),
|
"fdsink".into(),
|
||||||
"fd=1".into(),
|
"fd=1".into(),
|
||||||
];
|
"dbgtee.".into(),
|
||||||
|
"!".into(),
|
||||||
|
"queue".into(),
|
||||||
|
"!".into(),
|
||||||
|
"filesink".into(),
|
||||||
|
format!("location={path}"),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
args.extend(["fdsink".into(), "fd=1".into()]);
|
||||||
|
}
|
||||||
|
|
||||||
// Downscale step for the quality presets. `None` = encode at native size
|
// Downscale step for the quality presets. `None` = encode at native size
|
||||||
// (the "Source" preset, or a source already at/below the target height — we
|
// (the "Source" preset, or a source already at/below the target height — we
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
//! Synthetic graph builders for the taint-engine tests.
|
||||||
|
//!
|
||||||
|
//! Serials are handed out monotonically and never reused, exactly as
|
||||||
|
//! PipeWire does; global ids are handed out separately and **may be reused
|
||||||
|
//! on purpose**, which is what the recycling tests need.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::snapshot::{
|
||||||
|
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
|
||||||
|
PortDirection, PortSnapshot, Serial,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// pipewire-pulse's PID, as measured on the target machine.
|
||||||
|
pub const PULSE_PID: u32 = 2541;
|
||||||
|
/// WirePlumber's PID — one process owning every device node on the box.
|
||||||
|
pub const SESSION_PID: u32 = 900;
|
||||||
|
|
||||||
|
/// A node's identity in a fixture: what tests pass around.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub struct NodeRef {
|
||||||
|
pub serial: Serial,
|
||||||
|
pub id: GlobalId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Graph {
|
||||||
|
next_serial: u64,
|
||||||
|
next_id: u32,
|
||||||
|
nodes: Vec<NodeSnapshot>,
|
||||||
|
ports: Vec<PortSnapshot>,
|
||||||
|
links: Vec<LinkSnapshot>,
|
||||||
|
clients: Vec<ClientSnapshot>,
|
||||||
|
/// One client connection per process / per module, which is what the
|
||||||
|
/// live graph looks like. Tests that need the *split*-client shape
|
||||||
|
/// (GStreamer opens one per stream) pass clients explicitly instead.
|
||||||
|
client_by_app: BTreeMap<u32, 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Graph {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
// Start past u32::MAX so every fixture also exercises the phase
|
||||||
|
// 0a widening: a serial that a u32 model would have truncated.
|
||||||
|
next_serial: u64::from(u32::MAX) + 1,
|
||||||
|
next_id: 1,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serial(&mut self) -> Serial {
|
||||||
|
self.next_serial += 1;
|
||||||
|
Serial(self.next_serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(&mut self) -> GlobalId {
|
||||||
|
self.next_id += 1;
|
||||||
|
GlobalId(self.next_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's
|
||||||
|
/// PID for Pulse-emulated clients.
|
||||||
|
pub fn client(&mut self, sec_pid: Option<u32>) -> GlobalId {
|
||||||
|
let serial = self.serial();
|
||||||
|
let id = self.id();
|
||||||
|
self.clients.push(ClientSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
sec_pid,
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client connection an ordinary process holds — one per PID,
|
||||||
|
/// created on demand.
|
||||||
|
pub fn client_of_app(&mut self, pid: u32) -> GlobalId {
|
||||||
|
if let Some(id) = self.client_by_app.get(&pid) {
|
||||||
|
return *id;
|
||||||
|
}
|
||||||
|
let id = self.client(Some(PULSE_PID));
|
||||||
|
self.client_by_app.insert(pid, 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.
|
||||||
|
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
self.node(name, role, app(client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client a pactl module holds. Measured: each module gets its own
|
||||||
|
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
|
||||||
|
/// module does not fuse with the next.
|
||||||
|
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
|
||||||
|
match self.client_by_module.get(&module) {
|
||||||
|
Some(id) => *id,
|
||||||
|
None => {
|
||||||
|
let id = self.client(Some(PULSE_PID));
|
||||||
|
self.client_by_module.insert(module, id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A leg of a pactl-loaded module: one client per module, and the
|
||||||
|
/// node's `application.process.id` is **pipewire-pulse's own**, because
|
||||||
|
/// pipewire-pulse genuinely is the client.
|
||||||
|
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
|
||||||
|
let client = self.client_of_module(module);
|
||||||
|
self.node(name, role, pulse_module(client, module, PULSE_PID))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A leg joined to its siblings by `node.link-group` — loopback,
|
||||||
|
/// filter-chain, echo-cancel.
|
||||||
|
pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
self.node(name, role, link_group(group, client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device node as the session manager creates it: no strong key,
|
||||||
|
/// WirePlumber's client and PID — shared with every other device — and
|
||||||
|
/// a `device.id`, which is what marks it as session-manager-exported.
|
||||||
|
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
|
||||||
|
let session = match self.session_client {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
let id = self.client(None);
|
||||||
|
self.session_client = Some(id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.node(name, role, device(session, SESSION_PID))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node that *belongs to* a Device but is not a passive device node —
|
||||||
|
/// a filter associated with a card. Phase 3 must not classify this as a
|
||||||
|
/// session device, or it loses both its coarse owner keys and its
|
||||||
|
/// ability to trip the fail-closed backstop.
|
||||||
|
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(pid);
|
||||||
|
self.node(name, role, app(client, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A **virtual** sink an application created natively: an `Audio/Sink`
|
||||||
|
/// with no `device.id` and no strong key, sharing one client with the
|
||||||
|
/// stream that re-emits what it receives. Coarse keys must still bridge
|
||||||
|
/// these two, or the whole call leaks through the re-emitting leg.
|
||||||
|
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
|
||||||
|
let client = self.client_of_app(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 {
|
||||||
|
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);
|
||||||
|
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 {
|
||||||
|
let id = self.id();
|
||||||
|
self.node_with_id(name, role, id, props)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a global id — for reproducing id recycling after teardown.
|
||||||
|
pub fn node_with_id(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
role: MediaRole,
|
||||||
|
id: GlobalId,
|
||||||
|
props: NodeProps,
|
||||||
|
) -> NodeRef {
|
||||||
|
let serial = self.serial();
|
||||||
|
self.nodes.push(NodeSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
role,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
NodeRef { serial, id }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
|
||||||
|
let serial = self.serial();
|
||||||
|
let id = self.id();
|
||||||
|
self.ports.push(PortSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
node: node.id,
|
||||||
|
direction,
|
||||||
|
exclusive,
|
||||||
|
monitor: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signal edge: audio flows `from → to`.
|
||||||
|
pub fn link(&mut self, from: NodeRef, to: NodeRef) {
|
||||||
|
self.link_ids(from.id, to.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link naming raw ids, so a test can dangle an endpoint.
|
||||||
|
pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) {
|
||||||
|
let serial = self.serial();
|
||||||
|
let id = self.id();
|
||||||
|
self.links.push(LinkSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
output_node: from,
|
||||||
|
input_node: to,
|
||||||
|
output_port: None,
|
||||||
|
input_port: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id that belongs to nothing — for unresolved-endpoint tests.
|
||||||
|
pub fn dangling_id(&mut self) -> GlobalId {
|
||||||
|
self.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(&self) -> GraphSnapshot {
|
||||||
|
self.build_without(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A later snapshot in which some nodes have gone away, along with
|
||||||
|
/// their ports and every link touching them. Surviving objects keep
|
||||||
|
/// their serials, which is what makes sticky-taint sequences testable.
|
||||||
|
pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot {
|
||||||
|
let gone_serials: Vec<Serial> = dropped.iter().map(|n| n.serial).collect();
|
||||||
|
let nodes: Vec<NodeSnapshot> = self
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| !gone_serials.contains(&n.serial))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
// Filter by what was *dropped*, not by what is live: a link to an id
|
||||||
|
// that never had a node is a dangling endpoint, and dropping those
|
||||||
|
// here would quietly disarm every unresolved-ancestry test.
|
||||||
|
let gone_ids: Vec<GlobalId> = dropped.iter().map(|n| n.id).collect();
|
||||||
|
GraphSnapshot::new(
|
||||||
|
nodes,
|
||||||
|
self.ports
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !gone_ids.contains(&p.node))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
self.links
|
||||||
|
.iter()
|
||||||
|
.filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
self.clients.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop clients too — full owner teardown.
|
||||||
|
///
|
||||||
|
/// Invalidates the per-app/per-module caches as well: leaving them
|
||||||
|
/// stale made a later `client_of_app` hand back the *removed* client's
|
||||||
|
/// id, so a test that meant "a brand-new client after teardown" was
|
||||||
|
/// really building a node pointing at a client object that no longer
|
||||||
|
/// existed (Codex round 1, finding 8).
|
||||||
|
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
|
||||||
|
self.clients.retain(|c| !ids.contains(&c.id));
|
||||||
|
self.client_by_app.retain(|_, id| !ids.contains(id));
|
||||||
|
self.client_by_module.retain(|_, id| !ids.contains(id));
|
||||||
|
if self.session_client.is_some_and(|id| ids.contains(&id)) {
|
||||||
|
self.session_client = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client that reuses a global id a dead client had — the recycling
|
||||||
|
/// case, with a fresh serial.
|
||||||
|
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
|
||||||
|
let serial = self.serial();
|
||||||
|
self.clients.push(ClientSnapshot {
|
||||||
|
serial,
|
||||||
|
id,
|
||||||
|
sec_pid,
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ordinary application stream: real PID, one client connection.
|
||||||
|
pub fn app(client: GlobalId, pid: u32) -> NodeProps {
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(client),
|
||||||
|
process_id: Some(pid),
|
||||||
|
..NodeProps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pactl-module-created stream: the daemon is the client, so the node's
|
||||||
|
/// `application.process.id` is pipewire-pulse's own.
|
||||||
|
pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps {
|
||||||
|
NodeProps {
|
||||||
|
pulse_module_id: Some(module),
|
||||||
|
client_id: Some(client),
|
||||||
|
process_id: Some(pulse_pid),
|
||||||
|
..NodeProps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PipeWire-module leg joined to its siblings by `node.link-group`
|
||||||
|
/// (loopback, filter-chain, echo-cancel).
|
||||||
|
pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
|
||||||
|
NodeProps {
|
||||||
|
link_group: Some(group.to_string()),
|
||||||
|
client_id: Some(client),
|
||||||
|
process_id: Some(pid),
|
||||||
|
..NodeProps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device node as the session manager creates it: no strong key, and the
|
||||||
|
/// session manager's own client and PID — shared with every other device.
|
||||||
|
///
|
||||||
|
/// Measured 2026-07-21: real ALSA device nodes carry the shared
|
||||||
|
/// `client.id` but **no** `application.process.id` at all. Giving them one
|
||||||
|
/// here is deliberately *more* pessimistic than reality — it hands the
|
||||||
|
/// engine a second coarse key it could fuse devices on, so a test that
|
||||||
|
/// passes here also passes against the real props.
|
||||||
|
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
|
||||||
|
NodeProps {
|
||||||
|
client_id: Some(session_client),
|
||||||
|
process_id: Some(session_pid),
|
||||||
|
session_device: true,
|
||||||
|
..NodeProps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps {
|
||||||
|
NodeProps {
|
||||||
|
peerspeak_owned: true,
|
||||||
|
..app(client, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
|||||||
|
//! The owner bridge — grouping nodes that belong to the same *owner* even
|
||||||
|
//! though the graph shows no Link between them.
|
||||||
|
//!
|
||||||
|
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
|
||||||
|
//! exists to handle: a `module-loopback` forwarder's input leg and output
|
||||||
|
//! leg have **no Link between them**, so walking Links alone from the
|
||||||
|
//! leaking output leg finds no inbound links at all — a dead end that reads
|
||||||
|
//! as "clean". The legs are related only by shared properties.
|
||||||
|
//!
|
||||||
|
//! ## The rule
|
||||||
|
//!
|
||||||
|
//! A union of keys, strongest first:
|
||||||
|
//!
|
||||||
|
//! | # | key | scope |
|
||||||
|
//! | --- | --- | --- |
|
||||||
|
//! | 1 | `node.link-group` | per module/filter instance |
|
||||||
|
//! | 2 | `pulse.module.id` | per pactl module |
|
||||||
|
//! | 3 | `client.id` | per **connection** |
|
||||||
|
//! | 4 | `application.process.id` | per process |
|
||||||
|
//!
|
||||||
|
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
|
||||||
|
//! EQUAL — not "the first key present".** A first-present implementation
|
||||||
|
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
|
||||||
|
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
|
||||||
|
//! first-present stops at key 3, sees a mismatch, and concludes "different
|
||||||
|
//! owners". The legs are in fact one process (`application.process.id`
|
||||||
|
//! 20172 on both). So: try each key in order, and a key resolves only if
|
||||||
|
//! both legs carry it and the values are equal; otherwise fall through.
|
||||||
|
//!
|
||||||
|
//! ## Two exceptions, both guarding against mass over-exclusion
|
||||||
|
//!
|
||||||
|
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
|
||||||
|
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
|
||||||
|
//! bridging on it fuses every Pulse module into one owner and a single
|
||||||
|
//! tainted module input would exclude every module-created stream on the
|
||||||
|
//! box. Keys 1 and 2 already cover those cases precisely.
|
||||||
|
//!
|
||||||
|
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
|
||||||
|
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
|
||||||
|
//! in design v3.4; it was found while implementing, and it is the exact
|
||||||
|
//! analogue of exception 1 for the session manager.
|
||||||
|
//! ✅ **MEASURED on the live graph 2026-07-21:**
|
||||||
|
//!
|
||||||
|
//! | node | `client.id` | `device.id` | `factory.name` |
|
||||||
|
//! | --- | --- | --- | --- |
|
||||||
|
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
|
||||||
|
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
|
||||||
|
//!
|
||||||
|
//! So one shared coarse key genuinely does relate every hardware device
|
||||||
|
//! on the box, and `device.id` cleanly separates that set from virtual
|
||||||
|
//! sinks. Without the rule, the hardware sink carrying peerspeak's
|
||||||
|
//! playback (tainted by design, every single recompute) would bridge to
|
||||||
|
//! *every other device node including the microphone source*, whose
|
||||||
|
//! readers would then taint their owners' playback legs — reproducing
|
||||||
|
//! precisely the §6.1.1 catastrophe ("excludes any app using a
|
||||||
|
//! microphone") through a different door.
|
||||||
|
//!
|
||||||
|
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
|
||||||
|
//! The first cut suppressed coarse keys for every device-*role* node,
|
||||||
|
//! and Codex refuted it: a **native virtual sink** — an app that creates
|
||||||
|
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
|
||||||
|
//! `link-group` and no `pulse.module.id` — would then have had its only
|
||||||
|
//! correlation stripped, and it would have leaked the whole call. Such a
|
||||||
|
//! sink has no `device.id`, so it now bridges on `client.id` as it
|
||||||
|
//! should.
|
||||||
|
//!
|
||||||
|
//! Grouping is **transitive** (union-find). That is the fail-closed
|
||||||
|
//! direction: bigger owner components mean more taint, never less.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
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
|
||||||
|
/// load-bearing for "report the strongest shared key".
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
|
pub enum OwnerKey {
|
||||||
|
LinkGroup,
|
||||||
|
PulseModuleId,
|
||||||
|
ClientId,
|
||||||
|
ProcessId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerKey {
|
||||||
|
/// Stable, machine-readable — this ends up in the phase 5 audit output
|
||||||
|
/// 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 {
|
||||||
|
match self {
|
||||||
|
Self::LinkGroup => "node.link-group",
|
||||||
|
Self::PulseModuleId => "pulse.module.id",
|
||||||
|
Self::ClientId => "client.id",
|
||||||
|
Self::ProcessId => "application.process.id",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value a node presents for a given key, if it presents one at all.
|
||||||
|
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||||
|
enum KeyValue {
|
||||||
|
Text(String),
|
||||||
|
Num(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner keys usable on this node, strongest first.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// two exceptions.
|
||||||
|
fn keys_of(node: &NodeSnapshot, ctx: &OwnerCtx) -> Vec<(OwnerKey, KeyValue)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(group) = &node.props.link_group {
|
||||||
|
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
|
||||||
|
}
|
||||||
|
if let Some(module) = node.props.pulse_module_id {
|
||||||
|
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
|
||||||
|
}
|
||||||
|
// Exception 2: coarse keys never bridge passive session-manager device
|
||||||
|
// nodes — they all share the session manager's client.
|
||||||
|
if node.props.session_device {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if let Some(client) = node.props.client_id {
|
||||||
|
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
|
||||||
|
}
|
||||||
|
// Key 4, from the node **and** from its Client (round 10, R10-3; made a
|
||||||
|
// union rather than a fallback by the round-10 review, finding 1).
|
||||||
|
//
|
||||||
|
// ⚠️ **A union, not `node.or_else(client)`, and the difference is a leak.**
|
||||||
|
// The node's `application.process.id` is client-controlled and optional;
|
||||||
|
// the Client's `pipewire.sec.pid` is `pipewire.*`, protected, and the only
|
||||||
|
// one that can carry a soundness argument (the same reason
|
||||||
|
// `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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this node's owner be positively bounded — i.e. can we enumerate its
|
||||||
|
/// sibling legs and be right?
|
||||||
|
///
|
||||||
|
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
|
||||||
|
/// `client.id` alone does **not** bound an owner: that is the measured
|
||||||
|
/// GStreamer refutation, where one process presented two different
|
||||||
|
/// `client.id`s for its two legs. So an owner is bounded only by a strong
|
||||||
|
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
|
||||||
|
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
|
||||||
|
///
|
||||||
|
/// The case this exists for is v3.4 §12's "module forwarder with neither
|
||||||
|
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
|
||||||
|
/// therefore suppressed, its two legs may carry different `client.id`s, and
|
||||||
|
/// nothing else relates them. Its sibling output leg cannot be found, so
|
||||||
|
/// the engine must fail closed rather than declare it clean
|
||||||
|
/// (v3.4 §6.1.1, final paragraph).
|
||||||
|
///
|
||||||
|
/// # F11-1 — CLOSED. A self-claimed PID is not provenance
|
||||||
|
///
|
||||||
|
/// **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.
|
||||||
|
///
|
||||||
|
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
|
||||||
|
/// bridge asks for keys once per (tainted member × component member) pair —
|
||||||
|
/// so recomputing was the hot spot in an otherwise linear pass.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct OwnerKeyIndex {
|
||||||
|
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 {
|
||||||
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
|
let mut keys = BTreeMap::new();
|
||||||
|
let mut bounded = BTreeSet::new();
|
||||||
|
for node in snapshot.nodes() {
|
||||||
|
let node_keys = keys_of(node, ctx);
|
||||||
|
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.
|
||||||
|
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
|
||||||
|
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
// Stored strongest-first, so the first match is the strongest.
|
||||||
|
a_keys.iter().find_map(|(key, value)| {
|
||||||
|
b_keys
|
||||||
|
.iter()
|
||||||
|
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||||||
|
.then_some(*key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `client.id` a usable owner key for this node?
|
||||||
|
///
|
||||||
|
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
|
||||||
|
/// suppressed by exception 2, so recording the session manager's Client
|
||||||
|
/// as a *member* of a tainted device's sticky owner would smuggle the
|
||||||
|
/// suppressed key back in: the next recompute would expand that Client
|
||||||
|
/// to every hardware node on the box — the microphone included — and
|
||||||
|
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
|
||||||
|
/// (Codex round 2, finding 1.)
|
||||||
|
pub fn uses_client_key(&self, serial: Serial) -> bool {
|
||||||
|
self.keys
|
||||||
|
.get(&serial)
|
||||||
|
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner keys that are safe to remember *across* connections, for
|
||||||
|
/// sticky taint: the strong keys plus a usable process id.
|
||||||
|
///
|
||||||
|
/// `client.id` is deliberately excluded — it identifies a *connection*,
|
||||||
|
/// and the whole point of a fingerprint is to survive one process
|
||||||
|
/// closing a connection and opening another. A live Client member is
|
||||||
|
/// what covers the same-connection case, precisely.
|
||||||
|
///
|
||||||
|
/// These are recyclable strings and numbers, so they are only ever
|
||||||
|
/// applied while some **serial** member of the owner is still live
|
||||||
|
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
|
||||||
|
/// handed to anyone else.
|
||||||
|
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
|
||||||
|
self.keys
|
||||||
|
.get(&serial)
|
||||||
|
.map(|keys| {
|
||||||
|
keys.iter()
|
||||||
|
.filter(|(key, _)| *key != OwnerKey::ClientId)
|
||||||
|
.map(|(key, value)| Fingerprint(*key, value.clone()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this node currently present `fingerprint`?
|
||||||
|
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
|
||||||
|
self.keys.get(&serial).is_some_and(|keys| {
|
||||||
|
keys.iter()
|
||||||
|
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
self.bounded.contains(&serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strongest key two nodes share, or `None` if they share none. Used to
|
||||||
|
/// *name* the key in a bridge decision; membership itself is transitive and
|
||||||
|
/// comes from [`OwnerComponents`].
|
||||||
|
pub fn strongest_shared_key(
|
||||||
|
a: &NodeSnapshot,
|
||||||
|
b: &NodeSnapshot,
|
||||||
|
ctx: &OwnerCtx,
|
||||||
|
) -> Option<OwnerKey> {
|
||||||
|
let a_keys = keys_of(a, ctx);
|
||||||
|
let b_keys = keys_of(b, ctx);
|
||||||
|
// `keys_of` yields strongest-first, so the first match is the strongest.
|
||||||
|
a_keys.iter().find_map(|(key, value)| {
|
||||||
|
b_keys
|
||||||
|
.iter()
|
||||||
|
.any(|(other_key, other_value)| other_key == key && other_value == value)
|
||||||
|
.then_some(*key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
|
||||||
|
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||||
|
pub struct Fingerprint(OwnerKey, KeyValue);
|
||||||
|
|
||||||
|
/// Nodes partitioned into owner components.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct OwnerComponents {
|
||||||
|
/// node serial → component index.
|
||||||
|
of_node: BTreeMap<Serial, usize>,
|
||||||
|
/// component index → member node serials, ascending.
|
||||||
|
members: Vec<Vec<Serial>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerComponents {
|
||||||
|
pub fn build(snapshot: &GraphSnapshot, ctx: &OwnerCtx) -> Self {
|
||||||
|
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
|
||||||
|
let index: BTreeMap<Serial, usize> =
|
||||||
|
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
|
||||||
|
let mut uf = UnionFind::new(serials.len());
|
||||||
|
|
||||||
|
// Group by (key, value) and union within each group. Equivalent to
|
||||||
|
// the pairwise "some key resolves" rule, and O(n log n).
|
||||||
|
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
|
||||||
|
for node in snapshot.nodes() {
|
||||||
|
let slot = index[&node.serial];
|
||||||
|
for (key, value) in keys_of(node, ctx) {
|
||||||
|
buckets.entry((key, value)).or_default().push(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for group in buckets.values() {
|
||||||
|
for pair in group.windows(2) {
|
||||||
|
uf.union(pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact roots into dense component indices, deterministically.
|
||||||
|
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
|
||||||
|
let mut members: Vec<Vec<Serial>> = Vec::new();
|
||||||
|
let mut of_node = BTreeMap::new();
|
||||||
|
for (slot, serial) in serials.iter().enumerate() {
|
||||||
|
let root = uf.find(slot);
|
||||||
|
let component = *root_to_component.entry(root).or_insert_with(|| {
|
||||||
|
members.push(Vec::new());
|
||||||
|
members.len() - 1
|
||||||
|
});
|
||||||
|
members[component].push(*serial);
|
||||||
|
of_node.insert(*serial, component);
|
||||||
|
}
|
||||||
|
Self { of_node, members }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn component_of(&self, serial: Serial) -> Option<usize> {
|
||||||
|
self.of_node.get(&serial).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member serials of the component containing `serial`, including it.
|
||||||
|
/// Empty if the node is not in this snapshot.
|
||||||
|
pub fn members_with(&self, serial: Serial) -> &[Serial] {
|
||||||
|
match self.component_of(serial) {
|
||||||
|
Some(component) => &self.members[component],
|
||||||
|
None => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
|
||||||
|
self.members.iter().map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnionFind {
|
||||||
|
parent: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnionFind {
|
||||||
|
fn new(len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
parent: (0..len).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(&mut self, mut node: usize) -> usize {
|
||||||
|
while self.parent[node] != node {
|
||||||
|
self.parent[node] = self.parent[self.parent[node]];
|
||||||
|
node = self.parent[node];
|
||||||
|
}
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union(&mut self, a: usize, b: usize) {
|
||||||
|
let (a, b) = (self.find(a), self.find(b));
|
||||||
|
if a != b {
|
||||||
|
// Lowest root wins, so components are deterministic.
|
||||||
|
let (low, high) = if a < b { (a, b) } else { (b, a) };
|
||||||
|
self.parent[high] = low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client objects belonging to an owner component, so sticky taint can be
|
||||||
|
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
|
||||||
|
/// entry only once **all** member objects are gone).
|
||||||
|
pub fn client_serials_of(
|
||||||
|
snapshot: &GraphSnapshot,
|
||||||
|
keys: &OwnerKeyIndex,
|
||||||
|
nodes: &[Serial],
|
||||||
|
) -> Vec<Serial> {
|
||||||
|
let mut out: Vec<Serial> = nodes
|
||||||
|
.iter()
|
||||||
|
// Only nodes for which `client.id` is a *usable* owner key. See
|
||||||
|
// `uses_client_key`: recording a device node's shared session-manager
|
||||||
|
// Client here would defeat exception 2 on the next recompute.
|
||||||
|
.filter(|serial| keys.uses_client_key(**serial))
|
||||||
|
.filter_map(|serial| snapshot.node(*serial))
|
||||||
|
.filter_map(|node| node.props.client_id)
|
||||||
|
// An ambiguous client id means two Clients claim it and we cannot
|
||||||
|
// say which one is ours, so remember both: an entry that recorded
|
||||||
|
// neither could be retired while its owner was still live.
|
||||||
|
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
|
||||||
|
.collect();
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! The plain, owned graph model the taint engine reasons over.
|
||||||
|
//!
|
||||||
|
//! **No PipeWire types appear in this file, by design** (impl plan §4,
|
||||||
|
//! phase 2). The registry observer (phase 3) translates live globals into
|
||||||
|
//! these structs; every test builds them by hand. Nothing here ever links
|
||||||
|
//! against libpipewire.
|
||||||
|
//!
|
||||||
|
//! Two id-ish things live in this model and confusing them is the bug the
|
||||||
|
//! whole file is shaped to prevent:
|
||||||
|
//!
|
||||||
|
//! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**.
|
||||||
|
//! This is *identity*. Sticky taint is keyed on it.
|
||||||
|
//! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is
|
||||||
|
//! a *lookup key within one snapshot* and nothing else: links name their
|
||||||
|
//! endpoints with it, nodes name their client with it. It must never
|
||||||
|
//! outlive the snapshot it was read from (design v3.4 §6.1.3).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// `object.serial` — 64-bit, monotonic, never recycled. Identity.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
|
pub struct Serial(pub u64);
|
||||||
|
|
||||||
|
/// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup
|
||||||
|
/// key only; see the module docs.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
|
pub struct GlobalId(pub u32);
|
||||||
|
|
||||||
|
/// What a node does with audio, parsed from `media.class`.
|
||||||
|
///
|
||||||
|
/// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the
|
||||||
|
/// monitor connection is already a real Link whose output node is the sink
|
||||||
|
/// itself, so a node-level walk crosses `app → sink → monitor-reader` for
|
||||||
|
/// free). Ports exist in the model for link creation in phase 6 and for the
|
||||||
|
/// `port.exclusive` predicate, not for taint.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
|
||||||
|
pub enum MediaRole {
|
||||||
|
/// `Stream/Output/Audio` — an application playing audio. The only
|
||||||
|
/// fan-out candidate.
|
||||||
|
StreamOutput,
|
||||||
|
/// `Stream/Input/Audio` — an application capturing audio.
|
||||||
|
StreamInput,
|
||||||
|
/// `Audio/Sink` — a real or virtual sink.
|
||||||
|
Sink,
|
||||||
|
/// `Audio/Source` — a real or virtual source.
|
||||||
|
Source,
|
||||||
|
/// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles
|
||||||
|
/// of these; accepted for v1 as fail-closed over-exclusion
|
||||||
|
/// (v3.4 §6.1, edge type 2 caveat).
|
||||||
|
Duplex,
|
||||||
|
/// Anything else, including video and unparseable/absent `media.class`.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaRole {
|
||||||
|
pub fn parse(media_class: Option<&str>) -> Self {
|
||||||
|
match media_class {
|
||||||
|
Some("Stream/Output/Audio") => Self::StreamOutput,
|
||||||
|
Some("Stream/Input/Audio") => Self::StreamInput,
|
||||||
|
Some("Audio/Sink") => Self::Sink,
|
||||||
|
Some("Audio/Source") => Self::Source,
|
||||||
|
Some("Audio/Duplex") => Self::Duplex,
|
||||||
|
_ => Self::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this node *receive* audio? This is the gate on the owner bridge:
|
||||||
|
/// taint crosses the intra-process hop only when the owner is actually
|
||||||
|
/// reading tainted audio (v3.4 §6.1.1 — "this client has both an input
|
||||||
|
/// and an output leg ⇒ exclude the output" is the catastrophic rule
|
||||||
|
/// that excludes every app with a microphone).
|
||||||
|
///
|
||||||
|
/// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that
|
||||||
|
/// receives the tainted mix, and its re-emitting leg is joined to it by
|
||||||
|
/// `node.link-group` with no Link between them.
|
||||||
|
pub fn receives_audio(self) -> bool {
|
||||||
|
matches!(self, Self::StreamInput | Self::Sink | Self::Duplex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner
|
||||||
|
/// keys are not allowed to bridge these; see [`super::owner`].
|
||||||
|
pub fn is_device_role(self) -> bool {
|
||||||
|
matches!(self, Self::Sink | Self::Source | Self::Duplex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2).
|
||||||
|
pub fn is_candidate(self) -> bool {
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Deliberately a struct of parsed fields rather than a property bag: the
|
||||||
|
/// parsing (and its failure modes) belongs at the observer boundary, and a
|
||||||
|
/// bag invites `props.get("...")` typos that silently read `None` — which
|
||||||
|
/// on this feature means "not tainted".
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct NodeProps {
|
||||||
|
/// `peerspeak.owned` is present and **exactly**
|
||||||
|
/// [`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,
|
||||||
|
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
|
||||||
|
/// parse-defensively note and the phase 0a truncation bug.
|
||||||
|
pub pulse_module_id: Option<u64>,
|
||||||
|
/// `node.link-group` — owner key 1, and the `echo-cancel-` hazard
|
||||||
|
/// prefix (v3.4 §5.4 / D3).
|
||||||
|
pub link_group: Option<String>,
|
||||||
|
/// `client.id` — owner key 3. A **connection**, not an owner: GStreamer
|
||||||
|
/// opens one per stream (v3.4 §6.1.2, measured refutation).
|
||||||
|
pub client_id: Option<GlobalId>,
|
||||||
|
/// `application.process.id` **on the node** — owner key 4. For
|
||||||
|
/// module-created streams this is pipewire-pulse's own PID, which is
|
||||||
|
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
|
||||||
|
pub process_id: Option<u32>,
|
||||||
|
/// The stream negotiated an encoded/passthrough format; a second link
|
||||||
|
/// would refuse or corrupt it (v3.4 §6.2).
|
||||||
|
pub passthrough: bool,
|
||||||
|
/// This node is a **passive device node exported by the session
|
||||||
|
/// manager** — a real sound card's sink or source, not something that
|
||||||
|
/// forwards audio.
|
||||||
|
///
|
||||||
|
/// ⚠️ **A positive high-confidence classification the observer owes, not
|
||||||
|
/// a raw property** (Codex rounds 2–3). PipeWire defines `device.id`
|
||||||
|
/// only as "the Device this node belongs to" and `device.api` as that
|
||||||
|
/// Device's access API; **neither promises the node passively terminates
|
||||||
|
/// audio**, so a card-associated filter can satisfy both. Setting this
|
||||||
|
/// flag *removes* two protections at once — the node's coarse owner keys
|
||||||
|
/// (`owner` exception 2) and its ability to trip the fail-closed
|
||||||
|
/// backstop — so a false positive is a leak, not over-exclusion.
|
||||||
|
///
|
||||||
|
/// **Phase-3 contract:**
|
||||||
|
/// - Set `true` only on positively-identified passive hardware
|
||||||
|
/// terminals: a resolved `device.id` on a real backend
|
||||||
|
/// (`device.api` present) whose `factory.name` is on an **explicit
|
||||||
|
/// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`,
|
||||||
|
/// and the equivalent for other real backends (bluez5, v4l2 for the
|
||||||
|
/// media case) as phase 3 enumerates them — never a filter, loopback,
|
||||||
|
/// or `support.null-audio-sink` factory. An allowlist, not a
|
||||||
|
/// substring or a denylist: an unknown factory is not a device.
|
||||||
|
/// Measured discriminator on the
|
||||||
|
/// target box: the five ALSA nodes carry `device.api=alsa` +
|
||||||
|
/// `factory.name=api.alsa.pcm.*` and share `client.id=42`
|
||||||
|
/// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes
|
||||||
|
/// carry neither. (`node.physical` was measured **null** on the ALSA
|
||||||
|
/// nodes here, so it is *not* a usable discriminator — do not rely on
|
||||||
|
/// it.)
|
||||||
|
/// - **Fail closed: unknown ⇒ `false`.** A node that cannot be
|
||||||
|
/// positively classified keeps its owner keys and can trip the
|
||||||
|
/// backstop; both are the safe direction.
|
||||||
|
/// - A node MUST NOT enter a snapshot with this field provisional. If
|
||||||
|
/// the Device backing a node has not yet been bound, withhold the node
|
||||||
|
/// and keep the epoch not-ready — otherwise a provisional `false`
|
||||||
|
/// during not-ready fuses sink and mic on the shared session client
|
||||||
|
/// and that fusion can persist as sticky over-exclusion (round-3
|
||||||
|
/// finding 3).
|
||||||
|
///
|
||||||
|
/// ⚠️ **A false positive is leak-capable — do not treat it as braced.**
|
||||||
|
/// I claimed a mis-classified filter could not leak because its legs
|
||||||
|
/// share a `node.link-group` (strong-key bridge) or trip the unbounded
|
||||||
|
/// backstop. Codex refuted it (round 4): a filter *without* a shared
|
||||||
|
/// strong key, marked `session_device=true`, cannot activate the
|
||||||
|
/// backstop from its reading leg, so a differently-keyed re-emitting leg
|
||||||
|
/// leaks. Those braces catch *some* shapes, not all. The only real
|
||||||
|
/// defence is a correct classifier — hence "positive high-confidence"
|
||||||
|
/// and "fail closed to false" above, without exception.
|
||||||
|
///
|
||||||
|
/// What it is for: every real device node shares the session manager's
|
||||||
|
/// `client.id`, so coarse owner keys must not bridge them — else
|
||||||
|
/// peerspeak's playback (which taints the default sink every recompute)
|
||||||
|
/// would reach the microphone. See [`super::owner`] exception 2.
|
||||||
|
pub session_device: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct NodeSnapshot {
|
||||||
|
pub serial: Serial,
|
||||||
|
pub id: GlobalId,
|
||||||
|
/// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry
|
||||||
|
/// detection (v3.4 §6.2, cycle prevention).
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub role: MediaRole,
|
||||||
|
pub props: NodeProps,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum PortDirection {
|
||||||
|
In,
|
||||||
|
Out,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PortSnapshot {
|
||||||
|
pub serial: Serial,
|
||||||
|
pub id: GlobalId,
|
||||||
|
/// Owning node, by snapshot-local id.
|
||||||
|
pub node: GlobalId,
|
||||||
|
pub direction: PortDirection,
|
||||||
|
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
|
||||||
|
pub exclusive: bool,
|
||||||
|
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
|
||||||
|
/// need it at node granularity.
|
||||||
|
pub monitor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LinkSnapshot {
|
||||||
|
pub serial: Serial,
|
||||||
|
pub id: GlobalId,
|
||||||
|
/// `link.output.node` — the node audio flows **from**.
|
||||||
|
pub output_node: GlobalId,
|
||||||
|
/// `link.input.node` — the node audio flows **to**.
|
||||||
|
pub input_node: GlobalId,
|
||||||
|
pub output_port: Option<GlobalId>,
|
||||||
|
pub input_port: Option<GlobalId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ClientSnapshot {
|
||||||
|
pub serial: Serial,
|
||||||
|
pub id: GlobalId,
|
||||||
|
/// `pipewire.sec.pid` — for Pulse-emulated clients this is
|
||||||
|
/// **pipewire-pulse's** PID, identical across every unrelated app
|
||||||
|
/// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the
|
||||||
|
/// consistency of this value; the engine only consumes the result.
|
||||||
|
pub sec_pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a snapshot-local id resolves.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum IdLookup {
|
||||||
|
Unique(Serial),
|
||||||
|
/// Two live objects in one snapshot claim the same global id — the
|
||||||
|
/// observer missed a removal, so the recycled id is ambiguous. Every
|
||||||
|
/// edge touching it is treated as unresolved, i.e. fail closed.
|
||||||
|
Ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One coherent observation of the graph.
|
||||||
|
///
|
||||||
|
/// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity
|
||||||
|
/// detection cannot be skipped.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct GraphSnapshot {
|
||||||
|
nodes: BTreeMap<Serial, NodeSnapshot>,
|
||||||
|
ports: BTreeMap<Serial, PortSnapshot>,
|
||||||
|
links: BTreeMap<Serial, LinkSnapshot>,
|
||||||
|
clients: BTreeMap<Serial, ClientSnapshot>,
|
||||||
|
node_ids: BTreeMap<GlobalId, IdLookup>,
|
||||||
|
client_ids: BTreeMap<GlobalId, IdLookup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraphSnapshot {
|
||||||
|
pub fn new(
|
||||||
|
nodes: Vec<NodeSnapshot>,
|
||||||
|
ports: Vec<PortSnapshot>,
|
||||||
|
links: Vec<LinkSnapshot>,
|
||||||
|
clients: Vec<ClientSnapshot>,
|
||||||
|
) -> Self {
|
||||||
|
let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial)));
|
||||||
|
let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial)));
|
||||||
|
Self {
|
||||||
|
nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(),
|
||||||
|
ports: ports.into_iter().map(|p| (p.serial, p)).collect(),
|
||||||
|
links: links.into_iter().map(|l| (l.serial, l)).collect(),
|
||||||
|
clients: clients.into_iter().map(|c| (c.serial, c)).collect(),
|
||||||
|
node_ids,
|
||||||
|
client_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nodes(&self) -> impl Iterator<Item = &NodeSnapshot> {
|
||||||
|
self.nodes.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> {
|
||||||
|
self.nodes.get(&serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn links(&self) -> impl Iterator<Item = &LinkSnapshot> {
|
||||||
|
self.links.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ports(&self) -> impl Iterator<Item = &PortSnapshot> {
|
||||||
|
self.ports.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clients(&self) -> impl Iterator<Item = &ClientSnapshot> {
|
||||||
|
self.clients.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a snapshot-local node id. `None` means "no such node in this
|
||||||
|
/// snapshot", which for a link endpoint means unresolved ancestry.
|
||||||
|
pub fn node_by_id(&self, id: GlobalId) -> Option<IdLookup> {
|
||||||
|
self.node_ids.get(&id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_by_id(&self, id: GlobalId) -> Option<IdLookup> {
|
||||||
|
self.client_ids.get(&id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every node claiming a global id. More than one means the id is
|
||||||
|
/// [`IdLookup::Ambiguous`] and each claimant must be treated as a
|
||||||
|
/// possible endpoint of any link naming it.
|
||||||
|
pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator<Item = &NodeSnapshot> {
|
||||||
|
self.nodes.values().filter(move |node| node.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every client claiming a global id — same fail-closed reasoning.
|
||||||
|
pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator<Item = &ClientSnapshot> {
|
||||||
|
self.clients.values().filter(move |client| client.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports belonging to a node, by the node's snapshot-local id.
|
||||||
|
pub fn ports_of(&self, node: GlobalId) -> impl Iterator<Item = &PortSnapshot> {
|
||||||
|
self.ports.values().filter(move |p| p.node == node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_ids(entries: impl Iterator<Item = (GlobalId, Serial)>) -> BTreeMap<GlobalId, IdLookup> {
|
||||||
|
let mut out: BTreeMap<GlobalId, IdLookup> = BTreeMap::new();
|
||||||
|
for (id, serial) in entries {
|
||||||
|
out.entry(id)
|
||||||
|
.and_modify(|slot| {
|
||||||
|
if *slot != IdLookup::Unique(serial) {
|
||||||
|
*slot = IdLookup::Ambiguous;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_insert(IdLookup::Unique(serial));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -281,8 +281,10 @@ impl Player {
|
|||||||
&[
|
&[
|
||||||
// No `--untimed`: it ignores audio timestamps and drifts a
|
// No `--untimed`: it ignores audio timestamps and drifts a
|
||||||
// shared video out of sync. Pacing to audio keeps A/V synced.
|
// shared video out of sync. Pacing to audio keeps A/V synced.
|
||||||
|
// Also leave hwdec at the `low-latency` default (software
|
||||||
|
// decode): forcing `--hwdec=auto` froze some viewers on
|
||||||
|
// frame 1 while audio kept playing.
|
||||||
"--profile=low-latency",
|
"--profile=low-latency",
|
||||||
"--hwdec=auto",
|
|
||||||
"--audio-buffer=0.2",
|
"--audio-buffer=0.2",
|
||||||
"--demuxer-max-bytes=2M",
|
"--demuxer-max-bytes=2M",
|
||||||
"--demuxer-readahead-secs=0.5",
|
"--demuxer-readahead-secs=0.5",
|
||||||
|
|||||||
+16
-1
@@ -1,5 +1,6 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod common;
|
mod common;
|
||||||
|
mod doctor;
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
mod gui;
|
mod gui;
|
||||||
mod host;
|
mod host;
|
||||||
@@ -36,12 +37,26 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Diagnostics run before pipewire::init() (they don't need it) and work
|
||||||
|
// regardless of the `gui` feature, so a headless tester can probe their box.
|
||||||
|
if cli.doctor {
|
||||||
|
let relay = common::endpoint::relay_override(cli.relay.as_deref());
|
||||||
|
return doctor::run(relay).await;
|
||||||
|
}
|
||||||
|
|
||||||
// libpipewire requires global init before any pw_* call. Idempotent;
|
// libpipewire requires global init before any pw_* call. Idempotent;
|
||||||
// safe to call even when the per-app audio thread never spawns.
|
// safe to call even when the per-app audio thread never spawns.
|
||||||
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
|
||||||
|
// engine concludes, create nothing. Placed before the host/viewer dispatch
|
||||||
|
// because it is neither — it shares no screen and connects to no peer.
|
||||||
|
if cli.audit_audio {
|
||||||
|
return host::audit::run::run_standalone().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if cli.reconfigure {
|
if cli.reconfigure {
|
||||||
|
|||||||
-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
+1
-1
@@ -102,7 +102,7 @@ fn print_viewer_banner(url: &str) {
|
|||||||
eprintln!("│ Connected to host. Open the stream in your player:");
|
eprintln!("│ Connected to host. Open the stream in your player:");
|
||||||
eprintln!("│");
|
eprintln!("│");
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"│ mpv --profile=low-latency --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
|
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
|
||||||
);
|
);
|
||||||
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
|
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
|
||||||
eprintln!("│");
|
eprintln!("│");
|
||||||
|
|||||||
+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