16 Commits
Author SHA1 Message Date
mollusk ca3122b92f feat(windows): add viewer-only PixelPass milestone 2026-08-22 20:42:23 -04:00
mollusk 2f00df758d chore: update h2 for security advisory 2026-08-22 14:52:11 -04:00
mollusk ce909afc4b fix: clear resolved audio exclusion statuses 2026-08-22 02:41:26 -04:00
mollusk 360d7112e6 test(host): harden Phase 9 live rig 2026-08-22 00:39:38 -04:00
mollusk 98bb78f9cf test(host): add Phase 9 correlation rig 2026-08-21 22:24:07 -04:00
mollusk 792f2bd55a feat(cli): publish desktop audio exclusion 2026-08-21 21:53:54 -04:00
mollusk e027bc65e5 test(host): qualify Phase 6 AEC leak path 2026-08-21 20:59:08 -04:00
mollusk 7b118272b0 test(host): close Row 9 fanout partition 2026-08-21 19:55:41 -04:00
mollusk 956534f63c fix(host): close unsupported fanout gates 2026-08-21 19:45:18 -04:00
mollusk 6be07ef706 fix(host): close desktop audio failure gates 2026-08-21 17:34:44 -04:00
mollusk d09ee9b02f feat(host): recover desktop audio fanout after sink replacement 2026-08-21 17:00:30 -04:00
mollusk 5a65f50c4b feat(host): emit desktop audio exclusion status events 2026-08-21 16:31:11 -04:00
mollusk 98cde2c19b feat(host): fan out desktop audio through owned links 2026-08-21 16:12:52 -04:00
mollusk 781defcd84 feat(host): build desktop audio exclusion foundation 2026-08-21 15:39:07 -04:00
mollusk 5d3da8b006 fix(host): contain capture owners and fail closed
Bound the libpipewire router shutdown without detaching its OS handle, contain GStreamer and pactl children in parent-bound process groups, and make ownership failures terminal through the capture supervisor.\n\nAdd focused lifecycle tests plus a serialized live router teardown gate.
2026-08-15 15:30:43 -04:00
mollusk 70820cf903 build(deps): refresh audited AppImage inputs
Update the vulnerable PixelPass lockfile entries and document the exact Rust and Pulse development requirements for AppImage builds.
2026-08-14 18:05:00 -04:00
40 changed files with 8711 additions and 638 deletions
Generated
+22 -31
View File
@@ -160,7 +160,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -171,7 +171,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1625,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1910,7 +1910,7 @@ dependencies = [
"libc", "libc",
"log", "log",
"rustversion", "rustversion",
"windows-link 0.2.1", "windows-link 0.1.3",
"windows-result 0.4.1", "windows-result 0.4.1",
] ]
@@ -2092,9 +2092,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -3557,7 +3557,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4015,7 +4015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.45.0",
] ]
[[package]] [[package]]
@@ -4250,7 +4250,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64", "base64",
"indexmap", "indexmap",
"quick-xml 0.41.0", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -4452,15 +4452,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -4737,7 +4728,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -4795,7 +4786,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -4887,7 +4878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -5203,7 +5194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5391,7 +5382,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -5800,7 +5791,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -6211,12 +6202,12 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.10" version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml 0.39.4", "quick-xml",
"quote", "quote",
] ]
@@ -6254,15 +6245,15 @@ dependencies = [
[[package]] [[package]]
name = "webbrowser" name = "webbrowser"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f"
dependencies = [ dependencies = [
"core-foundation 0.10.1",
"jni 0.22.4", "jni 0.22.4",
"log", "log",
"ndk-context", "ndk-context",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"url", "url",
"web-sys", "web-sys",
@@ -6433,7 +6424,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
+9 -4
View File
@@ -40,9 +40,17 @@ anyhow = "1"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nix = { version = "0.30", features = ["signal", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The host/capture stack is intentionally Linux-only. Keeping it out of the
# Windows dependency graph lets the same binary provide the transport/viewer
# half without trying to cross-link PipeWire, PulseAudio, Wayland, or X11.
nix = { version = "0.30", features = ["signal", "process"] }
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"
@@ -57,9 +65,6 @@ pipewire = "0.9"
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0. # RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
libpulse-binding = "2.30" 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"] }
iroh-tickets = "1.0.0"
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"] }
toml = "1" toml = "1"
+68 -11
View File
@@ -1,6 +1,7 @@
# pixelpass # pixelpass
P2P screen sharing CLI for Linux. Single binary, hole-punched over P2P screen sharing CLI. Linux can host or view; the first Windows milestone
can view a Linux-hosted share. A single binary, hole-punched over
[iroh](https://www.iroh.computer/) — no port forwarding, no signup, no [iroh](https://www.iroh.computer/) — no port forwarding, no signup, no
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
mpv or VLC. mpv or VLC.
@@ -21,13 +22,17 @@ Working:
- VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable - VAAPI H.264 encode in GStreamer (RDNA3 confirmed; other VAAPI-capable
GPUs should work), with a software x264 fallback via `--no-hwencode` GPUs should work), with a software x264 fallback via `--no-hwencode`
- 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>`) and a guarded whole-desktop mode for parent
integrations (`--audio-mode=desktop-excluding`)
- `--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, - `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics) 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>`)
- Headless Windows 10 viewer: consumes the same ticket and JSON event protocol,
tunnels the stream to localhost, and works with PeerSpeak's external VLC/mpv
launcher
- Multi-viewer fanout (default 2, configurable via `--max-viewers`; - Multi-viewer fanout (default 2, configurable via `--max-viewers`;
shared gst pipeline, one broadcast channel per host) shared gst pipeline, one broadcast channel per host)
- First-run upstream bandwidth pre-flight, persisted to - First-run upstream bandwidth pre-flight, persisted to
@@ -38,6 +43,9 @@ Working:
derives quality from the bandwidth pre-flight derives quality from the bandwidth pre-flight
Not yet built (deferred, not blocking): Not yet built (deferred, not blocking):
- Windows hosting/capture, including desktop capture, WASAPI system audio, and
PeerSpeak voice exclusion. The current Windows binary is deliberately
viewer-only and reports Linux host-audio capabilities as unavailable.
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the - Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
whole root canvas; single-monitor cropping needs xrandr region coords whole root canvas; single-monitor cropping needs xrandr region coords
- `use-damage=true` CPU optimization for the X11 capture path - `use-damage=true` CPU optimization for the X11 capture path
@@ -102,6 +110,8 @@ the capture machinery is untouched by it. On a build without the feature,
## Requirements ## Requirements
### Linux host or viewer
- Linux (Wayland or X11; the backend is autodetected) - Linux (Wayland or X11; the backend is autodetected)
- A VAAPI-capable GPU and the right driver: - A VAAPI-capable GPU and the right driver:
- AMD: `libva-mesa-driver` - AMD: `libva-mesa-driver`
@@ -123,6 +133,13 @@ the capture machinery is untouched by it. On a build without the feature,
mpv ships its own decoder stack and doesn't share either dependency. mpv ships its own decoder stack and doesn't share either dependency.
- PipeWire (for screencast portal + audio capture) - PipeWire (for screencast portal + audio capture)
### Windows viewer
- Windows 10 build 19045 or newer
- VLC or mpv on `PATH`; VLC is available as `VideoLAN.VLC` through `winget`
- A share ticket from a PixelPass host (hosting remains Linux-only in this
milestone)
On Arch / CachyOS / EndeavourOS: On Arch / CachyOS / EndeavourOS:
```sh ```sh
@@ -183,6 +200,23 @@ windowing stack). Build it with:
cargo build --release --features gui cargo build --release --features gui
``` ```
### Windows viewer
Cross-build the headless viewer with MinGW; the Linux-only capture dependencies
are excluded from this target:
```sh
rustup target add x86_64-pc-windows-gnu
# Install the MinGW-w64 compiler using your distro's package manager.
cargo build --release --target x86_64-pc-windows-gnu
```
The result is
`target/x86_64-pc-windows-gnu/release/pixelpass.exe`. Validate it on Windows
with `pixelpass.exe --doctor`, then pass a ticket directly or let PeerSpeak
drive it with `--output json`. See [Windows support](docs/WINDOWS.md) for the
support boundary and smoke-test gates.
## How it works ## How it works
``` ```
@@ -316,12 +350,34 @@ matches a built-in overrides that built-in.
## Audio ## Audio
By default pixelpass captures the default sink's monitor — the viewer By default pixelpass captures the default sink's monitor — the viewer
hears whatever the host hears. `--app <name>` narrows that to a single hears whatever the host hears. This is also available explicitly as
application: pixelpass creates a per-PID null-sink and uses libpipewire to `--audio-mode=desktop-shared`.
reroute matching `Stream/Output/Audio` nodes (by `application.name`) into
it, so the viewer hears just that app instead of the whole desktop. In the `--app <name>` narrows capture to a single application: pixelpass creates a
interactive menu you can pick the app from a list of what's currently per-PID null-sink and uses libpipewire to reroute matching
playing. `Stream/Output/Audio` nodes (by `application.name`) into it, so the viewer
hears just that app instead of the whole desktop. In the interactive menu
you can pick the app from a list of what's currently playing.
Parent integrations can select guarded whole-desktop capture with
`--audio-mode=desktop-excluding`. This copies eligible playback streams into
a connection-owned capture sink while excluding PeerSpeak-tagged playback,
the exact configured echo-canceller, unsafe ancestry, and unsupported stream
formats. The mode requires an explicit AEC state so a missing integration
argument cannot silently mean "no AEC":
```sh
pixelpass --host --audio-mode=desktop-excluding --aec=off
pixelpass --host --audio-mode=desktop-excluding --aec=pulse-module:536870919
```
Integrations should use `pixelpass --capabilities`, not scrape `--help`. Its
versioned response advertises strict per-app audio and desktop exclusion as
independent capabilities:
```json
{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}
```
Microphone capture is intentionally out of scope — pixelpass is a Microphone capture is intentionally out of scope — pixelpass is a
screen-share tool meant to be paired with a dedicated voice app (Mumble, screen-share tool meant to be paired with a dedicated voice app (Mumble,
@@ -407,9 +463,10 @@ each take precedence over the chosen preset's value for that field.
either one breaks playback (the first kills the demuxer, the second either one breaks playback (the first kills the demuxer, the second
kills the H.264 decoder). pixelpass warns at player-launch time if kills the H.264 decoder). pixelpass warns at player-launch time if
either plugin isn't on disk. mpv doesn't share these dependencies. either plugin isn't on disk. mpv doesn't share these dependencies.
- **Audio echo** if the host plays the stream through speakers and - **Audio echo in `desktop-shared` mode** if the host plays the stream through
captures system audio — expected, the mic / monitor picks up the speakers while capturing system audio. The guarded `desktop-excluding`
playback. Headphones bypass it. mode requires a cooperating parent integration to tag its playback and
supply the active AEC identity.
- **Late joiners see ~2 s of garbage** before the next keyframe lets - **Late joiners see ~2 s of garbage** before the next keyframe lets
their decoder lock. Expected behavior, not a bug. their decoder lock. Expected behavior, not a bug.
- **VAAPI driver must be package-tracked**, not an orphaned `.so` on - **VAAPI driver must be package-tracked**, not an orphaned `.so` on
+102
View File
@@ -0,0 +1,102 @@
# Windows support
## Current milestone
PixelPass on Windows is a **viewer**, not a screen-share host. It preserves the
same viewer-side architecture used on Linux:
1. parse an iroh endpoint ticket;
2. connect to the Linux host over the existing `pixelpass/0` ALPN;
3. expose the tunneled MPEG-TS stream on a random loopback HTTP port;
4. emit `{"event":"connected","url":"http://127.0.0.1:..."}` when
`--output json` is enabled;
5. let PeerSpeak launch VLC/mpv, or launch one from PixelPass's interactive
viewer prompt.
No codec, container, ticket, ALPN, or JSON protocol fork was introduced for
Windows.
## Support matrix
| Capability | Linux | Windows 10 |
| --- | --- | --- |
| View a share | Yes | Yes |
| Headless `--output json` viewer | Yes | Yes |
| Interactive viewer/player launch | Yes | Yes |
| Host Wayland/X11 capture | Yes | No |
| Host desktop/system audio | PipeWire/Pulse | No |
| PeerSpeak voice exclusion | Yes | No |
| PixelPass GUI | Yes (feature build) | No |
Unsupported host operations fail with an explicit viewer-only error. On
Windows, `--capabilities` reports both host-audio capability flags as `false`,
so parent integrations cannot mistake this milestone for full hosting parity.
## Build
From Linux with Rust 1.95+ and MinGW-w64 installed:
```sh
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnu
```
The artifact is:
```text
target/x86_64-pc-windows-gnu/release/pixelpass.exe
```
The Windows target does not compile or link PipeWire, PulseAudio, Wayland, X11,
or the Linux GUI stack. Keep the build headless; `--gui` is intentionally
rejected on Windows.
## Validation gates
Before bundling a Windows binary with PeerSpeak:
```sh
cargo fmt -- --check
cargo clippy --target x86_64-pc-windows-gnu -- -D warnings
cargo test -- --test-threads=1
cargo build --release --target x86_64-pc-windows-gnu
```
Then on an actual Windows 10 build 19045 VM:
1. verify the transferred SHA-256;
2. run `pixelpass.exe --version` and `pixelpass.exe --capabilities`;
3. run `pixelpass.exe --doctor` with VLC or mpv installed;
4. start a real Linux host, pass its fresh ticket to the Windows executable
with `--output json`, and verify the `connected` event;
5. open the emitted loopback URL in the player and confirm moving video and
audio;
6. stop the player/viewer and confirm both sides terminate cleanly.
### Verified baseline
On 2026-08-22 this gate passed on Windows 10 Enterprise Evaluation build 19045
against a Wayland Linux host using software x264 at the Low preset. The Windows
viewer emitted a loopback URL, VLC 3.0.23 rendered the live desktop, closing VLC
terminated the viewer, and the host emitted `viewer_left` followed by
`capture: stopped`. The final release artifact was 13,165,056 bytes with
SHA-256:
```text
3eabd9f0dcd8565136a5c87a79470eceedd426cea5708904d7492a6fa37b3c49
```
The run deliberately declined Windows Defender's one-off public-network allow
prompt; relay viewing succeeded without it. A packaged application should own
an explicit, idempotent firewall rule for the final installed path instead of
depending on that prompt.
## Next Windows phases
Windows hosting should be added behind a native capture boundary rather than by
weakening the Linux implementation:
1. desktop/window video capture and H.264 encode;
2. WASAPI system-audio capture;
3. PeerSpeak-owned voice/AEC exclusion with a fail-closed contract;
4. dependency packaging, firewall rules, and installer upgrade coverage.
+3 -2
View File
@@ -47,9 +47,10 @@ distrobox create --yes --image ubuntu:24.04 --name pixelpass-build
distrobox enter pixelpass-build -- sudo apt-get update distrobox enter pixelpass-build -- sudo apt-get update
distrobox enter pixelpass-build -- sudo apt-get install -y \ distrobox enter pixelpass-build -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \ build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev curl ca-certificates file libpipewire-0.3-dev libspa-0.2-dev libpulse-dev curl ca-certificates file
# Install rustup inside the box (edition 2024 needs rustc >= 1.85), then: rustup toolchain install 1.97.1 --profile default
distrobox enter pixelpass-build -- env \ distrobox enter pixelpass-build -- env \
PATH="$HOME/.rustup/toolchains/1.97.1-x86_64-unknown-linux-gnu/bin:$PATH" \
CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \ CARGO_TARGET_DIR=~/.cache/pixelpass-ubuntu/target \
./packaging/appimage/build-appimage.sh ./packaging/appimage/build-appimage.sh
``` ```
+67
View File
@@ -0,0 +1,67 @@
//! Versioned machine-readable feature discovery for parent integrations.
//!
//! PeerSpeak may execute an older PixelPass from `PATH`, so recognizing a CLI
//! token in `--help` cannot be the primary protocol. This response advertises
//! strict per-app audio and desktop audio exclusion independently; neither can
//! accidentally imply the other.
use anyhow::{Context, Result};
use serde::Serialize;
use std::io::Write;
const CAPABILITY_SCHEMA_VERSION: u32 = 1;
#[derive(Serialize)]
struct CapabilityResponse {
schema_version: u32,
capabilities: CapabilitySet,
}
#[derive(Serialize)]
struct CapabilitySet {
strict_app_audio: bool,
desktop_audio_exclusion: bool,
}
fn response() -> CapabilityResponse {
CapabilityResponse {
schema_version: CAPABILITY_SCHEMA_VERSION,
capabilities: CapabilitySet {
// These are host-side audio features. The Windows milestone is a
// viewer only, so advertising either one there would falsely
// imply that its Linux capture/audio stack is available.
strict_app_audio: cfg!(target_os = "linux"),
desktop_audio_exclusion: cfg!(target_os = "linux"),
},
}
}
fn write_response(mut writer: impl Write) -> Result<()> {
serde_json::to_writer(&mut writer, &response()).context("serialize capability response")?;
writeln!(writer).context("write capability response")
}
pub fn run() -> Result<()> {
write_response(std::io::stdout().lock())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_one_wire_shape_is_exact_and_capabilities_are_independent() {
let mut output = Vec::new();
write_response(&mut output).expect("serialize the capability golden");
let expected = if cfg!(target_os = "linux") {
br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}
"#
.as_slice()
} else {
br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":false}}
"#
.as_slice()
};
assert_eq!(output, expected);
}
}
+293 -5
View File
@@ -1,15 +1,20 @@
#![cfg_attr(target_os = "windows", allow(dead_code))]
use anyhow::{Result, bail};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use crate::common::aec::{AecConfig, parse_aec_arg};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
name = "pixelpass", name = "pixelpass",
version, version,
about = "P2P screen sharing over iroh", about = "P2P screen sharing over iroh",
long_about = "Run with no arguments for an interactive Host/View menu. \ long_about = "Run with no arguments for the platform's interactive mode. \
Pass a ticket positionally to skip the menu and view headlessly." Pass a ticket positionally to skip the menu and view headlessly."
)] )]
pub struct Cli { pub struct Cli {
/// iroh ticket. If present, runs as viewer. If absent, runs as host. /// iroh ticket. If present, runs as viewer. If absent, enters interactive mode.
pub ticket: Option<String>, pub ticket: Option<String>,
// ── host options ────────────────────────────────────────────────── // ── host options ──────────────────────────────────────────────────
@@ -38,6 +43,38 @@ pub struct Cli {
#[arg(long)] #[arg(long)]
pub strict_audio: bool, pub strict_audio: bool,
/// Select whole-desktop audio behavior. `desktop-shared` captures the
/// default output mix. `desktop-excluding` captures system audio while
/// excluding PeerSpeak-owned playback and the configured AEC identity.
#[arg(
long,
value_enum,
value_name = "MODE",
requires = "host",
conflicts_with_all = ["app", "internal_desktop_excluding"]
)]
pub audio_mode: Option<AudioModeArg>,
/// Echo-canceller identity for `--audio-mode=desktop-excluding`.
/// PeerSpeak passes `off` when no AEC is active, or the exact Pulse module
/// index returned by `pactl load-module` as `pulse-module:<idx>`.
#[arg(
long,
value_name = "off|pulse-module:<idx>",
requires = "host",
value_parser = parse_aec_cli
)]
pub aec: Option<AecConfig>,
/// Internal phase-0d trigger retained for deterministic compatibility and
/// mutation tests. Production integrations use `--audio-mode`.
#[arg(
long,
hide = true,
conflicts_with_all = ["app", "audio_mode"]
)]
pub internal_desktop_excluding: bool,
/// Override display server autodetection. /// Override display server autodetection.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>, pub display_server: Option<DisplayServerArg>,
@@ -101,6 +138,10 @@ pub struct Cli {
#[arg(long, short)] #[arg(long, short)]
pub verbose: bool, pub verbose: bool,
/// Print the versioned machine-readable capability response, then exit.
#[arg(long)]
pub capabilities: bool,
/// Clean up orphaned PipeWire state from a crashed host run, then exit. /// Clean up orphaned PipeWire state from a crashed host run, then exit.
#[arg(long)] #[arg(long)]
pub repair: bool, pub repair: bool,
@@ -156,6 +197,14 @@ pub enum OutputFormat {
Json, Json,
} }
/// Public values for the whole-desktop audio selector. These names are the
/// Phase-7 cross-repository CLI contract consumed by PeerSpeak.
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AudioModeArg {
DesktopShared,
DesktopExcluding,
}
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps) /// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
/// tuple — resolution is a quality-per-bitrate knob, so the three only make /// tuple — resolution is a quality-per-bitrate knob, so the three only make
/// sense together. `Auto` has no fixed tuple; it picks one of the others from /// sense together. `Auto` has no fixed tuple; it picks one of the others from
@@ -174,6 +223,34 @@ pub enum Quality {
Auto, Auto,
} }
/// Internal input to the typed audio-capture planner. The public `AudioModeArg`
/// is resolved to this type once, at the CLI boundary.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum CaptureMode {
#[default]
Legacy,
DesktopExcluding,
}
impl CaptureMode {
fn validate_inputs(self, app: Option<&str>, legacy_null_sink: bool) -> Result<()> {
if self != Self::DesktopExcluding {
return Ok(());
}
if app.is_some() {
bail!(
"desktop-excluding audio conflicts with --app; refusing to fall back to legacy per-app routing"
);
}
if legacy_null_sink {
bail!(
"desktop-excluding audio conflicts with PIXELPASS_AUDIO_VIA_NULL_SINK; refusing to load the legacy default-monitor loopback"
);
}
Ok(())
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HostOpts { pub struct HostOpts {
pub window: bool, pub window: bool,
@@ -194,6 +271,16 @@ pub struct HostOpts {
pub no_hwencode: bool, pub no_hwencode: bool,
pub max_viewers: Option<u32>, pub max_viewers: Option<u32>,
pub interactive: bool, pub interactive: bool,
/// Resolved public or test-only selector.
pub(crate) capture_mode: CaptureMode,
/// Explicit AEC state for desktop-excluding fan-out. Legacy invocations
/// resolve to `Off`; public desktop-excluding invocations must spell this
/// on argv so absence can never masquerade as "no AEC".
pub(crate) aec: AecConfig,
/// Snapshot the legacy dogfood override during CLI resolution. Capture is
/// lazy, so reading the process environment later would let it change modes
/// between ticket creation and the first viewer.
pub(crate) legacy_null_sink: bool,
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults. /// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
pub relay: Option<String>, pub relay: Option<String>,
} }
@@ -207,8 +294,37 @@ pub struct ViewerOpts {
} }
impl Cli { impl Cli {
pub fn into_host_opts(self, interactive: bool) -> HostOpts { pub fn into_host_opts(self, interactive: bool) -> Result<HostOpts> {
HostOpts { let legacy_null_sink = std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
self.into_host_opts_with_legacy_override(interactive, legacy_null_sink)
}
fn into_host_opts_with_legacy_override(
self,
interactive: bool,
legacy_null_sink: bool,
) -> Result<HostOpts> {
let public_desktop_excluding = self.audio_mode == Some(AudioModeArg::DesktopExcluding);
let capture_mode = match self.audio_mode {
Some(AudioModeArg::DesktopExcluding) => CaptureMode::DesktopExcluding,
Some(AudioModeArg::DesktopShared) => CaptureMode::Legacy,
None if self.internal_desktop_excluding => CaptureMode::DesktopExcluding,
None => CaptureMode::Legacy,
};
capture_mode.validate_inputs(self.app.as_deref(), legacy_null_sink)?;
let aec = match (capture_mode, self.aec) {
(CaptureMode::DesktopExcluding, Some(aec)) => aec,
(CaptureMode::DesktopExcluding, None) if public_desktop_excluding => {
bail!("--audio-mode=desktop-excluding requires --aec=off|pulse-module:<idx>");
}
(CaptureMode::DesktopExcluding, None) => AecConfig::Off,
(CaptureMode::Legacy, Some(_)) => {
bail!("--aec requires --audio-mode=desktop-excluding");
}
(CaptureMode::Legacy, None) => AecConfig::Off,
};
Ok(HostOpts {
window: self.window, window: self.window,
app: self.app, app: self.app,
strict_audio: self.strict_audio, strict_audio: self.strict_audio,
@@ -222,8 +338,11 @@ impl Cli {
no_hwencode: self.no_hwencode, no_hwencode: self.no_hwencode,
max_viewers: self.max_viewers, max_viewers: self.max_viewers,
interactive, interactive,
capture_mode,
aec,
legacy_null_sink,
relay: crate::common::endpoint::relay_override(self.relay.as_deref()), relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
} })
} }
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts { pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
@@ -234,3 +353,172 @@ impl Cli {
} }
} }
} }
fn parse_aec_cli(value: &str) -> std::result::Result<AecConfig, String> {
parse_aec_arg(value).map_err(|_| {
format!(
"invalid AEC identity {value:?}; expected `off` or `pulse-module:<unsigned decimal>`"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn phase_0d_trigger_is_hidden_from_help() {
let help = Cli::command().render_long_help().to_string();
assert!(!help.contains("internal-desktop-excluding"));
}
#[test]
fn phase_0d_trigger_conflicts_with_app_during_clap_parsing() {
let error = Cli::try_parse_from([
"pixelpass",
"--host",
"--internal-desktop-excluding",
"--app",
"Firefox",
])
.expect_err("clap must reject the hidden mode combined with --app");
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn phase_0d_trigger_conflicts_with_legacy_env_override_during_option_resolution() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let error = cli
.into_host_opts_with_legacy_override(false, true)
.expect_err("legacy override must be rejected before host startup");
assert!(error.to_string().contains("PIXELPASS_AUDIO_VIA_NULL_SINK"));
}
#[test]
fn phase_0d_trigger_reaches_the_internal_host_mode() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--internal-desktop-excluding"])
.expect("hidden trigger parses");
let opts = cli
.into_host_opts_with_legacy_override(false, false)
.expect("non-conflicting hidden mode resolves");
assert_eq!(opts.capture_mode, CaptureMode::DesktopExcluding);
assert_eq!(opts.aec, AecConfig::Off);
assert!(!opts.legacy_null_sink);
}
#[test]
fn phase_7_public_contract_is_visible_in_help() {
let help = Cli::command().render_long_help().to_string();
assert!(help.contains("--audio-mode <MODE>"));
assert!(help.contains("desktop-shared"));
assert!(help.contains("desktop-excluding"));
assert!(help.contains("--aec <off|pulse-module:<idx>>"));
assert!(help.contains("--capabilities"));
}
#[test]
fn public_desktop_modes_resolve_to_the_typed_planner() {
let shared = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-shared"])
.expect("public shared mode parses")
.into_host_opts_with_legacy_override(false, false)
.expect("public shared mode resolves");
assert_eq!(shared.capture_mode, CaptureMode::Legacy);
assert_eq!(shared.aec, AecConfig::Off);
let excluding = Cli::try_parse_from([
"pixelpass",
"--host",
"--audio-mode=desktop-excluding",
"--aec=pulse-module:536870919",
])
.expect("public excluding mode parses")
.into_host_opts_with_legacy_override(false, false)
.expect("public excluding mode resolves");
assert_eq!(excluding.capture_mode, CaptureMode::DesktopExcluding);
assert_eq!(excluding.aec, AecConfig::PulseModule(536_870_919));
}
#[test]
fn public_desktop_excluding_requires_explicit_aec_state() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--audio-mode=desktop-excluding"])
.expect("mode token parses before semantic validation");
let error = cli
.into_host_opts_with_legacy_override(false, false)
.expect_err("missing AEC state must fail the public excluding mode");
assert!(error.to_string().contains("requires --aec"));
}
#[test]
fn public_audio_protocol_flags_require_host_mode() {
for args in [
["pixelpass", "--audio-mode=desktop-shared"],
["pixelpass", "--aec=off"],
] {
let error = Cli::try_parse_from(args)
.expect_err("host-only audio protocol flag parsed without --host");
assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
}
#[test]
fn aec_is_rejected_outside_desktop_excluding_and_malformed_at_parse_time() {
let cli = Cli::try_parse_from(["pixelpass", "--host", "--aec=off"])
.expect("AEC token parses before mode validation");
assert!(
cli.into_host_opts_with_legacy_override(false, false)
.expect_err("legacy capture must not silently ignore an AEC identity")
.to_string()
.contains("requires --audio-mode=desktop-excluding")
);
let malformed = Cli::try_parse_from([
"pixelpass",
"--host",
"--audio-mode=desktop-excluding",
"--aec=module:7",
])
.expect_err("the public CLI must use the Phase-4 exact grammar");
assert_eq!(malformed.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn old_peerspeak_host_argv_golden_is_byte_compatible_without_aec() {
let whole_desktop = Cli::try_parse_from(["pixelpass", "--host", "--output", "json"])
.expect("new PixelPass must accept old whole-desktop argv unchanged")
.into_host_opts_with_legacy_override(false, false)
.expect("old whole-desktop argv resolves without new flags");
assert_eq!(whole_desktop.capture_mode, CaptureMode::Legacy);
assert_eq!(whole_desktop.aec, AecConfig::Off);
assert!(whole_desktop.app.is_none());
// Exact argv emitted by PeerSpeak before the Phase-8 integration.
let cli = Cli::try_parse_from([
"pixelpass",
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
])
.expect("new PixelPass must accept old PeerSpeak argv unchanged");
assert!(cli.host);
assert_eq!(cli.output, Some(OutputFormat::Json));
assert_eq!(cli.app.as_deref(), Some("Firefox"));
assert!(cli.strict_audio);
assert!(cli.audio_mode.is_none());
assert!(cli.aec.is_none());
let opts = cli
.into_host_opts_with_legacy_override(false, false)
.expect("old PeerSpeak argv resolves without new flags");
assert_eq!(opts.capture_mode, CaptureMode::Legacy);
assert_eq!(opts.aec, AecConfig::Off);
assert_eq!(opts.app.as_deref(), Some("Firefox"));
assert!(opts.strict_audio);
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Platform-neutral parsing for PixelPass's host audio/AEC command-line contract.
//!
//! Hosting currently remains Linux-only, but the CLI is shared by the Windows
//! viewer build so unsupported host invocations can be parsed and rejected with
//! a direct platform message instead of looking like unknown arguments.
/// 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. This is distinct from an
/// absent argument; the CLI decides when explicit state is required.
Off,
/// Validate this live Pulse module index before trusting it. The index is
/// compared as `u64`, never `u32`.
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge:
/// a malformed identity must never silently become "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
Empty,
UnknownForm,
MissingIndex,
InvalidIndex,
}
/// Parse one exact `off` or `pulse-module:<bare u64 decimal>` value.
///
/// The grammar deliberately rejects signs, whitespace, non-decimal digits,
/// and overflow while accepting indices beyond `u32::MAX`. PeerSpeak produces
/// this machine argument from `pactl load-module`, so widening the grammar is
/// less safe than requiring its canonical unsigned decimal.
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);
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_cross_platform_cli_contract() {
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
assert_eq!(
parse_aec_arg("pulse-module:536870919"),
Ok(AecConfig::PulseModule(536_870_919))
);
assert_eq!(
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
Ok(AecConfig::PulseModule(u64::MAX))
);
}
#[test]
fn rejects_noncanonical_or_incomplete_values() {
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
assert_eq!(
parse_aec_arg("pulse-module:"),
Err(AecParseError::MissingIndex)
);
assert_eq!(
parse_aec_arg("pulse-module:+7"),
Err(AecParseError::InvalidIndex)
);
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
}
}
+1 -1
View File
@@ -10,5 +10,5 @@ pub const ALPN: &[u8] = b"pixelpass/0";
/// without their accept loops colliding, and so a control dial never lands on a /// without their accept loops colliding, and so a control dial never lands on a
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest /// bare video host (which doesn't speak this protocol). GUI-only, like the rest
/// of the friends stack. /// of the friends stack.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0"; pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
+188
View File
@@ -0,0 +1,188 @@
//! Linux child-process containment for capture-side helpers.
//!
//! PixelPass owns `gst-launch-1.0` and the short-lived `pactl load-module`
//! workers. They must not outlive a host that is killed or fail-stops: GStreamer
//! can retain a portal/PipeWire capture resource, and a late pactl mutation can
//! race teardown. Each child therefore gets both:
//!
//! - `PR_SET_PDEATHSIG(SIGKILL)`, with the standard parent-race check; and
//! - its own process group, so explicit teardown reaches descendants as well as
//! the direct child.
//!
//! This is intentionally the inverse of [`super::process`], whose viewer
//! players are user-facing detached processes and are meant to survive their
//! launcher.
use nix::libc;
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use std::io;
use std::os::unix::process::CommandExt;
use std::process::{Child, Command};
/// Install parent-death and process-group containment on `command`.
///
/// The closure runs between `fork` and `exec`, so it contains only direct
/// async-signal-safe syscalls. A failure aborts the spawn rather than launching
/// an uncontained graph-mutating child.
fn configure(command: &mut Command) {
let expected_parent = std::process::id() as libc::pid_t;
// SAFETY: `setpgid`, `prctl`, `getppid`, and `_exit` are direct syscalls and
// are async-signal-safe in the post-fork child. No allocation, logging, or
// locking occurs in the closure.
unsafe {
command.pre_exec(move || {
if libc::setpgid(0, 0) == -1 {
return Err(io::Error::last_os_error());
}
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
return Err(io::Error::last_os_error());
}
// The parent may have died after fork but before PR_SET_PDEATHSIG
// was installed. Checking after the prctl closes that window: a
// later death delivers SIGKILL, an earlier one is handled here.
if libc::getppid() != expected_parent {
libc::_exit(127);
}
Ok(())
});
}
}
/// Spawn a contained blocking child.
pub fn spawn(command: &mut Command) -> io::Result<Child> {
configure(command);
command.spawn()
}
/// Spawn a contained Tokio child.
pub fn spawn_tokio(command: &mut tokio::process::Command) -> io::Result<tokio::process::Child> {
configure(command.as_std_mut());
command.spawn()
}
/// Signal the process group created by [`configure`].
pub fn signal_group(leader_pid: u32, signal: Signal) -> nix::Result<()> {
killpg(Pid::from_raw(leader_pid as i32), signal)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::process::Stdio;
use std::time::{Duration, Instant};
const PDEATH_HELPER: &str = "PIXELPASS_TEST_PDEATH_HELPER";
fn process_is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false;
};
// comm is parenthesized and may contain spaces; the state byte follows
// the final `) `. A zombie holds no resources and only awaits init's
// reap, so it is not a surviving capture child for this gate.
stat.rsplit_once(") ")
.and_then(|(_, rest)| rest.as_bytes().first().copied())
.is_some_and(|state| state != b'Z' && state != b'X')
}
#[test]
fn contained_child_has_its_own_process_group() {
let mut command = Command::new("sleep");
command.arg("30");
let mut child = spawn(&mut command).expect("spawn contained child");
let pid = child.id();
// SAFETY: getpgid is a read-only syscall for the live child we own.
let pgid = unsafe { libc::getpgid(pid as libc::pid_t) };
assert_eq!(pgid, pid as libc::pid_t);
signal_group(pid, Signal::SIGKILL).expect("kill contained group");
child.wait().expect("reap contained child");
}
#[test]
fn process_group_signal_reaches_descendants() {
let mut command = Command::new("sh");
command
.args(["-c", "sleep 30 & child=$!; echo $child; wait"])
.stdout(Stdio::piped());
let mut leader = spawn(&mut command).expect("spawn group leader");
let mut line = String::new();
use std::io::BufRead;
std::io::BufReader::new(leader.stdout.take().expect("piped stdout"))
.read_line(&mut line)
.expect("read descendant pid");
let descendant: u32 = line.trim().parse().expect("numeric descendant pid");
assert!(process_is_running(descendant));
signal_group(leader.id(), Signal::SIGKILL).expect("kill whole group");
leader.wait().expect("reap group leader");
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(descendant) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_is_running(descendant),
"a descendant must not survive explicit group teardown"
);
}
/// Subprocess-only half of the parent-death gate. The outer test launches
/// this exact test in a disposable harness process; when that process exits,
/// the contained sleep must receive SIGKILL.
#[test]
fn pdeathsig_helper() {
if std::env::var_os(PDEATH_HELPER).is_none() {
return;
}
let mut command = Command::new("sleep");
command
.arg("30")
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = spawn(&mut command).expect("spawn pdeath child");
println!("PIXELPASS_CONTAINED_PID={}", child.id());
std::io::stdout().flush().expect("flush child pid");
// std::process::Child has no kill-on-drop behavior. Returning lets the
// helper harness exit while the contained process is still live.
drop(child);
}
#[test]
fn parent_death_kills_the_contained_child() {
let helper = std::env::current_exe().expect("test executable path");
let output = Command::new(helper)
.args([
"--exact",
"common::contained::tests::pdeathsig_helper",
"--nocapture",
])
.env(PDEATH_HELPER, "1")
.output()
.expect("run pdeath helper harness");
assert!(
output.status.success(),
"helper failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let pid: u32 = stdout
.lines()
.find_map(|line| line.strip_prefix("PIXELPASS_CONTAINED_PID="))
.expect("helper printed contained pid")
.parse()
.expect("numeric contained pid");
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!process_is_running(pid),
"PR_SET_PDEATHSIG must stop the child when its PixelPass parent exits"
);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
/// Bind the **control-plane** endpoint with the machine's persistent identity /// Bind the **control-plane** endpoint with the machine's persistent identity
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its /// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
/// `EndpointId` is the stable id friends know you by. /// `EndpointId` is the stable id friends know you by.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> { pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
let secret_key = super::identity::load_or_create()?; let secret_key = super::identity::load_or_create()?;
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
+10 -3
View File
@@ -1,17 +1,24 @@
pub mod aec;
pub mod alpn; pub mod alpn;
#[cfg(target_os = "linux")]
pub mod bandwidth; pub mod bandwidth;
#[cfg(target_os = "linux")]
pub mod config; pub mod config;
#[cfg(target_os = "linux")]
pub mod contained;
// The friends stack (persistent identity + control plane) is GUI-only — a // The friends stack (persistent identity + control plane) is GUI-only — a
// headless CLI host runs no presence service — so it's gated with the feature // headless CLI host runs no presence service — so it's gated with the feature
// that pulls the rest of the GUI, keeping the headless build lean. // that pulls the rest of the GUI, keeping the headless build lean.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod control; pub mod control;
#[cfg(target_os = "linux")]
pub mod deps; pub mod deps;
#[cfg(target_os = "linux")]
pub mod display; pub mod display;
pub mod endpoint; pub mod endpoint;
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod friends; pub mod friends;
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod identity; pub mod identity;
pub mod output; pub mod output;
pub mod process; pub mod process;
+151
View File
@@ -10,6 +10,8 @@
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json` //! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
//! and parses these lines to drive its window. //! and parses these lines to drive its window.
#![cfg_attr(target_os = "windows", allow(dead_code))]
use std::io::Write; use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -17,6 +19,14 @@ use serde::Serialize;
static JSON_ENABLED: AtomicBool = AtomicBool::new(false); static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
/// First wire version for the desktop-audio-exclusion status family.
///
/// Existing event tags predate explicit versioning. These events are consumed
/// across the PixelPass/PeerSpeak process boundary and are landing before the
/// PeerSpeak parser, so their version is carried on every record rather than
/// inferred from either binary's package version.
pub(crate) const AUDIO_EXCLUSION_EVENT_VERSION: u8 = 1;
/// Turn JSON event output on. Called once at startup from `--output json`. /// Turn JSON event output on. Called once at startup from `--output json`.
pub fn set_json(enabled: bool) { pub fn set_json(enabled: bool) {
JSON_ENABLED.store(enabled, Ordering::Relaxed); JSON_ENABLED.store(enabled, Ordering::Relaxed);
@@ -63,6 +73,24 @@ pub enum Event<'a> {
/// stream went away. Under `--strict-audio`, `lost` means viewers currently /// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio. /// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState }, AppAudio { state: AppAudioState },
/// One otherwise-eligible playback stream could not be linked safely.
StreamUnsupported {
version: u8,
stream_serial: u64,
reason: &'a str,
},
/// A previously reported per-stream exclusion no longer applies because
/// the stream became capturable or disappeared from the live graph.
StreamStatusCleared { version: u8, stream_serial: u64 },
/// The configured AEC identity never appeared before its validation
/// deadline. Fan-out remains fail-closed.
AecFailed { version: u8, module_index: u64 },
/// A previously validated AEC identity disappeared. Every owned fan-out
/// link is revoked before this transition is reported.
AecRevoked { version: u8, module_index: u64 },
/// An echo-cancel group other than the configured PeerSpeak instance is
/// present and excluded from fan-out.
ForeignAecWarning { version: u8, link_group: &'a str },
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -79,6 +107,65 @@ pub enum AppAudioState {
Lost, Lost,
} }
/// Owned form of the audio-exclusion status family. The PipeWire observer can
/// enqueue this through an unbounded sender without borrowing its snapshot;
/// a Tokio-side forwarder then converts it to the public JSON [`Event`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AudioExclusionEvent {
StreamUnsupported {
stream_serial: u64,
reason: &'static str,
},
StreamStatusCleared {
stream_serial: u64,
},
AecFailed {
module_index: u64,
},
AecRevoked {
module_index: u64,
},
ForeignAecWarning {
link_group: String,
},
}
impl AudioExclusionEvent {
fn as_event(&self) -> Event<'_> {
match self {
Self::StreamUnsupported {
stream_serial,
reason,
} => Event::StreamUnsupported {
version: AUDIO_EXCLUSION_EVENT_VERSION,
stream_serial: *stream_serial,
reason,
},
Self::StreamStatusCleared { stream_serial } => Event::StreamStatusCleared {
version: AUDIO_EXCLUSION_EVENT_VERSION,
stream_serial: *stream_serial,
},
Self::AecFailed { module_index } => Event::AecFailed {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::AecRevoked { module_index } => Event::AecRevoked {
version: AUDIO_EXCLUSION_EVENT_VERSION,
module_index: *module_index,
},
Self::ForeignAecWarning { link_group } => Event::ForeignAecWarning {
version: AUDIO_EXCLUSION_EVENT_VERSION,
link_group,
},
}
}
/// Emit this owned status record through the stable stdout protocol.
pub(crate) fn emit(&self) {
emit(self.as_event());
}
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON /// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these /// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching. /// unconditionally without branching.
@@ -118,4 +205,68 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#); assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
} }
#[test]
fn audio_exclusion_event_wire_shapes_are_versioned_and_exact() {
let unsupported = serde_json::to_string(
&AudioExclusionEvent::StreamUnsupported {
stream_serial: 4_294_967_297,
reason: "link-creation-failed",
}
.as_event(),
)
.unwrap();
assert_eq!(
unsupported,
r#"{"event":"stream_unsupported","version":1,"stream_serial":4294967297,"reason":"link-creation-failed"}"#
);
let cleared = serde_json::to_string(
&AudioExclusionEvent::StreamStatusCleared {
stream_serial: 4_294_967_297,
}
.as_event(),
)
.unwrap();
assert_eq!(
cleared,
r#"{"event":"stream_status_cleared","version":1,"stream_serial":4294967297}"#
);
let failed = serde_json::to_string(
&AudioExclusionEvent::AecFailed {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
failed,
r#"{"event":"aec_failed","version":1,"module_index":536870919}"#
);
let revoked = serde_json::to_string(
&AudioExclusionEvent::AecRevoked {
module_index: 536_870_919,
}
.as_event(),
)
.unwrap();
assert_eq!(
revoked,
r#"{"event":"aec_revoked","version":1,"module_index":536870919}"#
);
let warning = serde_json::to_string(
&AudioExclusionEvent::ForeignAecWarning {
link_group: "echo-cancel-9999-13".to_string(),
}
.as_event(),
)
.unwrap();
assert_eq!(
warning,
r#"{"event":"foreign_aec_warning","version":1,"link_group":"echo-cancel-9999-13"}"#
);
}
} }
+24 -4
View File
@@ -1,12 +1,15 @@
use std::io; use std::io;
#[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
/// Spawn a child process fully detached from this process group. /// Spawn a player detached from PixelPass's process group and console.
/// ///
/// The child gets its own session via `setsid(2)` and null stdio, so it /// On Unix the child gets its own session via `setsid(2)`. On Windows it gets a
/// survives the parent exiting and doesn't take a SIGKILL cascade when /// new process group with no console window. Both paths use null stdio, so the
/// pixelpass dies. /// player can survive PixelPass exiting without holding its terminal open.
/// ///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a /// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches /// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
@@ -18,6 +21,7 @@ use std::process::{Command, Stdio};
/// `fork(2)` followed by non-trivial work in this multithreaded process is /// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.) /// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> { pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
#[cfg(unix)]
let child = unsafe { let child = unsafe {
Command::new(prog) Command::new(prog)
.args(args) .args(args)
@@ -30,9 +34,25 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
}) })
.spawn()? .spawn()?
}; };
#[cfg(windows)]
let child = Command::new(prog)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
// Keep a console player out of PeerSpeak's process group and prevent a
// stray console window. GUI players ignore CREATE_NO_WINDOW and still
// show their normal window.
.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
.spawn()?;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut child = child; let mut child = child;
let _ = child.wait(); let _ = child.wait();
}); });
Ok(()) Ok(())
} }
#[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+3
View File
@@ -1,10 +1,13 @@
#[cfg(unix)]
use anyhow::{Context, Result}; use anyhow::{Context, Result};
#[cfg(unix)]
use tokio::signal::unix::{Signal, SignalKind}; 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 /// 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 /// 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. /// harness, a service manager). Ctrl-c alone covers only the interactive case.
#[cfg(unix)]
pub fn terminate_stream() -> Result<Signal> { pub fn terminate_stream() -> Result<Signal> {
tokio::signal::unix::signal(SignalKind::terminate()) tokio::signal::unix::signal(SignalKind::terminate())
.context("could not install a SIGTERM handler") .context("could not install a SIGTERM handler")
+9 -72
View File
@@ -46,84 +46,13 @@
//! adapter; this module consumes the already-parsed //! adapter; this module consumes the already-parsed
//! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps). //! [`NodeProps::pulse_module_id`](crate::host::taint::snapshot::NodeProps).
#![allow(dead_code)] // Wired into `--aec` parsing + the recompute loop by later phases.
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use crate::common::aec::{AecConfig, AecParseError, parse_aec_arg};
use crate::host::observer::Millis; use crate::host::observer::Millis;
use crate::host::taint::snapshot::GraphSnapshot; 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). /// The validation epoch (v3.4 §5.3, verbatim).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecState { pub enum AecState {
@@ -195,6 +124,14 @@ impl AecValidator {
self.state self.state
} }
/// The configured module index regardless of validation state. Status
/// reporting and foreign-AEC detection need to name the intended identity
/// without treating it as trusted for taint; only
/// [`Self::validated_module_id`] grants that trust.
pub fn configured_module_id(&self) -> Option<u64> {
self.target
}
/// The validated index to place in /// The validated index to place in
/// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) — /// [`ExclusionCtx::aec_module_id`](crate::host::taint::ExclusionCtx) —
/// `Some` **only** in [`AecState::Validated`]. `None` everywhere else, /// `Some` **only** in [`AecState::Validated`]. `None` everywhere else,
+175 -353
View File
@@ -1,21 +1,18 @@
//! Per-app audio routing. //! Connection-owned capture-sink and per-app audio routing.
//! //!
//! Two cooperating layers: //! Two cooperating layers:
//! //!
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink //! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the //! non-lingering per-PID sink named `pixelpass_capture_<pid>`. When
//! default sink's monitor into it. gst captures from the null-sink's //! [`HostOpts::app`] is set, the same actor finds matching
//! monitor, so the viewer hears whatever the user hears — by default. //! `Stream/Output/Audio` nodes and writes `target.object` so WirePlumber
//! reroutes them to that sink. The sink disappears with the actor's PipeWire
//! connection, including after SIGKILL.
//! //!
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread): //! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the //! mirrors the default sink's monitor into the native capture sink. Once at
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose //! least one selected app stream is routed, that loopback is unloaded so the
//! `application.name` matches the filter, and writes //! viewer does not hear the app twice.
//! `target.object` to the "default" metadata so WirePlumber reroutes
//! them to our null-sink. Once at least one stream is actually routed,
//! the loopback is unloaded — otherwise the viewer would hear the
//! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback).
//! //!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves* //! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without //! the chosen app off the sharer's speakers into the null-sink, so without
@@ -26,21 +23,20 @@
//! the first routed stream (after the default-sink loopback is gone, so the //! the first routed stream (after the default-sink loopback is gone, so the
//! two never coexist and feed back) and unloaded when the app stops. //! two never coexist and feed back) and unloaded when the app stops.
//! //!
//! pactl is the right tool for the one-shot null-sink/loopback graph //! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and
//! mutations. libpipewire is dragged in only when per-stream filtering //! only then releases the actor connection and native sink.
//! is requested, because that needs registry-event subscription.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::cell::RefCell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io::{self, Read}; use std::io::{self, Read};
use std::process::{Child, Command, ExitStatus, Stdio}; use std::process::{Child, Command, ExitStatus, Stdio};
use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::contained;
use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome};
use crate::host::health;
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome}; use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape}; use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
@@ -55,10 +51,10 @@ use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
const PACTL_BUDGET: Duration = Duration::from_secs(5); const PACTL_BUDGET: Duration = Duration::from_secs(5);
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1); const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1);
/// Owns the pactl-loaded modules plus, when filtering is active, the /// Owns the native graph actor plus its pactl-loaded dependent modules. Drop
/// libpipewire stream-router thread. Drop unloads modules as a backstop; /// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly,
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can /// which is the only path that can reconcile a load whose outcome was never
/// reconcile a load whose outcome was never observed. /// observed and acknowledge route restoration.
pub struct Routing { pub struct Routing {
/// Every module this host has loaded, is loading, or must ask the server /// Every module this host has loaded, is loading, or must ask the server
/// about. Shared with the event task, which loads and unloads the two /// about. Shared with the event task, which loads and unloads the two
@@ -69,39 +65,42 @@ pub struct Routing {
/// exactly like no load at all and its module was left behind. /// exactly like no load at all and its module was left behind.
ledger: Arc<ModuleLedger>, ledger: Arc<ModuleLedger>,
sink_name: String, sink_name: String,
stream_router: Option<StreamRouter>, graph_owner: Option<AudioGraphOwner>,
event_task: Option<tokio::task::JoinHandle<()>>, event_task: Option<tokio::task::JoinHandle<()>>,
health: health::Reporter,
} }
impl Routing { impl Routing {
/// Create the per-PID null-sink + loopback. If `opts.app` is set, /// Create the per-PID native sink and graph actor, plus the default-monitor
/// also spawn the libpipewire thread that reroutes matching streams. /// loopback when the selected routing mode permits it.
pub async fn start(opts: &HostOpts) -> Result<Self> { pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
let pid = std::process::id(); let pid = std::process::id();
let sink_name = repair_plan::sink_name_for(pid); let sink_name = repair_plan::sink_name_for(pid);
let ledger = ModuleLedger::new(); let ledger = ModuleLedger::new();
// Construct the owner before the first mutation. Any error or cancellation // Construct Routing before either ownership layer mutates the graph. Any
// below now drops a real `Routing`, whose backstop closes, quiesces, // error or cancellation below drops a real owner whose backstop closes,
// reconciles, and unloads this ledger. Previously the owner did not exist // reconciles, and unloads the ledger before releasing the native sink.
// until both initial modules had loaded, so constructor failure leaked
// everything loaded up to that point.
let mut routing = Self { let mut routing = Self {
ledger: Arc::clone(&ledger), ledger: Arc::clone(&ledger),
sink_name: sink_name.clone(), sink_name: sink_name.clone(),
stream_router: None, graph_owner: None,
event_task: None, event_task: None,
health: health.clone(),
}; };
// Every module this host loads carries an ownership token, minted per // S4: the capture sink is a native, non-lingering PipeWire object owned
// load, so `--repair` can tell whose pid the name refers to instead of // by this actor connection, not a module owned by pipewire-pulse. The
// assuming the number means the same thing everywhere. Without it a repair // actor also absorbs the per-app router so every sink-owning mode has one
// run in another pid namespace can unload a live host's audio; see // graph lifetime and one readiness handshake.
// `repair::plan::OwnerToken`. That same per-load nonce is what lets let (graph_owner, mut event_rx, _identity_rx) = AudioGraphOwner::start(
// reconciliation identify a module whose load was interrupted before its opts.app.clone(),
// index was ever read. CaptureSinkSpec::for_pid(pid),
load_module(&ledger, Shape::LegacyCaptureSink, pid) health.clone(),
)
.await .await
.context("failed to load module-null-sink")?; .context("failed to start the connection-owned audio graph")?;
debug_assert_eq!(graph_owner.identity().name, sink_name);
routing.graph_owner = Some(graph_owner);
// 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
// must hear *only* the chosen app, never the whole desktop (which would // must hear *only* the chosen app, never the whole desktop (which would
@@ -112,27 +111,31 @@ impl Routing {
// 20ms loopback latency keeps the mirrored audio tight; pactl's // 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible. // default of 200ms is enough to be perceptible.
let strict_app = opts.app.is_some() && opts.strict_audio; let strict_app = opts.app.is_some() && opts.strict_audio;
if !strict_app { if !strict_app
load_module(&ledger, Shape::LoopbackIntoCapture, pid) && let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await
.await {
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?; // Once the actor exists, an ordinary constructor error gets a full
// async teardown rather than falling through the narrower
// synchronous Drop backstop and quarantining a responsive thread.
routing.shutdown().await;
return Err(error)
.context("failed to load module-loopback (connection-owned sink cleaned up)");
} }
tracing::info!( tracing::info!(
strict_app, strict_app,
%sink_name, %sink_name,
"audio routing: null-sink ready (loopback skipped in strict app mode)" "audio routing: connection-owned sink ready (loopback skipped in strict app mode)"
); );
if let Some(app) = &opts.app { if opts.app.is_some() {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let ledger_for_task = Arc::clone(&ledger); let ledger_for_task = Arc::clone(&ledger);
let strict = opts.strict_audio; let strict = opts.strict_audio;
let event_task = tokio::spawn(async move { let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState}; use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await { while let Some(ev) = event_rx.recv().await {
match ev { match ev {
Event::FirstRoutedStream => { GraphEvent::FirstRoutedStream => {
tracing::info!( tracing::info!(
"audio routing: first stream routed → unloading default-sink loopback" "audio routing: first stream routed → unloading default-sink loopback"
); );
@@ -159,7 +162,7 @@ impl Routing {
state: AppAudioState::Routed, state: AppAudioState::Routed,
}); });
} }
Event::LastRoutedStreamGone => { GraphEvent::LastRoutedStreamGone => {
// Routed app exited/paused mid-session. Notify the // Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode. // front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio { output::emit(output::Event::AppAudio {
@@ -199,7 +202,6 @@ impl Routing {
} }
} }
}); });
routing.stream_router = Some(router);
routing.event_task = Some(event_task); routing.event_task = Some(event_task);
} }
@@ -220,10 +222,10 @@ impl Routing {
&self.sink_name &self.sink_name
} }
/// Stop the stream router and the event task, settle anything the ledger is /// Quiesce graph mutations, stop the event task, settle anything the ledger
/// unsure about, then unload every module in shape order — the loopbacks /// is unsure about, unload every dependent loopback, then release the native
/// before the sink they reference, because PipeWire can leave zombie links if /// sink. PipeWire can leave zombie links if a sink is destroyed with active
/// a sink is destroyed with active inputs. /// inputs, so that final ordering is load-bearing.
/// ///
/// The event task is **awaited, not merely aborted**. Aborting and walking /// The event task is **awaited, not merely aborted**. Aborting and walking
/// away is what left orphans behind: the task's load is an await point now, /// away is what left orphans behind: the task's load is an await point now,
@@ -233,31 +235,44 @@ impl Routing {
pub async fn shutdown(mut self) { pub async fn shutdown(mut self) {
// Closing is synchronous and happens first: after this point the event // Closing is synchronous and happens first: after this point the event
// task cannot register another mutation even if it receives one last // task cannot register another mutation even if it receives one last
// router event while shutdown is in progress. // graph event while shutdown is in progress.
self.ledger.close(); self.ledger.close();
if let Some(router) = self.stream_router.take() { let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() {
// ⚠️ Still an unbounded join: a wedged PipeWire thread parks this graph_owner.quiesce().await == QuiesceOutcome::Confirmed
// task indefinitely. That is the pre-existing defect S3b exists for. } else {
// Nothing here makes it worse, and the ledger is what will make true
// bounding it safe when it lands. };
router.shutdown();
}
if let Some(mut task) = self.event_task.take() { if let Some(mut task) = self.event_task.take() {
// The router's exit drops the event senders, so the task normally if !graph_quiesced {
// ends by itself. Abort is the fallback, and it is awaited through // An unresponsive graph actor may still own its event sender.
// `&mut JoinHandle` so the future is genuinely dropped — and with it // Cancel and await the task before ledger reconciliation.
// any in-flight permit — before reconciliation reads the ledger. task.abort();
// Dropping the handle instead would *detach* the task, which is how a let _ = task.await;
// load could still land after teardown believed it was finished. } else {
if tokio::time::timeout(PACTL_BUDGET, &mut task).await.is_err() { // Quiesce closes the event sender while retaining the native
// sink. Abort is the fallback and is awaited through `&mut
// JoinHandle`, so any in-flight affine permit is dropped before
// reconciliation reads the ledger.
match tokio::time::timeout(PACTL_BUDGET, &mut task).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
self.health
.poison(format!("audio routing event task failed: {e}"));
}
Err(_) => {
tracing::warn!( tracing::warn!(
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \ "audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
cancelling it" cancelling it"
); );
self.health.poison(format!(
"audio routing event task did not stop within {PACTL_BUDGET:?}"
));
task.abort(); task.abort();
let _ = task.await; let _ = task.await;
} }
} }
}
}
// A cancelled `spawn_blocking` await detaches its worker. Every worker is // A cancelled `spawn_blocking` await detaches its worker. Every worker is
// registered before it can be spawned, so this is a real ordering // registered before it can be spawned, so this is a real ordering
@@ -269,15 +284,29 @@ impl Routing {
.await .await
{ {
tracing::warn!("audio routing: module-operation wait task failed: {e}"); tracing::warn!("audio routing: module-operation wait task failed: {e}");
self.health
.poison(format!("audio module-operation wait task failed: {e}"));
} }
cleanup_modules(&self.ledger).await; cleanup_modules(&self.ledger).await;
// Loopbacks are gone before the actor connection is released. This is
// the S4 ordering invariant: dependent Pulse modules never outlive the
// native sink they reference during an ordinary shutdown.
if let Some(graph_owner) = self.graph_owner.take()
&& !graph_owner.shutdown().await
{
tracing::warn!("audio routing: AudioGraphOwner shutdown was not confirmed");
}
if !self.ledger.is_clean() { if !self.ledger.is_clean() {
tracing::warn!( tracing::warn!(
settled = self.ledger.is_settled(), settled = self.ledger.is_settled(),
"audio routing: some audio modules could not be removed safely; \ "audio routing: some audio modules could not be removed safely; \
`pixelpass --repair` will clean up anything left behind" `pixelpass --repair` will clean up anything left behind"
); );
self.health.poison(
"audio routing teardown left module ownership unresolved; repair is required",
);
} }
} }
} }
@@ -291,20 +320,24 @@ impl Drop for Routing {
/// After a completed `shutdown` the ledger holds nothing and this does nothing. /// After a completed `shutdown` the ledger holds nothing and this does nothing.
fn drop(&mut self) { fn drop(&mut self) {
self.ledger.close(); self.ledger.close();
if let Some(router) = self.stream_router.take() {
router.shutdown();
}
if let Some(task) = self.event_task.take() { if let Some(task) = self.event_task.take() {
task.abort(); task.abort();
} }
self.ledger.close_and_wait(); self.ledger.close_and_wait();
cleanup_modules_blocking(&self.ledger); cleanup_modules_blocking(&self.ledger);
// Keep the actor/sink alive until dependent modules have been handled,
// even on this synchronous error/unwind backstop.
if let Some(graph_owner) = self.graph_owner.take() {
drop(graph_owner);
}
if !self.ledger.is_clean() { if !self.ledger.is_clean() {
tracing::warn!( tracing::warn!(
settled = self.ledger.is_settled(), settled = self.ledger.is_settled(),
"audio routing: torn down with modules that could not be removed safely; \ "audio routing: torn down with modules that could not be removed safely; \
run `pixelpass --repair` to clean up anything left behind" run `pixelpass --repair` to clean up anything left behind"
); );
self.health
.poison("audio routing Drop left module ownership unresolved; repair is required");
} }
} }
} }
@@ -635,7 +668,7 @@ impl Drop for ReapedChild {
if self.reaped { if self.reaped {
return; return;
} }
let _ = self.child.kill(); self.kill_group();
match self.reap_within(PACTL_REAP_BUDGET) { match self.reap_within(PACTL_REAP_BUDGET) {
Ok(Some(_)) => {} Ok(Some(_)) => {}
Ok(None) => tracing::warn!( Ok(None) => tracing::warn!(
@@ -647,6 +680,12 @@ impl Drop for ReapedChild {
} }
impl ReapedChild { impl ReapedChild {
fn kill_group(&mut self) {
let _ = contained::signal_group(self.child.id(), nix::sys::signal::Signal::SIGKILL);
// Backstop in case the group disappeared between lookup and signal.
let _ = self.child.kill();
}
fn reap_within(&mut self, budget: Duration) -> io::Result<Option<ExitStatus>> { fn reap_within(&mut self, budget: Duration) -> io::Result<Option<ExitStatus>> {
let deadline = Instant::now() + budget; let deadline = Instant::now() + budget;
loop { loop {
@@ -665,7 +704,7 @@ impl ReapedChild {
fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<BoundedOutput> { fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<BoundedOutput> {
command.stdout(Stdio::piped()).stderr(Stdio::piped()); command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = ReapedChild { let mut child = ReapedChild {
child: command.spawn()?, child: contained::spawn(command)?,
reaped: false, reaped: false,
}; };
let stdout = child let stdout = child
@@ -700,7 +739,7 @@ fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<Bounded
break (status, false); break (status, false);
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
let _ = child.child.kill(); child.kill_group();
let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else { let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::TimedOut, io::ErrorKind::TimedOut,
@@ -779,210 +818,6 @@ fn cleanup_modules_blocking(ledger: &Arc<ModuleLedger>) {
} }
} }
// ──────────────────────────────────────────────────────────────────────
// Per-stream routing (libpipewire thread)
// ──────────────────────────────────────────────────────────────────────
/// Command from tokio → libpipewire thread.
enum Cmd {
/// Clear `target.object` for everything we routed, then quit the
/// MainLoop so the thread joins.
Shutdown,
}
/// Event from libpipewire thread → tokio. The pair drives loopback
/// oscillation: unload on `FirstRoutedStream`, re-load on
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
/// respectively), not on every change.
enum Event {
/// At least one stream is now routed to our sink. Receiver unloads
/// the default-sink loopback so the filtered audio isn't doubled.
FirstRoutedStream,
/// The last routed stream just disappeared (app closed, paused,
/// switched output). Receiver re-loads the default-sink loopback so
/// the viewer doesn't go silent.
LastRoutedStreamGone,
}
/// Handle to the libpipewire stream-router thread.
pub struct StreamRouter {
cmd_tx: pipewire::channel::Sender<Cmd>,
thread: Option<JoinHandle<()>>,
}
impl StreamRouter {
/// Spawn the libpipewire thread. Returns the router handle and the
/// event receiver tokio side polls.
fn spawn(
filter_name: String,
sink_name: String,
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
let thread = std::thread::Builder::new()
.name("pixelpass-pw-router".to_string())
.spawn(move || {
if let Err(e) = run_router(filter_name, sink_name, cmd_rx, event_tx) {
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
}
})
.context("failed to spawn libpipewire router thread")?;
Ok((
Self {
cmd_tx,
thread: Some(thread),
},
event_rx,
))
}
fn shutdown(mut self) {
// Best-effort: if the send fails the thread is already gone.
let _ = self.cmd_tx.send(Cmd::Shutdown);
if let Some(t) = self.thread.take()
&& let Err(e) = t.join()
{
tracing::warn!("audio routing: pw thread join failed: {e:?}");
}
}
}
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
/// all PipeWire proxies for the duration of the routing session.
fn run_router(
filter_name: String,
sink_name: String,
cmd_rx: pipewire::channel::Receiver<Cmd>,
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
) -> Result<()> {
use pipewire::{self as pw, types::ObjectType};
let main_loop =
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
let context =
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
let core = context
.connect_rc(None)
.context("pw core connect failed (is the daemon running?)")?;
let registry = core.get_registry_rc().context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(RouterState {
sink_serial: None,
default_metadata: None,
routed_node_ids: Vec::new(),
pending: Vec::new(),
}));
// Cmd handler: clear metadata for routed streams, then quit.
let main_loop_for_cmd = main_loop.clone();
let state_for_cmd = Rc::clone(&state);
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
Cmd::Shutdown => {
let s = state_for_cmd.borrow();
if let Some(meta) = &s.default_metadata {
for &nid in &s.routed_node_ids {
meta.set_property(nid, "target.object", None, None);
}
if !s.routed_node_ids.is_empty() {
tracing::info!(
n = s.routed_node_ids.len(),
"audio routing: cleared target.object on routed streams before quitting"
);
}
}
main_loop_for_cmd.quit();
}
});
let filter_lower = filter_name.to_ascii_lowercase();
let sink_name_owned = sink_name.clone();
let registry_weak = registry.downgrade();
let state_for_reg = Rc::clone(&state);
let event_tx_for_reg = event_tx.clone();
let state_for_remove = Rc::clone(&state);
let event_tx_for_remove = event_tx.clone();
let _reg_listener = registry
.add_listener_local()
.global(move |obj| {
let Some(reg) = registry_weak.upgrade() else {
return;
};
match obj.type_ {
ObjectType::Node => {
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) {
match props.get("object.serial").and_then(parse_object_serial) {
Some(serial) => {
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
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;
}
if props.get("media.class") != Some("Stream/Output/Audio") {
return;
}
let Some(app) = props.get("application.name") else {
return;
};
if !app.eq_ignore_ascii_case(&filter_lower) {
return;
}
tracing::info!(
node_id = obj.id,
%app,
"audio routing: matched stream, queued for route"
);
state_for_reg.borrow_mut().pending.push(obj.id);
try_flush(&state_for_reg, &event_tx_for_reg);
}
ObjectType::Metadata => {
let Some(props) = obj.props.as_ref() else {
return;
};
if props.get("metadata.name") != Some("default") {
return;
}
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
Ok(m) => m,
Err(e) => {
tracing::warn!("audio routing: bind default metadata failed: {e}");
return;
}
};
state_for_reg.borrow_mut().default_metadata = Some(metadata);
tracing::info!("audio routing: default metadata bound");
try_flush(&state_for_reg, &event_tx_for_reg);
}
_ => {}
}
})
.global_remove(move |id| {
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
})
.register();
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
main_loop.run();
tracing::info!("audio routing: pw thread exiting");
Ok(())
}
/// Parse a PipeWire `object.serial` property value. /// Parse a PipeWire `object.serial` property value.
/// ///
/// `object.serial` is a **64-bit** monotonically-increasing counter /// `object.serial` is a **64-bit** monotonically-increasing counter
@@ -1003,80 +838,14 @@ pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
raw.parse::<u64>().ok() raw.parse::<u64>().ok()
} }
struct RouterState {
/// 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>,
routed_node_ids: Vec<u32>,
pending: Vec<u32>,
}
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
/// was the last routed stream, emit `LastRoutedStreamGone` so the
/// tokio side restores the default-sink loopback.
fn handle_global_remove(
state: &Rc<RefCell<RouterState>>,
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
id: u32,
) {
let mut s = state.borrow_mut();
let was_routed = !s.routed_node_ids.is_empty();
s.routed_node_ids.retain(|&x| x != id);
s.pending.retain(|&x| x != id);
if was_routed && s.routed_node_ids.is_empty() {
tracing::info!(
node_id = id,
"audio routing: last routed stream disappeared"
);
let _ = event_tx.send(Event::LastRoutedStreamGone);
}
}
/// Drain pending streams to the sink, but only once both prerequisites
/// (sink serial known + default metadata bound) are in place. Emits
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
/// each time the count comes back up from zero, not just the first
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
fn try_flush(
state: &Rc<RefCell<RouterState>>,
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
) {
let mut s = state.borrow_mut();
let Some(serial) = s.sink_serial else { return };
if s.default_metadata.is_none() {
return;
}
if s.pending.is_empty() {
return;
}
let was_empty = s.routed_node_ids.is_empty();
let serial_str = serial.to_string();
let pending = std::mem::take(&mut s.pending);
if let Some(meta) = &s.default_metadata {
for nid in &pending {
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
tracing::info!(
node_id = *nid,
sink_serial = serial,
"audio routing: stream routed to pixelpass sink"
);
}
}
s.routed_node_ids.extend(pending);
if was_empty && !s.routed_node_ids.is_empty() {
let _ = event_tx.send(Event::FirstRoutedStream);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::host::ledger::SlotState; use crate::host::ledger::SlotState;
use crate::repair::plan::{ModuleObservation, classify}; use crate::repair::plan::{ModuleObservation, classify};
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event /// Whole-desktop routing: the graph actor owns the native sink, while the
/// task — just the null-sink and its default-sink loopback. /// ledger owns only its default-monitor loopback.
fn whole_desktop_opts() -> HostOpts { fn whole_desktop_opts() -> HostOpts {
HostOpts { HostOpts {
window: false, window: false,
@@ -1090,10 +859,30 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None, relay: None,
} }
} }
#[test]
fn bounded_module_worker_uses_the_contained_spawn_path() {
let mut command = Command::new("sh");
command.args([
"-c",
"read pid comm state ppid pgrp rest < /proc/self/stat; printf '%s %s' \"$pid\" \"$pgrp\"",
]);
let output = bounded_output(&mut command, Duration::from_secs(1))
.expect("run contained module-worker fixture");
assert!(output.status.success());
let ids = String::from_utf8(output.stdout).expect("ascii pid/pgid");
let mut ids = ids.split_whitespace();
let pid = ids.next().expect("child pid");
let pgid = ids.next().expect("child process group");
assert_eq!(pid, pgid, "module worker must lead its own process group");
}
/// The module table exactly as `--repair` observes it. /// The module table exactly as `--repair` observes it.
fn module_snapshot() -> Vec<(u32, String, String)> { fn module_snapshot() -> Vec<(u32, String, String)> {
let mut session = let mut session =
@@ -1148,7 +937,8 @@ mod tests {
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"] #[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
async fn live_teardown_leaves_the_module_table_as_it_found_it() { async fn live_teardown_leaves_the_module_table_as_it_found_it() {
let before = module_snapshot(); let before = module_snapshot();
let routing = Routing::start(&whole_desktop_opts()) let (health, _) = health::channel();
let routing = Routing::start(&whole_desktop_opts(), health)
.await .await
.expect("routing starts"); .expect("routing starts");
@@ -1159,8 +949,8 @@ mod tests {
.collect(); .collect();
assert_eq!( assert_eq!(
ours.len(), ours.len(),
2, 1,
"the null-sink and its default-sink loopback must both be loaded" "only the default-monitor loopback is a Pulse module; the sink is native"
); );
for (id, name, args) in ours { for (id, name, args) in ours {
let fp = classify(&ModuleObservation::new(*id, name, args)) let fp = classify(&ModuleObservation::new(*id, name, args))
@@ -1180,6 +970,38 @@ mod tests {
); );
} }
/// Exercise the real graph-actor command path with app routing enabled. The
/// deliberately unmatched filter avoids moving an unrelated live stream.
#[tokio::test]
#[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"]
async fn live_audio_graph_owner_stops_within_its_policy_budget() {
let before = module_snapshot();
let mut opts = whole_desktop_opts();
opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string());
opts.strict_audio = true;
let (health, _) = health::channel();
let routing = Routing::start(&opts, health.clone())
.await
.expect("per-app routing starts");
// Let the OS thread reach its normal running phase so this covers the
// tighter steady-state budget rather than only startup containment.
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::timeout(Duration::from_secs(10), routing.shutdown())
.await
.expect("actor and graph teardown stay globally bounded");
assert!(
health.fault().is_none(),
"an observed shutdown and successful join must remain healthy"
);
assert_eq!(
module_snapshot(),
before,
"per-app teardown must leave the module table byte-identical"
);
}
/// The orphan race, staged against a real server: a load cancelled while /// The orphan race, staged against a real server: a load cancelled while
/// `pactl` is in flight must still be findable and removable. /// `pactl` is in flight must still be findable and removable.
/// ///
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -8,18 +8,20 @@ use anyhow::Result;
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::display::DisplayServer; use crate::common::display::DisplayServer;
use crate::host::health;
use crate::host::pipeline::CaptureHandle; use crate::host::pipeline::CaptureHandle;
use crate::host::quality::EffectiveQuality; use crate::host::quality::EffectiveQuality;
use crate::host::{wayland, x11}; use crate::host::{wayland, x11};
pub async fn spawn( pub(super) async fn spawn(
display: DisplayServer, display: DisplayServer,
opts: &HostOpts, opts: &HostOpts,
quality: &EffectiveQuality, quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> { ) -> Result<CaptureHandle> {
match display { match display {
DisplayServer::Wayland => wayland::start(opts, quality).await, DisplayServer::Wayland => wayland::start(opts, quality, health).await,
DisplayServer::X11 => x11::start(opts, quality).await, DisplayServer::X11 => x11::start(opts, quality, health).await,
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"), DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
} }
} }
+1699
View File
File diff suppressed because it is too large Load Diff
+1861
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
//! Terminal health shared by capture components and the host supervisor.
//!
//! A capture-side ownership failure is not recoverable inside the same host
//! process: a wedged PipeWire owner or an unaccounted Pulse module can collide
//! with the next capture. Health is therefore one-way (`Healthy -> Poisoned`)
//! and first-fault-wins so a later, less precise teardown symptom cannot erase
//! the original cause.
use std::sync::Arc;
use tokio::sync::watch;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum State {
Healthy,
Poisoned(Arc<str>),
}
#[derive(Clone)]
pub(super) struct Reporter {
tx: watch::Sender<State>,
}
pub(super) struct Monitor {
rx: watch::Receiver<State>,
}
pub(super) fn channel() -> (Reporter, Monitor) {
let (tx, rx) = watch::channel(State::Healthy);
(Reporter { tx }, Monitor { rx })
}
impl Reporter {
/// Poison the host exactly once. Returns true for the first fault.
pub(super) fn poison(&self, reason: impl Into<Arc<str>>) -> bool {
let reason = reason.into();
self.tx.send_if_modified(move |state| {
if matches!(state, State::Healthy) {
*state = State::Poisoned(reason);
true
} else {
false
}
})
}
#[cfg(test)]
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.tx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
}
impl Monitor {
pub(super) fn fault(&self) -> Option<Arc<str>> {
match &*self.rx.borrow() {
State::Healthy => None,
State::Poisoned(reason) => Some(Arc::clone(reason)),
}
}
pub(super) async fn changed(&mut self) {
// The supervisor owns a Reporter for the whole run, so closure is not a
// normal state. Treat it like a wake and let `fault()` decide.
let _ = self.rx.changed().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn poison_is_terminal_and_first_fault_wins() {
let (reporter, mut monitor) = channel();
assert!(reporter.poison("first"));
monitor.changed().await;
assert_eq!(monitor.fault().as_deref(), Some("first"));
assert!(!reporter.poison("second"));
assert_eq!(reporter.fault().as_deref(), Some("first"));
}
}
+114 -3
View File
@@ -1,9 +1,14 @@
pub mod aec; pub mod aec;
pub mod audio; pub mod audio;
mod audio_plan;
pub mod audit; pub mod audit;
mod capture; mod capture;
mod fanout;
mod graph;
mod health;
pub mod ledger; pub mod ledger;
mod observer; mod observer;
mod owned_thread;
mod pipeline; mod pipeline;
mod quality; mod quality;
mod serve; mod serve;
@@ -127,12 +132,15 @@ pub async fn run(opts: HostOpts) -> Result<()> {
}); });
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16); let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
let (capture_health, capture_health_monitor) = health::channel();
let supervisor = tokio::spawn(supervise( let supervisor = tokio::spawn(supervise(
opts.clone(), opts.clone(),
quality, quality,
display, display,
resolution.value, resolution.value,
sup_rx, sup_rx,
(capture_health, capture_health_monitor),
cancel.clone(),
)); ));
// Command channel for the GUI front-end: read `kick <endpoint-id>` lines // Command channel for the GUI front-end: read `kick <endpoint-id>` lines
@@ -289,14 +297,46 @@ async fn supervise(
display: DisplayServer, display: DisplayServer,
max_viewers: u32, max_viewers: u32,
mut rx: mpsc::Receiver<SupervisorMsg>, mut rx: mpsc::Receiver<SupervisorMsg>,
capture_health: (health::Reporter, health::Monitor),
host_cancel: CancellationToken,
) { ) {
let (capture_health, mut capture_health_monitor) = capture_health;
let mut handle: Option<CaptureHandle> = None; let mut handle: Option<CaptureHandle> = None;
// Active viewers, keyed by endpoint id, holding each one's kill switch. // Active viewers, keyed by endpoint id, holding each one's kill switch.
// The count is just `viewers.len()`. (A given endpoint connecting twice is // The count is just `viewers.len()`. (A given endpoint connecting twice is
// a non-case here: each viewer process uses a fresh ephemeral identity.) // a non-case here: each viewer process uses a fresh ephemeral identity.)
let mut viewers: HashMap<String, CancellationToken> = HashMap::new(); let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
while let Some(msg) = rx.recv().await { loop {
// Poison is terminal for this host process. A surviving wedged owner or
// an unaccounted graph mutation can collide with a later capture, so do
// not detach it and keep advertising the ticket. Cancelling the host
// closes the endpoint; PeerSpeak's S2 EOF path then clears presence.
if let Some(reason) = capture_health_monitor.fault() {
tracing::error!(%reason, "capture supervisor poisoned — stopping host");
host_cancel.cancel();
for cancel in viewers.values() {
cancel.cancel();
}
if let Some(h) = handle.take() {
h.shutdown().await;
output::emit(output::Event::Capture {
state: output::CaptureState::Stopped,
});
}
break;
}
let msg = tokio::select! {
// Health wins a simultaneous race with another viewer request: a
// poisoned host must not begin one more capture.
biased;
_ = capture_health_monitor.changed() => continue,
msg = rx.recv() => msg,
};
let Some(msg) = msg else {
break;
};
match msg { match msg {
SupervisorMsg::AddViewer { id, cancel, reply } => { SupervisorMsg::AddViewer { id, cancel, reply } => {
let count = viewers.len() as u32; let count = viewers.len() as u32;
@@ -310,8 +350,37 @@ async fn supervise(
if handle.is_none() { if handle.is_none() {
tracing::info!("first viewer arriving — spawning capture"); tracing::info!("first viewer arriving — spawning capture");
match capture::spawn(display, &opts, &quality).await { let spawned = tokio::select! {
// A subsystem can fail while the portal/GStreamer setup
// future is still running. Do not wait for setup to
// return and then admit one viewer to an already-poisoned
// capture.
biased;
_ = capture_health_monitor.changed() => {
let reason = capture_health_monitor
.fault()
.unwrap_or_else(|| "capture health channel changed unexpectedly".into());
let _ = reply.send(Err(format!(
"capture ownership failed during startup: {reason}"
)));
continue;
}
result = capture::spawn(
display,
&opts,
&quality,
capture_health.clone(),
) => result,
};
match spawned {
Ok(h) => { Ok(h) => {
if let Some(reason) = capture_health_monitor.fault() {
h.shutdown().await;
let _ = reply.send(Err(format!(
"capture ownership failed during startup: {reason}"
)));
continue;
}
handle = Some(h); handle = Some(h);
output::emit(output::Event::Capture { output::emit(output::Event::Capture {
state: output::CaptureState::Started, state: output::CaptureState::Started,
@@ -498,7 +567,9 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String { fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()]; let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app { if opts.capture_mode == crate::cli::CaptureMode::DesktopExcluding {
bits.push("desktop-excluding-audio".to_string());
} else if let Some(app) = &opts.app {
if opts.strict_audio { if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)")); bits.push(format!("app-audio={app} (strict)"));
} else { } else {
@@ -528,6 +599,9 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers: None, max_viewers: None,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None, relay: None,
} }
} }
@@ -551,6 +625,13 @@ mod tests {
capture_summary(&opts(None, true)), capture_summary(&opts(None, true)),
"fullscreen + system-audio" "fullscreen + system-audio"
); );
let mut desktop_excluding = opts(None, false);
desktop_excluding.capture_mode = crate::cli::CaptureMode::DesktopExcluding;
assert_eq!(
capture_summary(&desktop_excluding),
"fullscreen + desktop-excluding-audio"
);
} }
#[test] #[test]
@@ -568,4 +649,34 @@ mod tests {
assert_eq!(initial_app_audio_state(&opts(None, true)), None); assert_eq!(initial_app_audio_state(&opts(None, true)), None);
assert_eq!(initial_app_audio_state(&opts(None, false)), None); assert_eq!(initial_app_audio_state(&opts(None, false)), None);
} }
#[tokio::test]
async fn supervisor_health_poison_cancels_the_host_with_command_channel_open() {
let opts = opts(None, false);
let quality = quality::resolve(&opts, 1);
let (tx, rx) = mpsc::channel(1);
let (health, monitor) = health::channel();
let cancel = CancellationToken::new();
let supervisor = tokio::spawn(supervise(
opts,
quality,
DisplayServer::Unknown,
1,
rx,
(health.clone(), monitor),
cancel.clone(),
));
assert!(health.poison("test ownership fault"));
tokio::time::timeout(Duration::from_secs(1), supervisor)
.await
.expect("poisoned supervisor must stop promptly")
.expect("supervisor task must not panic");
assert!(cancel.is_cancelled());
// The sender deliberately stayed open until the supervisor exited. If
// the health arm were removed, the task above would still be blocked on
// `rx.recv()` and the timeout would fail.
drop(tx);
}
} }
+504 -32
View File
@@ -1,19 +1,26 @@
//! PipeWire I/O adapter for the pure registry observer. //! PipeWire I/O adapter for the registry observer and Phase-6 link owner.
//! //!
//! This module owns a read-only PipeWire main-loop thread, translates registry //! The default entry points are read-only: they translate registry callbacks
//! callbacks into [`RegEvent`]s, and publishes the latest [`Projection`] for //! into [`RegEvent`]s and publish the latest [`Projection`]. The explicit
//! consumers running outside the PipeWire thread. //! mutating entry point additionally owns retained non-lingering fan-out Link
//! proxies on that same ordered main-loop thread. The Phase-5 audit can only
//! receive the read-only trait and therefore cannot reach the mutator.
use super::classify::{DeviceClaim, DeviceProps}; use super::classify::{DeviceClaim, DeviceProps};
use super::{ use super::{
EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel, EventKind, LinkEndpoints, NodeObservation, Outcome, Projection, RegEvent, RegistryModel,
}; };
use crate::host::audio::parse_object_serial; use crate::host::audio::parse_object_serial;
use crate::host::fanout::{
DesiredLink, LinkMutation, ManagedLinkState, MutationProjectionSink, revalidated_links,
};
use crate::host::taint::snapshot::{ use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial, ClientSnapshot, GlobalId, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
StreamFormat,
}; };
use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE}; use crate::host::taint::{PEERSPEAK_OWNED_PROP, PEERSPEAK_OWNED_VALUE};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use pipewire::proxy::ProxyT;
use pipewire::{self as pw, types::ObjectType}; use pipewire::{self as pw, types::ObjectType};
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -25,6 +32,16 @@ use std::time::{Duration, Instant};
const READINESS_TIMEOUT_MILLIS: u64 = 2_000; const READINESS_TIMEOUT_MILLIS: u64 = 2_000;
const TICK_INTERVAL: Duration = Duration::from_millis(250); const TICK_INTERVAL: Duration = Duration::from_millis(250);
fn recoverable_core_error(result: i32) -> bool {
// A global can disappear after registry enumeration but before (or while)
// this observer's bound proxy finishes an operation. PipeWire reports that
// ordinary churn as asynchronous -ENOENT on the Core. The model's removal
// event and serial revalidation still fail closed, so terminating the
// mutation owner here would make sink recreation impossible without
// adding safety. Other Core failures remain fatal.
result == -(nix::errno::Errno::ENOENT as i32)
}
/// A consumer that sees **every** projection, one per applied registry event, /// A consumer that sees **every** projection, one per applied registry event,
/// on the observer thread. /// on the observer thread.
/// ///
@@ -49,10 +66,31 @@ pub trait ProjectionSink: Send {
/// Tokio-side access to the observer's most recent coherent projection. /// Tokio-side access to the observer's most recent coherent projection.
pub struct RegistryObserverHandle { pub struct RegistryObserverHandle {
latest: Arc<Mutex<Option<Projection>>>, latest: Arc<Mutex<Option<Projection>>>,
shutdown_tx: pw::channel::Sender<()>, command_tx: pw::channel::Sender<ObserverCommand>,
thread: Option<JoinHandle<()>>, thread: Option<JoinHandle<()>>,
} }
enum ObserverCommand {
ReplaceCaptureSink(Serial),
Shutdown,
}
/// Cloneable Tokio-side capability for the one mutation-controller command
/// needed during capture-sink recreation. The serial is consumed on the
/// observer's PipeWire thread; callers never receive or reuse a global id.
#[derive(Clone)]
pub(in crate::host) struct FanoutControl {
command_tx: pw::channel::Sender<ObserverCommand>,
}
impl FanoutControl {
pub(in crate::host) fn replace_capture_sink(&self, capture_sink: Serial) -> bool {
self.command_tx
.send(ObserverCommand::ReplaceCaptureSink(capture_sink))
.is_ok()
}
}
impl RegistryObserverHandle { impl RegistryObserverHandle {
/// Spawn the read-only PipeWire registry observer. /// Spawn the read-only PipeWire registry observer.
pub fn spawn() -> Result<Self> { pub fn spawn() -> Result<Self> {
@@ -65,23 +103,41 @@ impl RegistryObserverHandle {
/// exits, which is what lets a sink emit a final summary on shutdown without /// exits, which is what lets a sink emit a final summary on shutdown without
/// the caller arranging one. /// the caller arranging one.
pub fn spawn_with_sink(sink: Option<Box<dyn ProjectionSink>>) -> Result<Self> { pub fn spawn_with_sink(sink: Option<Box<dyn ProjectionSink>>) -> Result<Self> {
Self::spawn_with_consumer(ObserverConsumer::ReadOnly(sink))
}
/// Spawn the observer with the explicit Phase-6 mutation capability.
/// This is intentionally a different entry point from `spawn_with_sink`:
/// the Phase-5 audit's trait has no path to a PipeWire mutator.
pub(in crate::host) fn spawn_with_mutation_sink(
sink: Box<dyn MutationProjectionSink>,
health: crate::host::health::Reporter,
) -> Result<Self> {
Self::spawn_with_consumer(ObserverConsumer::Mutating { sink, health })
}
fn spawn_with_consumer(consumer: ObserverConsumer) -> Result<Self> {
let mutation_health = consumer.mutation_health();
let latest = Arc::new(Mutex::new(None)); let latest = Arc::new(Mutex::new(None));
let latest_for_thread = Arc::clone(&latest); let latest_for_thread = Arc::clone(&latest);
let (shutdown_tx, shutdown_rx) = pw::channel::channel::<()>(); let (command_tx, command_rx) = pw::channel::channel::<ObserverCommand>();
let thread = std::thread::Builder::new() let thread = std::thread::Builder::new()
.name("pixelpass-pw-observer".to_string()) .name("pixelpass-pw-observer".to_string())
.spawn(move || { .spawn(move || {
if let Err(e) = run_observer(latest_for_thread, shutdown_rx, sink) { if let Err(e) = run_observer(latest_for_thread, command_rx, consumer) {
tracing::warn!( tracing::warn!(
"registry observer: libpipewire thread exited with error: {e:#}" "registry observer: libpipewire thread exited with error: {e:#}"
); );
if let Some(health) = mutation_health {
health.poison(format!("audio fan-out observer failed: {e:#}"));
}
} }
}) })
.context("failed to spawn libpipewire registry observer thread")?; .context("failed to spawn libpipewire registry observer thread")?;
Ok(Self { Ok(Self {
latest, latest,
shutdown_tx, command_tx,
thread: Some(thread), thread: Some(thread),
}) })
} }
@@ -94,11 +150,34 @@ impl RegistryObserverHandle {
.unwrap_or_else(|poisoned| poisoned.into_inner()) .unwrap_or_else(|poisoned| poisoned.into_inner())
.clone() .clone()
} }
pub(in crate::host) fn fanout_control(&self) -> FanoutControl {
FanoutControl {
command_tx: self.command_tx.clone(),
}
}
}
enum ObserverConsumer {
ReadOnly(Option<Box<dyn ProjectionSink>>),
Mutating {
sink: Box<dyn MutationProjectionSink>,
health: crate::host::health::Reporter,
},
}
impl ObserverConsumer {
fn mutation_health(&self) -> Option<crate::host::health::Reporter> {
match self {
Self::ReadOnly(_) => None,
Self::Mutating { health, .. } => Some(health.clone()),
}
}
} }
impl Drop for RegistryObserverHandle { impl Drop for RegistryObserverHandle {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.shutdown_tx.send(()); let _ = self.command_tx.send(ObserverCommand::Shutdown);
if let Some(thread) = self.thread.take() if let Some(thread) = self.thread.take()
&& let Err(e) = thread.join() && let Err(e) = thread.join()
{ {
@@ -110,7 +189,7 @@ impl Drop for RegistryObserverHandle {
enum BoundProxy { enum BoundProxy {
Node { Node {
_listener: pw::node::NodeListener, _listener: pw::node::NodeListener,
_proxy: pw::node::Node, _proxy: Rc<pw::node::Node>,
}, },
Device { Device {
_listener: pw::device::DeviceListener, _listener: pw::device::DeviceListener,
@@ -127,6 +206,161 @@ struct LiveGlobal {
bound_proxy: Option<BoundProxy>, bound_proxy: Option<BoundProxy>,
} }
struct OwnedFanoutLink {
// Both listeners must unhook callbacks before the proxy is dropped.
_info_listener: pw::link::LinkListener,
_proxy_listener: pw::proxy::ProxyListener,
_proxy: pw::link::Link,
}
struct PipeWireLinkMutation {
core: pw::core::CoreRc,
owned: BTreeMap<DesiredLink, OwnedFanoutLink>,
states: Rc<RefCell<BTreeMap<DesiredLink, ManagedLinkState>>>,
}
impl PipeWireLinkMutation {
fn new(core: pw::core::CoreRc) -> Self {
Self {
core,
owned: BTreeMap::new(),
states: Rc::new(RefCell::new(BTreeMap::new())),
}
}
fn create_link(&mut self, desired: DesiredLink) -> Result<OwnedFanoutLink> {
let output_node = desired.output.node_id.0.to_string();
let output_port = desired.output.port_id.0.to_string();
let input_node = desired.input.node_id.0.to_string();
let input_port = desired.input.port_id.0.to_string();
let mut props = pw::properties::properties! {
// Load-bearing: dropping the retained proxy or losing this
// connection removes the server-side link.
"object.linger" => "false"
};
props.insert("link.output.node", output_node.as_str());
props.insert("link.output.port", output_port.as_str());
props.insert("link.input.node", input_node.as_str());
props.insert("link.input.port", input_port.as_str());
let link = self
.core
.create_object::<pw::link::Link>("link-factory", &props)
.context("PipeWire link-factory rejected a fan-out link")?;
self.states
.borrow_mut()
.insert(desired, ManagedLinkState::Pending);
let weak_states = Rc::downgrade(&self.states);
let info_listener = link
.add_listener_local()
.info(move |info| {
let Some(states) = weak_states.upgrade() else {
return;
};
let state = match info.state() {
pw::link::LinkState::Active => ManagedLinkState::Active,
pw::link::LinkState::Error(error) => {
tracing::warn!(%error, "audio fan-out: owned link entered error state");
ManagedLinkState::Failed
}
_ => ManagedLinkState::Pending,
};
states.borrow_mut().insert(desired, state);
})
.register();
let weak_states = Rc::downgrade(&self.states);
let weak_states_for_error = Rc::downgrade(&self.states);
let proxy_listener = link
.upcast_ref()
.add_listener_local()
.removed(move || {
if let Some(states) = weak_states.upgrade() {
states
.borrow_mut()
.insert(desired, ManagedLinkState::Failed);
}
})
.error(move |seq, res, message| {
tracing::warn!(seq, result = res, %message, "audio fan-out: link proxy error");
if let Some(states) = weak_states_for_error.upgrade() {
states
.borrow_mut()
.insert(desired, ManagedLinkState::Failed);
}
})
.register();
Ok(OwnedFanoutLink {
_info_listener: info_listener,
_proxy_listener: proxy_listener,
_proxy: link,
})
}
}
impl LinkMutation for PipeWireLinkMutation {
fn reconcile(
&mut self,
snapshot: &crate::host::taint::snapshot::GraphSnapshot,
desired: &BTreeSet<DesiredLink>,
) -> BTreeMap<DesiredLink, ManagedLinkState> {
// Revoke before creating. Revalidation applies to retained proxies as
// well as new requests: a stale serial-guarded intent is no longer
// authority merely because it already reached `owned`.
let current = revalidated_links(snapshot, desired);
self.owned.retain(|link, _| current.contains(link));
self.states
.borrow_mut()
.retain(|link, _| current.contains(link));
// A Link that reached Error stays failed until the graph changes
// enough to remove this exact serial-guarded intent. Retrying the same
// broken request on every 250 ms observer tick would hot-loop.
let failed: Vec<DesiredLink> = self
.owned
.keys()
.copied()
.filter(|link| self.states.borrow().get(link) == Some(&ManagedLinkState::Failed))
.collect();
for link in failed {
self.owned.remove(&link);
}
for &link in &current {
if self.owned.contains_key(&link) || self.states.borrow().contains_key(&link) {
continue;
}
match self.create_link(link) {
Ok(owned) => {
self.owned.insert(link, owned);
}
Err(error) => {
tracing::warn!(error = %error, "audio fan-out: could not create link");
self.states
.borrow_mut()
.insert(link, ManagedLinkState::Failed);
}
}
}
let states = self.states.borrow();
desired
.iter()
.map(|link| {
(
*link,
states
.get(link)
.copied()
.unwrap_or(ManagedLinkState::Failed),
)
})
.collect()
}
}
struct ObserverState { struct ObserverState {
model: RegistryModel, model: RegistryModel,
latest: Arc<Mutex<Option<Projection>>>, latest: Arc<Mutex<Option<Projection>>>,
@@ -134,7 +368,8 @@ struct ObserverState {
/// it are read from `/proc`. /// it are read from `/proc`.
last_candidates: BTreeSet<u32>, last_candidates: BTreeSet<u32>,
live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>, live_globals: BTreeMap<GlobalId, VecDeque<LiveGlobal>>,
sink: Option<Box<dyn ProjectionSink>>, consumer: ObserverConsumer,
link_mutation: PipeWireLinkMutation,
started_at: Instant, started_at: Instant,
} }
@@ -144,7 +379,8 @@ impl ObserverState {
/// `now` are the same clock, not two that drift. /// `now` are the same clock, not two that drift.
fn new( fn new(
latest: Arc<Mutex<Option<Projection>>>, latest: Arc<Mutex<Option<Projection>>>,
sink: Option<Box<dyn ProjectionSink>>, consumer: ObserverConsumer,
link_mutation: PipeWireLinkMutation,
started_at: Instant, started_at: Instant,
) -> Self { ) -> Self {
Self { Self {
@@ -152,7 +388,8 @@ impl ObserverState {
latest, latest,
last_candidates: BTreeSet::new(), last_candidates: BTreeSet::new(),
live_globals: BTreeMap::new(), live_globals: BTreeMap::new(),
sink, consumer,
link_mutation,
started_at, started_at,
} }
} }
@@ -202,10 +439,16 @@ impl ObserverState {
fn publish(&mut self, kind: EventKind) { fn publish(&mut self, kind: EventKind) {
let projection = self.model.project(); let projection = self.model.project();
if let Some(sink) = self.sink.as_mut() {
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX); let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
match &mut self.consumer {
ObserverConsumer::ReadOnly(Some(sink)) => {
sink.on_projection(&projection, kind, now_us); sink.on_projection(&projection, kind, now_us);
} }
ObserverConsumer::ReadOnly(None) => {}
ObserverConsumer::Mutating { sink, .. } => {
sink.on_projection(&projection, kind, now_us, &mut self.link_mutation);
}
}
// Published after the sink has seen it, so the projection is moved // Published after the sink has seen it, so the projection is moved
// rather than cloned — the snapshot is the largest thing the observer // rather than cloned — the snapshot is the largest thing the observer
// owns and this runs on every event. // owns and this runs on every event.
@@ -215,6 +458,14 @@ impl ObserverState {
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection); .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(projection);
} }
fn replace_capture_sink(&mut self, capture_sink: Serial) {
let projection = self.model.project();
let now_us = u64::try_from(self.started_at.elapsed().as_micros()).unwrap_or(u64::MAX);
if let ObserverConsumer::Mutating { sink, .. } = &mut self.consumer {
sink.replace_capture_sink(capture_sink, &projection, now_us, &mut self.link_mutation);
}
}
/// Record the global's id and apply its add event as one step, so the /// Record the global's id and apply its add event as one step, so the
/// bound-proxy FIFO stays provably lockstep with the model's own `live_ids` /// bound-proxy FIFO stays provably lockstep with the model's own `live_ids`
/// index. Recording only on *applied* adds (never on unknown object types /// index. Recording only on *applied* adds (never on unknown object types
@@ -280,8 +531,8 @@ impl ObserverState {
fn run_observer( fn run_observer(
latest: Arc<Mutex<Option<Projection>>>, latest: Arc<Mutex<Option<Projection>>>,
shutdown_rx: pw::channel::Receiver<()>, command_rx: pw::channel::Receiver<ObserverCommand>,
sink: Option<Box<dyn ProjectionSink>>, consumer: ObserverConsumer,
) -> Result<()> { ) -> Result<()> {
let started_at = Instant::now(); let started_at = Instant::now();
let main_loop = let main_loop =
@@ -292,16 +543,36 @@ fn run_observer(
.connect_rc(None) .connect_rc(None)
.context("pw core connect failed (is the daemon running?)")?; .context("pw core connect failed (is the daemon running?)")?;
let registry = core.get_registry_rc().context("pw get_registry failed")?; let registry = core.get_registry_rc().context("pw get_registry failed")?;
let state = Rc::new(RefCell::new(ObserverState::new(latest, sink, started_at))); let mutation_health = consumer.mutation_health();
let link_mutation = PipeWireLinkMutation::new(core.clone());
let state = Rc::new(RefCell::new(ObserverState::new(
latest,
consumer,
link_mutation,
started_at,
)));
let main_loop_for_shutdown = main_loop.clone(); let shutdown_observed = Rc::new(Cell::new(false));
let _shutdown_receiver = shutdown_rx.attach(main_loop.loop_(), move |()| { let shutdown_for_receiver = Rc::clone(&shutdown_observed);
main_loop_for_shutdown.quit(); let main_loop_for_command = main_loop.clone();
let state_for_command = Rc::clone(&state);
let _command_receiver = command_rx.attach(main_loop.loop_(), move |command| match command {
ObserverCommand::ReplaceCaptureSink(capture_sink) => {
state_for_command
.borrow_mut()
.replace_capture_sink(capture_sink);
}
ObserverCommand::Shutdown => {
shutdown_for_receiver.set(true);
main_loop_for_command.quit();
}
}); });
let pending_sync = Rc::new(Cell::new(None)); let pending_sync = Rc::new(Cell::new(None));
let pending_sync_for_done = Rc::clone(&pending_sync); let pending_sync_for_done = Rc::clone(&pending_sync);
let state_for_done = Rc::clone(&state); let state_for_done = Rc::clone(&state);
let main_loop_for_error = main_loop.clone();
let mutation_health_for_error = mutation_health.clone();
let _core_listener = core let _core_listener = core
.add_listener_local() .add_listener_local()
.done(move |id, seq| { .done(move |id, seq| {
@@ -310,7 +581,7 @@ fn run_observer(
state_for_done.borrow_mut().apply(RegEvent::ServerSynced); state_for_done.borrow_mut().apply(RegEvent::ServerSynced);
} }
}) })
.error(|id, seq, res, message| { .error(move |id, seq, res, message| {
tracing::warn!( tracing::warn!(
id, id,
seq, seq,
@@ -318,6 +589,20 @@ fn run_observer(
%message, %message,
"registry observer: PipeWire core error" "registry observer: PipeWire core error"
); );
if recoverable_core_error(res) {
tracing::info!(
id,
seq,
result = res,
%message,
"registry observer: stale resource disappeared during graph churn"
);
} else if let Some(health) = &mutation_health_for_error {
health.poison(format!(
"audio fan-out PipeWire core error {res}: {message}"
));
main_loop_for_error.quit();
}
}) })
.register(); .register();
@@ -364,11 +649,15 @@ fn run_observer(
return; return;
} }
}; };
let node = Rc::new(node);
// This bit only recognizes the initial callback for the // This bit only recognizes the initial callback for the
// change-mask fast path. Admission vs update remains // change-mask fast path. Admission vs update remains
// entirely the model's decision. // entirely the model's decision.
let first_info = Cell::new(true); let first_info = Cell::new(true);
let format_subscribed = Cell::new(false);
let node_for_info = Rc::downgrade(&node);
let state_for_info = Rc::downgrade(&state_for_global); let state_for_info = Rc::downgrade(&state_for_global);
let state_for_format = Rc::downgrade(&state_for_global);
let listener = node let listener = node
.add_listener_local() .add_listener_local()
.info(move |info| { .info(move |info| {
@@ -376,15 +665,65 @@ fn run_observer(
return; return;
}; };
let first = first_info.replace(false); let first = first_info.replace(false);
if !first let props_changed = first
&& !info.change_mask().contains(pw::node::NodeChangeMask::PROPS) || info.change_mask().contains(pw::node::NodeChangeMask::PROPS);
{ let params_changed = first
|| info
.change_mask()
.contains(pw::node::NodeChangeMask::PARAMS);
if !props_changed && !params_changed {
return; return;
} }
if let Some(state) = state_for_info.upgrade() { let observation = node_observation_from_props(props);
if props_changed && let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeInfo { state.borrow_mut().apply(RegEvent::NodeInfo {
serial, serial,
observation: node_observation_from_props(props), observation: observation.clone(),
});
}
if !observation.role.is_candidate() {
return;
}
let format_readable = info.params().iter().any(|param| {
param.id() == pw::spa::param::ParamType::Format
&& param.flags().contains(pw::spa::param::ParamInfoFlags::READ)
});
if !format_readable {
if params_changed && let Some(state) = state_for_info.upgrade() {
state.borrow_mut().apply(RegEvent::NodeFormat {
serial,
format: StreamFormat::Unknown,
});
}
return;
}
if !format_subscribed.replace(true)
&& let Some(node) = node_for_info.upgrade()
{
// Subscription covers later renegotiation;
// enumeration supplies the current configured
// format. Only candidates advertising a
// readable Format are queried, so ordinary
// driver Nodes cannot turn their expected
// ENOENT/EIO replies into host health faults.
node.subscribe_params(&[pw::spa::param::ParamType::Format]);
node.enum_params(
0,
Some(pw::spa::param::ParamType::Format),
0,
u32::MAX,
);
}
})
.param(move |_seq, id, _index, _next, param| {
if id != pw::spa::param::ParamType::Format {
return;
}
if let Some(state) = state_for_format.upgrade() {
state.borrow_mut().apply(RegEvent::NodeFormat {
serial,
format: stream_format_from_pod(param),
}); });
} }
}) })
@@ -443,6 +782,7 @@ fn run_observer(
id, id,
node, node,
direction, direction,
channel: props.get("audio.channel").map(str::to_string),
exclusive: truthy(props.get("port.exclusive")), exclusive: truthy(props.get("port.exclusive")),
monitor: truthy(props.get("port.monitor")), monitor: truthy(props.get("port.monitor")),
}), }),
@@ -642,6 +982,11 @@ fn run_observer(
tracing::info!("registry observer: pw thread running"); tracing::info!("registry observer: pw thread running");
main_loop.run(); main_loop.run();
tracing::info!("registry observer: pw thread exiting"); tracing::info!("registry observer: pw thread exiting");
if let Some(health) = mutation_health
&& !shutdown_observed.get()
{
health.poison("audio fan-out observer exited without a shutdown request");
}
Ok(()) Ok(())
} }
@@ -668,6 +1013,63 @@ fn truthy(value: Option<&str>) -> bool {
value.is_some_and(|value| value != "false" && value != "0") value.is_some_and(|value| value != "false" && value != "0")
} }
/// Classify the configured SPA Format without depending on producer-specific
/// property aliases. `Unknown` is the safe result for an absent or malformed
/// param; the taint engine refuses that stream until a usable format arrives.
fn stream_format_from_pod(param: Option<&pw::spa::pod::Pod>) -> StreamFormat {
let Some(param) = param else {
return StreamFormat::Unknown;
};
let Ok((media_type, media_subtype)) = pw::spa::param::format_utils::parse_format(param) else {
tracing::warn!("registry observer: could not parse Node Format media type");
return StreamFormat::Unknown;
};
let audio_format = if media_type == pw::spa::param::format::MediaType::Audio
&& media_subtype == pw::spa::param::format::MediaSubtype::Raw
{
let mut raw = pw::spa::param::audio::AudioInfoRaw::new();
match raw.parse(param) {
Ok(_) => Some(raw.format()),
Err(error) => {
tracing::warn!(
?error,
"registry observer: could not parse raw audio Format"
);
None
}
}
} else {
None
};
classify_stream_format(media_type, media_subtype, audio_format)
}
fn classify_stream_format(
media_type: pw::spa::param::format::MediaType,
media_subtype: pw::spa::param::format::MediaSubtype,
audio_format: Option<pw::spa::param::audio::AudioFormat>,
) -> StreamFormat {
use pw::spa::param::audio::AudioFormat;
use pw::spa::param::format::{MediaSubtype, MediaType};
if media_type != MediaType::Audio {
return StreamFormat::Unknown;
}
match media_subtype {
MediaSubtype::Unknown => StreamFormat::Unknown,
MediaSubtype::Iec958 => StreamFormat::Iec958,
MediaSubtype::Raw => match audio_format {
Some(AudioFormat::Encoded) => StreamFormat::Encoded,
Some(AudioFormat::Unknown) | None => StreamFormat::Unknown,
Some(_) => StreamFormat::Raw,
},
// Any configured non-raw audio subtype is encoded. Keeping this
// conservative also covers codecs newer than this libspa binding.
_ => StreamFormat::Encoded,
}
}
/// The ownership carrier is matched **exactly**, not leniently (round 10, /// The ownership carrier is matched **exactly**, not leniently (round 10,
/// R10-4). /// R10-4).
/// ///
@@ -689,6 +1091,10 @@ fn peerspeak_owned(value: Option<&str>) -> bool {
} }
fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation { fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObservation {
let device_id = props
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId);
NodeObservation { NodeObservation {
name: props.get("node.name").map(str::to_string), name: props.get("node.name").map(str::to_string),
role: MediaRole::parse(props.get("media.class")), role: MediaRole::parse(props.get("media.class")),
@@ -710,13 +1116,12 @@ fn node_observation_from_props(props: &pw::spa::utils::dict::DictRef) -> NodeObs
.get("application.process.id") .get("application.process.id")
.and_then(|value| value.parse::<u32>().ok()), .and_then(|value| value.parse::<u32>().ok()),
passthrough: truthy(props.get("node.passthrough")), passthrough: truthy(props.get("node.passthrough")),
stream_format: StreamFormat::Unknown,
device_id,
session_device: false, session_device: false,
}, },
device_claim: DeviceClaim { device_claim: DeviceClaim {
device_id: props device_id,
.get("device.id")
.and_then(|value| value.parse::<u32>().ok())
.map(GlobalId),
device_api: props.get("device.api").map(str::to_string), device_api: props.get("device.api").map(str::to_string),
factory_name: props.get("factory.name").map(str::to_string), factory_name: props.get("factory.name").map(str::to_string),
alsa_driver_name: props.get("alsa.driver_name").map(str::to_string), alsa_driver_name: props.get("alsa.driver_name").map(str::to_string),
@@ -757,6 +1162,60 @@ mod tests {
use super::*; use super::*;
use std::process::Command; use std::process::Command;
#[test]
fn only_stale_resource_core_errors_are_recoverable() {
assert!(recoverable_core_error(-(nix::errno::Errno::ENOENT as i32)));
assert!(!recoverable_core_error(-(nix::errno::Errno::EPIPE as i32)));
assert!(!recoverable_core_error(0));
}
#[test]
fn configured_format_classifier_separates_raw_encoded_and_iec958() {
use pw::spa::param::audio::AudioFormat;
use pw::spa::param::format::{MediaSubtype, MediaType};
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::F32LE),
),
StreamFormat::Raw
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Mp3, None),
StreamFormat::Encoded
);
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::Encoded),
),
StreamFormat::Encoded
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Iec958, None),
StreamFormat::Iec958
);
assert_eq!(
classify_stream_format(MediaType::Video, MediaSubtype::Raw, None),
StreamFormat::Unknown
);
assert_eq!(
classify_stream_format(
MediaType::Audio,
MediaSubtype::Raw,
Some(AudioFormat::Unknown),
),
StreamFormat::Unknown
);
assert_eq!(
classify_stream_format(MediaType::Audio, MediaSubtype::Unknown, None),
StreamFormat::Unknown
);
}
/// **R10-4.** The ownership carrier is matched exactly; the lenient /// **R10-4.** The ownership carrier is matched exactly; the lenient
/// [`truthy`] spelling is wrong for it. /// [`truthy`] spelling is wrong for it.
/// ///
@@ -832,6 +1291,7 @@ mod tests {
props.insert("media.class", "Stream/Output/Audio"); props.insert("media.class", "Stream/Output/Audio");
props.insert("node.name", "probe"); props.insert("node.name", "probe");
props.insert("client.id", "42"); props.insert("client.id", "42");
props.insert("device.id", "77");
if let Some(value) = value { if let Some(value) = value {
props.insert(PEERSPEAK_OWNED_PROP, value); props.insert(PEERSPEAK_OWNED_PROP, value);
} }
@@ -846,6 +1306,8 @@ mod tests {
assert_eq!(observation.role, MediaRole::StreamOutput); assert_eq!(observation.role, MediaRole::StreamOutput);
assert_eq!(observation.name.as_deref(), Some("probe")); assert_eq!(observation.name.as_deref(), Some("probe"));
assert_eq!(observation.props.client_id, Some(GlobalId(42))); assert_eq!(observation.props.client_id, Some(GlobalId(42)));
assert_eq!(observation.props.device_id, Some(GlobalId(77)));
assert_eq!(observation.device_claim.device_id, Some(GlobalId(77)));
} }
} }
@@ -1168,9 +1630,19 @@ mod tests {
.is_some_and(|name| name.contains("alsa")) .is_some_and(|name| name.contains("alsa"))
|| matches!(node.role, MediaRole::Sink | MediaRole::Source)) || matches!(node.role, MediaRole::Sink | MediaRole::Source))
}); });
let session_device = session_device.expect(
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device",
);
assert!( assert!(
session_device.is_some(), session_device.props.device_id.is_some(),
"a named ALSA or Audio/Sink/Audio/Source node must classify as a session device" "a live session device must retain the device.id used by the hardware bridge"
);
assert!(
projection
.snapshot
.ports()
.any(|port| port.channel.is_some()),
"at least one live audio port must retain audio.channel for Phase-6 pairing"
); );
assert!(projection.graph_ready); assert!(projection.graph_ready);
+37 -2
View File
@@ -88,7 +88,7 @@ mod tests;
use crate::host::taint::snapshot::{ use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot, ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortSnapshot, Serial, PortSnapshot, Serial, StreamFormat,
}; };
use classify::{Classification, DeviceClaim, DeviceProps}; use classify::{Classification, DeviceClaim, DeviceProps};
use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -145,6 +145,13 @@ pub enum RegEvent {
serial: Serial, serial: Serial,
observation: NodeObservation, observation: NodeObservation,
}, },
/// The Node's configured `SPA_PARAM_Format`. This is independent of
/// [`RegEvent::NodeInfo`]: property and parameter callbacks have separate
/// lifetimes, and either may change without the other.
NodeFormat {
serial: Serial,
format: StreamFormat,
},
/// A Port global appeared. /// A Port global appeared.
PortAdded(PortSnapshot), PortAdded(PortSnapshot),
/// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`. /// A Client global appeared. Feeds pulse-PID derivation via `sec_pid`.
@@ -305,6 +312,10 @@ struct NodeEntry {
/// `None` while the bind is outstanding — withheld from the snapshot and /// `None` while the bind is outstanding — withheld from the snapshot and
/// an outstanding readiness obligation (v3.5 §6.7 decision 3). /// an outstanding readiness obligation (v3.5 §6.7 decision 3).
obs: Option<NodeObservation>, obs: Option<NodeObservation>,
/// `Unknown` until the bound Node returns its configured Format param.
/// Unknown streams remain projected but are refused locally, so one idle
/// app cannot hold the entire graph's readiness epoch open.
format: StreamFormat,
} }
/// A live Device: its global id plus its bound properties once they arrive. /// A live Device: its global id plus its bound properties once they arrive.
@@ -427,7 +438,14 @@ impl RegistryModel {
match event { match event {
RegEvent::NodeAdded { serial, id } => { RegEvent::NodeAdded { serial, id } => {
self.push_id(id, Slot::Node(serial)); self.push_id(id, Slot::Node(serial));
self.nodes.insert(serial, NodeEntry { id, obs: None }); self.nodes.insert(
serial,
NodeEntry {
id,
obs: None,
format: StreamFormat::Unknown,
},
);
// A node awaiting its bind is a fresh obligation, so this can // A node awaiting its bind is a fresh obligation, so this can
// only ever *hold* readiness, never complete it — but the // only ever *hold* readiness, never complete it — but the
// re-check is cheap and keeps the invariant local. // re-check is cheap and keeps the invariant local.
@@ -438,6 +456,7 @@ impl RegistryModel {
serial, serial,
observation, observation,
} => self.on_node_info(serial, observation), } => self.on_node_info(serial, observation),
RegEvent::NodeFormat { serial, format } => self.on_node_format(serial, format),
RegEvent::PortAdded(port) => { RegEvent::PortAdded(port) => {
self.push_id(port.id, Slot::Port(port.serial)); self.push_id(port.id, Slot::Port(port.serial));
self.ports.insert(port.serial, port); self.ports.insert(port.serial, port);
@@ -517,6 +536,21 @@ impl RegistryModel {
Outcome::Applied Outcome::Applied
} }
fn on_node_format(&mut self, serial: Serial, format: StreamFormat) -> Outcome {
let Some(entry) = self.nodes.get_mut(&serial) else {
tracing::debug!(
serial = serial.0,
"observer: node format for an unknown node"
);
return Outcome::Suppressed;
};
if entry.format == format {
return Outcome::Suppressed;
}
entry.format = format;
Outcome::Applied
}
fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome { fn on_device_info(&mut self, serial: Serial, props: DeviceProps) -> Outcome {
let Some(entry) = self.devices.get_mut(&serial) else { let Some(entry) = self.devices.get_mut(&serial) else {
tracing::debug!( tracing::debug!(
@@ -737,6 +771,7 @@ impl RegistryModel {
}; };
let mut props = obs.props.clone(); let mut props = obs.props.clone();
props.session_device = session_device; props.session_device = session_device;
props.stream_format = entry.format;
Some(NodeSnapshot { Some(NodeSnapshot {
serial, serial,
id: entry.id, id: entry.id,
+54 -1
View File
@@ -15,6 +15,7 @@ use super::pulse_pid;
use super::*; use super::*;
use crate::host::taint::snapshot::{ use crate::host::taint::snapshot::{
ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial, ClientSnapshot, GlobalId, IdLookup, MediaRole, NodeProps, PortDirection, PortSnapshot, Serial,
StreamFormat,
}; };
// ---- builders ------------------------------------------------------------- // ---- builders -------------------------------------------------------------
@@ -74,10 +75,14 @@ fn device_with(api: Option<&str>, driver: Option<&str>) -> DeviceProps {
} }
fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation { fn obs(name: &str, role: MediaRole, claim: DeviceClaim) -> NodeObservation {
let device_id = claim.device_id;
NodeObservation { NodeObservation {
name: Some(name.to_string()), name: Some(name.to_string()),
role, role,
props: NodeProps::default(), props: NodeProps {
device_id,
..NodeProps::default()
},
device_claim: claim, device_claim: claim,
} }
} }
@@ -148,6 +153,7 @@ fn port(serial: u64, id: u32, node_id: u32, dir: PortDirection) -> RegEvent {
id: gid(id), id: gid(id),
node: gid(node_id), node: gid(node_id),
direction: dir, direction: dir,
channel: None,
exclusive: false, exclusive: false,
monitor: false, monitor: false,
}) })
@@ -652,6 +658,46 @@ fn model_adds_all_four_object_types() {
assert_eq!(snap.links().count(), 1); assert_eq!(snap.links().count(), 1);
} }
#[test]
fn model_projects_configured_node_format_independently_of_props() {
let mut m = model();
add_stream_out(&mut m, 100, 50);
assert_eq!(
m.project()
.snapshot
.node(ser(100))
.unwrap()
.props
.stream_format,
StreamFormat::Unknown
);
assert_eq!(
m.apply(RegEvent::NodeFormat {
serial: ser(100),
format: StreamFormat::Encoded,
}),
Outcome::Applied
);
assert_eq!(
m.project()
.snapshot
.node(ser(100))
.unwrap()
.props
.stream_format,
StreamFormat::Encoded
);
assert_eq!(
m.apply(RegEvent::NodeFormat {
serial: ser(100),
format: StreamFormat::Encoded,
}),
Outcome::Suppressed,
"an unchanged param must not inflate the graph event stream"
);
}
#[test] #[test]
fn model_removes_all_four_object_types() { fn model_removes_all_four_object_types() {
let mut m = model(); let mut m = model();
@@ -911,6 +957,13 @@ fn model_readiness_does_not_release_with_obligation_outstanding() {
}); });
assert_eq!(m.readiness(), Readiness::Complete); assert_eq!(m.readiness(), Readiness::Complete);
assert!(m.graph_ready()); assert!(m.graph_ready());
let projected = m.project();
let device_node = projected
.snapshot
.node(ser(100))
.expect("resolved device node is projected");
assert!(device_node.props.session_device);
assert_eq!(device_node.props.device_id, Some(gid(42)));
} }
#[test] #[test]
+170
View File
@@ -0,0 +1,170 @@
//! Cancellation-safe ownership for blocking subsystem threads.
//!
//! `std::thread::JoinHandle` has no timed join. Moving it into
//! `spawn_blocking` only moves the problem: cancelling the async waiter detaches
//! the blocking task and loses the only handle. Instead this owner polls
//! `is_finished()` while retaining the handle, joins only after completion, and
//! quarantines an unfinished handle on timeout or Drop. Quarantine is
//! process-lifetime ownership, not recovery; the shared health channel makes
//! the supervisor terminate the poisoned host.
use super::health::Reporter;
use std::sync::{Mutex, OnceLock};
use std::thread::JoinHandle;
use std::time::Duration;
static QUARANTINED: OnceLock<Mutex<Vec<JoinHandle<()>>>> = OnceLock::new();
fn quarantine(handle: JoinHandle<()>) {
let mut handles = QUARANTINED
.get_or_init(|| Mutex::new(Vec::new()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// Reap anything that happened to finish since the previous quarantine;
// every still-running handle remains owned until process exit.
let old = std::mem::take(&mut *handles);
for old_handle in old {
if old_handle.is_finished() {
let _ = old_handle.join();
} else {
handles.push(old_handle);
}
}
handles.push(handle);
}
pub(super) struct OwnedThread {
name: &'static str,
handle: Option<JoinHandle<()>>,
health: Reporter,
}
impl OwnedThread {
pub(super) fn new(name: &'static str, handle: JoinHandle<()>, health: Reporter) -> Self {
Self {
name,
handle: Some(handle),
health,
}
}
/// Wait up to `budget`, retaining the OS handle across every await.
///
/// Returns true only when the thread was joined successfully. Timeout and
/// panic poison the process; a timeout also moves the still-running handle
/// into process-lifetime quarantine.
pub(super) async fn join_within(&mut self, budget: Duration) -> bool {
let deadline = tokio::time::Instant::now() + budget;
loop {
let Some(handle) = self.handle.as_ref() else {
return true;
};
if handle.is_finished() {
let handle = self.handle.take().expect("checked as present");
return match handle.join() {
Ok(()) => true,
Err(_) => {
self.health
.poison(format!("{} panicked during shutdown", self.name));
false
}
};
}
if tokio::time::Instant::now() >= deadline {
self.health.poison(format!(
"{} did not stop within {:?}; its thread handle is quarantined",
self.name, budget
));
quarantine(self.handle.take().expect("checked as present"));
return false;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
impl Drop for OwnedThread {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
if handle.is_finished() {
if handle.join().is_err() {
self.health
.poison(format!("{} panicked before it was joined", self.name));
}
return;
}
self.health.poison(format!(
"{} was dropped before it stopped; its thread handle is quarantined",
self.name
));
quarantine(handle);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::health;
use std::sync::mpsc;
fn reap_finished_quarantine() {
let Some(handles) = QUARANTINED.get() else {
return;
};
let mut handles = handles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let old = std::mem::take(&mut *handles);
for handle in old {
if handle.is_finished() {
let _ = handle.join();
} else {
handles.push(handle);
}
}
}
#[tokio::test]
async fn completed_thread_is_joined_without_poison() {
let (health, _) = health::channel();
let handle = std::thread::spawn(|| {});
let mut owner = OwnedThread::new("test worker", handle, health.clone());
assert!(owner.join_within(Duration::from_secs(1)).await);
assert!(health.fault().is_none());
}
#[tokio::test]
async fn timeout_poisons_and_quarantines_instead_of_detaching() {
let (release_tx, release_rx) = mpsc::channel();
let (health, _) = health::channel();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
let mut owner = OwnedThread::new("wedged worker", handle, health.clone());
assert!(!owner.join_within(Duration::from_millis(20)).await);
assert!(health.fault().is_some());
drop(owner);
release_tx.send(()).expect("release quarantined worker");
std::thread::sleep(Duration::from_millis(20));
reap_finished_quarantine();
}
#[test]
fn dropping_an_unfinished_owner_poisons_and_quarantines() {
let (release_tx, release_rx) = mpsc::channel();
let (health, _) = health::channel();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
drop(OwnedThread::new("cancelled worker", handle, health.clone()));
assert!(health.fault().is_some());
release_tx.send(()).expect("release quarantined worker");
std::thread::sleep(Duration::from_millis(20));
reap_finished_quarantine();
}
}
+252 -79
View File
@@ -5,23 +5,123 @@
//! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared //! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared
//! and lives here. Backends call [`spawn`] with just their source-element args. //! and lives here. Backends call [`spawn`] with just their source-element args.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result};
use nix::sys::signal::{Signal, kill}; use nix::sys::signal::Signal;
use nix::unistd::Pid;
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use tokio::time::timeout; use tokio::time::timeout;
use super::audio::Routing; use super::audio_plan::CapturePlan;
use super::health;
use super::quality::EffectiveQuality; use super::quality::EffectiveQuality;
use super::serve::Serve; use super::serve::Serve;
use crate::cli::HostOpts; use crate::cli::HostOpts;
use crate::common::contained;
pub struct CaptureHandle { const GST_TERM_BUDGET: Duration = Duration::from_secs(1);
gst: Option<Child>, const GST_KILL_BUDGET: Duration = Duration::from_secs(1);
audio: Option<Routing>,
/// Owns the contained GStreamer process from spawn through confirmed reap.
///
/// Keeping this guard alive during `Serve::bind` closes the old constructor
/// leak: any error after spawn kills the whole process group, not merely the
/// direct child. An unconfirmed Drop poisons the host so the supervisor cannot
/// start another capture on top of a possibly-live portal/PipeWire owner.
struct CaptureProcess {
child: Child,
leader_pid: u32,
reaped: bool,
health: health::Reporter,
}
impl CaptureProcess {
fn new(child: Child, health: health::Reporter) -> Result<Self> {
let leader_pid = child
.id()
.context("gst-launch-1.0 exited before its process id was recorded")?;
Ok(Self {
child,
leader_pid,
reaped: false,
health,
})
}
fn take_stdout(&mut self) -> Option<tokio::process::ChildStdout> {
self.child.stdout.take()
}
async fn shutdown(&mut self) {
// Reap an already-dead child before addressing its process group. A
// zombie still reserves its pid, but after `try_wait` succeeds that pid
// may be reused; returning here avoids ever signalling a new group that
// inherited the old numeric id.
match self.child.try_wait() {
Ok(Some(_)) => {
self.reaped = true;
self.health
.poison("gst-launch-1.0 exited before PixelPass began capture shutdown");
return;
}
Ok(None) => {}
Err(e) => tracing::warn!(
"capture: could not inspect gst before SIGTERM ({e}); continuing teardown"
),
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGTERM);
match timeout(GST_TERM_BUDGET, self.child.wait()).await {
Ok(Ok(_)) => {
self.reaped = true;
return;
}
Ok(Err(e)) => tracing::warn!(
"capture: gst wait after SIGTERM failed ({e}); escalating to SIGKILL"
),
Err(_) => tracing::warn!(
"capture: gst did not exit within {GST_TERM_BUDGET:?}; escalating to SIGKILL"
),
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
let _ = self.child.start_kill();
match timeout(GST_KILL_BUDGET, self.child.wait()).await {
Ok(Ok(_)) => self.reaped = true,
Ok(Err(e)) => {
self.health.poison(format!(
"gst-launch-1.0 could not be reaped after SIGKILL: {e}"
));
}
Err(_) => {
self.health.poison(format!(
"gst-launch-1.0 did not exit within {GST_KILL_BUDGET:?} after SIGKILL"
));
}
}
}
}
impl Drop for CaptureProcess {
fn drop(&mut self) {
if self.reaped {
return;
}
let _ = contained::signal_group(self.leader_pid, Signal::SIGKILL);
let _ = self.child.start_kill();
self.health.poison(
"gst-launch-1.0 was dropped before a confirmed reap; its process group was killed",
);
}
}
pub(super) struct CaptureHandle {
gst: Option<CaptureProcess>,
audio: Option<CapturePlan>,
serve: Option<Serve>, serve: Option<Serve>,
stopping: Arc<AtomicBool>,
} }
impl CaptureHandle { impl CaptureHandle {
@@ -32,22 +132,18 @@ impl CaptureHandle {
.local_port() .local_port()
} }
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL, /// Graceful teardown: SIGTERM the gst process group, give it ~1s to exit,
/// then SIGKILL and a second bounded reap,
/// unload audio routing (if any), then tear down the serve layer. /// unload audio routing (if any), then tear down the serve layer.
/// The serve reader will see EOF on gst stdout and exit on its own; /// The serve reader will see EOF on gst stdout and exit on its own;
/// serve.shutdown() is the backstop. /// serve.shutdown() is the backstop.
pub async fn shutdown(mut self) { pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut() self.stopping.store(true, Ordering::Release);
&& let Some(pid) = child.id() if let Some(mut gst) = self.gst.take() {
{ gst.shutdown().await;
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM);
} }
if let Some(child) = self.gst.as_mut() { if let Some(audio_plan) = self.audio.take() {
let _ = timeout(Duration::from_millis(1000), child.wait()).await; audio_plan.shutdown().await;
let _ = child.start_kill();
}
if let Some(audio) = self.audio.take() {
audio.shutdown().await;
} }
if let Some(serve) = self.serve.take() { if let Some(serve) = self.serve.take() {
serve.shutdown().await; serve.shutdown().await;
@@ -57,10 +153,9 @@ impl CaptureHandle {
impl Drop for CaptureHandle { impl Drop for CaptureHandle {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(child) = self.gst.as_mut() { self.stopping.store(true, Ordering::Release);
let _ = child.start_kill(); // CaptureProcess kills the whole process group and poisons the host;
} // the typed plan's inner owner and Serve handle their own Drop layers.
// Routing's and Serve's own Drop impls handle the rest.
} }
} }
@@ -73,74 +168,51 @@ impl Drop for CaptureHandle {
/// `after_spawn` runs once, immediately after the gst child is launched — /// `after_spawn` runs once, immediately after the gst child is launched —
/// Wayland uses it to `close` the pipewire fd it leaked into the child; X11 /// Wayland uses it to `close` the pipewire fd it leaked into the child; X11
/// passes a no-op. /// passes a no-op.
pub async fn spawn( pub(super) async fn spawn(
opts: &HostOpts, opts: &HostOpts,
quality: &EffectiveQuality, quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>, source_dims: Option<(u32, u32)>,
source_args: Vec<String>, source_args: Vec<String>,
health: health::Reporter,
after_spawn: impl FnOnce(), after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> { ) -> Result<CaptureHandle> {
let (audio_routing, audio_device) = setup_audio(opts).await?; let audio_plan = CapturePlan::start(opts, health.clone()).await?;
let args = build_args(&source_args, &audio_device, opts, quality, source_dims); let args = build_args(&source_args, &audio_plan, opts, quality, source_dims);
let mut gst_cmd = Command::new("gst-launch-1.0"); let mut gst_cmd = Command::new("gst-launch-1.0");
gst_cmd gst_cmd
.args(&args) .args(&args)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()); .stderr(Stdio::inherit())
.kill_on_drop(true);
if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() { if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() {
gst_cmd.env("GST_DEBUG", "3"); gst_cmd.env("GST_DEBUG", "3");
} }
let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?; let gst = contained::spawn_tokio(&mut gst_cmd).context("failed to spawn gst-launch-1.0")?;
let mut gst = CaptureProcess::new(gst, health.clone())?;
// Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here, // Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here,
// once gst has inherited its own copy). // once gst has inherited its own copy).
after_spawn(); after_spawn();
let gst_stdout = gst let gst_stdout = gst
.stdout .take_stdout()
.take()
.context("gst-launch-1.0 stdout pipe unavailable")?; .context("gst-launch-1.0 stdout pipe unavailable")?;
// Hand stdout to the serve layer, which binds the localhost HTTP listener // Hand stdout to the serve layer, which binds the localhost HTTP listener
// and runs the broadcast fanout. No demux/remux, no codec assumptions. // and runs the broadcast fanout. No demux/remux, no codec assumptions.
let serve = Serve::bind(gst_stdout).await?; let stopping = Arc::new(AtomicBool::new(false));
let serve = Serve::bind(gst_stdout, health.clone(), Arc::clone(&stopping)).await?;
Ok(CaptureHandle { Ok(CaptureHandle {
gst: Some(gst), gst: Some(gst),
audio: audio_routing, audio: Some(audio_plan),
serve: Some(serve), serve: Some(serve),
stopping,
}) })
} }
/// Decide whether per-app audio routing is active and produce the `device=…`
/// argument for `pulsesrc`. Routing activates when either `--app` is set
/// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1`
/// is set (no app filter — captures everything via the null-sink, used for
/// dogfooding the loopback path). Otherwise we capture the default sink's
/// monitor (system audio out), not the default source (the mic).
async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
let routing_requested =
opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some();
let audio_routing = if routing_requested {
Some(
Routing::start(opts)
.await
.context("audio routing setup failed")?,
)
} else {
None
};
let audio_device = if let Some(r) = &audio_routing {
format!("device={}.monitor", r.sink_name())
} else {
let default = default_audio_monitor().await?;
format!("device={default}")
};
Ok((audio_routing, audio_device))
}
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the /// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
/// video branch (caller's `source` → videorate cap → optional downscale → /// video branch (caller's `source` → videorate cap → optional downscale →
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.). /// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
@@ -150,7 +222,7 @@ async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
/// wants I420). /// wants I420).
fn build_args( fn build_args(
source: &[String], source: &[String],
audio_device: &str, audio_plan: &CapturePlan,
opts: &HostOpts, opts: &HostOpts,
quality: &EffectiveQuality, quality: &EffectiveQuality,
source_dims: Option<(u32, u32)>, source_dims: Option<(u32, u32)>,
@@ -304,7 +376,7 @@ fn build_args(
// not the default source (which is the mic). // not the default source (which is the mic).
args.extend([ args.extend([
"pulsesrc".into(), "pulsesrc".into(),
audio_device.to_string(), audio_plan.gst_device_arg(),
"do-timestamp=true".into(), "do-timestamp=true".into(),
"!".into(), "!".into(),
"queue".into(), "queue".into(),
@@ -326,26 +398,127 @@ fn build_args(
args args
} }
async fn default_audio_monitor() -> Result<String> { #[cfg(test)]
let output = Command::new("pactl") mod tests {
.arg("get-default-sink") use super::*;
.output() use crate::cli::{CaptureMode, Quality};
.await use std::time::{Duration, Instant};
.context(
"failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)", fn legacy_opts() -> HostOpts {
)?; HostOpts {
if !output.status.success() { window: false,
bail!( app: None,
"pactl get-default-sink failed: {}", strict_audio: false,
String::from_utf8_lossy(&output.stderr).trim() display_server: None,
quality: Quality::Source,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
capture_mode: CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None,
}
}
#[test]
fn legacy_desktop_audio_tail_is_byte_identical() {
let opts = legacy_opts();
let quality = super::super::quality::resolve(&opts, 1);
let plan = CapturePlan::legacy_fixture("alsa_output.fixture.monitor");
let args = build_args(&["ximagesrc".to_string()], &plan, &opts, &quality, None);
let audio_start = args
.iter()
.position(|arg| arg == "pulsesrc")
.expect("pipeline has an audio branch");
assert_eq!(
&args[audio_start..],
[
"pulsesrc",
"device=alsa_output.fixture.monitor",
"do-timestamp=true",
"!",
"queue",
"!",
"audioconvert",
"!",
"audioresample",
"!",
"audio/x-raw,rate=48000,channels=2",
"!",
"avenc_aac",
"bitrate=128000",
"!",
"aacparse",
"!",
"mux.",
]
); );
} }
let sink = String::from_utf8(output.stdout)
.context("default sink name was not UTF-8")? fn process_is_running(pid: u32) -> bool {
.trim() let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
.to_string(); return false;
if sink.is_empty() { };
bail!("pactl get-default-sink returned no name (is a sound server running?)"); stat.rsplit_once(") ")
.and_then(|(_, rest)| rest.as_bytes().first().copied())
.is_some_and(|state| state != b'Z' && state != b'X')
}
fn sleeping_capture(health: health::Reporter) -> CaptureProcess {
let mut command = Command::new("sleep");
command.arg("30").kill_on_drop(true);
let child = contained::spawn_tokio(&mut command).expect("spawn contained fixture");
CaptureProcess::new(child, health).expect("capture process guard")
}
#[tokio::test]
async fn graceful_capture_shutdown_confirms_reap_without_poison() {
let (health, _) = health::channel();
let mut capture = sleeping_capture(health.clone());
capture.shutdown().await;
assert!(health.fault().is_none());
}
#[tokio::test]
async fn an_already_exited_capture_is_not_misreported_as_a_clean_shutdown() {
let (health, _) = health::channel();
let mut command = Command::new("true");
command.kill_on_drop(true);
let child = contained::spawn_tokio(&mut command).expect("spawn short contained fixture");
let mut capture = CaptureProcess::new(child, health.clone()).expect("capture guard");
let deadline = Instant::now() + Duration::from_secs(1);
while process_is_running(capture.leader_pid) && Instant::now() < deadline {
tokio::task::yield_now().await;
}
assert!(
!process_is_running(capture.leader_pid),
"short fixture must exit before shutdown begins"
);
capture.shutdown().await;
assert_eq!(
health.fault().as_deref(),
Some("gst-launch-1.0 exited before PixelPass began capture shutdown")
);
}
#[tokio::test]
async fn dropping_live_capture_kills_its_group_and_poisons() {
let (health, _) = health::channel();
let capture = sleeping_capture(health.clone());
let pid = capture.leader_pid;
drop(capture);
assert!(health.fault().is_some());
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_running(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!process_is_running(pid));
} }
Ok(format!("{sink}.monitor"))
} }
+3
View File
@@ -214,6 +214,9 @@ mod tests {
no_hwencode: false, no_hwencode: false,
max_viewers, max_viewers,
interactive: false, interactive: false,
capture_mode: crate::cli::CaptureMode::Legacy,
aec: crate::host::aec::AecConfig::Off,
legacy_null_sink: false,
relay: None, relay: None,
} }
} }
+55 -3
View File
@@ -10,6 +10,7 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
@@ -18,6 +19,8 @@ use tokio::sync::broadcast;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{Instant, sleep}; use tokio::time::{Instant, sleep};
use super::health;
/// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from /// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from
/// the capture child's stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered /// the capture child's stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered
/// jitter at typical bitrates. A viewer that falls behind by more than /// jitter at typical bitrates. A viewer that falls behind by more than
@@ -40,14 +43,18 @@ impl Serve {
/// Bind a localhost listener on a random port, set up the broadcast /// Bind a localhost listener on a random port, set up the broadcast
/// fanout, and spawn the reader + accept-loop tasks. The provided /// fanout, and spawn the reader + accept-loop tasks. The provided
/// `stdout` is assumed to produce MPEG-TS bytes. /// `stdout` is assumed to produce MPEG-TS bytes.
pub async fn bind(stdout: ChildStdout) -> Result<Self> { pub(super) async fn bind(
stdout: ChildStdout,
health: health::Reporter,
stopping: Arc<AtomicBool>,
) -> Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0") let listener = TcpListener::bind("127.0.0.1:0")
.await .await
.context("could not bind local capture HTTP listener")?; .context("could not bind local capture HTTP listener")?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY); let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY);
let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone())); let reader = tokio::spawn(pump_to_broadcast(stdout, tx.clone(), health, stopping));
let server = tokio::spawn(run_accept_loop(listener, tx)); let server = tokio::spawn(run_accept_loop(listener, tx));
Ok(Self { Ok(Self {
@@ -105,12 +112,20 @@ pub async fn connect_to_capture(port: u16, max_wait: Duration) -> Result<TcpStre
/// current subscribers. `broadcast::send` returns Err when there are no /// current subscribers. `broadcast::send` returns Err when there are no
/// receivers; we ignore it so the capture child isn't backpressured /// receivers; we ignore it so the capture child isn't backpressured
/// waiting for a viewer. /// waiting for a viewer.
async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Vec<u8>>>) { async fn pump_to_broadcast(
mut stdout: impl tokio::io::AsyncRead + Unpin,
tx: broadcast::Sender<Arc<Vec<u8>>>,
health: health::Reporter,
stopping: Arc<AtomicBool>,
) {
let mut buf = vec![0u8; READ_CHUNK]; let mut buf = vec![0u8; READ_CHUNK];
loop { loop {
match stdout.read(&mut buf).await { match stdout.read(&mut buf).await {
Ok(0) => { Ok(0) => {
tracing::info!("capture stdout EOF — fanout reader exiting"); tracing::info!("capture stdout EOF — fanout reader exiting");
if !stopping.load(Ordering::Acquire) {
health.poison("GStreamer capture stdout closed unexpectedly");
}
return; return;
} }
Ok(n) => { Ok(n) => {
@@ -119,6 +134,9 @@ async fn pump_to_broadcast(mut stdout: ChildStdout, tx: broadcast::Sender<Arc<Ve
} }
Err(e) => { Err(e) => {
tracing::warn!("capture stdout read error: {e}"); tracing::warn!("capture stdout read error: {e}");
if !stopping.load(Ordering::Acquire) {
health.poison(format!("GStreamer capture stdout failed: {e}"));
}
return; return;
} }
} }
@@ -192,3 +210,37 @@ async fn drain_http_request(sock: &mut TcpStream) -> bool {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn unexpected_capture_eof_poisons_the_host() {
let (reader, writer) = tokio::io::duplex(16);
let (tx, _) = broadcast::channel(FANOUT_CAPACITY);
let (health, _) = health::channel();
let stopping = Arc::new(AtomicBool::new(false));
drop(writer);
pump_to_broadcast(reader, tx, health.clone(), stopping).await;
assert_eq!(
health.fault().as_deref(),
Some("GStreamer capture stdout closed unexpectedly")
);
}
#[tokio::test]
async fn expected_capture_eof_during_shutdown_stays_healthy() {
let (reader, writer) = tokio::io::duplex(16);
let (tx, _) = broadcast::channel(FANOUT_CAPACITY);
let (health, _) = health::channel();
let stopping = Arc::new(AtomicBool::new(true));
drop(writer);
pump_to_broadcast(reader, tx, health.clone(), stopping).await;
assert!(health.fault().is_none());
}
}
+27 -2
View File
@@ -155,7 +155,17 @@ impl Graph {
/// A device node as the session manager creates it: no strong key, /// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and /// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported. /// a `device.id`, which is what marks it as session-manager-exported.
/// Each call models a distinct physical device; use [`Self::device_node_on`]
/// when two terminals belong to the same card.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef { pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let device_id = self.id();
self.device_node_on(name, role, device_id)
}
/// A passive terminal exported by a particular physical Device. Sink
/// and source nodes given the same id model the hidden playback-to-capture
/// path that an ALSA/USB device may expose outside PipeWire's Link graph.
pub fn device_node_on(&mut self, name: &str, role: MediaRole, device_id: GlobalId) -> NodeRef {
let session = match self.session_client { let session = match self.session_client {
Some(id) => id, Some(id) => id,
None => { None => {
@@ -164,7 +174,7 @@ impl Graph {
id id
} }
}; };
self.node(name, role, device(session, SESSION_PID)) self.node(name, role, device(session, SESSION_PID, device_id))
} }
/// A node that *belongs to* a Device but is not a passive device node — /// A node that *belongs to* a Device but is not a passive device node —
@@ -247,6 +257,18 @@ impl Graph {
} }
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) { pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
self.port_on_channel(node, direction, exclusive, None);
}
/// A port with the channel identity Phase 6 uses for deterministic link
/// pairing. Returns its snapshot-local id for exact plan assertions.
pub fn port_on_channel(
&mut self,
node: NodeRef,
direction: PortDirection,
exclusive: bool,
channel: Option<&str>,
) -> GlobalId {
let serial = self.serial(); let serial = self.serial();
let id = self.id(); let id = self.id();
self.ports.push(PortSnapshot { self.ports.push(PortSnapshot {
@@ -254,9 +276,11 @@ impl Graph {
id, id,
node: node.id, node: node.id,
direction, direction,
channel: channel.map(str::to_string),
exclusive, exclusive,
monitor: false, monitor: false,
}); });
id
} }
/// A signal edge: audio flows `from → to`. /// A signal edge: audio flows `from → to`.
@@ -386,10 +410,11 @@ pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
/// here is deliberately *more* pessimistic than reality — it hands the /// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that /// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props. /// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps { pub fn device(session_client: GlobalId, session_pid: u32, device_id: GlobalId) -> NodeProps {
NodeProps { NodeProps {
client_id: Some(session_client), client_id: Some(session_client),
process_id: Some(session_pid), process_id: Some(session_pid),
device_id: Some(device_id),
session_device: true, session_device: true,
..NodeProps::default() ..NodeProps::default()
} }
+76 -9
View File
@@ -34,7 +34,12 @@
//! *is* a real Link whose output node is the sink node itself (measured). //! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one //! A port-granular walk would need a synthetic edge; a node-granular one
//! does not. //! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See //! 3. **Hardware-device bridges** — a passive sink can feed a passive source
//! on the same physical Device through a mixer/loopback path that PipeWire
//! does not expose as a Link. The observer positively classifies both
//! terminals and retains their shared `device.id`; the walk conservatively
//! adds `sink → source` for that one Device.
//! 4. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one. //! [`owner`]; this is the hard one.
//! //!
//! ## Stickiness //! ## Stickiness
@@ -196,9 +201,15 @@ pub enum Reason {
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local /// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
/// to the node; does not propagate. /// to the node; does not propagate.
PortExclusive, PortExclusive,
/// An encoded/passthrough stream — a second link would corrupt it. /// No usable configured Format param has arrived. Fan-out cannot prove a
/// second link is safe. Local to the node; does not propagate.
FormatUnknown,
/// An encoded stream — a second raw-audio link would refuse or corrupt.
/// Local to the node; does not propagate. /// Local to the node; does not propagate.
Passthrough, Encoded,
/// An IEC958/S/PDIF passthrough stream. Local to the node; does not
/// propagate.
Iec958Passthrough,
} }
impl Reason { impl Reason {
@@ -214,10 +225,25 @@ impl Reason {
Self::UnresolvedOwner => "unresolved-owner", Self::UnresolvedOwner => "unresolved-owner",
Self::GraphNotReady => "graph-not-ready", Self::GraphNotReady => "graph-not-ready",
Self::PortExclusive => "port-exclusive", Self::PortExclusive => "port-exclusive",
Self::Passthrough => "passthrough", Self::FormatUnknown => "format-unknown",
Self::Encoded => "encoded",
Self::Iec958Passthrough => "iec958-passthrough",
} }
} }
/// A clean, otherwise-eligible stream whose known format/port shape this
/// fan-out mode cannot link safely. These reasons are user-visible
/// `stream_unsupported` statuses; taint roots and observation gating are
/// intentional exclusions, not failures. `FormatUnknown` is deliberately
/// omitted because the initial Node-info callback can precede the Format
/// reply; reporting that transient would produce a false warning.
pub(super) fn reports_stream_unsupported(self) -> bool {
matches!(
self,
Self::PortExclusive | Self::Encoded | Self::Iec958Passthrough
)
}
/// Lower wins. A node can acquire taint several ways in one recompute /// Lower wins. A node can acquire taint several ways in one recompute
/// and the reported reason must not depend on traversal order, or the /// and the reported reason must not depend on traversal order, or the
/// audit output is unstable and the fixture tests are flaky. Explicit /// audit output is unstable and the fixture tests are flaky. Explicit
@@ -236,7 +262,9 @@ impl Reason {
// because it is only consulted for untainted candidates. // because it is only consulted for untainted candidates.
Self::GraphNotReady => 8, Self::GraphNotReady => 8,
Self::PortExclusive => 9, Self::PortExclusive => 9,
Self::Passthrough => 10, Self::FormatUnknown => 10,
Self::Encoded => 11,
Self::Iec958Passthrough => 12,
} }
} }
@@ -784,6 +812,40 @@ fn downstream_edges(snapshot: &GraphSnapshot, unresolved_input: &mut BTreeSet<Se
_ => {} _ => {}
} }
} }
// A physical sound device may route playback back into capture in its
// own mixer/firmware without publishing a PipeWire Link (HDA "Stereo
// Mix", USB loopback channels, vendor DSPs). Control-name inspection is
// neither portable nor proof of absence, so v1 fails closed: once a
// passive hardware sink is tainted, passive capture terminals exported
// by the same Device are downstream too.
//
// Both guards are load-bearing. `session_device` limits this to the
// observer's positive hardware-terminal allowlist, so an app-associated
// filter cannot invent a bridge. `device_id` limits it to one physical
// Device, so the shared WirePlumber client does not fuse every card.
let hardware_outputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Sink | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
let hardware_inputs: Vec<(Serial, snapshot::GlobalId)> = snapshot
.nodes()
.filter(|node| {
node.props.session_device && matches!(node.role, MediaRole::Source | MediaRole::Duplex)
})
.filter_map(|node| node.props.device_id.map(|id| (node.serial, id)))
.collect();
for (from, output_device) in hardware_outputs {
for &(to, input_device) in &hardware_inputs {
if from != to && output_device == input_device {
edges.entry(from).or_default().push(to);
receivers.insert(to);
}
}
}
for targets in edges.values_mut() { for targets in edges.values_mut() {
targets.sort_unstable(); targets.sort_unstable();
targets.dedup(); targets.dedup();
@@ -1018,13 +1080,18 @@ fn build_decisions(
/// clean. These do not propagate — an exclusive-port stream is unlinkable, /// clean. These do not propagate — an exclusive-port stream is unlinkable,
/// not hazardous. /// not hazardous.
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> { fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
if node.props.passthrough {
return Some(Reason::Passthrough);
}
if snapshot.ports_of(node.id).any(|port| port.exclusive) { if snapshot.ports_of(node.id).any(|port| port.exclusive) {
return Some(Reason::PortExclusive); return Some(Reason::PortExclusive);
} }
None if node.props.passthrough {
return Some(Reason::Iec958Passthrough);
}
match node.props.stream_format {
snapshot::StreamFormat::Raw => None,
snapshot::StreamFormat::Unknown => Some(Reason::FormatUnknown),
snapshot::StreamFormat::Encoded => Some(Reason::Encoded),
snapshot::StreamFormat::Iec958 => Some(Reason::Iec958Passthrough),
}
} }
/// Sticky bookkeeping for the next recompute: every tainted owner, with /// Sticky bookkeeping for the next recompute: every tainted owner, with
+37 -2
View File
@@ -52,6 +52,25 @@ pub enum MediaRole {
Other, Other,
} }
/// The configured format of an application playback stream.
///
/// The production observer reads this from the Node's `SPA_PARAM_Format`.
/// Fixtures default to [`Self::Raw`] because almost every graph fixture models
/// ordinary PCM playback; the observer explicitly uses [`Self::Unknown`]
/// until PipeWire supplies a format. Unknown is fail-closed at eligibility.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StreamFormat {
/// Ordinary PCM audio, safe to fan out subject to the other predicates.
#[default]
Raw,
/// No usable configured format has been observed yet.
Unknown,
/// Encoded audio other than IEC958 passthrough.
Encoded,
/// IEC958/S/PDIF passthrough.
Iec958,
}
impl MediaRole { impl MediaRole {
pub fn parse(media_class: Option<&str>) -> Self { pub fn parse(media_class: Option<&str>) -> Self {
match media_class { match media_class {
@@ -136,9 +155,22 @@ pub struct NodeProps {
/// module-created streams this is pipewire-pulse's own PID, which is /// module-created streams this is pipewire-pulse's own PID, which is
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists. /// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
pub process_id: Option<u32>, pub process_id: Option<u32>,
/// The stream negotiated an encoded/passthrough format; a second link /// `node.passthrough`; an explicit producer/session-manager refusal flag.
/// would refuse or corrupt it (v3.4 §6.2). /// Kept separate from [`Self::stream_format`] so the two Phase-6 refusal
/// predicates cannot accidentally mask one another.
pub passthrough: bool, pub passthrough: bool,
/// The Node's configured `SPA_PARAM_Format`, classified at the observer
/// boundary. Encoded and IEC958 streams are distinct refusal predicates.
pub stream_format: StreamFormat,
/// `device.id` — the snapshot-local PipeWire Device this node belongs
/// to. This is retained separately from [`Self::session_device`]: the
/// latter says the node is a positively-classified passive hardware
/// terminal, while this id lets the taint walk relate the playback and
/// capture terminals exported by that *same* device.
///
/// Like every [`GlobalId`], this is valid only within this snapshot. It
/// must never enter sticky identity or survive a recompute.
pub device_id: Option<GlobalId>,
/// This node is a **passive device node exported by the session /// This node is a **passive device node exported by the session
/// manager** — a real sound card's sink or source, not something that /// manager** — a real sound card's sink or source, not something that
/// forwards audio. /// forwards audio.
@@ -219,6 +251,9 @@ pub struct PortSnapshot {
/// Owning node, by snapshot-local id. /// Owning node, by snapshot-local id.
pub node: GlobalId, pub node: GlobalId,
pub direction: PortDirection, pub direction: PortDirection,
/// `audio.channel` (for example `FL`, `FR`, `MONO`). Phase 6 pairs
/// ports by channel, never by global-id or enumeration order.
pub channel: Option<String>,
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2). /// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
pub exclusive: bool, pub exclusive: bool,
/// `port.monitor`. Recorded for phase 6 link creation; taint does not /// `port.monitor`. Recorded for phase 6 link creation; taint does not
+84 -2
View File
@@ -16,7 +16,7 @@ use std::collections::BTreeSet;
use super::fixture::{Graph, NodeRef, PULSE_PID, app}; use super::fixture::{Graph, NodeRef, PULSE_PID, app};
use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key}; use super::owner::{OwnerCtx, OwnerKey, strongest_shared_key};
use super::snapshot::{MediaRole, NodeProps, PortDirection, Serial}; use super::snapshot::{GlobalId, MediaRole, NodeProps, PortDirection, Serial};
use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate}; use super::{Decisions, Eligibility, ExclusionCtx, ObjectRef, Reason, StickyState, evaluate};
fn ctx() -> ExclusionCtx { fn ctx() -> ExclusionCtx {
@@ -176,6 +176,88 @@ fn peerspeak_tagged_nodes_are_excluded_and_plain_apps_are_not() {
assert_tainted(&decisions, sink, "tainted-upstream"); assert_tainted(&decisions, sink, "tainted-upstream");
} }
// ──────────────────────────────────────────────────────────────────────
// Hidden hardware playback-to-capture paths — same Device only
// ─────────────────────────────────────────────────────────────────────
#[test]
fn same_hardware_device_closes_an_unpublished_playback_to_capture_hop() {
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("card-playback", MediaRole::Sink, card);
let source = graph.device_node_on("card-capture", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let music = graph.app_node("music", MediaRole::StreamOutput, 8);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(music, sink);
// There is deliberately no sink → source Link: the hardware bridge is
// the route being modeled.
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("music", music)],
&[
("call", call, "peerspeak-owned"),
("recorder-out", recorder_out, "tainted-owner-bridge"),
],
);
assert_tainted(&decisions, sink, "tainted-upstream");
assert_tainted(&decisions, source, "tainted-upstream");
assert_tainted(&decisions, recorder_in, "tainted-upstream");
}
#[test]
fn different_hardware_devices_do_not_invent_a_capture_path() {
let mut graph = Graph::new();
let sink = graph.device_node_on("speaker", MediaRole::Sink, GlobalId(700));
let source = graph.device_node_on("usb-mic", MediaRole::Source, GlobalId(701));
let call = graph.peerspeak_node("peerspeak-call", 7);
let recorder_in = graph.app_node("recorder-in", MediaRole::StreamInput, 9);
let recorder_out = graph.app_node("recorder-out", MediaRole::StreamOutput, 9);
graph.link(call, sink);
graph.link(source, recorder_in);
let decisions = run(&graph, &ctx());
assert_partition(
&decisions,
&[("recorder-out", recorder_out)],
&[("call", call, "peerspeak-owned")],
);
assert_untainted(&decisions, source);
assert_untainted(&decisions, recorder_in);
}
#[test]
fn same_device_microphone_use_is_intentionally_over_excluded() {
// The hardware's private mixer/firmware path is not observable in the
// PipeWire graph. If an app captures the same device receiving the call,
// v1 cannot prove that its capture is clean, so its playback is excluded.
let mut graph = Graph::new();
let card = GlobalId(700);
let sink = graph.device_node_on("headset-output", MediaRole::Sink, card);
let mic = graph.device_node_on("headset-mic", MediaRole::Source, card);
let call = graph.peerspeak_node("peerspeak-call", 7);
let firefox_in = graph.app_node("firefox-mic", MediaRole::StreamInput, 11_114);
let firefox_out = graph.app_node("firefox-audio", MediaRole::StreamOutput, 11_114);
graph.link(call, sink);
graph.link(mic, firefox_in);
assert_partition(
&run(&graph, &ctx()),
&[],
&[
("call", call, "peerspeak-owned"),
("firefox-out", firefox_out, "tainted-owner-bridge"),
],
);
}
/// Each ownership carrier must work **alone** (v3.5 §5.1). /// Each ownership carrier must work **alone** (v3.5 §5.1).
/// ///
/// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value /// ⚠️ The phase-3r lesson, applied deliberately: a gate that asserts a value
@@ -517,7 +599,7 @@ fn port_exclusive_and_passthrough_are_local_exclusions() {
&[("ok", ok)], &[("ok", ok)],
&[ &[
("exclusive", exclusive, "port-exclusive"), ("exclusive", exclusive, "port-exclusive"),
("passthrough", passthrough, "passthrough"), ("passthrough", passthrough, "iec958-passthrough"),
], ],
); );
// Neither is hazardous — an unlinkable stream must not taint anything. // Neither is hazardous — an unlinkable stream must not taint anything.
+7 -1
View File
@@ -14,11 +14,16 @@ use ashpd::{
use nix::fcntl::{FcntlArg, FdFlag, fcntl}; use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::health;
use super::pipeline::{self, CaptureHandle}; use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality; use super::quality::EffectiveQuality;
use crate::cli::HostOpts; use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> { pub(super) async fn start(
opts: &HostOpts,
quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal. // 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new() let proxy = Screencast::new()
.await .await
@@ -81,6 +86,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
quality, quality,
Some((w as u32, h as u32)), Some((w as u32, h as u32)),
source_args, source_args,
health,
move || { move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy. // Parent no longer needs the pipewire fd — gst inherited its own copy.
drop(pw_fd); drop(pw_fd);
+7 -2
View File
@@ -10,11 +10,16 @@ use tokio::process::Command;
use x11rb::connection::Connection; use x11rb::connection::Connection;
use x11rb::protocol::xproto::ConnectionExt; use x11rb::protocol::xproto::ConnectionExt;
use super::health;
use super::pipeline::{self, CaptureHandle}; use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality; use super::quality::EffectiveQuality;
use crate::cli::HostOpts; use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> { pub(super) async fn start(
opts: &HostOpts,
quality: &EffectiveQuality,
health: health::Reporter,
) -> Result<CaptureHandle> {
let xid = if opts.window { let xid = if opts.window {
Some(pick_window().await?) Some(pick_window().await?)
} else { } else {
@@ -60,7 +65,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
} }
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op. // X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
pipeline::spawn(opts, quality, source_dims, source_args, || {}).await pipeline::spawn(opts, quality, source_dims, source_args, health, || {}).await
} }
/// Run `xwininfo` and let the user click the window they want to share, then /// Run `xwininfo` and let the user click the window they want to share, then
+1 -1
View File
@@ -30,7 +30,7 @@ pub async fn run(cli: Cli) -> Result<()> {
if cli.quality.is_none() { if cli.quality.is_none() {
cli.quality = Some(pick_quality(&theme)?); cli.quality = Some(pick_quality(&theme)?);
} }
host::run(cli.into_host_opts(true)).await host::run(cli.into_host_opts(true)?).await
} }
_ => { _ => {
let ticket = prompt_ticket(&theme)?; let ticket = prompt_ticket(&theme)?;
+69 -2
View File
@@ -1,10 +1,21 @@
mod capabilities;
mod cli; mod cli;
mod common; mod common;
#[cfg(target_os = "linux")]
mod doctor; mod doctor;
#[cfg(feature = "gui")] #[cfg(target_os = "windows")]
#[path = "windows/doctor.rs"]
mod doctor;
#[cfg(all(feature = "gui", target_os = "linux"))]
mod gui; mod gui;
#[cfg(target_os = "linux")]
mod host; mod host;
#[cfg(target_os = "linux")]
mod interactive; mod interactive;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
#[cfg(target_os = "linux")]
mod repair; mod repair;
mod viewer; mod viewer;
@@ -19,6 +30,15 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_tracing(cli.verbose); init_tracing(cli.verbose);
run(cli).await
}
#[cfg(target_os = "linux")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) { if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true); common::output::set_json(true);
} }
@@ -70,7 +90,7 @@ async fn main() -> Result<()> {
screen, a ticket views someone else's." screen, a ticket views someone else's."
); );
} }
return host::run(cli.into_host_opts(false)).await; return host::run(cli.into_host_opts(false)?).await;
} }
match cli.ticket.as_deref() { match cli.ticket.as_deref() {
@@ -87,6 +107,53 @@ async fn main() -> Result<()> {
} }
} }
#[cfg(target_os = "windows")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true);
}
if cli.gui {
anyhow::bail!(
"the Windows PixelPass milestone is viewer-only and headless; use it through \
PeerSpeak or pass a ticket directly"
);
}
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
if cli.host {
anyhow::bail!(
"hosting a screen share is not available in this Windows PixelPass milestone; \
this build can view shares hosted by Linux"
);
}
if cli.repair || cli.audit_audio || cli.reconfigure {
anyhow::bail!("this operation belongs to PixelPass's Linux host/capture stack");
}
match cli.ticket.as_deref() {
Some(s) => {
let ticket: EndpointTicket = s.parse().map_err(|e| {
anyhow::anyhow!(
"argument doesn't look like a pixelpass ticket ({e}).\n\
Run with no arguments for the viewer prompt, or pass a ticket to view."
)
})?;
viewer::run(ticket, cli.into_viewer_opts(false)).await
}
None => interactive::run(cli).await,
}
}
fn init_tracing(verbose: bool) { fn init_tracing(verbose: bool) {
let default = if verbose { let default = if verbose {
"pixelpass=trace,iroh=info" "pixelpass=trace,iroh=info"
+76
View File
@@ -0,0 +1,76 @@
//! Viewer-only diagnostics for the first Windows PixelPass milestone.
use anyhow::{Result, bail};
use std::path::PathBuf;
use std::time::Duration;
use crate::common::endpoint;
pub async fn run(relay: Option<String>) -> Result<()> {
eprintln!();
eprintln!("PixelPass doctor — Windows viewer");
eprintln!("─────────────────────────────────");
eprintln!("· pixelpass : {}", env!("CARGO_PKG_VERSION"));
eprintln!("· hosting : unavailable in this viewer-only milestone");
let player = find_program("mpv")
.map(|path| ("mpv", path))
.or_else(|| find_program("vlc").map(|path| ("vlc", path)));
match &player {
Some((name, path)) => eprintln!("✓ player : {name} ({})", path.display()),
None => eprintln!("✗ player : neither mpv nor VLC was found"),
}
let relay_ok = match endpoint::bind(relay.as_deref()).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let has_relay = ep.addr().addrs.iter().any(|addr| addr.is_relay());
ep.close().await;
if online && has_relay {
eprintln!("✓ network : relay reachable");
} else if online {
eprintln!("✗ network : endpoint online, but no relay address is available");
} else {
eprintln!("✗ network : no relay reached within 8 seconds");
}
online && has_relay
}
Err(error) => {
eprintln!("✗ network : could not bind an iroh endpoint ({error:#})");
false
}
};
eprintln!();
if player.is_none() || !relay_ok {
bail!("Windows viewer prerequisites are incomplete");
}
eprintln!("Ready to view a PixelPass share hosted by Linux.");
Ok(())
}
fn find_program(name: &str) -> Option<PathBuf> {
let file = format!("{name}.exe");
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_program_is_reported_without_panicking() {
assert!(find_program("pixelpass-definitely-not-installed").is_none());
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Minimal interactive wrapper for the Windows viewer milestone.
use anyhow::Result;
use dialoguer::{Input, Select, theme::ColorfulTheme};
use iroh_tickets::endpoint::EndpointTicket;
use std::str::FromStr;
use crate::cli::Cli;
use crate::viewer;
pub async fn run(cli: Cli) -> Result<()> {
eprintln!();
eprintln!("Welcome to PixelPass for Windows (viewer milestone).");
eprintln!("This build can watch a share hosted by PixelPass on Linux.");
eprintln!();
let theme = ColorfulTheme::default();
let ticket = prompt_ticket(&theme)?;
viewer::run(ticket, cli.into_viewer_opts(true)).await
}
fn prompt_ticket(theme: &ColorfulTheme) -> Result<EndpointTicket> {
loop {
let raw: String = Input::with_theme(theme)
.with_prompt("Paste the share code you received")
.interact_text()?;
match EndpointTicket::from_str(raw.trim()) {
Ok(ticket) => return Ok(ticket),
Err(_) => eprintln!("That doesn't look like a share code. Try again."),
}
}
}
#[derive(Clone, Copy)]
pub enum Player {
Mpv,
Vlc,
}
impl Player {
pub fn spawn(self, url: &str) -> std::io::Result<()> {
match self {
Self::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
url,
],
),
Self::Vlc => crate::common::process::spawn_detached(
"vlc",
&["--network-caching=200", "--live-caching=200", url],
),
}
}
}
pub fn prompt_player() -> Result<Player> {
let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme)
.with_prompt("Open stream with")
.items(["mpv (recommended)", "VLC"])
.default(0)
.interact()?;
Ok(if choice == 0 {
Player::Mpv
} else {
Player::Vlc
})
}