Compare commits
16
Commits
eaea188e05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca3122b92f | ||
|
|
2f00df758d | ||
|
|
ce909afc4b | ||
|
|
360d7112e6 | ||
|
|
98bb78f9cf | ||
|
|
792f2bd55a | ||
|
|
e027bc65e5 | ||
|
|
7b118272b0 | ||
|
|
956534f63c | ||
|
|
6be07ef706 | ||
|
|
d09ee9b02f | ||
|
|
5a65f50c4b | ||
|
|
98cde2c19b | ||
|
|
781defcd84 | ||
|
|
5d3da8b006 | ||
|
|
70820cf903 |
Generated
+22
-31
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+180
-358
@@ -1,21 +1,18 @@
|
|||||||
//! Per-app audio routing.
|
//! Connection-owned capture-sink and per-app audio routing.
|
||||||
//!
|
//!
|
||||||
//! Two cooperating layers:
|
//! Two cooperating layers:
|
||||||
//!
|
//!
|
||||||
//! - **Null-sink + loopback** (pactl shell-out): a per-PID null-sink
|
//! - **Native graph actor** (libpipewire on a dedicated OS thread): owns a
|
||||||
//! `pixelpass_capture_<pid>` plus a `module-loopback` that mirrors the
|
//! non-lingering per-PID sink named `pixelpass_capture_<pid>`. When
|
||||||
//! default sink's monitor into it. gst captures from the null-sink's
|
//! [`HostOpts::app`] is set, the same actor finds matching
|
||||||
//! monitor, so the viewer hears whatever the user hears — by default.
|
//! `Stream/Output/Audio` nodes and writes `target.object` so WirePlumber
|
||||||
|
//! reroutes them to that sink. The sink disappears with the actor's PipeWire
|
||||||
|
//! connection, including after SIGKILL.
|
||||||
//!
|
//!
|
||||||
//! - **Per-stream rerouting** (libpipewire on a dedicated OS thread):
|
//! - **Pulse loopbacks** (bounded pactl shell-outs): by default one loopback
|
||||||
//! when [`HostOpts::app`] is set, a [`StreamRouter`] subscribes to the
|
//! mirrors the default sink's monitor into the native capture sink. Once at
|
||||||
//! PipeWire registry, finds `Stream/Output/Audio` nodes whose
|
//! least one selected app stream is routed, that loopback is unloaded so the
|
||||||
//! `application.name` matches the filter, and writes
|
//! viewer does not hear the app twice.
|
||||||
//! `target.object` to the "default" metadata so WirePlumber reroutes
|
|
||||||
//! them to our null-sink. Once at least one stream is actually routed,
|
|
||||||
//! the loopback is unloaded — otherwise the viewer would hear the
|
|
||||||
//! filtered audio twice (once via the routed stream, once via the
|
|
||||||
//! default-sink monitor loopback).
|
|
||||||
//!
|
//!
|
||||||
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
|
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
|
||||||
//! the chosen app off the sharer's speakers into the null-sink, so without
|
//! the chosen app off the sharer's speakers into the null-sink, so without
|
||||||
@@ -26,21 +23,20 @@
|
|||||||
//! the first routed stream (after the default-sink loopback is gone, so the
|
//! the first routed stream (after the default-sink loopback is gone, so the
|
||||||
//! two never coexist and feed back) and unloaded when the app stops.
|
//! two never coexist and feed back) and unloaded when the app stops.
|
||||||
//!
|
//!
|
||||||
//! pactl is the right tool for the one-shot null-sink/loopback graph
|
//! Shutdown quiesces route writes, unloads every dependent Pulse loopback, and
|
||||||
//! mutations. libpipewire is dragged in only when per-stream filtering
|
//! only then releases the actor connection and native sink.
|
||||||
//! is requested, because that needs registry-event subscription.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||||
use std::rc::Rc;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::JoinHandle;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
|
use crate::common::contained;
|
||||||
|
use crate::host::graph::{AudioGraphOwner, CaptureSinkSpec, GraphEvent, QuiesceOutcome};
|
||||||
|
use crate::host::health;
|
||||||
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
|
use crate::host::ledger::{self, LedgerError, ModuleLedger, UnloadOutcome};
|
||||||
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
||||||
|
|
||||||
@@ -55,10 +51,10 @@ use crate::repair::plan::{self as repair_plan, Fingerprint, Shape};
|
|||||||
const PACTL_BUDGET: Duration = Duration::from_secs(5);
|
const PACTL_BUDGET: Duration = Duration::from_secs(5);
|
||||||
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1);
|
const PACTL_REAP_BUDGET: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
/// Owns the pactl-loaded modules plus, when filtering is active, the
|
/// Owns the native graph actor plus its pactl-loaded dependent modules. Drop
|
||||||
/// libpipewire stream-router thread. Drop unloads modules as a backstop;
|
/// unloads modules as a backstop; prefer [`Routing::shutdown`] explicitly,
|
||||||
/// prefer [`Routing::shutdown`] explicitly, which is the only path that can
|
/// which is the only path that can reconcile a load whose outcome was never
|
||||||
/// reconcile a load whose outcome was never observed.
|
/// observed and acknowledge route restoration.
|
||||||
pub struct Routing {
|
pub struct Routing {
|
||||||
/// Every module this host has loaded, is loading, or must ask the server
|
/// Every module this host has loaded, is loading, or must ask the server
|
||||||
/// about. Shared with the event task, which loads and unloads the two
|
/// about. Shared with the event task, which loads and unloads the two
|
||||||
@@ -69,39 +65,42 @@ pub struct Routing {
|
|||||||
/// exactly like no load at all and its module was left behind.
|
/// exactly like no load at all and its module was left behind.
|
||||||
ledger: Arc<ModuleLedger>,
|
ledger: Arc<ModuleLedger>,
|
||||||
sink_name: String,
|
sink_name: String,
|
||||||
stream_router: Option<StreamRouter>,
|
graph_owner: Option<AudioGraphOwner>,
|
||||||
event_task: Option<tokio::task::JoinHandle<()>>,
|
event_task: Option<tokio::task::JoinHandle<()>>,
|
||||||
|
health: health::Reporter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Routing {
|
impl Routing {
|
||||||
/// Create the per-PID null-sink + loopback. If `opts.app` is set,
|
/// Create the per-PID native sink and graph actor, plus the default-monitor
|
||||||
/// also spawn the libpipewire thread that reroutes matching streams.
|
/// loopback when the selected routing mode permits it.
|
||||||
pub async fn start(opts: &HostOpts) -> Result<Self> {
|
pub(super) async fn start(opts: &HostOpts, health: health::Reporter) -> Result<Self> {
|
||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
let sink_name = repair_plan::sink_name_for(pid);
|
let sink_name = repair_plan::sink_name_for(pid);
|
||||||
let ledger = ModuleLedger::new();
|
let ledger = ModuleLedger::new();
|
||||||
// Construct the owner before the first mutation. Any error or cancellation
|
// Construct Routing before either ownership layer mutates the graph. Any
|
||||||
// below now drops a real `Routing`, whose backstop closes, quiesces,
|
// error or cancellation below drops a real owner whose backstop closes,
|
||||||
// reconciles, and unloads this ledger. Previously the owner did not exist
|
// reconciles, and unloads the ledger before releasing the native sink.
|
||||||
// until both initial modules had loaded, so constructor failure leaked
|
|
||||||
// everything loaded up to that point.
|
|
||||||
let mut routing = Self {
|
let mut routing = Self {
|
||||||
ledger: Arc::clone(&ledger),
|
ledger: Arc::clone(&ledger),
|
||||||
sink_name: sink_name.clone(),
|
sink_name: sink_name.clone(),
|
||||||
stream_router: None,
|
graph_owner: None,
|
||||||
event_task: None,
|
event_task: None,
|
||||||
|
health: health.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Every module this host loads carries an ownership token, minted per
|
// S4: the capture sink is a native, non-lingering PipeWire object owned
|
||||||
// load, so `--repair` can tell whose pid the name refers to instead of
|
// by this actor connection, not a module owned by pipewire-pulse. The
|
||||||
// assuming the number means the same thing everywhere. Without it a repair
|
// actor also absorbs the per-app router so every sink-owning mode has one
|
||||||
// run in another pid namespace can unload a live host's audio; see
|
// graph lifetime and one readiness handshake.
|
||||||
// `repair::plan::OwnerToken`. That same per-load nonce is what lets
|
let (graph_owner, mut event_rx, _identity_rx) = AudioGraphOwner::start(
|
||||||
// reconciliation identify a module whose load was interrupted before its
|
opts.app.clone(),
|
||||||
// index was ever read.
|
CaptureSinkSpec::for_pid(pid),
|
||||||
load_module(&ledger, Shape::LegacyCaptureSink, pid)
|
health.clone(),
|
||||||
.await
|
)
|
||||||
.context("failed to load module-null-sink")?;
|
.await
|
||||||
|
.context("failed to start the connection-owned audio graph")?;
|
||||||
|
debug_assert_eq!(graph_owner.identity().name, sink_name);
|
||||||
|
routing.graph_owner = Some(graph_owner);
|
||||||
|
|
||||||
// In strict per-app mode we never mirror the default sink: the viewer
|
// In strict per-app mode we never mirror the default sink: the viewer
|
||||||
// must hear *only* the chosen app, never the whole desktop (which would
|
// must hear *only* the chosen app, never the whole desktop (which would
|
||||||
@@ -112,27 +111,31 @@ impl Routing {
|
|||||||
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
// 20ms loopback latency keeps the mirrored audio tight; pactl's
|
||||||
// default of 200ms is enough to be perceptible.
|
// default of 200ms is enough to be perceptible.
|
||||||
let strict_app = opts.app.is_some() && opts.strict_audio;
|
let strict_app = opts.app.is_some() && opts.strict_audio;
|
||||||
if !strict_app {
|
if !strict_app
|
||||||
load_module(&ledger, Shape::LoopbackIntoCapture, pid)
|
&& let Err(error) = load_module(&ledger, Shape::LoopbackIntoCapture, pid).await
|
||||||
.await
|
{
|
||||||
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?;
|
// Once the actor exists, an ordinary constructor error gets a full
|
||||||
|
// async teardown rather than falling through the narrower
|
||||||
|
// synchronous Drop backstop and quarantining a responsive thread.
|
||||||
|
routing.shutdown().await;
|
||||||
|
return Err(error)
|
||||||
|
.context("failed to load module-loopback (connection-owned sink cleaned up)");
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
strict_app,
|
strict_app,
|
||||||
%sink_name,
|
%sink_name,
|
||||||
"audio routing: null-sink ready (loopback skipped in strict app mode)"
|
"audio routing: connection-owned sink ready (loopback skipped in strict app mode)"
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(app) = &opts.app {
|
if opts.app.is_some() {
|
||||||
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
|
|
||||||
let ledger_for_task = Arc::clone(&ledger);
|
let ledger_for_task = Arc::clone(&ledger);
|
||||||
let strict = opts.strict_audio;
|
let strict = opts.strict_audio;
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
use crate::common::output::{self, AppAudioState};
|
use crate::common::output::{self, AppAudioState};
|
||||||
while let Some(ev) = event_rx.recv().await {
|
while let Some(ev) = event_rx.recv().await {
|
||||||
match ev {
|
match ev {
|
||||||
Event::FirstRoutedStream => {
|
GraphEvent::FirstRoutedStream => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"audio routing: first stream routed → unloading default-sink loopback"
|
"audio routing: first stream routed → unloading default-sink loopback"
|
||||||
);
|
);
|
||||||
@@ -159,7 +162,7 @@ impl Routing {
|
|||||||
state: AppAudioState::Routed,
|
state: AppAudioState::Routed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Event::LastRoutedStreamGone => {
|
GraphEvent::LastRoutedStreamGone => {
|
||||||
// Routed app exited/paused mid-session. Notify the
|
// Routed app exited/paused mid-session. Notify the
|
||||||
// front-end either way; the recovery differs by mode.
|
// front-end either way; the recovery differs by mode.
|
||||||
output::emit(output::Event::AppAudio {
|
output::emit(output::Event::AppAudio {
|
||||||
@@ -199,7 +202,6 @@ impl Routing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
routing.stream_router = Some(router);
|
|
||||||
routing.event_task = Some(event_task);
|
routing.event_task = Some(event_task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,10 +222,10 @@ impl Routing {
|
|||||||
&self.sink_name
|
&self.sink_name
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the stream router and the event task, settle anything the ledger is
|
/// Quiesce graph mutations, stop the event task, settle anything the ledger
|
||||||
/// unsure about, then unload every module in shape order — the loopbacks
|
/// is unsure about, unload every dependent loopback, then release the native
|
||||||
/// before the sink they reference, because PipeWire can leave zombie links if
|
/// sink. PipeWire can leave zombie links if a sink is destroyed with active
|
||||||
/// a sink is destroyed with active inputs.
|
/// inputs, so that final ordering is load-bearing.
|
||||||
///
|
///
|
||||||
/// The event task is **awaited, not merely aborted**. Aborting and walking
|
/// The event task is **awaited, not merely aborted**. Aborting and walking
|
||||||
/// away is what left orphans behind: the task's load is an await point now,
|
/// away is what left orphans behind: the task's load is an await point now,
|
||||||
@@ -233,29 +235,42 @@ impl Routing {
|
|||||||
pub async fn shutdown(mut self) {
|
pub async fn shutdown(mut self) {
|
||||||
// Closing is synchronous and happens first: after this point the event
|
// Closing is synchronous and happens first: after this point the event
|
||||||
// task cannot register another mutation even if it receives one last
|
// task cannot register another mutation even if it receives one last
|
||||||
// router event while shutdown is in progress.
|
// graph event while shutdown is in progress.
|
||||||
self.ledger.close();
|
self.ledger.close();
|
||||||
if let Some(router) = self.stream_router.take() {
|
let graph_quiesced = if let Some(graph_owner) = self.graph_owner.as_mut() {
|
||||||
// ⚠️ Still an unbounded join: a wedged PipeWire thread parks this
|
graph_owner.quiesce().await == QuiesceOutcome::Confirmed
|
||||||
// task indefinitely. That is the pre-existing defect S3b exists for.
|
} else {
|
||||||
// Nothing here makes it worse, and the ledger is what will make
|
true
|
||||||
// bounding it safe when it lands.
|
};
|
||||||
router.shutdown();
|
|
||||||
}
|
|
||||||
if let Some(mut task) = self.event_task.take() {
|
if let Some(mut task) = self.event_task.take() {
|
||||||
// The router's exit drops the event senders, so the task normally
|
if !graph_quiesced {
|
||||||
// ends by itself. Abort is the fallback, and it is awaited through
|
// An unresponsive graph actor may still own its event sender.
|
||||||
// `&mut JoinHandle` so the future is genuinely dropped — and with it
|
// Cancel and await the task before ledger reconciliation.
|
||||||
// any in-flight permit — before reconciliation reads the ledger.
|
|
||||||
// Dropping the handle instead would *detach* the task, which is how a
|
|
||||||
// load could still land after teardown believed it was finished.
|
|
||||||
if tokio::time::timeout(PACTL_BUDGET, &mut task).await.is_err() {
|
|
||||||
tracing::warn!(
|
|
||||||
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
|
|
||||||
cancelling it"
|
|
||||||
);
|
|
||||||
task.abort();
|
task.abort();
|
||||||
let _ = task.await;
|
let _ = task.await;
|
||||||
|
} else {
|
||||||
|
// Quiesce closes the event sender while retaining the native
|
||||||
|
// sink. Abort is the fallback and is awaited through `&mut
|
||||||
|
// JoinHandle`, so any in-flight affine permit is dropped before
|
||||||
|
// reconciliation reads the ledger.
|
||||||
|
match tokio::time::timeout(PACTL_BUDGET, &mut task).await {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
self.health
|
||||||
|
.poison(format!("audio routing event task failed: {e}"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"audio routing: the event task did not finish within {PACTL_BUDGET:?}; \
|
||||||
|
cancelling it"
|
||||||
|
);
|
||||||
|
self.health.poison(format!(
|
||||||
|
"audio routing event task did not stop within {PACTL_BUDGET:?}"
|
||||||
|
));
|
||||||
|
task.abort();
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +284,29 @@ impl Routing {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("audio routing: module-operation wait task failed: {e}");
|
tracing::warn!("audio routing: module-operation wait task failed: {e}");
|
||||||
|
self.health
|
||||||
|
.poison(format!("audio module-operation wait task failed: {e}"));
|
||||||
}
|
}
|
||||||
cleanup_modules(&self.ledger).await;
|
cleanup_modules(&self.ledger).await;
|
||||||
|
|
||||||
|
// Loopbacks are gone before the actor connection is released. This is
|
||||||
|
// the S4 ordering invariant: dependent Pulse modules never outlive the
|
||||||
|
// native sink they reference during an ordinary shutdown.
|
||||||
|
if let Some(graph_owner) = self.graph_owner.take()
|
||||||
|
&& !graph_owner.shutdown().await
|
||||||
|
{
|
||||||
|
tracing::warn!("audio routing: AudioGraphOwner shutdown was not confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
if !self.ledger.is_clean() {
|
if !self.ledger.is_clean() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
settled = self.ledger.is_settled(),
|
settled = self.ledger.is_settled(),
|
||||||
"audio routing: some audio modules could not be removed safely; \
|
"audio routing: some audio modules could not be removed safely; \
|
||||||
`pixelpass --repair` will clean up anything left behind"
|
`pixelpass --repair` will clean up anything left behind"
|
||||||
);
|
);
|
||||||
|
self.health.poison(
|
||||||
|
"audio routing teardown left module ownership unresolved; repair is required",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,20 +320,24 @@ impl Drop for Routing {
|
|||||||
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
|
/// After a completed `shutdown` the ledger holds nothing and this does nothing.
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.ledger.close();
|
self.ledger.close();
|
||||||
if let Some(router) = self.stream_router.take() {
|
|
||||||
router.shutdown();
|
|
||||||
}
|
|
||||||
if let Some(task) = self.event_task.take() {
|
if let Some(task) = self.event_task.take() {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
self.ledger.close_and_wait();
|
self.ledger.close_and_wait();
|
||||||
cleanup_modules_blocking(&self.ledger);
|
cleanup_modules_blocking(&self.ledger);
|
||||||
|
// Keep the actor/sink alive until dependent modules have been handled,
|
||||||
|
// even on this synchronous error/unwind backstop.
|
||||||
|
if let Some(graph_owner) = self.graph_owner.take() {
|
||||||
|
drop(graph_owner);
|
||||||
|
}
|
||||||
if !self.ledger.is_clean() {
|
if !self.ledger.is_clean() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
settled = self.ledger.is_settled(),
|
settled = self.ledger.is_settled(),
|
||||||
"audio routing: torn down with modules that could not be removed safely; \
|
"audio routing: torn down with modules that could not be removed safely; \
|
||||||
run `pixelpass --repair` to clean up anything left behind"
|
run `pixelpass --repair` to clean up anything left behind"
|
||||||
);
|
);
|
||||||
|
self.health
|
||||||
|
.poison("audio routing Drop left module ownership unresolved; repair is required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -635,7 +668,7 @@ impl Drop for ReapedChild {
|
|||||||
if self.reaped {
|
if self.reaped {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = self.child.kill();
|
self.kill_group();
|
||||||
match self.reap_within(PACTL_REAP_BUDGET) {
|
match self.reap_within(PACTL_REAP_BUDGET) {
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
Ok(None) => tracing::warn!(
|
Ok(None) => tracing::warn!(
|
||||||
@@ -647,6 +680,12 @@ impl Drop for ReapedChild {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReapedChild {
|
impl ReapedChild {
|
||||||
|
fn kill_group(&mut self) {
|
||||||
|
let _ = contained::signal_group(self.child.id(), nix::sys::signal::Signal::SIGKILL);
|
||||||
|
// Backstop in case the group disappeared between lookup and signal.
|
||||||
|
let _ = self.child.kill();
|
||||||
|
}
|
||||||
|
|
||||||
fn reap_within(&mut self, budget: Duration) -> io::Result<Option<ExitStatus>> {
|
fn reap_within(&mut self, budget: Duration) -> io::Result<Option<ExitStatus>> {
|
||||||
let deadline = Instant::now() + budget;
|
let deadline = Instant::now() + budget;
|
||||||
loop {
|
loop {
|
||||||
@@ -665,7 +704,7 @@ impl ReapedChild {
|
|||||||
fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<BoundedOutput> {
|
fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<BoundedOutput> {
|
||||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
let mut child = ReapedChild {
|
let mut child = ReapedChild {
|
||||||
child: command.spawn()?,
|
child: contained::spawn(command)?,
|
||||||
reaped: false,
|
reaped: false,
|
||||||
};
|
};
|
||||||
let stdout = child
|
let stdout = child
|
||||||
@@ -700,7 +739,7 @@ fn bounded_output(command: &mut Command, budget: Duration) -> io::Result<Bounded
|
|||||||
break (status, false);
|
break (status, false);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
let _ = child.child.kill();
|
child.kill_group();
|
||||||
let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else {
|
let Some(status) = child.reap_within(PACTL_REAP_BUDGET)? else {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::TimedOut,
|
io::ErrorKind::TimedOut,
|
||||||
@@ -779,210 +818,6 @@ fn cleanup_modules_blocking(ledger: &Arc<ModuleLedger>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
// Per-stream routing (libpipewire thread)
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Command from tokio → libpipewire thread.
|
|
||||||
enum Cmd {
|
|
||||||
/// Clear `target.object` for everything we routed, then quit the
|
|
||||||
/// MainLoop so the thread joins.
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event from libpipewire thread → tokio. The pair drives loopback
|
|
||||||
/// oscillation: unload on `FirstRoutedStream`, re-load on
|
|
||||||
/// `LastRoutedStreamGone`. Both fire on count-transitions (0→N and N→0
|
|
||||||
/// respectively), not on every change.
|
|
||||||
enum Event {
|
|
||||||
/// At least one stream is now routed to our sink. Receiver unloads
|
|
||||||
/// the default-sink loopback so the filtered audio isn't doubled.
|
|
||||||
FirstRoutedStream,
|
|
||||||
/// The last routed stream just disappeared (app closed, paused,
|
|
||||||
/// switched output). Receiver re-loads the default-sink loopback so
|
|
||||||
/// the viewer doesn't go silent.
|
|
||||||
LastRoutedStreamGone,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle to the libpipewire stream-router thread.
|
|
||||||
pub struct StreamRouter {
|
|
||||||
cmd_tx: pipewire::channel::Sender<Cmd>,
|
|
||||||
thread: Option<JoinHandle<()>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StreamRouter {
|
|
||||||
/// Spawn the libpipewire thread. Returns the router handle and the
|
|
||||||
/// event receiver tokio side polls.
|
|
||||||
fn spawn(
|
|
||||||
filter_name: String,
|
|
||||||
sink_name: String,
|
|
||||||
) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver<Event>)> {
|
|
||||||
let (cmd_tx, cmd_rx) = pipewire::channel::channel::<Cmd>();
|
|
||||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
|
||||||
|
|
||||||
let thread = std::thread::Builder::new()
|
|
||||||
.name("pixelpass-pw-router".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
if let Err(e) = run_router(filter_name, sink_name, cmd_rx, event_tx) {
|
|
||||||
tracing::warn!("audio routing: libpipewire thread exited with error: {e:#}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.context("failed to spawn libpipewire router thread")?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
Self {
|
|
||||||
cmd_tx,
|
|
||||||
thread: Some(thread),
|
|
||||||
},
|
|
||||||
event_rx,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(mut self) {
|
|
||||||
// Best-effort: if the send fails the thread is already gone.
|
|
||||||
let _ = self.cmd_tx.send(Cmd::Shutdown);
|
|
||||||
if let Some(t) = self.thread.take()
|
|
||||||
&& let Err(e) = t.join()
|
|
||||||
{
|
|
||||||
tracing::warn!("audio routing: pw thread join failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Body of the libpipewire thread. Owns MainLoop, registry listener, and
|
|
||||||
/// all PipeWire proxies for the duration of the routing session.
|
|
||||||
fn run_router(
|
|
||||||
filter_name: String,
|
|
||||||
sink_name: String,
|
|
||||||
cmd_rx: pipewire::channel::Receiver<Cmd>,
|
|
||||||
event_tx: tokio::sync::mpsc::UnboundedSender<Event>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use pipewire::{self as pw, types::ObjectType};
|
|
||||||
|
|
||||||
let main_loop =
|
|
||||||
pw::main_loop::MainLoopRc::new(None).context("pw main loop construction failed")?;
|
|
||||||
let context =
|
|
||||||
pw::context::ContextRc::new(&main_loop, None).context("pw context construction failed")?;
|
|
||||||
let core = context
|
|
||||||
.connect_rc(None)
|
|
||||||
.context("pw core connect failed (is the daemon running?)")?;
|
|
||||||
let registry = core.get_registry_rc().context("pw get_registry failed")?;
|
|
||||||
|
|
||||||
let state = Rc::new(RefCell::new(RouterState {
|
|
||||||
sink_serial: None,
|
|
||||||
default_metadata: None,
|
|
||||||
routed_node_ids: Vec::new(),
|
|
||||||
pending: Vec::new(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Cmd handler: clear metadata for routed streams, then quit.
|
|
||||||
let main_loop_for_cmd = main_loop.clone();
|
|
||||||
let state_for_cmd = Rc::clone(&state);
|
|
||||||
let _cmd_recv = cmd_rx.attach(main_loop.loop_(), move |cmd| match cmd {
|
|
||||||
Cmd::Shutdown => {
|
|
||||||
let s = state_for_cmd.borrow();
|
|
||||||
if let Some(meta) = &s.default_metadata {
|
|
||||||
for &nid in &s.routed_node_ids {
|
|
||||||
meta.set_property(nid, "target.object", None, None);
|
|
||||||
}
|
|
||||||
if !s.routed_node_ids.is_empty() {
|
|
||||||
tracing::info!(
|
|
||||||
n = s.routed_node_ids.len(),
|
|
||||||
"audio routing: cleared target.object on routed streams before quitting"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
main_loop_for_cmd.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let filter_lower = filter_name.to_ascii_lowercase();
|
|
||||||
let sink_name_owned = sink_name.clone();
|
|
||||||
let registry_weak = registry.downgrade();
|
|
||||||
let state_for_reg = Rc::clone(&state);
|
|
||||||
let event_tx_for_reg = event_tx.clone();
|
|
||||||
let state_for_remove = Rc::clone(&state);
|
|
||||||
let event_tx_for_remove = event_tx.clone();
|
|
||||||
|
|
||||||
let _reg_listener = registry
|
|
||||||
.add_listener_local()
|
|
||||||
.global(move |obj| {
|
|
||||||
let Some(reg) = registry_weak.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match obj.type_ {
|
|
||||||
ObjectType::Node => {
|
|
||||||
let Some(props) = obj.props.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
|
||||||
match props.get("object.serial").and_then(parse_object_serial) {
|
|
||||||
Some(serial) => {
|
|
||||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
|
||||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
|
||||||
}
|
|
||||||
// Never silently: without a serial `try_flush` can
|
|
||||||
// never route anything, so the whole app-filter mode
|
|
||||||
// is dead and the only symptom is missing audio.
|
|
||||||
None => tracing::warn!(
|
|
||||||
node_id = obj.id,
|
|
||||||
serial = props.get("object.serial").unwrap_or("<absent>"),
|
|
||||||
"audio routing: pixelpass sink has no usable object.serial; \
|
|
||||||
stream rerouting disabled"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(app) = props.get("application.name") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !app.eq_ignore_ascii_case(&filter_lower) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
node_id = obj.id,
|
|
||||||
%app,
|
|
||||||
"audio routing: matched stream, queued for route"
|
|
||||||
);
|
|
||||||
state_for_reg.borrow_mut().pending.push(obj.id);
|
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
|
||||||
}
|
|
||||||
ObjectType::Metadata => {
|
|
||||||
let Some(props) = obj.props.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if props.get("metadata.name") != Some("default") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let metadata: pw::metadata::Metadata = match reg.bind(obj) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("audio routing: bind default metadata failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
state_for_reg.borrow_mut().default_metadata = Some(metadata);
|
|
||||||
tracing::info!("audio routing: default metadata bound");
|
|
||||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.global_remove(move |id| {
|
|
||||||
handle_global_remove(&state_for_remove, &event_tx_for_remove, id);
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
|
|
||||||
tracing::info!(filter = %filter_name, "audio routing: pw thread running");
|
|
||||||
main_loop.run();
|
|
||||||
tracing::info!("audio routing: pw thread exiting");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a PipeWire `object.serial` property value.
|
/// Parse a PipeWire `object.serial` property value.
|
||||||
///
|
///
|
||||||
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||||
@@ -1003,80 +838,14 @@ pub(crate) fn parse_object_serial(raw: &str) -> Option<u64> {
|
|||||||
raw.parse::<u64>().ok()
|
raw.parse::<u64>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RouterState {
|
|
||||||
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
|
||||||
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
|
||||||
sink_serial: Option<u64>,
|
|
||||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
|
||||||
routed_node_ids: Vec<u32>,
|
|
||||||
pending: Vec<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the vanished node from `routed_node_ids` and `pending`. If it
|
|
||||||
/// was the last routed stream, emit `LastRoutedStreamGone` so the
|
|
||||||
/// tokio side restores the default-sink loopback.
|
|
||||||
fn handle_global_remove(
|
|
||||||
state: &Rc<RefCell<RouterState>>,
|
|
||||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
|
||||||
id: u32,
|
|
||||||
) {
|
|
||||||
let mut s = state.borrow_mut();
|
|
||||||
let was_routed = !s.routed_node_ids.is_empty();
|
|
||||||
s.routed_node_ids.retain(|&x| x != id);
|
|
||||||
s.pending.retain(|&x| x != id);
|
|
||||||
if was_routed && s.routed_node_ids.is_empty() {
|
|
||||||
tracing::info!(
|
|
||||||
node_id = id,
|
|
||||||
"audio routing: last routed stream disappeared"
|
|
||||||
);
|
|
||||||
let _ = event_tx.send(Event::LastRoutedStreamGone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain pending streams to the sink, but only once both prerequisites
|
|
||||||
/// (sink serial known + default metadata bound) are in place. Emits
|
|
||||||
/// `FirstRoutedStream` when routed count crosses 0→N (so it fires
|
|
||||||
/// each time the count comes back up from zero, not just the first
|
|
||||||
/// time — pairs with `LastRoutedStreamGone` to oscillate the loopback).
|
|
||||||
fn try_flush(
|
|
||||||
state: &Rc<RefCell<RouterState>>,
|
|
||||||
event_tx: &tokio::sync::mpsc::UnboundedSender<Event>,
|
|
||||||
) {
|
|
||||||
let mut s = state.borrow_mut();
|
|
||||||
let Some(serial) = s.sink_serial else { return };
|
|
||||||
if s.default_metadata.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if s.pending.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let was_empty = s.routed_node_ids.is_empty();
|
|
||||||
let serial_str = serial.to_string();
|
|
||||||
let pending = std::mem::take(&mut s.pending);
|
|
||||||
if let Some(meta) = &s.default_metadata {
|
|
||||||
for nid in &pending {
|
|
||||||
meta.set_property(*nid, "target.object", Some("Spa:Id"), Some(&serial_str));
|
|
||||||
tracing::info!(
|
|
||||||
node_id = *nid,
|
|
||||||
sink_serial = serial,
|
|
||||||
"audio routing: stream routed to pixelpass sink"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.routed_node_ids.extend(pending);
|
|
||||||
if was_empty && !s.routed_node_ids.is_empty() {
|
|
||||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::host::ledger::SlotState;
|
use crate::host::ledger::SlotState;
|
||||||
use crate::repair::plan::{ModuleObservation, classify};
|
use crate::repair::plan::{ModuleObservation, classify};
|
||||||
|
|
||||||
/// Whole-desktop routing: no app filter, so no PipeWire thread and no event
|
/// Whole-desktop routing: the graph actor owns the native sink, while the
|
||||||
/// task — just the null-sink and its default-sink loopback.
|
/// ledger owns only its default-monitor loopback.
|
||||||
fn whole_desktop_opts() -> HostOpts {
|
fn whole_desktop_opts() -> HostOpts {
|
||||||
HostOpts {
|
HostOpts {
|
||||||
window: false,
|
window: false,
|
||||||
@@ -1090,10 +859,30 @@ mod tests {
|
|||||||
no_hwencode: false,
|
no_hwencode: false,
|
||||||
max_viewers: None,
|
max_viewers: None,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
|
capture_mode: crate::cli::CaptureMode::Legacy,
|
||||||
|
aec: crate::host::aec::AecConfig::Off,
|
||||||
|
legacy_null_sink: false,
|
||||||
relay: None,
|
relay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_module_worker_uses_the_contained_spawn_path() {
|
||||||
|
let mut command = Command::new("sh");
|
||||||
|
command.args([
|
||||||
|
"-c",
|
||||||
|
"read pid comm state ppid pgrp rest < /proc/self/stat; printf '%s %s' \"$pid\" \"$pgrp\"",
|
||||||
|
]);
|
||||||
|
let output = bounded_output(&mut command, Duration::from_secs(1))
|
||||||
|
.expect("run contained module-worker fixture");
|
||||||
|
assert!(output.status.success());
|
||||||
|
let ids = String::from_utf8(output.stdout).expect("ascii pid/pgid");
|
||||||
|
let mut ids = ids.split_whitespace();
|
||||||
|
let pid = ids.next().expect("child pid");
|
||||||
|
let pgid = ids.next().expect("child process group");
|
||||||
|
assert_eq!(pid, pgid, "module worker must lead its own process group");
|
||||||
|
}
|
||||||
|
|
||||||
/// The module table exactly as `--repair` observes it.
|
/// The module table exactly as `--repair` observes it.
|
||||||
fn module_snapshot() -> Vec<(u32, String, String)> {
|
fn module_snapshot() -> Vec<(u32, String, String)> {
|
||||||
let mut session =
|
let mut session =
|
||||||
@@ -1148,7 +937,8 @@ mod tests {
|
|||||||
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
|
#[ignore = "loads real Pulse modules; run with --ignored --test-threads=1"]
|
||||||
async fn live_teardown_leaves_the_module_table_as_it_found_it() {
|
async fn live_teardown_leaves_the_module_table_as_it_found_it() {
|
||||||
let before = module_snapshot();
|
let before = module_snapshot();
|
||||||
let routing = Routing::start(&whole_desktop_opts())
|
let (health, _) = health::channel();
|
||||||
|
let routing = Routing::start(&whole_desktop_opts(), health)
|
||||||
.await
|
.await
|
||||||
.expect("routing starts");
|
.expect("routing starts");
|
||||||
|
|
||||||
@@ -1159,8 +949,8 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ours.len(),
|
ours.len(),
|
||||||
2,
|
1,
|
||||||
"the null-sink and its default-sink loopback must both be loaded"
|
"only the default-monitor loopback is a Pulse module; the sink is native"
|
||||||
);
|
);
|
||||||
for (id, name, args) in ours {
|
for (id, name, args) in ours {
|
||||||
let fp = classify(&ModuleObservation::new(*id, name, args))
|
let fp = classify(&ModuleObservation::new(*id, name, args))
|
||||||
@@ -1180,6 +970,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exercise the real graph-actor command path with app routing enabled. The
|
||||||
|
/// deliberately unmatched filter avoids moving an unrelated live stream.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "uses the real Pulse/PipeWire graph; run with --ignored --test-threads=1"]
|
||||||
|
async fn live_audio_graph_owner_stops_within_its_policy_budget() {
|
||||||
|
let before = module_snapshot();
|
||||||
|
let mut opts = whole_desktop_opts();
|
||||||
|
opts.app = Some("__pixelpass_s3b_no_matching_application__".to_string());
|
||||||
|
opts.strict_audio = true;
|
||||||
|
let (health, _) = health::channel();
|
||||||
|
let routing = Routing::start(&opts, health.clone())
|
||||||
|
.await
|
||||||
|
.expect("per-app routing starts");
|
||||||
|
|
||||||
|
// Let the OS thread reach its normal running phase so this covers the
|
||||||
|
// tighter steady-state budget rather than only startup containment.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), routing.shutdown())
|
||||||
|
.await
|
||||||
|
.expect("actor and graph teardown stay globally bounded");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
health.fault().is_none(),
|
||||||
|
"an observed shutdown and successful join must remain healthy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
module_snapshot(),
|
||||||
|
before,
|
||||||
|
"per-app teardown must leave the module table byte-identical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The orphan race, staged against a real server: a load cancelled while
|
/// The orphan race, staged against a real server: a load cancelled while
|
||||||
/// `pactl` is in flight must still be findable and removable.
|
/// `pactl` is in flight must still be findable and removable.
|
||||||
///
|
///
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -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
File diff suppressed because it is too large
Load Diff
+1861
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
|||||||
|
//! Terminal health shared by capture components and the host supervisor.
|
||||||
|
//!
|
||||||
|
//! A capture-side ownership failure is not recoverable inside the same host
|
||||||
|
//! process: a wedged PipeWire owner or an unaccounted Pulse module can collide
|
||||||
|
//! with the next capture. Health is therefore one-way (`Healthy -> Poisoned`)
|
||||||
|
//! and first-fault-wins so a later, less precise teardown symptom cannot erase
|
||||||
|
//! the original cause.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) enum State {
|
||||||
|
Healthy,
|
||||||
|
Poisoned(Arc<str>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct Reporter {
|
||||||
|
tx: watch::Sender<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Monitor {
|
||||||
|
rx: watch::Receiver<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn channel() -> (Reporter, Monitor) {
|
||||||
|
let (tx, rx) = watch::channel(State::Healthy);
|
||||||
|
(Reporter { tx }, Monitor { rx })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter {
|
||||||
|
/// Poison the host exactly once. Returns true for the first fault.
|
||||||
|
pub(super) fn poison(&self, reason: impl Into<Arc<str>>) -> bool {
|
||||||
|
let reason = reason.into();
|
||||||
|
self.tx.send_if_modified(move |state| {
|
||||||
|
if matches!(state, State::Healthy) {
|
||||||
|
*state = State::Poisoned(reason);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn fault(&self) -> Option<Arc<str>> {
|
||||||
|
match &*self.tx.borrow() {
|
||||||
|
State::Healthy => None,
|
||||||
|
State::Poisoned(reason) => Some(Arc::clone(reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Monitor {
|
||||||
|
pub(super) fn fault(&self) -> Option<Arc<str>> {
|
||||||
|
match &*self.rx.borrow() {
|
||||||
|
State::Healthy => None,
|
||||||
|
State::Poisoned(reason) => Some(Arc::clone(reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn changed(&mut self) {
|
||||||
|
// The supervisor owns a Reporter for the whole run, so closure is not a
|
||||||
|
// normal state. Treat it like a wake and let `fault()` decide.
|
||||||
|
let _ = self.rx.changed().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn poison_is_terminal_and_first_fault_wins() {
|
||||||
|
let (reporter, mut monitor) = channel();
|
||||||
|
assert!(reporter.poison("first"));
|
||||||
|
monitor.changed().await;
|
||||||
|
assert_eq!(monitor.fault().as_deref(), Some("first"));
|
||||||
|
assert!(!reporter.poison("second"));
|
||||||
|
assert_eq!(reporter.fault().as_deref(), Some("first"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-3
@@ -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
@@ -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 ¤t {
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user