19 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
mollusk eaea188e05 chore: enable automatic Nix development shell 2026-08-11 11:16:29 -04:00
mollusk 15d13742f5 Merge 0c step 2 S3a: cancellation-safe Pulse module ledger 2026-08-10 03:56:20 -04:00
mollusk 6f3e26a78c fix(audio): make module teardown cancellation-safe 2026-08-10 03:43:31 -04:00
42 changed files with 9684 additions and 869 deletions
+1
View File
@@ -0,0 +1 @@
use flake
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,
+599 -495
View File
File diff suppressed because it is too large Load Diff
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"));
}
}
+553 -94
View File
@@ -33,12 +33,13 @@
//! # Why the permit is affine //! # Why the permit is affine
//! //!
//! [`LoadPermit`] is not `Clone`, is consumed by value to settle, and its [`Drop`] //! [`LoadPermit`] is not `Clone`, is consumed by value to settle, and its [`Drop`]
//! marks the slot [`Reconcile::Load`] when it was never settled. That is what makes //! marks the slot [`Reconcile::Load`] when it was never settled. The permit moves
//! the guarantee structural rather than a discipline: a cancelled task drops its //! into a registered blocking worker before any await can detach that work; either
//! locals, so an aborted load *cannot* silently forget a module the server may //! the worker settles it, or dropping the worker marks the question ambiguous.
//! already have created. Two permitted loads for one slot cannot both commit, //! Teardown waits for all such permits before reconciling. Two permitted loads for
//! because [`ModuleLedger::begin_load`] issues a permit only for a `Vacant` slot //! one slot cannot both commit, because [`ModuleLedger::begin_load`] issues a permit
//! and every other state — including the ambiguous one — refuses. //! only for a `Vacant` slot and every other state — including the ambiguous one —
//! refuses.
//! //!
//! # Why an ambiguous slot blocks the next load //! # Why an ambiguous slot blocks the next load
//! //!
@@ -50,8 +51,8 @@
//! proceed until the question is answered is the whole point. //! proceed until the question is answered is the whole point.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@@ -99,7 +100,7 @@ pub enum SlotState {
Loaded { fp: Fingerprint }, Loaded { fp: Fingerprint },
/// An unload is in flight. The fingerprint is retained deliberately: an unload /// An unload is in flight. The fingerprint is retained deliberately: an unload
/// that times out must not leave the module unrecorded. /// that times out must not leave the module unrecorded.
Unloading { fp: Fingerprint }, Unloading { fp: Fingerprint, permit: u64 },
/// The slot's real state is unknown and must be resolved against the server. /// The slot's real state is unknown and must be resolved against the server.
Ambiguous(Reconcile), Ambiguous(Reconcile),
/// Resolution found something we refuse to act on. Terminal. /// Resolution found something we refuse to act on. Terminal.
@@ -129,6 +130,20 @@ pub enum LedgerError {
Poisoned { shape: Shape, reason: String }, Poisoned { shape: Shape, reason: String },
/// `pactl` reported `PA_INVALID_INDEX` where an index was expected. /// `pactl` reported `PA_INVALID_INDEX` where an index was expected.
InvalidIndex { shape: Shape }, InvalidIndex { shape: Shape },
/// Teardown has begun, so event-driven work may no longer start.
Closed { shape: Shape },
/// The two inverse loopbacks must never coexist.
Incompatible {
shape: Shape,
occupied: Shape,
state: &'static str,
},
/// The capture sink cannot be removed while a loopback may still reference it.
Referenced {
shape: Shape,
dependency: Shape,
state: &'static str,
},
} }
impl std::fmt::Display for LedgerError { impl std::fmt::Display for LedgerError {
@@ -145,6 +160,31 @@ impl std::fmt::Display for LedgerError {
"pactl reported PA_INVALID_INDEX for the {} module", "pactl reported PA_INVALID_INDEX for the {} module",
shape.label() shape.label()
), ),
LedgerError::Closed { shape } => write!(
f,
"the module ledger is closing; refusing new work on the {} slot",
shape.label()
),
LedgerError::Incompatible {
shape,
occupied,
state,
} => write!(
f,
"cannot load the {} while the incompatible {} slot is {state}",
shape.label(),
occupied.label()
),
LedgerError::Referenced {
shape,
dependency,
state,
} => write!(
f,
"cannot unload the {} while the dependent {} slot is {state}",
shape.label(),
dependency.label()
),
} }
} }
} }
@@ -180,6 +220,59 @@ pub enum Resolution {
pub struct ModuleLedger { pub struct ModuleLedger {
slots: Mutex<BTreeMap<Shape, SlotState>>, slots: Mutex<BTreeMap<Shape, SlotState>>,
next_permit: AtomicU64, next_permit: AtomicU64,
closed: AtomicBool,
operations: OperationCoordinator,
}
/// Registers module mutations before they can be detached from their caller.
///
/// `spawn_blocking` work continues when the awaiting future is cancelled. The
/// count is therefore registered synchronously, while the slot lock is held, and
/// is released only by the affine permit's `Drop`. Teardown closes the ledger and
/// waits on this condition variable before it asks the server what exists.
struct OperationCoordinator {
active: Mutex<usize>,
idle: Condvar,
server: Mutex<()>,
}
impl OperationCoordinator {
fn new() -> Self {
Self {
active: Mutex::new(0),
idle: Condvar::new(),
server: Mutex::new(()),
}
}
fn register(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
*active = active
.checked_add(1)
.expect("module operation count overflow");
}
fn finish(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
*active = active
.checked_sub(1)
.expect("module operation count underflow");
if *active == 0 {
self.idle.notify_all();
}
}
fn wait_idle(&self) {
let mut active = self.active.lock().unwrap_or_else(|e| e.into_inner());
while *active != 0 {
active = self.idle.wait(active).unwrap_or_else(|e| e.into_inner());
}
}
fn run<T>(&self, operation: impl FnOnce() -> T) -> T {
let _serial = self.server.lock().unwrap_or_else(|e| e.into_inner());
operation()
}
} }
impl ModuleLedger { impl ModuleLedger {
@@ -187,9 +280,36 @@ impl ModuleLedger {
Arc::new(Self { Arc::new(Self {
slots: Mutex::new(BTreeMap::new()), slots: Mutex::new(BTreeMap::new()),
next_permit: AtomicU64::new(1), next_permit: AtomicU64::new(1),
closed: AtomicBool::new(false),
operations: OperationCoordinator::new(),
}) })
} }
/// Stop event-driven mutations and wait until every already-registered load
/// or unload has settled its affine permit.
///
/// The slots-lock hand-off closes the only race that matters: an operation
/// that saw `closed == false` has registered itself before this method can
/// pass the hand-off and begin waiting.
pub(super) fn close(&self) {
self.closed.store(true, Ordering::SeqCst);
drop(self.slots.lock().unwrap_or_else(|e| e.into_inner()));
}
pub(super) fn wait_for_operations(&self) {
self.operations.wait_idle();
}
pub(super) fn close_and_wait(&self) {
self.close();
self.wait_for_operations();
}
/// Serialize one server mutation or observation with every other such action.
pub(super) fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.operations.run(operation)
}
/// The current state of one slot. Absent keys read as [`SlotState::Vacant`]. /// The current state of one slot. Absent keys read as [`SlotState::Vacant`].
/// ///
/// Test-only: production code never needs to look a slot up, because every /// Test-only: production code never needs to look a slot up, because every
@@ -219,6 +339,9 @@ impl ModuleLedger {
token: OwnerToken, token: OwnerToken,
) -> Result<LoadPermit, LedgerError> { ) -> Result<LoadPermit, LedgerError> {
let mut slots = self.slots.lock().unwrap(); let mut slots = self.slots.lock().unwrap();
if self.closed.load(Ordering::SeqCst) {
return Err(LedgerError::Closed { shape });
}
let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant); let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
match current { match current {
SlotState::Vacant => {} SlotState::Vacant => {}
@@ -232,7 +355,18 @@ impl ModuleLedger {
}); });
} }
} }
if let Some(occupied) = inverse_loopback(shape) {
let state = slots.get(&occupied).cloned().unwrap_or(SlotState::Vacant);
if !matches!(state, SlotState::Vacant) {
return Err(LedgerError::Incompatible {
shape,
occupied,
state: state.label(),
});
}
}
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed); let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
self.operations.register();
slots.insert( slots.insert(
shape, shape,
SlotState::Loading { SlotState::Loading {
@@ -253,36 +387,76 @@ impl ModuleLedger {
/// Move a `Loaded` slot to `Unloading` and hand back what to unload. /// Move a `Loaded` slot to `Unloading` and hand back what to unload.
/// ///
/// `None` for any other state: there is nothing to unload, or the slot is not /// `Ok(None)` means confirmed vacancy. Busy, poisoned, closed, and still-
/// in a condition to be acted on. /// referenced states are errors so callers cannot mistake uncertainty for
pub fn begin_unload(&self, shape: Shape) -> Option<Fingerprint> { /// absence and load an inverse loopback or destroy the capture sink.
let mut slots = self.slots.lock().unwrap(); pub fn begin_unload(
let SlotState::Loaded { fp } = slots.get(&shape).cloned()? else { self: &Arc<Self>,
return None; shape: Shape,
}; ) -> Result<Option<UnloadPermit>, LedgerError> {
slots.insert(shape, SlotState::Unloading { fp: fp.clone() }); self.begin_unload_inner(shape, false)
Some(fp)
} }
/// Record how an unload turned out. An uncertain one becomes ambiguous rather /// Teardown-only form of [`ModuleLedger::begin_unload`]. New event work is
/// than being assumed done — the module is not forgotten either way. /// closed by then, but cleanup must still be able to remove tracked modules.
pub fn finish_unload(&self, shape: Shape, outcome: UnloadOutcome) { pub(super) fn begin_cleanup_unload(
self: &Arc<Self>,
shape: Shape,
) -> Result<Option<UnloadPermit>, LedgerError> {
self.begin_unload_inner(shape, true)
}
fn begin_unload_inner(
self: &Arc<Self>,
shape: Shape,
cleanup: bool,
) -> Result<Option<UnloadPermit>, LedgerError> {
let mut slots = self.slots.lock().unwrap(); let mut slots = self.slots.lock().unwrap();
let Some(SlotState::Unloading { fp }) = slots.get(&shape).cloned() else { if !cleanup && self.closed.load(Ordering::SeqCst) {
return; return Err(LedgerError::Closed { shape });
}; }
let next = match outcome { let current = slots.get(&shape).cloned().unwrap_or(SlotState::Vacant);
UnloadOutcome::Confirmed => SlotState::Vacant, let fp = match current {
UnloadOutcome::Uncertain(why) => { SlotState::Vacant => return Ok(None),
tracing::warn!( SlotState::Loaded { fp } => fp,
shape = fp.shape.label(), SlotState::Poisoned { reason } => {
module = fp.id, return Err(LedgerError::Poisoned { shape, reason });
"audio ledger: unload outcome uncertain ({why}); slot needs reconciling" }
); other => {
SlotState::Ambiguous(Reconcile::Unload { fp }) return Err(LedgerError::Busy {
shape,
state: other.label(),
});
} }
}; };
slots.insert(shape, next); if shape == Shape::LegacyCaptureSink {
for dependency in [Shape::LoopbackIntoCapture, Shape::LoopbackOutOfCapture] {
let state = slots.get(&dependency).cloned().unwrap_or(SlotState::Vacant);
if !matches!(state, SlotState::Vacant) {
return Err(LedgerError::Referenced {
shape,
dependency,
state: state.label(),
});
}
}
}
let permit = self.next_permit.fetch_add(1, Ordering::Relaxed);
self.operations.register();
slots.insert(
shape,
SlotState::Unloading {
fp: fp.clone(),
permit,
},
);
Ok(Some(UnloadPermit {
ledger: Arc::clone(self),
shape,
fp,
permit,
settled: false,
}))
} }
/// Every slot currently holding a module, in [`Shape`] declaration order — /// Every slot currently holding a module, in [`Shape`] declaration order —
@@ -313,14 +487,28 @@ impl ModuleLedger {
.collect() .collect()
} }
/// Is every slot in a state we can explain? False while anything is ambiguous /// Is every slot in a state we can explain? False while an operation is in
/// or poisoned. /// flight, its outcome is ambiguous, or a conflict poisoned the slot.
pub fn is_settled(&self) -> bool { pub fn is_settled(&self) -> bool {
self.slots self.slots
.lock() .lock()
.unwrap() .unwrap()
.values() .values()
.all(|state| !matches!(state, SlotState::Ambiguous(_) | SlotState::Poisoned { .. })) .all(|state| matches!(state, SlotState::Vacant | SlotState::Loaded { .. }))
}
/// Has teardown removed every module rather than merely accounted for it?
pub fn is_clean(&self) -> bool {
self.slots
.lock()
.unwrap()
.values()
.all(|state| matches!(state, SlotState::Vacant))
}
/// A stable snapshot used to stop cleanup when another pass made no progress.
pub(super) fn snapshot(&self) -> BTreeMap<Shape, SlotState> {
self.slots.lock().unwrap().clone()
} }
/// Apply a reconciliation result to an ambiguous slot. /// Apply a reconciliation result to an ambiguous slot.
@@ -359,6 +547,14 @@ impl ModuleLedger {
} }
} }
fn inverse_loopback(shape: Shape) -> Option<Shape> {
match shape {
Shape::LoopbackIntoCapture => Some(Shape::LoopbackOutOfCapture),
Shape::LoopbackOutOfCapture => Some(Shape::LoopbackIntoCapture),
Shape::LegacyCaptureSink => None,
}
}
/// Permission to perform exactly one load, which must be settled by value. /// Permission to perform exactly one load, which must be settled by value.
/// ///
/// Dropping it unsettled is not an error — it is the *reporting* path for a /// Dropping it unsettled is not an error — it is the *reporting* path for a
@@ -375,6 +571,12 @@ pub struct LoadPermit {
} }
impl LoadPermit { impl LoadPermit {
/// Run the server-facing part of this load in the same serialized domain as
/// unload verification and reconciliation.
pub fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.ledger.with_server_operation(operation)
}
/// Record that the server created the module at `index`. /// Record that the server created the module at `index`.
/// ///
/// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that /// Fails on [`PA_INVALID_INDEX`], leaving the permit unsettled so that
@@ -427,29 +629,106 @@ impl LoadPermit {
impl Drop for LoadPermit { impl Drop for LoadPermit {
fn drop(&mut self) { fn drop(&mut self) {
if self.settled { if !self.settled {
let mut slots = self.ledger.slots.lock().unwrap();
// Only claim the slot if it is still *our* load. Anything else already
// moved past this permit.
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
"audio ledger: a load was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Load {
shape: self.shape,
pid: self.pid,
token: self.token.clone(),
}),
);
}
}
self.ledger.operations.finish();
}
}
/// Permission to perform exactly one unload, which must be settled by value.
///
/// Like [`LoadPermit`], this is affine. Cancellation drops it unsettled, retaining
/// the full fingerprint as a reconciliation question instead of leaving the slot
/// stuck in an invisible `Unloading` state.
#[must_use = "an unsettled permit marks the unload ambiguous when dropped"]
pub struct UnloadPermit {
ledger: Arc<ModuleLedger>,
shape: Shape,
fp: Fingerprint,
permit: u64,
settled: bool,
}
impl UnloadPermit {
pub fn fingerprint(&self) -> &Fingerprint {
&self.fp
}
pub fn with_server_operation<T>(&self, operation: impl FnOnce() -> T) -> T {
self.ledger.with_server_operation(operation)
}
/// Record how the server operation ended. An uncertain answer retains the
/// fingerprint and makes the next action reconcile it before doing anything.
pub fn finish(mut self, outcome: UnloadOutcome) {
let mut slots = self.ledger.slots.lock().unwrap();
let Some(SlotState::Unloading { fp, permit }) = slots.get(&self.shape).cloned() else {
self.settled = true;
return;
};
if permit != self.permit {
self.settled = true;
return; return;
} }
let mut slots = self.ledger.slots.lock().unwrap(); let next = match outcome {
// Only claim the slot if it is still *our* load. Anything else already UnloadOutcome::Confirmed => SlotState::Vacant,
// moved past this permit. UnloadOutcome::Uncertain(why) => {
if let Some(SlotState::Loading { permit, .. }) = slots.get(&self.shape) tracing::warn!(
&& *permit == self.permit shape = fp.shape.label(),
{ module = fp.id,
tracing::warn!( "audio ledger: unload outcome uncertain ({why}); slot needs reconciling"
shape = self.shape.label(), );
"audio ledger: a load was cancelled before its outcome was known; \ SlotState::Ambiguous(Reconcile::Unload { fp })
the slot needs reconciling" }
); };
slots.insert( slots.insert(self.shape, next);
self.shape, drop(slots);
SlotState::Ambiguous(Reconcile::Load { self.settled = true;
shape: self.shape, }
pid: self.pid, }
token: self.token.clone(),
}), impl Drop for UnloadPermit {
); fn drop(&mut self) {
if !self.settled {
let mut slots = self.ledger.slots.lock().unwrap();
if let Some(SlotState::Unloading { permit, .. }) = slots.get(&self.shape)
&& *permit == self.permit
{
tracing::warn!(
shape = self.shape.label(),
module = self.fp.id,
"audio ledger: an unload was cancelled before its outcome was known; \
the slot needs reconciling"
);
slots.insert(
self.shape,
SlotState::Ambiguous(Reconcile::Unload {
fp: self.fp.clone(),
}),
);
}
} }
self.ledger.operations.finish();
} }
} }
@@ -509,26 +788,35 @@ pub fn resolve(reconcile: &Reconcile, observations: &[ModuleObservation]) -> Res
/// the life of a share. Reconciliation is rare and off the hot path, so paying a /// the life of a share. Reconciliation is rare and off the hot path, so paying a
/// connection for it costs nothing that matters. /// connection for it costs nothing that matters.
pub async fn reconcile_pending(ledger: &Arc<ModuleLedger>) -> Result<()> { pub async fn reconcile_pending(ledger: &Arc<ModuleLedger>) -> Result<()> {
let pending = ledger.pending(); let ledger = Arc::clone(ledger);
if pending.is_empty() { tokio::task::spawn_blocking(move || reconcile_pending_blocking(&ledger))
return Ok(()); .await
} .context("the Pulse reconciliation task failed to run")?
tracing::info!( }
n = pending.len(),
"audio ledger: reconciling unresolved module slots against the server"
);
let observations = tokio::task::spawn_blocking(|| -> Result<Vec<ModuleObservation>> {
let mut session = PulseSession::connect()?;
session.list_modules()
})
.await
.context("the Pulse listing task failed to run")?
.context("could not list Pulse modules to reconcile the audio ledger")?;
for (shape, reconcile) in pending { /// Synchronous reconciliation for [`Routing::drop`](crate::host::audio::Routing).
ledger.apply(shape, resolve(&reconcile, &observations)); /// The caller closes and quiesces the ledger first; serialization here also makes
} /// ordinary async reconciliation wait behind any server operation already running.
Ok(()) pub(super) fn reconcile_pending_blocking(ledger: &Arc<ModuleLedger>) -> Result<()> {
ledger.with_server_operation(|| {
let pending = ledger.pending();
if pending.is_empty() {
return Ok(());
}
tracing::info!(
n = pending.len(),
"audio ledger: reconciling unresolved module slots against the server"
);
let mut session = PulseSession::connect()?;
let observations = session
.list_modules()
.context("could not list Pulse modules to reconcile the audio ledger")?;
for (shape, reconcile) in pending {
ledger.apply(shape, resolve(&reconcile, &observations));
}
Ok(())
})
} }
#[cfg(test)] #[cfg(test)]
@@ -684,14 +972,12 @@ mod tests {
.expect("a vacant slot issues a permit") .expect("a vacant slot issues a permit")
.commit(3) .commit(3)
.expect("3 is a real index"); .expect("3 is a real index");
assert_eq!( let permit = ledger
ledger.begin_unload(Shape::LoopbackIntoCapture), .begin_unload(Shape::LoopbackIntoCapture)
Some(fp.clone()) .expect("the ledger accepts the unload")
); .expect("the loaded slot issues a permit");
ledger.finish_unload( assert_eq!(permit.fingerprint(), &fp);
Shape::LoopbackIntoCapture, permit.finish(UnloadOutcome::Uncertain("pactl was killed".to_string()));
UnloadOutcome::Uncertain("pactl was killed".to_string()),
);
assert_eq!( assert_eq!(
ledger.state(Shape::LoopbackIntoCapture), ledger.state(Shape::LoopbackIntoCapture),
SlotState::Ambiguous(Reconcile::Unload { fp }), SlotState::Ambiguous(Reconcile::Unload { fp }),
@@ -707,23 +993,194 @@ mod tests {
.expect("a vacant slot issues a permit") .expect("a vacant slot issues a permit")
.commit(3) .commit(3)
.expect("3 is a real index"); .expect("3 is a real index");
ledger.begin_unload(Shape::LoopbackIntoCapture); ledger
ledger.finish_unload(Shape::LoopbackIntoCapture, UnloadOutcome::Confirmed); .begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit")
.finish(UnloadOutcome::Confirmed);
assert_eq!(ledger.state(Shape::LoopbackIntoCapture), SlotState::Vacant); assert_eq!(ledger.state(Shape::LoopbackIntoCapture), SlotState::Vacant);
assert!(ledger.is_settled()); assert!(ledger.is_settled());
assert!(ledger.is_clean());
} }
#[test] #[test]
fn begin_unload_only_acts_on_a_loaded_slot() { fn dropping_an_unload_permit_marks_the_slot_ambiguous() {
let ledger = ModuleLedger::new(); let ledger = ModuleLedger::new();
assert_eq!(ledger.begin_unload(Shape::LegacyCaptureSink), None); let fp = ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("a vacant slot issues a permit")
.commit(3)
.expect("3 is a real index");
let permit = ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit");
assert!(matches!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Unloading { .. }
));
assert!(!ledger.is_settled(), "in-flight unloads are not settled");
drop(permit);
assert_eq!(
ledger.state(Shape::LoopbackIntoCapture),
SlotState::Ambiguous(Reconcile::Unload { fp })
);
}
#[test]
fn inverse_loopbacks_cannot_coexist_or_cross_an_unknown_boundary() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(7))
.expect("the first loopback may load")
.commit(3)
.expect("3 is a real index");
assert_eq!(
ledger
.begin_load(Shape::LoopbackOutOfCapture, 42, token(8))
.err(),
Some(LedgerError::Incompatible {
shape: Shape::LoopbackOutOfCapture,
occupied: Shape::LoopbackIntoCapture,
state: "loaded"
})
);
let unload = ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the loaded slot issues a permit");
drop(unload);
assert_eq!(
ledger
.begin_load(Shape::LoopbackOutOfCapture, 42, token(9))
.err(),
Some(LedgerError::Incompatible {
shape: Shape::LoopbackOutOfCapture,
occupied: Shape::LoopbackIntoCapture,
state: "ambiguous"
}),
"an unconfirmed absence must block the inverse loopback"
);
}
#[test]
fn capture_sink_unload_waits_for_every_dependent_slot_to_be_vacant() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(1))
.expect("the sink may load")
.commit(1)
.expect("1 is a real index");
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(2))
.expect("the mirror may load")
.commit(2)
.expect("2 is a real index");
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink).err(),
Some(LedgerError::Referenced {
shape: Shape::LegacyCaptureSink,
dependency: Shape::LoopbackIntoCapture,
state: "loaded"
})
);
ledger
.begin_unload(Shape::LoopbackIntoCapture)
.expect("the ledger accepts the unload")
.expect("the mirror issues an unload permit")
.finish(UnloadOutcome::Confirmed);
assert!(
ledger
.begin_unload(Shape::LegacyCaptureSink)
.expect("the sink is no longer referenced")
.is_some()
);
}
#[test]
fn closing_waits_for_a_previously_registered_operation() {
use std::sync::mpsc;
use std::time::Duration;
let ledger = ModuleLedger::new();
let permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("the operation registers before it can be detached");
let (entered_tx, entered_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let waiter_ledger = Arc::clone(&ledger);
let waiter = std::thread::spawn(move || {
entered_tx.send(()).unwrap();
waiter_ledger.close_and_wait();
done_tx.send(()).unwrap();
});
entered_rx.recv().unwrap();
assert!(
matches!(
done_rx.recv_timeout(Duration::from_millis(30)),
Err(mpsc::RecvTimeoutError::Timeout)
),
"the close barrier must not pass a live affine permit"
);
permit.abandon();
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("settling the operation releases teardown");
waiter.join().unwrap();
assert!(ledger.is_clean());
}
#[test]
fn a_closed_ledger_refuses_event_work_but_allows_cleanup() {
let ledger = ModuleLedger::new();
ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("the sink may load before close")
.commit(3)
.expect("3 is a real index");
ledger.close_and_wait();
assert_eq!(
ledger
.begin_load(Shape::LoopbackIntoCapture, 42, token(8))
.err(),
Some(LedgerError::Closed {
shape: Shape::LoopbackIntoCapture
})
);
assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink).err(),
Some(LedgerError::Closed {
shape: Shape::LegacyCaptureSink
})
);
assert!(
ledger
.begin_cleanup_unload(Shape::LegacyCaptureSink)
.expect("teardown retains its cleanup authority")
.is_some()
);
}
#[test]
fn begin_unload_distinguishes_vacant_from_busy() {
let ledger = ModuleLedger::new();
assert!(
ledger
.begin_unload(Shape::LegacyCaptureSink)
.expect("vacancy is not an error")
.is_none()
);
let _permit = ledger let _permit = ledger
.begin_load(Shape::LegacyCaptureSink, 42, token(7)) .begin_load(Shape::LegacyCaptureSink, 42, token(7))
.expect("a vacant slot issues a permit"); .expect("a vacant slot issues a permit");
assert_eq!( assert_eq!(
ledger.begin_unload(Shape::LegacyCaptureSink), ledger.begin_unload(Shape::LegacyCaptureSink).err(),
None, Some(LedgerError::Busy {
"a load in flight has no id to unload yet" shape: Shape::LegacyCaptureSink,
state: "loading"
}),
"an in-flight load must not look like a confirmed vacancy"
); );
} }
@@ -864,13 +1321,12 @@ mod tests {
} }
#[test] #[test]
fn loaded_lists_modules_in_shape_declaration_order() { fn loaded_lists_a_loopback_before_the_capture_sink() {
// Declaration order is unload order: the loopbacks that reference the // Declaration order is unload order: the loopbacks that reference the
// capture sink must come before the sink itself. // capture sink must come before the sink itself.
let ledger = ModuleLedger::new(); let ledger = ModuleLedger::new();
for (shape, id) in [ for (shape, id) in [
(Shape::LegacyCaptureSink, 1), (Shape::LegacyCaptureSink, 1),
(Shape::LoopbackIntoCapture, 2),
(Shape::LoopbackOutOfCapture, 3), (Shape::LoopbackOutOfCapture, 3),
] { ] {
ledger ledger
@@ -880,7 +1336,10 @@ mod tests {
.expect("a real index"); .expect("a real index");
} }
let order: Vec<Shape> = ledger.loaded().into_iter().map(|fp| fp.shape).collect(); let order: Vec<Shape> = ledger.loaded().into_iter().map(|fp| fp.shape).collect();
assert_eq!(order, crate::repair::plan::ALL_SHAPES.to_vec()); assert_eq!(
order,
vec![Shape::LoopbackOutOfCapture, Shape::LegacyCaptureSink]
);
assert_eq!( assert_eq!(
order.last(), order.last(),
Some(&Shape::LegacyCaptureSink), Some(&Shape::LegacyCaptureSink),
+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);
}
} }
+506 -34
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,9 +439,15 @@ 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 {
sink.on_projection(&projection, kind, now_us); ObserverConsumer::ReadOnly(Some(sink)) => {
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
@@ -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
})
}