Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774922c6a9 | ||
|
|
63c246d976 | ||
|
|
52d842160d | ||
|
|
e043810eb0 | ||
|
|
5b80a1a010 | ||
|
|
b9803f93fb | ||
|
|
3df2378831 | ||
|
|
81c230a09c | ||
|
|
be3740f5f9 | ||
|
|
0d836d14c2 | ||
|
|
76c1a13e11 | ||
|
|
aa0515af1c | ||
|
|
9f06741b99 | ||
|
|
3aa768af52 | ||
|
|
1cfa932fbe | ||
|
|
d8b8fd79cf | ||
|
|
92a64465a4 | ||
|
|
6ba763774d | ||
|
|
692ad677d2 | ||
|
|
bf908adbf0 | ||
|
|
b68fca689e | ||
|
|
c82ef07464 | ||
|
|
9eab6c118d | ||
|
|
21ba633825 | ||
|
|
45b1b97dd8 | ||
|
|
ae2e9de523 | ||
|
|
985c63806b | ||
|
|
6fc55a286d | ||
|
|
d63db68318 | ||
|
|
e7923a1b5c | ||
|
|
b5569fe2c6 | ||
|
|
503f78153b | ||
|
|
d40385f85c | ||
|
|
bcf1343a55 | ||
|
|
6773a3882b | ||
|
|
1cd19b355f | ||
|
|
297f4397a7 | ||
|
|
283d938b79 | ||
|
|
fd72e6f018 | ||
|
|
8768cd242c | ||
|
|
da72541e18 | ||
|
|
8610ab2eb6 | ||
|
|
100117085d | ||
|
|
cab6bafce5 | ||
|
|
10203e1edb | ||
|
|
88ad5a0807 | ||
|
|
0588d92537 | ||
|
|
b4a4c00711 | ||
|
|
8c4f4a0b8b |
@@ -11,3 +11,9 @@
|
|||||||
# the .iss script and .ico are the tracked sources.
|
# the .iss script and .ico are the tracked sources.
|
||||||
/packaging/windows/peerspeak.exe
|
/packaging/windows/peerspeak.exe
|
||||||
/packaging/windows/output/
|
/packaging/windows/output/
|
||||||
|
|
||||||
|
# Nix: the symlink `nix build` drops, and direnv's local cache. flake.nix and
|
||||||
|
# flake.lock ARE tracked — the lock is what pins the toolchain.
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
|
/.direnv/
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ All notable changes to PeerSpeak are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.6] — 2026-07-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A screen share that falls behind now catches back up.** On a lossy
|
||||||
|
connection (satellite links are the worst case) the share could settle several
|
||||||
|
seconds behind the host and simply stay there for the rest of the call. The
|
||||||
|
viewer now notices a deep buffer and plays imperceptibly fast until it is back
|
||||||
|
at the live edge — the audio stays in tune and in sync while it does. This
|
||||||
|
replaces the previous attempt at the problem, which measurement showed did not
|
||||||
|
help. Applies to the Low latency setting; Smooth intentionally keeps its
|
||||||
|
larger buffer.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
|
||||||
|
setting is a size in megabytes, which at a given bitrate quietly decides how
|
||||||
|
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
|
||||||
|
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
|
||||||
|
regardless of the setting, which halved how far behind a share fell on a bad
|
||||||
|
connection before anything else kicked in. Smooth still honors the value you
|
||||||
|
choose, since a deep buffer is the point of that mode.
|
||||||
|
|
||||||
## [0.6.5] — 2026-07-19
|
## [0.6.5] — 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+2
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.5"
|
version = "0.6.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4883,6 +4883,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-gossip",
|
"iroh-gossip",
|
||||||
|
"libc",
|
||||||
"opus",
|
"opus",
|
||||||
"pipewire",
|
"pipewire",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.6.5"
|
version = "0.6.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -109,3 +109,10 @@ windows-sys = { version = "0.61", features = [
|
|||||||
"Win32_System_Diagnostics_ToolHelp",
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
|
# Unix-only. Used for exactly one thing: sending SIGINT to our own
|
||||||
|
# pixelpass child so it can run its cleanup before we resort to SIGKILL
|
||||||
|
# (src/core/teardown.rs). Already in the tree via alsa/cpal/tokio, so
|
||||||
|
# declaring it directly adds no new code to the build.
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2.186"
|
||||||
|
|||||||
@@ -137,6 +137,35 @@ covers internals). When you ship a feature, add it here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Known bugs
|
||||||
|
|
||||||
|
Defects found by code review, not yet fixed.
|
||||||
|
|
||||||
|
1. **Adaptive playout delay never shrinks back in real conversation**
|
||||||
|
(`src/core/jitter.rs`) — `target_delay` grows +1 per disruption up to
|
||||||
|
`MAX_DELAY_FRAMES` (12 frames = 240 ms) but only shrinks after
|
||||||
|
`CLEAN_RUN_TO_SHRINK` = 250 consecutive cleanly-played frames, i.e. **5 s of
|
||||||
|
unbroken audio**. `clean_run` is reset in five places; two of them fire on
|
||||||
|
every natural pause in speech: the benign-underrun branch (`jitter.rs:201`,
|
||||||
|
talker went quiet) and the subsequent re-prime (`jitter.rs:181`). Because the
|
||||||
|
sender skips transmitting entirely while the noise gate is closed
|
||||||
|
(`src/core/mod.rs:2041`), a pause between sentences *always* underruns the
|
||||||
|
receiver and zeroes the clean run — twice.
|
||||||
|
|
||||||
|
Net effect: the controller is a one-way ratchet. A single burst of jitter
|
||||||
|
early in a call pins up to 240 ms of extra playout latency for the rest of
|
||||||
|
the session, because no conversational speaker talks for 5 continuous
|
||||||
|
seconds without the gate closing. The AIMD "decrease" half is effectively
|
||||||
|
unreachable under the workload the app is built for.
|
||||||
|
|
||||||
|
Likely fix: let `clean_run` survive a benign idle→re-prime transition rather
|
||||||
|
than resetting it. Silence is not evidence the link is bad, so it should not
|
||||||
|
count against the clean run. Distinguish "talker stopped" (benign) from
|
||||||
|
"playout broke" (real) at `jitter.rs:195-202`.
|
||||||
|
|
||||||
|
Found 2026-07-31 by code review. Not yet reproduced in a live call — pairs
|
||||||
|
with field-test debt item 5 below.
|
||||||
|
|
||||||
## Known field-test debt (the 🧪 rows above, collected)
|
## Known field-test debt (the 🧪 rows above, collected)
|
||||||
|
|
||||||
Re-run on a real desktop ↔ dopedart call before calling these done:
|
Re-run on a real desktop ↔ dopedart call before calling these done:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,472 @@
|
|||||||
|
# Phase 5 — dry-run audit gate: results
|
||||||
|
|
||||||
|
**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
|
||||||
|
eligible half of every row is non-empty. O5 re-measured on the fixed graph and
|
||||||
|
stays closed.** One new defect was found and fixed during the run (F13-1); three
|
||||||
|
findings are recorded as non-blocking, and three rows carry recorded
|
||||||
|
substitutions. Phase 6 is unblocked **by this file**, and F11-1 — the other gate —
|
||||||
|
was closed with this data on 2026-07-26 (see "What still blocks phase 6").
|
||||||
|
|
||||||
|
- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below)
|
||||||
|
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||||
|
- **Audit build:** pixelpass `main` @ `91c4ded`, release profile
|
||||||
|
- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
|
||||||
|
- **Ambient load:** Firefox playing audio throughout (a live, uncontrived
|
||||||
|
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
|
||||||
|
- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed since run 1
|
||||||
|
|
||||||
|
Run 1 failed on two defects, both fixed before this run:
|
||||||
|
|
||||||
|
- **F1** (fatal): the registry `global` event delivers only a filtered subset of
|
||||||
|
node properties, so eight properties the engine depends on were permanently
|
||||||
|
absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device
|
||||||
|
and read properties from `info`.
|
||||||
|
- **F2**: a machine-wide over-exclusion cascade downstream of F1.
|
||||||
|
|
||||||
|
Both are gone: the baseline run (no fixture at all) reports **1 candidate,
|
||||||
|
eligible, empty taint set**.
|
||||||
|
|
||||||
|
### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN
|
||||||
|
|
||||||
|
**Row 1 failed on its first attempt, and the cause was a third defect of exactly
|
||||||
|
the F2 class from a new source: pipewire-pulse's PID was unresolvable on this
|
||||||
|
host, permanently.**
|
||||||
|
|
||||||
|
`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or
|
||||||
|
more Clients, on the stated reasoning that "native PipeWire clients carry their
|
||||||
|
own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber
|
||||||
|
repeats one too.** It holds two Clients — `WirePlumber` and
|
||||||
|
`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and
|
||||||
|
pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`.
|
||||||
|
|
||||||
|
With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry
|
||||||
|
takes over: key 4's suppression never fires, every Pulse-emulated node fuses into
|
||||||
|
one owner, and the cascade follows. Row 1's observed failure:
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE (1): r1_plain_app
|
||||||
|
EXCLUDED: Firefox tainted-owner-bridge key=application.process.id
|
||||||
|
r1_c_play tainted-owner-bridge <- the CLEAN control half
|
||||||
|
TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule was wrong in **both** directions, so the prefilter was removed rather
|
||||||
|
than patched:
|
||||||
|
|
||||||
|
- **False ambiguity** — any second process holding two Clients defeats it.
|
||||||
|
WirePlumber always does, so this was permanent, not a corner case.
|
||||||
|
- **False absence** — a session where pipewire-pulse holds exactly one Client
|
||||||
|
(one Pulse app running) repeats nothing, so the candidate is missed and the
|
||||||
|
same cascade follows.
|
||||||
|
|
||||||
|
`comm` was always the authoritative check; repetition was a heuristic standing in
|
||||||
|
front of it, and it was a guess about other processes' Client counts. Fixed in
|
||||||
|
pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()`
|
||||||
|
picks the unique one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`, and
|
||||||
|
several matches still fail closed (a single `Option<u32>` cannot suppress two
|
||||||
|
daemons — recorded, not approximated). The adapter probes only PIDs *entering*
|
||||||
|
the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID
|
||||||
|
that leaves and returns is re-probed instead of answered from a stale `comm`.
|
||||||
|
|
||||||
|
**This is the §5.1 exact-partition requirement earning its keep for the second
|
||||||
|
time.** The verdict was fail-closed and silent; only the asserted *eligible* half
|
||||||
|
exposed it. An exclusion-only checklist would have passed this build too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5.1 — the matrix
|
||||||
|
|
||||||
|
Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran
|
||||||
|
in its **own** audit process, so nothing carries over (sticky taint is
|
||||||
|
per-process state).
|
||||||
|
|
||||||
|
⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each
|
||||||
|
fixture *before* starting the audit. On this host the entire graph then arrives
|
||||||
|
as one enumeration burst (~122 events in 1–2 ms), so every node is first tainted
|
||||||
|
while `graph_ready` is still false, that partial-graph taint is recorded into
|
||||||
|
sticky state, and on the single ready record the sticky pass raises
|
||||||
|
`TaintedOwnerBridge { key: None }` before the evidence pass can name a key —
|
||||||
|
`raise` will not replace a same-rank reason. Verdicts were still correct but rows
|
||||||
|
could not assert their key. This run starts the audit first, waits for readiness,
|
||||||
|
then builds the fixture, so taint is derived from real topology *changes* against
|
||||||
|
a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at
|
||||||
|
**derivation** (first non-sticky appearance), not from the final record.
|
||||||
|
|
||||||
|
| # | scenario | status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) |
|
||||||
|
| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below |
|
||||||
|
| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation |
|
||||||
|
| 3 | two Pulse modules, one tainted | ✅ **pass** |
|
||||||
|
| 4 | peerspeak native call playback | ✅ **pass** — real tagging site |
|
||||||
|
| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible |
|
||||||
|
| 6 | peerspeak notification sound | ✅ **pass** — real tagging site |
|
||||||
|
| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty |
|
||||||
|
| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in |
|
||||||
|
| 9 | Firefox three cases | ✅ **pass** (cases 2–3 via gst; see substitution) |
|
||||||
|
| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement |
|
||||||
|
| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous |
|
||||||
|
| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** |
|
||||||
|
| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed |
|
||||||
|
|
||||||
|
### Row 1 — owner bridge, key named
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app
|
||||||
|
EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned
|
||||||
|
r1_t_play tainted-owner-bridge key=node.link-group
|
||||||
|
TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest
|
||||||
|
```
|
||||||
|
|
||||||
|
The clean half is an **identically shaped** forwarder — same module type, same
|
||||||
|
monitor-read, same re-emit — differing only in whether anything tainted feeds it.
|
||||||
|
`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy.
|
||||||
|
The key is `node.link-group`, a strong key, not a link walk.
|
||||||
|
|
||||||
|
### Row 2 — GStreamer split clients, key 4
|
||||||
|
|
||||||
|
Measured props confirm the shape is the real refutation: `r2_gst_tainted_src`
|
||||||
|
(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of
|
||||||
|
**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So
|
||||||
|
`application.process.id` is the only key that can relate them.
|
||||||
|
|
||||||
|
Derivation record (seq 209): `r2_gst_tainted_sink` → `tainted-owner-bridge`,
|
||||||
|
**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted
|
||||||
|
monitor in a second process, is eligible.
|
||||||
|
|
||||||
|
### Rows 4–6 — peerspeak's own paths, through the real call sites
|
||||||
|
|
||||||
|
Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real
|
||||||
|
tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak
|
||||||
|
talks, not that pixelpass listens" (impl plan §3).
|
||||||
|
|
||||||
|
| node | verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` |
|
||||||
|
| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) |
|
||||||
|
| `mpv` (launched by hand, untagged) | **ELIGIBLE** |
|
||||||
|
|
||||||
|
This is the cross-repo contract closed end to end on live nodes.
|
||||||
|
|
||||||
|
### Row 9 — the over-exclusion promise
|
||||||
|
|
||||||
|
```
|
||||||
|
ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device)
|
||||||
|
EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id
|
||||||
|
```
|
||||||
|
|
||||||
|
`r9_mic_out` is the row that defends §6.1.1: an app that captures a real
|
||||||
|
`session_device` source and also plays audio stays shareable. The device source
|
||||||
|
itself never entered the taint set.
|
||||||
|
|
||||||
|
### Row 10 — the full sticky lifecycle
|
||||||
|
|
||||||
|
| phase | topology | verdict |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` |
|
||||||
|
| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it |
|
||||||
|
| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives |
|
||||||
|
| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s |
|
||||||
|
|
||||||
|
Phase B proves stickiness works; phase D proves it is not permanent. Phase C is
|
||||||
|
worth keeping in mind when reading any future report: partial teardown legitimately
|
||||||
|
does *not* retire taint, and that is easy to mistake for over-exclusion.
|
||||||
|
|
||||||
|
### Row 11 — recycled identifiers, provably non-vacuous
|
||||||
|
|
||||||
|
| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 |
|
||||||
|
| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 |
|
||||||
|
|
||||||
|
The `node.link-group` came back **byte-identical** — and it is the very key that
|
||||||
|
carried the taint in generation 1 — and the global id was reused. Generation 2's
|
||||||
|
`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did
|
||||||
|
not recycle, which is why the model keys everything by it.
|
||||||
|
|
||||||
|
### Row 12 — AEC lifecycle
|
||||||
|
|
||||||
|
| stage | `aec_state` | `fan_out_permitted` | candidates |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` |
|
||||||
|
| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` |
|
||||||
|
|
||||||
|
All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry
|
||||||
|
`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one
|
||||||
|
in the excluded partition. Ordinary apps staying eligible *while validated* is
|
||||||
|
what makes "the gate is open" observable rather than inferred.
|
||||||
|
|
||||||
|
### Row 13 — `Audio/Duplex` over-taint (known accepted)
|
||||||
|
|
||||||
|
No real duplex device exists on this host, so one was synthesised by overriding
|
||||||
|
`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its
|
||||||
|
capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with
|
||||||
|
the eligible half intact. **Fixture limit, stated plainly:** on a null sink the
|
||||||
|
capture side *is* the monitor, so this cannot separate the duplex smear from the
|
||||||
|
ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*;
|
||||||
|
a real duplex device is still the only way to isolate the mechanism.
|
||||||
|
|
||||||
|
### Row 1b — Sunshine (opportunistic, non-gating)
|
||||||
|
|
||||||
|
Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the
|
||||||
|
**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and
|
||||||
|
correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is
|
||||||
|
tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over
|
||||||
|
the network — so it is never a candidate and there is nothing to exclude. Recorded
|
||||||
|
as observed; the "if a re-emitting leg exists" clause did not apply. A real
|
||||||
|
third-party forwarder sample remains owed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5.2 — O5 re-measured
|
||||||
|
|
||||||
|
The run-1 numbers do not carry over: they were measured on the graph F1 degraded,
|
||||||
|
and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O
|
||||||
|
that run never exercised.
|
||||||
|
|
||||||
|
Per-run, across all 13 rows (`recompute` in µs):
|
||||||
|
|
||||||
|
| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 |
|
||||||
|
| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 |
|
||||||
|
| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 |
|
||||||
|
| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 |
|
||||||
|
| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 |
|
||||||
|
| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 |
|
||||||
|
| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 |
|
||||||
|
| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 |
|
||||||
|
| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 |
|
||||||
|
| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 |
|
||||||
|
| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 |
|
||||||
|
| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 |
|
||||||
|
| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 |
|
||||||
|
|
||||||
|
The dedicated churn run (five load/unload cycles of null-sink + loopback, the
|
||||||
|
same shape as run 1's measurement):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407,
|
||||||
|
"span_us":9249639,"graph_events_per_sec":44.0,
|
||||||
|
"recompute_max_us":32,"recompute_mean_us":10,
|
||||||
|
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
||||||
|
"recompute_distribution":[["<50us",444]],
|
||||||
|
"emit_max_us":9,"emit_mean_us":1,
|
||||||
|
"busy_us":5240,"busy_fraction":0.0006,
|
||||||
|
"queued_events":292,"queue_threshold_us":100}
|
||||||
|
```
|
||||||
|
|
||||||
|
**O5 stays closed on the real graph.** Worst recompute across every run is
|
||||||
|
**67 µs**; every single recompute in the churn run finished under 50 µs, against
|
||||||
|
a 44 Hz event rate under churn heavier than a desktop produces at rest. The
|
||||||
|
observer thread spent **0.06 %** of wall time working. Node binding roughly
|
||||||
|
doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on
|
||||||
|
the same churn shape) and that is the honest cost of the F1 fix — it buys three
|
||||||
|
orders of magnitude of remaining headroom, not one.
|
||||||
|
|
||||||
|
**Readiness with node binds: 1–2 ms**, with ~122 enumeration events and 18 binds
|
||||||
|
(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high
|
||||||
|
(292) for the same benign reason as run 1: PipeWire delivers enumeration and
|
||||||
|
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
|
||||||
|
`busy_fraction` is the number to trust.
|
||||||
|
|
||||||
|
⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against
|
||||||
|
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
|
||||||
|
not an argument about a host with a large USB interface, many virtual devices, or
|
||||||
|
a cold cache. Carried forward as open, unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings recorded, not blocking
|
||||||
|
|
||||||
|
### R2-1 — the audit's `sticky` flag is nearly always true, so it says little
|
||||||
|
|
||||||
|
As emitted, `sticky` means "this node is in the remembered set", which
|
||||||
|
`seed_sticky` populates for any node whose current reason the sticky pass agrees
|
||||||
|
with — i.e. essentially every currently-tainted node. It does **not** mean
|
||||||
|
"excluded *only* because remembered", which is what its doc comment implies and
|
||||||
|
what a reader diagnosing "why is this still excluded?" wants.
|
||||||
|
|
||||||
|
The information exists: round 9 already computes a second, **evidence-only** pass
|
||||||
|
(that is the whole provenance mechanism). Emitting "excluded by memory alone"
|
||||||
|
would make row 10 phase B assertable from a single record instead of from a
|
||||||
|
sequence. Not fixed here — it is a reporting change to a merged phase in the
|
||||||
|
middle of a gate run. Row 10 was asserted behaviourally instead, which is
|
||||||
|
stronger anyway.
|
||||||
|
|
||||||
|
### R2-2 — a bridge key is lost when a leg reappears under a new serial
|
||||||
|
|
||||||
|
Row 2 named `application.process.id` at derivation (seq 209), then gst re-created
|
||||||
|
that node; the sticky owner re-seeded the new serial through `reason_for`, whose
|
||||||
|
documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not
|
||||||
|
replace a same-rank reason with a better-informed one. The verdict is unaffected;
|
||||||
|
only the diagnosis degrades. The fallback is honest when the owner has no live
|
||||||
|
tainted receiver, and stale when it does — which is the case worth improving.
|
||||||
|
|
||||||
|
### R2-3 — `owner_key` had to be added to the record to run row 1 at all
|
||||||
|
|
||||||
|
Row 1 asserts "reason = owner bridge, **naming the key**", and the record could
|
||||||
|
not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one
|
||||||
|
string. `OwnerKey::code` already documented itself as ending up in the phase 5
|
||||||
|
audit output; it was simply never wired to it. Added in pixelpass `d462754`
|
||||||
|
(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec
|
||||||
|
lesson: the row could not have been asserted from any previous build's output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Substitutions, stated so they are not mistaken for passes
|
||||||
|
|
||||||
|
| row | asked for | used instead | why |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. |
|
||||||
|
| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. |
|
||||||
|
| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What still blocks phase 6
|
||||||
|
|
||||||
|
This file passing removes **one** of the two gates. F11-1, the other, is now
|
||||||
|
closed. Still outstanding:
|
||||||
|
|
||||||
|
1. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
|
||||||
|
and are a real echo path — needs ALSA control inspection; user design call owed.
|
||||||
|
2. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
|
||||||
|
3. **The readiness budget calibration argument** (above).
|
||||||
|
4. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
|
||||||
|
a real `Audio/Duplex` device (row 13).
|
||||||
|
|
||||||
|
### ✅ F11-1 — closed 2026-07-26, with this matrix's data
|
||||||
|
|
||||||
|
The rule now implemented (pixelpass `c78eb2d`, §6.1.2's round-13 box): **key 4 bounds an
|
||||||
|
owner only when the node's Client resolves** — an unambiguous Client yielding
|
||||||
|
`Some(pipewire.sec.pid)`, read *before* pipewire-pulse suppression — so a node can no
|
||||||
|
longer bound itself, and escape `propagate_unresolved_owner`'s sweep, with an
|
||||||
|
`application.process.id` it invented. Bridging still uses the full union.
|
||||||
|
|
||||||
|
Codex's round-12 sharpening was the decisive part: "resolved" must mean a `sec_pid`, not
|
||||||
|
"a unique Client object exists", and the **unique-but-pid-less** row is the only one that
|
||||||
|
tells the two apart. All five Client cases are unit tests (absent · ambiguous ·
|
||||||
|
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded
|
||||||
|
three-step leak path end to end. Mutation-verified: dropping the provenance test fails
|
||||||
|
four of the six rows and leaves the two no-over-exclusion rows green.
|
||||||
|
|
||||||
|
**The cost question the deferral was waiting on, measured on this host:** the before- and
|
||||||
|
after-binaries audited the *same* live graph simultaneously (both are read-only observers)
|
||||||
|
— tagged producer into the default sink, `parec` on its monitor as a live tainted reader
|
||||||
|
so the sweep was genuinely armed, Firefox + `aplay` + `pacat` as bystanders. **181 records
|
||||||
|
each, the same 14 distinct decision states, none exclusive to either side, no
|
||||||
|
`unresolved-owner` on either, eligible half non-empty throughout.** O5 unmoved (identical
|
||||||
|
p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and
|
||||||
|
**both resolve**; sweeping all 18 live nodes, the only unresolved-Client ones were
|
||||||
|
`Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproducing this run
|
||||||
|
|
||||||
|
Scripts live in the session scratchpad (not committed — they hard-code paths):
|
||||||
|
one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
audit_start out.jsonl off # start FIRST, wait for graph_ready
|
||||||
|
... build fixture ... # taint arrives as topology CHANGES
|
||||||
|
audit_stop # SIGTERM: flushes the O5 summary
|
||||||
|
python3 summarize.py out.jsonl # final partition + derivations + metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \
|
||||||
|
./target/release/pixelpass --audit-audio
|
||||||
|
```
|
||||||
|
|
||||||
|
Rig notes that cost time:
|
||||||
|
|
||||||
|
- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name":
|
||||||
|
"peerspeak_owned_call_4242", "target.object": "<sink>" }' aplay -c 2 -r 48000
|
||||||
|
-f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object`
|
||||||
|
routes it.
|
||||||
|
- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with
|
||||||
|
`--help` as its argument instead of printing help. It was loaded accidentally
|
||||||
|
during this session and unloaded again; check `pactl list short modules` after
|
||||||
|
any such probe.
|
||||||
|
- ⚠️ `pkill -f <pattern>` matches the harness's own shell command line and kills
|
||||||
|
the script. Use `pkill -x` or an exact pid.
|
||||||
|
- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its
|
||||||
|
modules are unloaded; and `timeout` exiting 124 is *success* for the audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## History — run 1 (2026-07-25): GATE FAILED
|
||||||
|
|
||||||
|
Kept because the reasoning is still the record of why the observation boundary
|
||||||
|
was redesigned.
|
||||||
|
|
||||||
|
### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
||||||
|
|
||||||
|
The phase-3 adapter read eight node properties the registry never announces.
|
||||||
|
Parsed off `obj.props` in the registry `global` callback, they were silently
|
||||||
|
absent, so every one was permanently `None`/`false`.
|
||||||
|
|
||||||
|
The complete set the registry announces for a `Node` on this host:
|
||||||
|
|
||||||
|
```
|
||||||
|
application.name client.api client.id device.id factory.id media.class
|
||||||
|
node.description node.name node.nick object.path object.serial
|
||||||
|
priority.driver priority.session
|
||||||
|
```
|
||||||
|
|
||||||
|
| property | announced? | what died without it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
|
||||||
|
| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** |
|
||||||
|
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
||||||
|
| **`node.link-group`** | ❌ | the link-group owner key |
|
||||||
|
| **`application.process.id`** | ❌ | the process owner key |
|
||||||
|
| **`node.passthrough`** | ❌ | the passthrough local exclusion |
|
||||||
|
| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
|
||||||
|
|
||||||
|
Ports lost `port.exclusive`; Links and Clients were fine — notably
|
||||||
|
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
|
||||||
|
|
||||||
|
Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
|
||||||
|
monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
|
||||||
|
taint set**. In phase 6 that is an echo.
|
||||||
|
|
||||||
|
The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
|
||||||
|
props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
|
||||||
|
shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
|
||||||
|
`device.api` is on the *Device* global.
|
||||||
|
|
||||||
|
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
||||||
|
|
||||||
|
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
||||||
|
announced) was the only surviving taint root. Row 7 then excluded every
|
||||||
|
`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted
|
||||||
|
capture stream was an **unbounded tainted reader**, tripping phase 2's
|
||||||
|
fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the
|
||||||
|
device layer into one owner.
|
||||||
|
|
||||||
|
Net live behaviour: exclude everything, always, as soon as pixelpass's own
|
||||||
|
capture sink existed. Fail-closed, so silence rather than echo — but entirely
|
||||||
|
non-functional, and non-functional in a way that would have looked like "working
|
||||||
|
safely" to any test that asserted only exclusions.
|
||||||
|
|
||||||
|
### What run 1's machinery got right
|
||||||
|
|
||||||
|
None of this needed revisiting:
|
||||||
|
|
||||||
|
- Running the recompute **inline on the observer thread**, once per applied
|
||||||
|
registry event, upheld phase 4's no-coalescing contract and put the cost where
|
||||||
|
O5 could measure it.
|
||||||
|
- The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
|
||||||
|
- **Reason codes survived the trip** and were immediately diagnostic.
|
||||||
|
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
|
||||||
|
does the correct thing when handed correct properties. Both failures were at
|
||||||
|
the observation boundary, which is where phase 5 was designed to look.
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785989512,
|
||||||
|
"narHash": "sha256-HFQhkQcl5D1hUNoen3SGHCSFCt2Bg6uP+HgbrnA3InQ=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "445d861c6d31b4af0c79d8d4be2331f762a361d7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
description = "PeerSpeak — decentralized P2P voice chat (Rust/iroh/PipeWire/Opus/iced)";
|
||||||
|
|
||||||
|
# Pinned to the same channel the hosts run (nixos-config tracks nixos-26.05),
|
||||||
|
# so the libraries this shell links and dlopens are built against the same
|
||||||
|
# release as the PipeWire daemon and Vulkan ICD actually running on the
|
||||||
|
# machine. Floating to unstable here would reintroduce precisely the
|
||||||
|
# client/server version skew the pin exists to prevent.
|
||||||
|
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
|
||||||
|
# Libraries that iced/winit/wgpu open with dlopen at RUNTIME rather than
|
||||||
|
# linking at build time. Because nothing links them, they never land in
|
||||||
|
# the binary's rpath — under `cargo run` the loader finds them only
|
||||||
|
# through LD_LIBRARY_PATH. Leaving them out builds fine and then panics
|
||||||
|
# at window creation, which is a genuinely confusing failure, so they are
|
||||||
|
# listed explicitly instead of discovered the hard way.
|
||||||
|
runtimeLibs = with pkgs; [
|
||||||
|
vulkan-loader # wgpu's Vulkan backend (iced's renderer)
|
||||||
|
libxkbcommon # winit keyboard handling
|
||||||
|
wayland # wayland-sys, dlopen'd on a Wayland session
|
||||||
|
libx11 # x11-dl, dlopen'd on the X11 fallback path
|
||||||
|
libxcursor
|
||||||
|
libxrandr
|
||||||
|
libxi
|
||||||
|
];
|
||||||
|
|
||||||
|
# Screen sharing spawns pixelpass as a CHILD PROCESS, and pixelpass in
|
||||||
|
# turn drives GStreamer as a subprocess. That makes these tools a
|
||||||
|
# dependency of peerspeak's own test suite, not just of pixelpass:
|
||||||
|
# `tests/screenshare_host_fault.rs` starts a real pixelpass host, which
|
||||||
|
# aborts at its preflight if gst-launch-1.0 is missing.
|
||||||
|
#
|
||||||
|
# Deliberately duplicated from pixelpass's flake rather than importing it
|
||||||
|
# as an input. The two projects are mutually optional by design — neither
|
||||||
|
# is a dependency of the other, and the coupling is a runtime subprocess
|
||||||
|
# contract. Making one flake consume the other would quietly reintroduce
|
||||||
|
# exactly the build-level dependency that rule exists to prevent.
|
||||||
|
screenshareTools = with pkgs; [
|
||||||
|
gst_all_1.gstreamer
|
||||||
|
gst_all_1.gst-plugins-base
|
||||||
|
gst_all_1.gst-plugins-good
|
||||||
|
gst_all_1.gst-plugins-bad
|
||||||
|
gst_all_1.gst-plugins-ugly
|
||||||
|
gst_all_1.gst-libav
|
||||||
|
pipewire # pipewiresrc (Wayland capture; ships in this pkg)
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
rustc
|
||||||
|
cargo
|
||||||
|
rustfmt
|
||||||
|
clippy
|
||||||
|
|
||||||
|
# The supply-chain gates .gitea/workflows/ci.yml runs, so the same
|
||||||
|
# checks are reproducible locally before a push. These were `cargo
|
||||||
|
# install`ed on the CachyOS side, which does not carry over — those
|
||||||
|
# binaries link that distro's glibc and will not run here.
|
||||||
|
# cargo-deny reads deny.toml; cargo-audit reads .cargo/audit.toml.
|
||||||
|
cargo-audit
|
||||||
|
cargo-deny
|
||||||
|
# Debian packaging (`cargo deb --no-build`). Note the .deb itself
|
||||||
|
# should still be built inside a Debian/Ubuntu distrobox so the
|
||||||
|
# binary links that distro's glibc — see the packaging notes in
|
||||||
|
# Cargo.toml.
|
||||||
|
cargo-deb
|
||||||
|
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
# pipewire-sys and libspa-sys generate their bindings with bindgen,
|
||||||
|
# which needs a real libclang present at build time.
|
||||||
|
clang
|
||||||
|
|
||||||
|
# audiopus_sys prefers the system libopus via pkg-config but falls
|
||||||
|
# back to a vendored CMake build; cmake keeps that fallback working
|
||||||
|
# rather than failing obscurely inside a build script.
|
||||||
|
cmake
|
||||||
|
|
||||||
|
# build.rs shells out to `git rev-parse --short=8 HEAD` to stamp
|
||||||
|
# PEERSPEAK_GIT_SHORT into the binary (surfaced in Settings).
|
||||||
|
git
|
||||||
|
]
|
||||||
|
++ screenshareTools
|
||||||
|
++ [
|
||||||
|
pkgs.pulseaudio # `pactl`, used by pixelpass's audio routing
|
||||||
|
pkgs.mpv # the screen-share viewer
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs =
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
alsa-lib # alsa-sys, pulled in by rodio/cpal
|
||||||
|
libopus # audiopus_sys, linked dynamically
|
||||||
|
pipewire # pipewire-sys + libspa-sys: the Linux audio backend
|
||||||
|
]
|
||||||
|
++ runtimeLibs;
|
||||||
|
|
||||||
|
# bindgen finds libclang through this variable specifically — having
|
||||||
|
# clang on PATH is not sufficient.
|
||||||
|
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||||
|
|
||||||
|
# NixOS keeps every GStreamer plugin in its own store path, so the
|
||||||
|
# gst-launch-1.0 that pixelpass spawns discovers them ONLY through this
|
||||||
|
# search path. Same reasoning as hosts/darp5 and hosts/cazen in
|
||||||
|
# nixos-config.
|
||||||
|
GST_PLUGIN_SYSTEM_PATH_1_0 =
|
||||||
|
pkgs.lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" screenshareTools;
|
||||||
|
|
||||||
|
# Only greet an interactive shell. shellHook also runs under
|
||||||
|
# `nix develop --command …`, where printing this would interleave the
|
||||||
|
# banner with the command's own output.
|
||||||
|
shellHook = ''
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
echo "peerspeak — rustc $(rustc --version | cut -d' ' -f2) / cargo $(cargo --version | cut -d' ' -f2)"
|
||||||
|
echo " cargo build --release build"
|
||||||
|
echo " cargo test lib tests"
|
||||||
|
echo " cargo clippy --all-targets -- -D warnings lint"
|
||||||
|
echo
|
||||||
|
echo "Screen sharing spawns pixelpass as a child process — it must be"
|
||||||
|
echo "on PATH. Build it from ../pixelpass and add its target/release."
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.6.5"
|
#define MyAppVersion "0.6.6"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
@@ -328,4 +328,54 @@ mod tests {
|
|||||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||||
assert_eq!(seek_target(2.0, total), total);
|
assert_eq!(seek_target(2.0, total), total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
|
||||||
|
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
|
||||||
|
/// music and the local playlist — and asserts the node it puts on the
|
||||||
|
/// graph carries both ownership carriers.
|
||||||
|
///
|
||||||
|
/// This path was untagged through all of phase 1, which is a real echo:
|
||||||
|
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
|
||||||
|
/// own track. It was missed because phase 1 worked from the impl plan's
|
||||||
|
/// list of three playback sites and that list was incomplete — so this
|
||||||
|
/// gate drives the *player*, not the tagging helper.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
|
||||||
|
/// only sound single-threaded. In production `main` does this before
|
||||||
|
/// anything is spawned; a test binary has no such guarantee, hence
|
||||||
|
/// `--test-threads=1`.
|
||||||
|
///
|
||||||
|
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
|
||||||
|
fn clip_player_node_carries_both_ownership_carriers() {
|
||||||
|
use crate::audio::ownership::{self, live_test};
|
||||||
|
|
||||||
|
// SAFETY: `--test-threads=1` is documented above and in the ignore
|
||||||
|
// reason; this is the same call `main` makes, exercised for real
|
||||||
|
// rather than reimplemented, so the gate cannot pass against a
|
||||||
|
// formatter that production never uses.
|
||||||
|
unsafe { ownership::tag_this_process_alsa_audio() };
|
||||||
|
|
||||||
|
let (player, _status) = ClipPlayer::new(1.0);
|
||||||
|
// Six seconds of silence: long enough for the poll, inaudible.
|
||||||
|
player.play([0u8; 32], live_test::silent_wav(6));
|
||||||
|
|
||||||
|
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
|
||||||
|
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||||
|
player.stop();
|
||||||
|
|
||||||
|
let (name, owned) = found.unwrap_or_else(|| {
|
||||||
|
panic!("no live clip-player node named {prefix:?} appeared within 5s")
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
owned.as_deref(),
|
||||||
|
Some(ownership::OWNED_PROP_VALUE),
|
||||||
|
"carrier 1 must be on the live node, not just carrier 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ pub mod eq;
|
|||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod multitrack;
|
pub mod multitrack;
|
||||||
|
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||||||
|
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||||||
|
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||||||
|
pub mod ownership;
|
||||||
pub mod pan;
|
pub mod pan;
|
||||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::audio::ownership;
|
||||||
use crate::audio::{AudioBackend, AudioError};
|
use crate::audio::{AudioBackend, AudioError};
|
||||||
use pipewire as pw;
|
use pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
@@ -371,6 +372,11 @@ fn run_playback(
|
|||||||
mainloop_clone.quit();
|
mainloop_clone.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
|
||||||
|
// This is the node that carries the far end's voice, so it is the single
|
||||||
|
// most important thing for pixelpass to refuse to fan out: sharing it
|
||||||
|
// would send the call back to the person already speaking on it.
|
||||||
|
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
|
||||||
let mut props = properties! {
|
let mut props = properties! {
|
||||||
*pw::keys::MEDIA_TYPE => "Audio",
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
@@ -379,6 +385,19 @@ fn run_playback(
|
|||||||
// buffer — the real fix is the explicit Buffers param below — but it
|
// buffer — the real fix is the explicit Buffers param below — but it
|
||||||
// expresses the intended quantum for any node that honours it.
|
// expresses the intended quantum for any node that honours it.
|
||||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
*pw::keys::NODE_LATENCY => "1024/48000",
|
||||||
|
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
|
||||||
|
// Set explicitly rather than relying on the stream name passed to
|
||||||
|
// `StreamBox::new` below: props win over that name, and this one has
|
||||||
|
// to be exact.
|
||||||
|
*pw::keys::NODE_NAME => owned_node_name.as_str(),
|
||||||
|
// Measured: this stream sets neither `application.name` nor a
|
||||||
|
// description, so a mixer falls back to `node.name` — which the line
|
||||||
|
// above just turned into an internal identifier. The plan's rule is
|
||||||
|
// that the ownership prefix must not reach `node.description`; a
|
||||||
|
// human label there is what keeps that rule's *intent* (mixers stay
|
||||||
|
// readable) true for our own stream, exactly as mpv's own
|
||||||
|
// description does for the spawned players.
|
||||||
|
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
|
||||||
};
|
};
|
||||||
if let Some(target) = target_node {
|
if let Some(target) = target_node {
|
||||||
props.insert("node.target", target);
|
props.insert("node.target", target);
|
||||||
@@ -637,6 +656,62 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{sync::mpsc, thread};
|
use std::{sync::mpsc, thread};
|
||||||
|
|
||||||
|
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
|
||||||
|
/// that carries the far end's voice appears on the graph with **both**
|
||||||
|
/// ownership carriers, and still with the `Communication` media role.
|
||||||
|
///
|
||||||
|
/// The third and most important of the three tagged paths — this is the
|
||||||
|
/// node whose audio, if fanned out, would send the call back to whoever
|
||||||
|
/// is speaking on it.
|
||||||
|
///
|
||||||
|
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
|
||||||
|
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||||
|
fn native_playback_node_carries_both_ownership_carriers() {
|
||||||
|
use crate::audio::ownership::{self, live_test};
|
||||||
|
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
|
||||||
|
|
||||||
|
let backend = super::PipeWireBackend::new();
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
|
backend
|
||||||
|
.start_playback(rx, None, ring_fill.clone())
|
||||||
|
.expect("playback starts");
|
||||||
|
|
||||||
|
// Keep the ring fed so the node stays live for the whole poll; the
|
||||||
|
// stream is created on connect, but a starved one is not a fair test
|
||||||
|
// of what a real call looks like on the graph.
|
||||||
|
let feeder = thread::spawn(move || {
|
||||||
|
let silence = vec![0i16; 960 * 2];
|
||||||
|
for _ in 0..300 {
|
||||||
|
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
|
||||||
|
&& tx.send(silence.clone()).is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
|
||||||
|
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||||
|
let _ = backend.stop();
|
||||||
|
let _ = feeder.join();
|
||||||
|
|
||||||
|
let (name, owned) =
|
||||||
|
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||||
|
assert!(
|
||||||
|
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
owned.as_deref(),
|
||||||
|
Some(ownership::OWNED_PROP_VALUE),
|
||||||
|
"carrier 1 must be on the live node, not just carrier 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn requested_in_range_is_honored() {
|
fn requested_in_range_is_honored() {
|
||||||
// The graph's requested quantum is produced verbatim when it fits.
|
// The graph's requested quantum is produced verbatim when it fits.
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ pub enum CoreCommand {
|
|||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
|
/// **Core-internal.** The running pixelpass host's stdout ended — the
|
||||||
|
/// process died (or its event stream broke), so the share identified by
|
||||||
|
/// `generation` is over: reap the child, pull the ticket off presence, and
|
||||||
|
/// tell the user. Synthesized by the core's own notice-forwarder task; the
|
||||||
|
/// UI never sends it. `generation` scopes the fault to one specific host
|
||||||
|
/// spawn, so a stale fault (the user already stopped, or started a new
|
||||||
|
/// share) is ignored rather than tearing down the wrong share.
|
||||||
|
ScreenShareHostFault {
|
||||||
|
generation: u64,
|
||||||
|
},
|
||||||
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
||||||
/// open it in a local player.
|
/// open it in a local player.
|
||||||
ViewShare {
|
ViewShare {
|
||||||
@@ -271,6 +281,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
quality: _,
|
quality: _,
|
||||||
}
|
}
|
||||||
| CoreCommand::StopScreenShare
|
| CoreCommand::StopScreenShare
|
||||||
|
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||||
| CoreCommand::ViewShare {
|
| CoreCommand::ViewShare {
|
||||||
ticket: _,
|
ticket: _,
|
||||||
settings: _,
|
settings: _,
|
||||||
@@ -363,6 +374,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
|||||||
quality: _,
|
quality: _,
|
||||||
}
|
}
|
||||||
| CoreCommand::StopScreenShare
|
| CoreCommand::StopScreenShare
|
||||||
|
| CoreCommand::ScreenShareHostFault { generation: _ }
|
||||||
| CoreCommand::ViewShare {
|
| CoreCommand::ViewShare {
|
||||||
ticket: _,
|
ticket: _,
|
||||||
settings: _,
|
settings: _,
|
||||||
|
|||||||
+211
-59
@@ -4,6 +4,7 @@ pub mod fetchbudget;
|
|||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
|
mod teardown;
|
||||||
|
|
||||||
use crate::audio::eq::{Eq, EqSettings};
|
use crate::audio::eq::{Eq, EqSettings};
|
||||||
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
||||||
@@ -677,31 +678,33 @@ struct ActiveSession {
|
|||||||
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
||||||
grace_timers: GraceTimers,
|
grace_timers: GraceTimers,
|
||||||
transport: Arc<IrohTransport>,
|
transport: Arc<IrohTransport>,
|
||||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
/// The screen-share children and the echo-cancel module, held together
|
||||||
#[cfg(target_os = "linux")]
|
/// because their **destruction order** is load-bearing: the AEC module must
|
||||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
/// not unload while a pixelpass host is alive and fanning out (design v3.4
|
||||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
/// §7.1). `teardown` owns that ordering; see `core::teardown`.
|
||||||
/// also dies if the session is dropped without an explicit stop).
|
teardown: SessionTeardown,
|
||||||
screenshare_host: Option<tokio::process::Child>,
|
|
||||||
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
|
||||||
/// with the share ticket it's viewing so a re-watch of the same share can
|
|
||||||
/// replace (not stack) its player. Killed on session teardown (each also
|
|
||||||
/// self-exits when its player window closes).
|
|
||||||
screenshare_viewers: Vec<(String, tokio::process::Child)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The session's teardown set, with the echo-cancel guard the platform actually
|
||||||
|
/// has. On non-Linux there is no AEC module, and `Infallible` makes that
|
||||||
|
/// structural — the `Option` cannot be `Some`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
type SessionTeardown = teardown::ScreenshareTeardown<
|
||||||
|
tokio::process::Child,
|
||||||
|
crate::audio::echo_cancel::EchoCancelGuard,
|
||||||
|
>;
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
type SessionTeardown =
|
||||||
|
teardown::ScreenshareTeardown<tokio::process::Child, std::convert::Infallible>;
|
||||||
|
|
||||||
impl ActiveSession {
|
impl ActiveSession {
|
||||||
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
||||||
crate::log_msg("ActiveSession::shutdown started");
|
crate::log_msg("ActiveSession::shutdown started");
|
||||||
// Tear down any screen-share children first so the host stops streaming
|
// Tear down any screen-share children first so the host stops streaming
|
||||||
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
// promptly, and so they are dead *and reaped* well before the AEC guard
|
||||||
// see the stream end without waiting on drop ordering).
|
// unloads at the end of this function (design v3.4 §7.1). Drop ordering
|
||||||
if let Some(mut host) = self.screenshare_host.take() {
|
// is the backstop for the unwind path; this is the path we control.
|
||||||
let _ = host.kill().await;
|
self.teardown.shutdown_children().await;
|
||||||
}
|
|
||||||
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
|
|
||||||
let _ = viewer.kill().await;
|
|
||||||
}
|
|
||||||
self.datagram_task.abort();
|
self.datagram_task.abort();
|
||||||
self.mixer_task.abort();
|
self.mixer_task.abort();
|
||||||
self.event_task.abort();
|
self.event_task.abort();
|
||||||
@@ -726,8 +729,9 @@ impl ActiveSession {
|
|||||||
|
|
||||||
// Unload the echo-cancel module now that the audio streams releasing its
|
// Unload the echo-cancel module now that the audio streams releasing its
|
||||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||||
#[cfg(target_os = "linux")]
|
// The screen-share children were killed *and reaped* at the top of this
|
||||||
drop(self.echo_cancel);
|
// function, so nothing pixelpass-side is alive to see the module vanish.
|
||||||
|
drop(self.teardown);
|
||||||
|
|
||||||
crate::log_msg("Leaving room...");
|
crate::log_msg("Leaving room...");
|
||||||
let _ = self.room_state.leave().await;
|
let _ = self.room_state.leave().await;
|
||||||
@@ -1393,10 +1397,25 @@ async fn run_core_loop(
|
|||||||
// later opt-in can immediately publish whatever is currently running.
|
// later opt-in can immediately publish whatever is currently running.
|
||||||
let mut current_game: Option<crate::game::DetectedGame> = None;
|
let mut current_game: Option<crate::game::DetectedGame> = None;
|
||||||
let mut network_mode = NetworkMode::default();
|
let mut network_mode = NetworkMode::default();
|
||||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
// Pixelpass binary override (config), and our own active screen share: the
|
||||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
// ticket rides our presence so the room — incl. late joiners — can watch,
|
||||||
|
// and the generation ties host-fault notices to this specific host spawn
|
||||||
|
// (see `ScreenShareHostFault`). One variable on purpose: the ticket and the
|
||||||
|
// generation must appear and vanish together, or a stale fault could tear
|
||||||
|
// down a share it doesn't belong to.
|
||||||
let mut pixelpass_override: Option<String> = None;
|
let mut pixelpass_override: Option<String> = None;
|
||||||
let mut current_sharing: Option<String> = None;
|
struct ActiveShare {
|
||||||
|
generation: u64,
|
||||||
|
ticket: String,
|
||||||
|
}
|
||||||
|
let mut current_sharing: Option<ActiveShare> = None;
|
||||||
|
// Monotonic per-spawn counter feeding `ActiveShare::generation`.
|
||||||
|
let mut share_generations: u64 = 0;
|
||||||
|
// Host faults re-enter the loop here (the notice-forwarder task can't touch
|
||||||
|
// loop state). The loop keeps `host_fault_tx` to clone into each share's
|
||||||
|
// forwarder, so this channel never closes — the select arm's `Some` pattern
|
||||||
|
// is total in practice and a closed-channel branch would be unreachable.
|
||||||
|
let (host_fault_tx, mut host_fault_rx) = mpsc::unbounded_channel::<u64>();
|
||||||
|
|
||||||
let mut active_session: Option<ActiveSession> = None;
|
let mut active_session: Option<ActiveSession> = None;
|
||||||
// Standalone capture-only mic meter, live only when no session exists.
|
// Standalone capture-only mic meter, live only when no session exists.
|
||||||
@@ -1511,6 +1530,8 @@ async fn run_core_loop(
|
|||||||
biased;
|
biased;
|
||||||
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
|
// Every `CoreController`/`CoreCommandSender` is gone — the UI has
|
||||||
|
// dropped the core. Teardown happens once, after the loop.
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
maybe_wake = besteffort_wake_rx.recv() => match maybe_wake {
|
maybe_wake = besteffort_wake_rx.recv() => match maybe_wake {
|
||||||
@@ -1529,8 +1550,27 @@ async fn run_core_loop(
|
|||||||
None => continue,
|
None => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ⚠️ UNREACHABLE BY CONSTRUCTION, twice over — do not mistake this
|
||||||
|
// for a live teardown path (phase 0b finding, 2026-07-26):
|
||||||
|
// 1. this function owns `besteffort_wake_tx` (cloned at the
|
||||||
|
// `CoreController::new` spawn site, used just above for the
|
||||||
|
// `has_more` re-arm), so the channel can never close while
|
||||||
|
// this loop is running;
|
||||||
|
// 2. even without that, every holder of a wake sender —
|
||||||
|
// `CoreController` and `CoreCommandSender` — holds
|
||||||
|
// `reliable_tx` too, and the `biased` select polls that one
|
||||||
|
// first, so the reliable arm always wins the race to exit.
|
||||||
|
// Teardown is hoisted after the loop, so if this arm is ever made
|
||||||
|
// reachable it is already covered — nothing to add here.
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
|
// A share's notice-forwarder task reported the host's stdout ended.
|
||||||
|
// The `Some` pattern is total: this loop owns `host_fault_tx` (see
|
||||||
|
// its declaration), so the channel cannot close — no `None` arm is
|
||||||
|
// written because one would be unreachable by construction.
|
||||||
|
Some(generation) = host_fault_rx.recv() => {
|
||||||
|
CoreCommand::ScreenShareHostFault { generation }
|
||||||
|
}
|
||||||
game_change = next_game_change(&mut game_rx) => {
|
game_change = next_game_change(&mut game_rx) => {
|
||||||
// The detector worker published a new debounced game (or `None`).
|
// The detector worker published a new debounced game (or `None`).
|
||||||
let Some(detected) = game_change else {
|
let Some(detected) = game_change else {
|
||||||
@@ -1549,7 +1589,7 @@ async fn run_core_loop(
|
|||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
current_sharing.clone(),
|
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||||
);
|
);
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
@@ -1693,6 +1733,13 @@ async fn run_core_loop(
|
|||||||
net.file_router.clear();
|
net.file_router.clear();
|
||||||
*current_room.lock().unwrap() = None;
|
*current_room.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
|
// Any advertised share died with that session — deliberately —
|
||||||
|
// so retire it HERE, before the invalid-ticket early exit below
|
||||||
|
// can skip it. Left populated, the killed host's stdout EOF
|
||||||
|
// would pass the ScreenShareHostFault staleness gate and
|
||||||
|
// surface as a spurious "ended unexpectedly" error on top of
|
||||||
|
// the ticket error (Gemini review of S2, P2-1).
|
||||||
|
current_sharing = None;
|
||||||
|
|
||||||
// If a network-mode / identity change was deferred while a call was
|
// If a network-mode / identity change was deferred while a call was
|
||||||
// active, rebuild the persistent stack now — after the old session is
|
// active, rebuild the persistent stack now — after the old session is
|
||||||
@@ -1788,8 +1835,8 @@ async fn run_core_loop(
|
|||||||
secret_key.clone(),
|
secret_key.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Fresh join starts not sharing; clear any stale share ticket.
|
// (The share was already retired beside the session teardown
|
||||||
current_sharing = None;
|
// above; a fresh join starts not sharing.)
|
||||||
let self_state =
|
let self_state =
|
||||||
presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
|
presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
|
||||||
|
|
||||||
@@ -2726,9 +2773,9 @@ async fn run_core_loop(
|
|||||||
grace_timers,
|
grace_timers,
|
||||||
transport: transport.clone(),
|
transport: transport.clone(),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
echo_cancel: echo_cancel_guard,
|
teardown: SessionTeardown::new(echo_cancel_guard),
|
||||||
screenshare_host: None,
|
#[cfg(not(target_os = "linux"))]
|
||||||
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
|
teardown: SessionTeardown::new(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let self_id = endpoint.id().to_string();
|
let self_id = endpoint.id().to_string();
|
||||||
@@ -2810,8 +2857,11 @@ async fn run_core_loop(
|
|||||||
is_muted.store(new_state, Ordering::Relaxed);
|
is_muted.store(new_state, Ordering::Relaxed);
|
||||||
|
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let self_state =
|
let self_state = presence.to_state(
|
||||||
presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone());
|
new_state,
|
||||||
|
net.endpoint.addr(),
|
||||||
|
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||||
|
);
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2824,7 +2874,7 @@ async fn run_core_loop(
|
|||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
current_sharing.clone(),
|
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||||
);
|
);
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
@@ -3137,7 +3187,7 @@ async fn run_core_loop(
|
|||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
current_sharing.clone(),
|
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||||
);
|
);
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
@@ -3343,7 +3393,7 @@ async fn run_core_loop(
|
|||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
current_sharing.clone(),
|
current_sharing.as_ref().map(|s| s.ticket.clone()),
|
||||||
);
|
);
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
@@ -3403,7 +3453,7 @@ async fn run_core_loop(
|
|||||||
.await;
|
.await;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if session.screenshare_host.is_some() {
|
if session.teardown.is_sharing() {
|
||||||
continue; // already sharing
|
continue; // already sharing
|
||||||
}
|
}
|
||||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||||
@@ -3417,17 +3467,24 @@ async fn run_core_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Forward pixelpass `app_audio` events (only emitted when an app
|
// Every share gets a notice forwarder — not just app-audio ones.
|
||||||
// is selected) to the UI so it can warn when the chosen app's
|
// pixelpass `app_audio` events (only emitted when an app is
|
||||||
// audio drops. The channel closes when the host dies (drain hits
|
// selected) become UI warnings, and the drain's terminal `Eof`
|
||||||
// EOF), ending the forwarder task on its own.
|
// becomes a host fault scoped to this spawn's generation, so a
|
||||||
let notices = audio_app.as_deref().map(|_| {
|
// host that dies is torn down instead of staying advertised in
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<
|
// presence forever. On a failed spawn the sender is dropped
|
||||||
crate::screenshare::PixelpassEvent,
|
// before the drain ever runs, so the forwarder just ends and no
|
||||||
>();
|
// fault is sent (the spawn error carries the news instead).
|
||||||
|
share_generations += 1;
|
||||||
|
let generation = share_generations;
|
||||||
|
let (notices_tx, mut notices_rx) =
|
||||||
|
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::HostNotice>();
|
||||||
let ui_tx_notices = ui_tx.clone();
|
let ui_tx_notices = ui_tx.clone();
|
||||||
|
let fault_tx = host_fault_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = rx.recv().await {
|
while let Some(notice) = notices_rx.recv().await {
|
||||||
|
match notice {
|
||||||
|
crate::screenshare::HostNotice::Event(ev) => {
|
||||||
let active = match ev {
|
let active = match ev {
|
||||||
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
|
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
|
||||||
crate::screenshare::PixelpassEvent::AppAudioLost => false,
|
crate::screenshare::PixelpassEvent::AppAudioLost => false,
|
||||||
@@ -3441,22 +3498,31 @@ async fn run_core_loop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
// Terminal by contract: nothing follows on the
|
||||||
tx
|
// channel, so the task ends here.
|
||||||
|
crate::screenshare::HostNotice::Eof => {
|
||||||
|
let _ = fault_tx.send(generation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
match crate::screenshare::spawn_host(
|
match crate::screenshare::spawn_host(
|
||||||
&bin,
|
&bin,
|
||||||
audio_app.as_deref(),
|
audio_app.as_deref(),
|
||||||
&settings,
|
&settings,
|
||||||
quality,
|
quality,
|
||||||
notices,
|
notices_tx,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((child, ticket)) => {
|
Ok((child, ticket)) => {
|
||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.teardown.set_host(child);
|
||||||
current_sharing = Some(ticket.clone());
|
current_sharing = Some(ActiveShare {
|
||||||
|
generation,
|
||||||
|
ticket: ticket.clone(),
|
||||||
|
});
|
||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
@@ -3476,10 +3542,25 @@ async fn run_core_loop(
|
|||||||
CoreCommand::StopScreenShare => {
|
CoreCommand::StopScreenShare => {
|
||||||
current_sharing = None;
|
current_sharing = None;
|
||||||
if let Some(session) = &mut active_session {
|
if let Some(session) = &mut active_session {
|
||||||
if let Some(mut child) = session.screenshare_host.take() {
|
match session.teardown.stop_host().await {
|
||||||
let _ = child.kill().await;
|
None => {}
|
||||||
|
Some(teardown::StopOutcome::Reaped) => {
|
||||||
crate::log_msg("Screen share host stopped");
|
crate::log_msg("Screen share host stopped");
|
||||||
}
|
}
|
||||||
|
// We gave up waiting rather than freeze the client, so
|
||||||
|
// pixelpass may still be alive and serving. Saying
|
||||||
|
// "stopped" and nothing else would be a lie the user
|
||||||
|
// cannot see through (round-16 review, P3-2).
|
||||||
|
Some(teardown::StopOutcome::Unconfirmed) => {
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::Error(
|
||||||
|
"Couldn't confirm the screen-share process exited — \
|
||||||
|
it may still be sharing. Check for a stray pixelpass."
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
let self_state = presence.to_state(
|
let self_state = presence.to_state(
|
||||||
is_muted.load(Ordering::Relaxed),
|
is_muted.load(Ordering::Relaxed),
|
||||||
net.endpoint.addr(),
|
net.endpoint.addr(),
|
||||||
@@ -3490,6 +3571,66 @@ async fn run_core_loop(
|
|||||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CoreCommand::ScreenShareHostFault { generation } => {
|
||||||
|
// Stale unless it names the share we are advertising RIGHT NOW.
|
||||||
|
// Every deliberate end of a share (StopScreenShare, Leave, a
|
||||||
|
// fresh Join) clears `current_sharing` before or while reaping
|
||||||
|
// the child, and the reaped child's stdout EOF then arrives
|
||||||
|
// here late — dropping it is the correct handling, not an edge
|
||||||
|
// case. A mismatched generation likewise: that fault belongs to
|
||||||
|
// an older spawn than the share now running.
|
||||||
|
let stale = current_sharing.as_ref().map(|s| s.generation) != Some(generation);
|
||||||
|
if stale {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
crate::log_msg(
|
||||||
|
"Screen share host died (stdout EOF with the share still advertised)",
|
||||||
|
);
|
||||||
|
current_sharing = None;
|
||||||
|
// Pull the ticket off presence FIRST, before the reap: if the
|
||||||
|
// child only closed stdout and lives on, `stop_host` burns the
|
||||||
|
// full stop grace before the SIGKILL fallback, and for that
|
||||||
|
// whole window peers would still see (and click Watch on) a
|
||||||
|
// share whose host is already gone (Gemini S2-merge review,
|
||||||
|
// P2-1).
|
||||||
|
if let Some(session) = &mut active_session {
|
||||||
|
let self_state = presence.to_state(
|
||||||
|
is_muted.load(Ordering::Relaxed),
|
||||||
|
net.endpoint.addr(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
|
}
|
||||||
|
// Stopped next — it clears the UI's sharing state — so the
|
||||||
|
// local UI also stops saying "sharing" before the reap wait,
|
||||||
|
// and the error explaining why comes only after, so the user
|
||||||
|
// is never left looking at a "sharing" UI with an error
|
||||||
|
// beside it.
|
||||||
|
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||||
|
let mut unconfirmed = false;
|
||||||
|
if let Some(session) = &mut active_session {
|
||||||
|
// The child is usually already dead, so this confirms the
|
||||||
|
// reap immediately; if it merely closed stdout and lives
|
||||||
|
// on, this is the SIGINT → grace → SIGKILL path. Either
|
||||||
|
// way the dead-or-dying child leaves the teardown slot, so
|
||||||
|
// `is_sharing` stops lying.
|
||||||
|
unconfirmed = matches!(
|
||||||
|
session.teardown.stop_host().await,
|
||||||
|
Some(teardown::StopOutcome::Unconfirmed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let detail = if unconfirmed {
|
||||||
|
" Its process also couldn't be confirmed dead — check for a stray pixelpass."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::Error(format!(
|
||||||
|
"Screen share ended unexpectedly — pixelpass exited.{detail}"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::ViewShare { ticket, settings } => {
|
CoreCommand::ViewShare { ticket, settings } => {
|
||||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
@@ -3505,16 +3646,12 @@ async fn run_core_loop(
|
|||||||
if let Some(session) = &mut active_session {
|
if let Some(session) = &mut active_session {
|
||||||
// Drop viewers whose player window has already closed so the
|
// Drop viewers whose player window has already closed so the
|
||||||
// list only tracks live players.
|
// list only tracks live players.
|
||||||
session
|
session.teardown.sweep_exited_viewers();
|
||||||
.screenshare_viewers
|
|
||||||
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
|
|
||||||
// One player per share: a second Watch click on a share we're
|
// One player per share: a second Watch click on a share we're
|
||||||
// already viewing is a retry (usually because the first window
|
// already viewing is a retry (usually because the first window
|
||||||
// froze), so replace the existing player rather than stacking a
|
// froze), so replace the existing player rather than stacking a
|
||||||
// second mpv — two players would double the shared audio.
|
// second mpv — two players would double the shared audio.
|
||||||
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
|
if session.teardown.replace_viewer(&ticket).await {
|
||||||
let (_, mut old) = session.screenshare_viewers.remove(pos);
|
|
||||||
let _ = old.kill().await;
|
|
||||||
crate::log_msg("Screen share viewer replaced (re-watch)");
|
crate::log_msg("Screen share viewer replaced (re-watch)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3522,7 +3659,7 @@ async fn run_core_loop(
|
|||||||
Ok(child) => {
|
Ok(child) => {
|
||||||
crate::log_msg("Screen share viewer started");
|
crate::log_msg("Screen share viewer started");
|
||||||
if let Some(session) = &mut active_session {
|
if let Some(session) = &mut active_session {
|
||||||
session.screenshare_viewers.push((ticket, child));
|
session.teardown.push_viewer(ticket, child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -3535,6 +3672,21 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The command loop has exited, by any route. Tear the session down
|
||||||
|
// explicitly rather than letting it drop on the way out of this function:
|
||||||
|
// an implicit drop unloads the echo-cancel module without first reaping the
|
||||||
|
// pixelpass host (design v3.4 §7.2, decision D4).
|
||||||
|
//
|
||||||
|
// This sits *after* the loop rather than in the close arm on purpose. The
|
||||||
|
// impl plan pinned one teardown per channel-close arm, but the best-effort
|
||||||
|
// wake arm is unreachable by construction (see the comment at that arm), so
|
||||||
|
// that shape would have duplicated teardown to cover one live path and one
|
||||||
|
// dead one. Here every `break` is covered structurally, including any added
|
||||||
|
// later. Adjudication: impl plan §10, 2026-07-26.
|
||||||
|
if let Some(session) = active_session.take() {
|
||||||
|
session.shutdown(audio_backend.clone()).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,889 @@
|
|||||||
|
//! Destruction-order guarantees for the screen-share children and the
|
||||||
|
//! echo-cancel module (phase 0b of the screenshare audio-exclusion plan;
|
||||||
|
//! design v3.4 §7.1–§7.2, decision D4).
|
||||||
|
//!
|
||||||
|
//! # The invariant
|
||||||
|
//!
|
||||||
|
//! > **The echo-cancel module must not unload while a pixelpass host is alive
|
||||||
|
//! > and fanning out.**
|
||||||
|
//!
|
||||||
|
//! If it does, the AEC's virtual nodes vanish from under a live pixelpass that
|
||||||
|
//! still holds link proxies and a stale module index. Phase 6 makes this sharp
|
||||||
|
//! — it is the first phase whose objects live only as long as pixelpass does —
|
||||||
|
//! so the ordering guarantee has to exist *before* it.
|
||||||
|
//!
|
||||||
|
//! Two paths have to honour it, and only one of them is code we get to run:
|
||||||
|
//!
|
||||||
|
//! 1. **The explicit path** — [`ScreenshareTeardown::shutdown_children`], awaited
|
||||||
|
//! by `ActiveSession::shutdown` before the guard is dropped.
|
||||||
|
//! 2. **The drop/unwind path** — nobody calls anything. The core has numerous
|
||||||
|
//! `unwrap()` sites and no `panic=abort` profile, so unwind is reachable, and
|
||||||
|
//! on that path the only thing standing between us and a violated invariant
|
||||||
|
//! is *field declaration order* plus [`ReapOnDrop`].
|
||||||
|
//!
|
||||||
|
//! Hence the two structural rules enforced here:
|
||||||
|
//!
|
||||||
|
//! - `echo_cancel` is the **last declared field** of [`ScreenshareTeardown`].
|
||||||
|
//! Rust drops fields in declaration order, so last-declared is last-dropped.
|
||||||
|
//! This is not a style choice; reversing it reintroduces the bug.
|
||||||
|
//! - Killing is not enough — a child must be **reaped**. `kill_on_drop(true)`
|
||||||
|
//! only *signals*; it hands the child to the runtime's orphan queue and
|
||||||
|
//! returns, which on an unwinding runtime may never be drained. [`ReapOnDrop`]
|
||||||
|
//! therefore blocks, briefly and boundedly, until the child is actually gone.
|
||||||
|
//!
|
||||||
|
//! Everything here is generic over [`ChildProcess`] and over the guard type so
|
||||||
|
//! the ordering is unit-testable without spawning processes or loading PipeWire
|
||||||
|
//! modules — the same seam idiom as `replace_viewer_index` and
|
||||||
|
//! `rebuild_with_fallback` in the parent module.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// How long [`ReapOnDrop::drop`] will block waiting for a killed child to be
|
||||||
|
/// reaped before giving up and logging. This runs on the unwind path, so it is
|
||||||
|
/// a deliberate trade: a bounded stall is preferable to unloading the AEC out
|
||||||
|
/// from under a live pixelpass, and unbounded blocking in a `Drop` is not.
|
||||||
|
const REAP_BUDGET: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// Poll interval while waiting out [`REAP_BUDGET`].
|
||||||
|
const REAP_POLL: Duration = Duration::from_millis(5);
|
||||||
|
|
||||||
|
/// How long a child gets to honour the graceful stop before it is killed.
|
||||||
|
///
|
||||||
|
/// A healthy pixelpass exits in well under this, so the normal path never
|
||||||
|
/// spends it; only a wedged child does. It is awaited inline in the core
|
||||||
|
/// command loop, so it is also how long a wedged child can delay other
|
||||||
|
/// commands — hence seconds, not tens of seconds.
|
||||||
|
const STOP_GRACE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// The child-process operations the teardown ordering actually depends on.
|
||||||
|
///
|
||||||
|
/// Deliberately narrow, and deliberately not `ExitStatus`-shaped: the ordering
|
||||||
|
/// rules care only about *whether* a child has been signalled and *whether* it
|
||||||
|
/// has been reaped, so the test double is a few lines instead of a fabricated
|
||||||
|
/// exit status.
|
||||||
|
pub(super) trait ChildProcess {
|
||||||
|
/// Ask the child to exit **gracefully**, so it can run its own cleanup.
|
||||||
|
/// Does **not** wait, and is not guaranteed to be honoured.
|
||||||
|
fn request_stop(&mut self) -> std::io::Result<()>;
|
||||||
|
|
||||||
|
/// Signal the child to die. Does **not** wait.
|
||||||
|
fn start_kill(&mut self) -> std::io::Result<()>;
|
||||||
|
|
||||||
|
/// Poll once. `true` once the child has exited **and been reaped**.
|
||||||
|
fn try_reap(&mut self) -> bool;
|
||||||
|
|
||||||
|
/// Wait until the child has exited and been reaped.
|
||||||
|
///
|
||||||
|
/// The `io::Result` is load-bearing and must not be discarded by callers:
|
||||||
|
/// a failed wait is *not* a confirmed reap, and treating it as one is how
|
||||||
|
/// the AEC ends up unloading over a live child.
|
||||||
|
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildProcess for tokio::process::Child {
|
||||||
|
/// **SIGINT, not SIGTERM.** pixelpass installs only a `tokio::signal::ctrl_c()`
|
||||||
|
/// handler (`pixelpass/src/common/signal.rs`), so SIGTERM would be the default
|
||||||
|
/// disposition — instant death, no cleanup — which is indistinguishable from
|
||||||
|
/// SIGKILL for our purposes.
|
||||||
|
///
|
||||||
|
/// Signalling by pid is safe against pid reuse here because we have not
|
||||||
|
/// reaped this child: an exited-but-unreaped child is a zombie whose pid the
|
||||||
|
/// kernel reserves until we `wait` it, so the pid cannot name a stranger.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||||
|
let Some(pid) = self.id() else {
|
||||||
|
// Already reaped — nothing to signal.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// SAFETY: `kill` is async-signal-safe and takes no pointers; the pid is
|
||||||
|
// this process's own unreaped child (see above).
|
||||||
|
if unsafe { libc::kill(pid as libc::pid_t, libc::SIGINT) } == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows has no SIGINT to send to another process without attaching to its
|
||||||
|
/// console, so the graceful request degrades to the hard kill and the
|
||||||
|
/// bounded wait below simply returns early.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||||
|
tokio::process::Child::start_kill(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||||
|
tokio::process::Child::start_kill(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_reap(&mut self) -> bool {
|
||||||
|
matches!(self.try_wait(), Ok(Some(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_reaped(&mut self) -> std::io::Result<()> {
|
||||||
|
self.wait().await.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the explicit stop path actually confirm the child was reaped?
|
||||||
|
///
|
||||||
|
/// The distinction is not cosmetic: on [`Unconfirmed`](Self::Unconfirmed) we
|
||||||
|
/// deliberately stopped waiting (see [`ReapOnDrop::shutdown`]), so pixelpass may
|
||||||
|
/// still be alive and fanning out. A user-initiated Stop Share must not report
|
||||||
|
/// that as a clean stop.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[must_use = "an unconfirmed stop means the child may still be sharing"]
|
||||||
|
pub(super) enum StopOutcome {
|
||||||
|
/// The child is gone and has been reaped.
|
||||||
|
Reaped,
|
||||||
|
/// We could not confirm the reap within the bound and gave up waiting.
|
||||||
|
Unconfirmed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child that is killed **and reaped** when it is dropped.
|
||||||
|
///
|
||||||
|
/// The explicit path calls [`shutdown`](Self::shutdown), which releases the
|
||||||
|
/// child only once its reap is *confirmed*, so the `Drop` below is a no-op
|
||||||
|
/// afterwards but stays armed through every await until then. `Drop` is the
|
||||||
|
/// last-ditch protection for the panic/unwind/cancellation paths.
|
||||||
|
pub(super) struct ReapOnDrop<C: ChildProcess> {
|
||||||
|
/// `None` once the child has been reaped through the explicit path.
|
||||||
|
child: Option<C>,
|
||||||
|
/// Names the child in the reap-timeout log line.
|
||||||
|
label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: ChildProcess> ReapOnDrop<C> {
|
||||||
|
pub(super) fn new(child: C, label: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
child: Some(child),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll once, without killing. `true` if the child has exited on its own —
|
||||||
|
/// used to sweep player windows the user has already closed.
|
||||||
|
pub(super) fn has_exited(&mut self) -> bool {
|
||||||
|
match &mut self.child {
|
||||||
|
Some(child) => {
|
||||||
|
if child.try_reap() {
|
||||||
|
self.child = None;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Already reaped through the explicit path.
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the child gracefully if it will go, and by force if it will not.
|
||||||
|
/// Waits for it to be reaped either way. Idempotent.
|
||||||
|
///
|
||||||
|
/// Ask, then insist (design v3.4 §7.4): a pixelpass host that gets SIGINT
|
||||||
|
/// unloads its capture sink on the way out, whereas SIGKILL skips that and
|
||||||
|
/// leaks a null-sink module on every Stop Share.
|
||||||
|
///
|
||||||
|
/// The wait is the point: returning after signalling would let the caller
|
||||||
|
/// proceed to unload the AEC while the child is still running.
|
||||||
|
///
|
||||||
|
/// ⚠️ The child stays owned by `self` across every `.await`, and is released
|
||||||
|
/// **only after a confirmed reap**. Taking it out first would disarm the
|
||||||
|
/// `Drop` fallback for exactly as long as the wait lasts: cancel or unwind
|
||||||
|
/// this future at that moment and the raw child would drop with nothing but
|
||||||
|
/// `kill_on_drop` (which signals without reaping) while `Drop` below found
|
||||||
|
/// `None` and did nothing — the precise hole this type exists to close.
|
||||||
|
pub(super) async fn shutdown(&mut self) -> StopOutcome {
|
||||||
|
let Some(child) = self.child.as_mut() else {
|
||||||
|
return StopOutcome::Reaped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Three different things can go wrong here and they want three
|
||||||
|
// different operator diagnoses: the signal never left (a runtime or
|
||||||
|
// permission fault), the child ignored it (a wedged pixelpass), or the
|
||||||
|
// wait itself broke (we no longer know anything about the child).
|
||||||
|
// Collapsing them into one line was P3-1 of the round-16 review.
|
||||||
|
if let Err(e) = child.request_stop() {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"teardown: could not ask {} to stop: {e}",
|
||||||
|
self.label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
self.child = None;
|
||||||
|
return StopOutcome::Reaped;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => crate::log_msg(&format!(
|
||||||
|
"teardown: waiting for {} failed ({e}); killing it",
|
||||||
|
self.label
|
||||||
|
)),
|
||||||
|
Err(_) => crate::log_msg(&format!(
|
||||||
|
"teardown: {} ignored the graceful stop within {STOP_GRACE:?}; killing it",
|
||||||
|
self.label
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = child.start_kill() {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"teardown: {} could not be killed: {e}",
|
||||||
|
self.label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second wait is bounded too. An unbounded one lets a process stuck
|
||||||
|
// in uninterruptible sleep wedge the core command loop forever, and a
|
||||||
|
// permanently frozen app is a worse failure than the risk below.
|
||||||
|
if let Ok(Ok(())) = tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||||
|
self.child = None;
|
||||||
|
return StopOutcome::Reaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit policy for the one case where the two guarantees conflict:
|
||||||
|
// we could not confirm the reap and will NOT block indefinitely, so we
|
||||||
|
// give up availability-first and leave the child owned — `Drop`'s
|
||||||
|
// bounded retry stays armed, and the AEC may unload over a child that
|
||||||
|
// is still somehow alive. That residual risk is logged, not silent —
|
||||||
|
// and, for a user-initiated stop, reported to the caller rather than
|
||||||
|
// dressed up as success.
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"teardown: {} could not be confirmed dead; the echo-cancel module \
|
||||||
|
may unload while it lives",
|
||||||
|
self.label
|
||||||
|
));
|
||||||
|
StopOutcome::Unconfirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the `Drop` fallback still armed? Test-only: the arming rule is the
|
||||||
|
/// whole point of holding the child across the waits.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn is_armed(&self) -> bool {
|
||||||
|
self.child.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: ChildProcess> Drop for ReapOnDrop<C> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(child) = self.child.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = child.start_kill();
|
||||||
|
// `Drop` cannot await, so poll on a bounded budget. See `REAP_BUDGET`.
|
||||||
|
let deadline = Instant::now() + REAP_BUDGET;
|
||||||
|
loop {
|
||||||
|
if child.try_reap() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"teardown: {} did not exit within the reap budget; \
|
||||||
|
continuing (the echo-cancel module may unload while it lives)",
|
||||||
|
self.label
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(REAP_POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything in an `ActiveSession` whose **destruction order** is load-bearing.
|
||||||
|
///
|
||||||
|
/// ⚠️ Field order below **is** the invariant. `echo_cancel` is declared last so
|
||||||
|
/// it is dropped last, after every screen-share child has been killed and
|
||||||
|
/// reaped. Do not reorder these fields.
|
||||||
|
pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
|
||||||
|
/// Our pixelpass screen-share host child while sharing.
|
||||||
|
host: Option<ReapOnDrop<C>>,
|
||||||
|
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
||||||
|
/// with the share ticket it is viewing so a re-watch of the same share can
|
||||||
|
/// replace (not stack) its player.
|
||||||
|
viewers: Vec<(String, ReapOnDrop<C>)>,
|
||||||
|
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||||
|
///
|
||||||
|
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note.
|
||||||
|
///
|
||||||
|
/// Never read, and that is the design: the guard is held only so that its
|
||||||
|
/// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right
|
||||||
|
/// that nothing reads it and wrong that it does nothing.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
echo_cancel: Option<G>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
|
||||||
|
pub(super) fn new(echo_cancel: Option<G>) -> Self {
|
||||||
|
Self {
|
||||||
|
host: None,
|
||||||
|
viewers: Vec::new(),
|
||||||
|
echo_cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_sharing(&self) -> bool {
|
||||||
|
self.host.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set_host(&mut self, child: C) {
|
||||||
|
self.host = Some(ReapOnDrop::new(child, "screen-share host"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop sharing: kill the host and wait for it to be reaped. `None` if we
|
||||||
|
/// were not sharing; otherwise whether the reap was actually confirmed —
|
||||||
|
/// the caller owns telling the user, since an unconfirmed stop may leave
|
||||||
|
/// pixelpass fanning out after the UI says sharing ended.
|
||||||
|
pub(super) async fn stop_host(&mut self) -> Option<StopOutcome> {
|
||||||
|
let mut host = self.host.take()?;
|
||||||
|
Some(host.shutdown().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop viewers whose player window has already closed, so the list only
|
||||||
|
/// tracks live players.
|
||||||
|
pub(super) fn sweep_exited_viewers(&mut self) {
|
||||||
|
self.viewers.retain_mut(|(_, child)| !child.has_exited());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill and reap the viewer already showing `ticket`, if any, so a re-watch
|
||||||
|
/// replaces its player instead of stacking a second one.
|
||||||
|
pub(super) async fn replace_viewer(&mut self, ticket: &str) -> bool {
|
||||||
|
let Some(pos) = super::replace_viewer_index(&self.viewers, ticket) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (_, mut old) = self.viewers.remove(pos);
|
||||||
|
// A viewer is our own player window, not the thing peers are watching:
|
||||||
|
// an unconfirmed reap is already logged, and there is no user decision
|
||||||
|
// riding on it the way there is for Stop Share.
|
||||||
|
let _ = old.shutdown().await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn push_viewer(&mut self, ticket: String, child: C) {
|
||||||
|
self.viewers
|
||||||
|
.push((ticket, ReapOnDrop::new(child, "screen-share viewer")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill and reap **every** screen-share child, host first so viewers see the
|
||||||
|
/// stream end promptly.
|
||||||
|
///
|
||||||
|
/// The caller must await this before the echo-cancel guard is dropped. On
|
||||||
|
/// the drop/unwind path nothing calls it and field order carries the
|
||||||
|
/// invariant instead.
|
||||||
|
pub(super) async fn shutdown_children(&mut self) {
|
||||||
|
// Outcomes are discarded on purpose: this runs on the session/teardown
|
||||||
|
// path, where the policy is already availability-first and the residual
|
||||||
|
// risk is logged by `shutdown` itself. There is no user still waiting
|
||||||
|
// on an answer here, unlike `stop_host`.
|
||||||
|
if let Some(host) = &mut self.host {
|
||||||
|
let _ = host.shutdown().await;
|
||||||
|
}
|
||||||
|
self.host = None;
|
||||||
|
for (_, viewer) in self.viewers.iter_mut() {
|
||||||
|
let _ = viewer.shutdown().await;
|
||||||
|
}
|
||||||
|
self.viewers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ChildProcess, ReapOnDrop, STOP_GRACE, ScreenshareTeardown, StopOutcome};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
type Log = Arc<Mutex<Vec<String>>>;
|
||||||
|
|
||||||
|
fn log() -> Log {
|
||||||
|
Arc::new(Mutex::new(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entries(log: &Log) -> Vec<String> {
|
||||||
|
log.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position(log: &Log, entry: &str) -> Option<usize> {
|
||||||
|
entries(log).iter().position(|e| e == entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the events the ordering rules turn on. Death is gated on an
|
||||||
|
/// actual signal, so the double cannot report a reap that nothing caused.
|
||||||
|
struct FakeChild {
|
||||||
|
log: Log,
|
||||||
|
label: &'static str,
|
||||||
|
interrupted: bool,
|
||||||
|
killed: bool,
|
||||||
|
reaped: bool,
|
||||||
|
/// A well-behaved child exits on SIGINT. A wedged one ignores it and
|
||||||
|
/// dies only to SIGKILL.
|
||||||
|
honours_interrupt: bool,
|
||||||
|
/// When true the child is already dead before anyone signals it — the
|
||||||
|
/// closed-player-window case that `sweep_exited_viewers` looks for.
|
||||||
|
exited_on_its_own: bool,
|
||||||
|
/// Death is not instantaneous: `try_reap` reports the child alive this
|
||||||
|
/// many more times before it goes.
|
||||||
|
polls_before_death: u32,
|
||||||
|
/// `wait` reports an error instead of a reap.
|
||||||
|
wait_fails: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeChild {
|
||||||
|
/// A well-behaved child: exits when asked.
|
||||||
|
fn new(log: &Log, label: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
log: log.clone(),
|
||||||
|
label,
|
||||||
|
interrupted: false,
|
||||||
|
killed: false,
|
||||||
|
reaped: false,
|
||||||
|
honours_interrupt: true,
|
||||||
|
exited_on_its_own: false,
|
||||||
|
polls_before_death: 0,
|
||||||
|
wait_fails: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child that ignores the graceful stop entirely.
|
||||||
|
fn wedged(log: &Log, label: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
honours_interrupt: false,
|
||||||
|
..Self::new(log, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child that does not die the instant it is signalled: `try_reap`
|
||||||
|
/// reports it alive for `polls` calls first. Without this the `Drop`
|
||||||
|
/// polling loop could be replaced by a single `try_reap` and no test
|
||||||
|
/// would notice.
|
||||||
|
fn reaps_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
polls_before_death: polls,
|
||||||
|
..Self::new(log, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child that ignores SIGINT *and* does not die the instant it is
|
||||||
|
/// killed — the only shape that lets a test reach the post-SIGKILL
|
||||||
|
/// wait and still be reaped by the `Drop` poll loop afterwards.
|
||||||
|
fn wedged_then_dies_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
honours_interrupt: false,
|
||||||
|
polls_before_death: polls,
|
||||||
|
..Self::new(log, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child whose `wait` fails. A failed wait is not a confirmed reap,
|
||||||
|
/// so it must not be reported as one.
|
||||||
|
fn wait_fails(log: &Log, label: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
wait_fails: true,
|
||||||
|
..Self::new(log, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn already_exited(log: &Log, label: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
exited_on_its_own: true,
|
||||||
|
..Self::new(log, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Has anything actually made this child exit yet? A signalled child
|
||||||
|
/// still has to burn through `polls_before_death` first.
|
||||||
|
fn is_dead(&self) -> bool {
|
||||||
|
let signalled = self.killed
|
||||||
|
|| self.exited_on_its_own
|
||||||
|
|| (self.interrupted && self.honours_interrupt);
|
||||||
|
signalled && self.polls_before_death == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One observation of a dying-but-not-yet-dead child.
|
||||||
|
fn tick(&mut self) {
|
||||||
|
self.polls_before_death = self.polls_before_death.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, event: &str) {
|
||||||
|
self.log
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("{}:{event}", self.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_reaped(&mut self) {
|
||||||
|
if !self.reaped {
|
||||||
|
self.reaped = true;
|
||||||
|
self.record("reap");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildProcess for FakeChild {
|
||||||
|
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||||
|
if !self.interrupted {
|
||||||
|
self.interrupted = true;
|
||||||
|
self.record("sigint");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||||
|
if !self.killed {
|
||||||
|
self.killed = true;
|
||||||
|
self.record("kill");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_reap(&mut self) -> bool {
|
||||||
|
if self.is_dead() {
|
||||||
|
self.mark_reaped();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.tick();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending until something actually kills the child, so a wedged child
|
||||||
|
/// really does make the caller wait out `STOP_GRACE`. No waker is
|
||||||
|
/// registered: under `start_paused` the runtime auto-advances its clock
|
||||||
|
/// when every task is idle, which is exactly what fires the timeout.
|
||||||
|
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send {
|
||||||
|
std::future::poll_fn(move |_cx| {
|
||||||
|
if self.wait_fails {
|
||||||
|
return std::task::Poll::Ready(Err(std::io::Error::other("wait failed")));
|
||||||
|
}
|
||||||
|
if self.is_dead() {
|
||||||
|
self.mark_reaped();
|
||||||
|
std::task::Poll::Ready(Ok(()))
|
||||||
|
} else {
|
||||||
|
std::task::Poll::Pending
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for `EchoCancelGuard`, whose real `Drop` runs `pactl unload`.
|
||||||
|
struct FakeAec(Log);
|
||||||
|
|
||||||
|
impl Drop for FakeAec {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.lock().unwrap().push("aec:unload".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn teardown(log: &Log) -> ScreenshareTeardown<FakeChild, FakeAec> {
|
||||||
|
ScreenshareTeardown::new(Some(FakeAec(log.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The drop/unwind path: field order + ReapOnDrop carry the invariant ---
|
||||||
|
|
||||||
|
/// Mutation gate #5 (remove the reap loop from `ReapOnDrop::drop`).
|
||||||
|
///
|
||||||
|
/// Asserts only that dropping a guard reaps, and reaps *after* killing —
|
||||||
|
/// deliberately says nothing about the AEC, so reversing the struct's field
|
||||||
|
/// order leaves this test green and only the ordering test below fails.
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_guard_kills_and_then_reaps_the_child() {
|
||||||
|
let log = log();
|
||||||
|
drop(ReapOnDrop::new(FakeChild::new(&log, "host"), "host"));
|
||||||
|
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutation gate #4 (reverse the field order of `ScreenshareTeardown`).
|
||||||
|
///
|
||||||
|
/// Asserts only kill-before-unload, so removing the reap loop leaves this
|
||||||
|
/// test green and only the reap test above fails.
|
||||||
|
#[test]
|
||||||
|
fn the_aec_unloads_after_the_children_on_the_drop_path() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||||
|
drop(t);
|
||||||
|
|
||||||
|
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||||
|
let host_kill = position(&log, "host:kill").expect("the host must be killed");
|
||||||
|
let viewer_kill = position(&log, "viewer:kill").expect("the viewer must be killed");
|
||||||
|
assert!(
|
||||||
|
host_kill < unload,
|
||||||
|
"the AEC unloaded while the host was alive: {:?}",
|
||||||
|
entries(&log)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
viewer_kill < unload,
|
||||||
|
"the AEC unloaded while a viewer was alive: {:?}",
|
||||||
|
entries(&log)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole invariant in one sequence, as documentation.
|
||||||
|
#[test]
|
||||||
|
fn the_drop_path_reaps_every_child_before_unloading_the_aec() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
drop(t);
|
||||||
|
assert_eq!(entries(&log), vec!["host:kill", "host:reap", "aec:unload"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The explicit path: ask, then insist ---
|
||||||
|
|
||||||
|
/// A healthy child must be *asked*, never killed. If Stop Share went
|
||||||
|
/// straight to SIGKILL, pixelpass would skip its own cleanup and leak a
|
||||||
|
/// null-sink module every time (design v3.4 §7.4).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_healthy_child_is_asked_to_stop_and_never_killed() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
|
||||||
|
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||||
|
|
||||||
|
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||||
|
assert!(
|
||||||
|
!entries(&log).contains(&"host:kill".to_string()),
|
||||||
|
"a child that honoured the graceful stop must not be killed: {:?}",
|
||||||
|
entries(&log)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ...but a child that ignores the request must not be able to hold the
|
||||||
|
/// session open forever: the grace is bounded and SIGKILL follows.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_wedged_child_is_killed_once_the_grace_expires() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::wedged(&log, "host"));
|
||||||
|
|
||||||
|
// The outer bound turns "the fallback was removed" into a failure
|
||||||
|
// rather than a hung test. Under `start_paused` no real time passes.
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
tokio::time::timeout(Duration::from_secs(60), t.stop_host())
|
||||||
|
.await
|
||||||
|
.expect("a wedged child must not block teardown indefinitely");
|
||||||
|
|
||||||
|
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||||
|
assert!(
|
||||||
|
start.elapsed() >= STOP_GRACE,
|
||||||
|
"the child must actually be given the grace period, waited {:?}",
|
||||||
|
start.elapsed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The assertion above compares elapsed time against `STOP_GRACE` itself,
|
||||||
|
/// so it stays vacuously true if the constant is set to zero — both sides
|
||||||
|
/// move together. Pin the constant independently: the whole point of the
|
||||||
|
/// graceful stop is that pixelpass gets a real interval in which to unload
|
||||||
|
/// its capture sink, and zero is not one.
|
||||||
|
#[test]
|
||||||
|
fn the_grace_is_a_real_interval() {
|
||||||
|
assert!(
|
||||||
|
STOP_GRACE >= Duration::from_millis(500),
|
||||||
|
"too short to let pixelpass tear its pipeline down: {STOP_GRACE:?}"
|
||||||
|
);
|
||||||
|
// ...and short enough that a wedged child cannot visibly stall the core
|
||||||
|
// command loop, which awaits this inline.
|
||||||
|
assert!(
|
||||||
|
STOP_GRACE <= Duration::from_secs(5),
|
||||||
|
"long enough to freeze the UI's command handling: {STOP_GRACE:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hole the whole type exists to close, and the one place the old
|
||||||
|
/// implementation left open: if `shutdown` is cancelled while waiting, the
|
||||||
|
/// child must still be owned, so dropping the guard still kills and reaps.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn cancelling_shutdown_mid_wait_leaves_the_fallback_armed() {
|
||||||
|
let log = log();
|
||||||
|
let mut guard = ReapOnDrop::new(FakeChild::wedged(&log, "host"), "host");
|
||||||
|
|
||||||
|
// Cancel well inside the grace, while it is still waiting.
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(STOP_GRACE / 4, guard.shutdown())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"the wedged child should still have been waiting when we cancelled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
guard.is_armed(),
|
||||||
|
"a cancelled shutdown must not disarm the drop fallback"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The test above only ever cancels during the *graceful* wait, so a
|
||||||
|
/// mutation that disarmed the wrapper between the two waits would survive
|
||||||
|
/// it (round-16 review, P3-3). This one cancels during the post-SIGKILL
|
||||||
|
/// wait — the window where we have already given up on cooperation and the
|
||||||
|
/// `Drop` fallback is the only thing left.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn cancelling_shutdown_after_the_kill_leaves_the_fallback_armed() {
|
||||||
|
let log = log();
|
||||||
|
// Ignores SIGINT, so the grace expires and we reach the kill; then
|
||||||
|
// survives three polls, so the second wait is still pending when we
|
||||||
|
// cancel, and the drop loop still gets to reap it.
|
||||||
|
let mut guard = ReapOnDrop::new(
|
||||||
|
FakeChild::wedged_then_dies_after_polls(&log, "host", 3),
|
||||||
|
"host",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(STOP_GRACE + STOP_GRACE / 4, guard.shutdown())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"we should have been cancelled inside the post-kill wait"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entries(&log),
|
||||||
|
vec!["host:sigint", "host:kill"],
|
||||||
|
"the graceful stop must have expired and escalated before we cancelled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
guard.is_armed(),
|
||||||
|
"cancelling after the kill must not disarm the drop fallback either"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
// The fake's `start_kill` is idempotent, so `Drop` re-signalling an
|
||||||
|
// already-killed child adds no entry; the *reap* is what proves the
|
||||||
|
// fallback ran to completion after we abandoned the wait.
|
||||||
|
assert_eq!(
|
||||||
|
entries(&log),
|
||||||
|
vec!["host:sigint", "host:kill", "host:reap"],
|
||||||
|
"Drop must poll until the child is actually gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed wait is not a reap. Reporting it as one is how the AEC ends up
|
||||||
|
/// unloading over a child that is still alive.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn a_failed_wait_is_not_treated_as_a_confirmed_reap() {
|
||||||
|
let log = log();
|
||||||
|
let mut guard = ReapOnDrop::new(FakeChild::wait_fails(&log, "host"), "host");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
guard.shutdown().await,
|
||||||
|
StopOutcome::Unconfirmed,
|
||||||
|
"a stop we could not confirm must not be reported as a clean one"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!entries(&log).contains(&"host:reap".to_string()),
|
||||||
|
"nothing confirmed the reap: {:?}",
|
||||||
|
entries(&log)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entries(&log).contains(&"host:kill".to_string()),
|
||||||
|
"a child that would not stop must still be escalated: {:?}",
|
||||||
|
entries(&log)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
guard.is_armed(),
|
||||||
|
"an unconfirmed reap must leave the drop fallback armed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Death is not instantaneous, so the drop path has to keep polling. A
|
||||||
|
/// single `try_reap` in place of the loop must not pass.
|
||||||
|
#[test]
|
||||||
|
fn the_drop_path_polls_until_the_child_is_actually_gone() {
|
||||||
|
let log = log();
|
||||||
|
drop(ReapOnDrop::new(
|
||||||
|
FakeChild::reaps_after_polls(&log, "host", 3),
|
||||||
|
"host",
|
||||||
|
));
|
||||||
|
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutation gate #3 (remove the wait after the host kill).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_shutdown_reaps_the_host_before_the_aec_can_unload() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||||
|
|
||||||
|
t.shutdown_children().await;
|
||||||
|
|
||||||
|
// Reaped by the explicit path — before the guard is anywhere near dropped.
|
||||||
|
assert_eq!(
|
||||||
|
entries(&log),
|
||||||
|
vec!["host:sigint", "host:reap", "viewer:sigint", "viewer:reap"],
|
||||||
|
"children must be stopped and reaped by the explicit path"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(t);
|
||||||
|
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||||
|
let host_reap = position(&log, "host:reap").expect("the host must be reaped");
|
||||||
|
assert!(host_reap < unload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_shutdown_is_idempotent_with_the_drop_path() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
t.shutdown_children().await;
|
||||||
|
drop(t);
|
||||||
|
// Exactly one stop and one reap: the drop path must not re-signal a
|
||||||
|
// child the explicit path already took.
|
||||||
|
assert_eq!(
|
||||||
|
entries(&log),
|
||||||
|
vec!["host:sigint", "host:reap", "aec:unload"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Host/viewer bookkeeping ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stop_host_reports_whether_it_was_sharing() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
assert!(!t.is_sharing());
|
||||||
|
assert_eq!(t.stop_host().await, None, "not sharing: nothing to stop");
|
||||||
|
|
||||||
|
t.set_host(FakeChild::new(&log, "host"));
|
||||||
|
assert!(t.is_sharing());
|
||||||
|
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||||
|
assert!(!t.is_sharing());
|
||||||
|
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweeping_drops_only_the_players_that_already_closed() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.push_viewer(
|
||||||
|
"closed".to_string(),
|
||||||
|
FakeChild::already_exited(&log, "closed"),
|
||||||
|
);
|
||||||
|
t.push_viewer("live".to_string(), FakeChild::new(&log, "live"));
|
||||||
|
|
||||||
|
t.sweep_exited_viewers();
|
||||||
|
|
||||||
|
// The live player survives the sweep; only the closed one is dropped,
|
||||||
|
// and dropping it must not kill anything (it was already gone).
|
||||||
|
assert_eq!(t.viewers.len(), 1);
|
||||||
|
assert_eq!(t.viewers[0].0, "live");
|
||||||
|
assert_eq!(entries(&log), vec!["closed:reap"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn re_watching_a_share_replaces_that_player_only() {
|
||||||
|
let log = log();
|
||||||
|
let mut t = teardown(&log);
|
||||||
|
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "a"));
|
||||||
|
t.push_viewer("ticket-B".to_string(), FakeChild::new(&log, "b"));
|
||||||
|
|
||||||
|
assert!(t.replace_viewer("ticket-A").await);
|
||||||
|
assert_eq!(entries(&log), vec!["a:sigint", "a:reap"]);
|
||||||
|
assert_eq!(t.viewers.len(), 1);
|
||||||
|
assert_eq!(t.viewers[0].0, "ticket-B");
|
||||||
|
|
||||||
|
// A share we are not watching has nothing to replace.
|
||||||
|
assert!(!t.replace_viewer("ticket-C").await);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -3,6 +3,22 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
|
||||||
|
// peer music, local playlist tracks) so the screen-share exclusion engine
|
||||||
|
// can recognise it as ours and refuse to fan it back to the far end.
|
||||||
|
//
|
||||||
|
// First statement in the program, and that is load-bearing: this sets an
|
||||||
|
// environment variable, which is only sound while the process is still
|
||||||
|
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
|
||||||
|
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
|
||||||
|
//
|
||||||
|
// SAFETY: nothing has been spawned yet, so no thread can be reading the
|
||||||
|
// environment concurrently.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
unsafe {
|
||||||
|
peerspeak::audio::ownership::tag_this_process_alsa_audio()
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = peerspeak::app::run_gui() {
|
if let Err(e) = peerspeak::app::run_gui() {
|
||||||
eprintln!("Error running GUI: {:?}", e);
|
eprintln!("Error running GUI: {:?}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-3
@@ -10,6 +10,8 @@
|
|||||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||||
//! missing chime should never disrupt a call.
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use crate::audio::ownership;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -264,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn spawn_player(path: &Path) {
|
fn spawn_player(path: &Path) {
|
||||||
for player in ["pw-play", "paplay", "aplay"] {
|
for player in ["pw-play", "paplay", "aplay"] {
|
||||||
let started = Command::new(player)
|
let mut command = Command::new(player);
|
||||||
|
command
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null());
|
||||||
.status();
|
// Ownership tag (plan §5.1). A chime is short, but it is still our
|
||||||
|
// audio on the default sink, and an untagged one is an unowned root
|
||||||
|
// the exclusion engine would have to reason about from scratch.
|
||||||
|
// Measured on this host: all three fallbacks tag correctly, `aplay`
|
||||||
|
// included — it reaches the graph through PipeWire's ALSA plugin,
|
||||||
|
// which honours `PIPEWIRE_PROPS` like any other client.
|
||||||
|
ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
|
||||||
|
let started = command.status();
|
||||||
// `status()` errors only if the player binary isn't present; on a real
|
// `status()` errors only if the player binary isn't present; on a real
|
||||||
// playback error it still returns (non-zero), so a started player ends
|
// playback error it still returns (non-zero), so a started player ends
|
||||||
// the loop either way — we don't want to double-play through fallbacks.
|
// the loop either way — we don't want to double-play through fallbacks.
|
||||||
@@ -313,6 +323,49 @@ mod tests {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
|
||||||
|
/// actually plays produces a live PipeWire node carrying **both**
|
||||||
|
/// ownership carriers.
|
||||||
|
///
|
||||||
|
/// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
|
||||||
|
/// `audio::ownership` proves the environment is built correctly; only a
|
||||||
|
/// live run proves this module *uses* it and that the audio stack honours
|
||||||
|
/// it end to end. The chime is silent (a zero-filled WAV), so running it
|
||||||
|
/// never makes noise.
|
||||||
|
///
|
||||||
|
/// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
|
||||||
|
/// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn notification_chime_node_carries_both_ownership_carriers() {
|
||||||
|
use crate::audio::ownership::{self, live_test};
|
||||||
|
|
||||||
|
let dir = temp_wav_dir("ownership");
|
||||||
|
let path = dir.join("silence.wav");
|
||||||
|
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||||
|
|
||||||
|
set_enabled(true);
|
||||||
|
set_sound_enabled(Sound::PeerJoin, true);
|
||||||
|
play(Sound::PeerJoin, Some(path.to_str().unwrap()));
|
||||||
|
|
||||||
|
let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
|
||||||
|
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
||||||
|
let (name, owned) =
|
||||||
|
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||||
|
assert!(
|
||||||
|
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
owned.as_deref(),
|
||||||
|
Some(ownership::OWNED_PROP_VALUE),
|
||||||
|
"carrier 1 must be on the live node too, not just carrier 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_play_truth_table() {
|
fn test_should_play_truth_table() {
|
||||||
// Plays only when BOTH the master and the per-sound flag are on.
|
// Plays only when BOTH the master and the per-sound flag are on.
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
//! Live-edge catch-up for the screen-share viewer.
|
||||||
|
//!
|
||||||
|
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
|
||||||
|
//! a lossy link (satellite handovers are the pathological case) every loss burst
|
||||||
|
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
|
||||||
|
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
|
||||||
|
//! so the picture ends up seconds behind the host and stays there.
|
||||||
|
//!
|
||||||
|
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
|
||||||
|
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
|
||||||
|
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
|
||||||
|
//! help (~1.38 s, marginally worse) because it only removes pacing at
|
||||||
|
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
|
||||||
|
//! buffer never shrinks. Returning to the live edge requires consuming the
|
||||||
|
//! backlog faster than it arrives.
|
||||||
|
//!
|
||||||
|
//! So we nudge playback slightly faster than realtime while the buffer is deep,
|
||||||
|
//! and drop back to 1x once it has drained. mpv's default pitch correction
|
||||||
|
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
|
||||||
|
//! sped up together A/V sync is preserved — unlike `--untimed`.
|
||||||
|
//!
|
||||||
|
//! The control law and the JSON-IPC message handling are pure functions with
|
||||||
|
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
|
||||||
|
//! socket.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Buffer depth (seconds) above which we start draining.
|
||||||
|
pub const CACHE_HIGH_S: f64 = 1.0;
|
||||||
|
/// Buffer depth (seconds) below which we return to realtime.
|
||||||
|
pub const CACHE_LOW_S: f64 = 0.4;
|
||||||
|
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
|
||||||
|
/// above this the buffer actually is.
|
||||||
|
pub const CACHE_TARGET_S: f64 = 0.5;
|
||||||
|
/// Extra playback rate per second of excess buffer.
|
||||||
|
pub const CATCHUP_GAIN: f64 = 0.05;
|
||||||
|
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
|
||||||
|
/// unnoticeable, and a share that far behind is better served by the operator
|
||||||
|
/// restarting it than by a chipmunk impression.
|
||||||
|
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
|
||||||
|
/// Normal realtime playback.
|
||||||
|
pub const NORMAL_SPEED: f64 = 1.0;
|
||||||
|
/// How often we sample the buffer depth.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
/// Smallest rate change worth sending to the player.
|
||||||
|
pub const SPEED_EPSILON: f64 = 0.005;
|
||||||
|
|
||||||
|
/// The property we watch on the viewer.
|
||||||
|
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
|
||||||
|
|
||||||
|
/// Decide the playback rate for the next interval.
|
||||||
|
///
|
||||||
|
/// Proportional, because a fixed small speedup cannot recover a large backlog in
|
||||||
|
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
|
||||||
|
/// experiences as "still broken". The drain rate instead scales with how deep
|
||||||
|
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
|
||||||
|
/// excursion still gets only a gentle, inaudible nudge.
|
||||||
|
///
|
||||||
|
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
|
||||||
|
/// current rate is held, so a buffer hovering near a single threshold cannot
|
||||||
|
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
|
||||||
|
///
|
||||||
|
/// A non-finite reading (mpv reports `null` before playback starts, and the
|
||||||
|
/// caller maps that to NaN) holds the current rate rather than guessing.
|
||||||
|
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
|
||||||
|
if !cache_s.is_finite() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if cache_s < CACHE_LOW_S {
|
||||||
|
return NORMAL_SPEED;
|
||||||
|
}
|
||||||
|
if cache_s <= CACHE_HIGH_S {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
let excess = cache_s - CACHE_TARGET_S;
|
||||||
|
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where mpv should create its IPC socket. Kept separate from the runtime
|
||||||
|
/// lookup so tests can pin a directory. Pure.
|
||||||
|
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
|
||||||
|
dir.join(format!("peerspeak-mpv-{token}.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory for the IPC socket: the XDG runtime dir when the session
|
||||||
|
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
|
||||||
|
pub fn socket_dir() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `get_property` request for the buffer depth. Pure.
|
||||||
|
pub fn get_cache_request(request_id: u64) -> String {
|
||||||
|
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `set_property` request for the playback rate. Pure.
|
||||||
|
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
|
||||||
|
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the buffer depth from one line of mpv's IPC output.
|
||||||
|
///
|
||||||
|
/// mpv interleaves unsolicited event lines with command replies, so a line is
|
||||||
|
/// only ours when it carries the matching `request_id`. Returns:
|
||||||
|
/// - `Some(Some(secs))` — our reply, with a usable number,
|
||||||
|
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
|
||||||
|
/// playback starts, and reports `error` while the demuxer has no cache yet),
|
||||||
|
/// - `None` — not our reply (an event, or another command's response).
|
||||||
|
///
|
||||||
|
/// Pure.
|
||||||
|
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
|
||||||
|
let id = value.get("request_id")?.as_u64()?;
|
||||||
|
if id != request_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
|
Some(value.get("data").and_then(|d| d.as_f64()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
|
||||||
|
///
|
||||||
|
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
|
||||||
|
/// the player and needs no shutdown signal. Every failure path just ends the
|
||||||
|
/// task: catch-up is an optimization, and a viewer that never gets it still
|
||||||
|
/// plays, exactly as before this existed.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub async fn drive(socket: PathBuf) {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
// mpv creates the socket a moment after exec, so the first connects race it.
|
||||||
|
let mut stream = None;
|
||||||
|
for _ in 0..40 {
|
||||||
|
match UnixStream::connect(&socket).await {
|
||||||
|
Ok(s) => {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(stream) = stream else {
|
||||||
|
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (read_half, mut write_half) = stream.into_split();
|
||||||
|
let mut lines = BufReader::new(read_half).lines();
|
||||||
|
let mut request_id: u64 = 0;
|
||||||
|
let mut speed = NORMAL_SPEED;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
|
||||||
|
request_id += 1;
|
||||||
|
let query = format!("{}\n", get_cache_request(request_id));
|
||||||
|
if write_half.write_all(query.as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip event lines until our reply arrives.
|
||||||
|
let cache = loop {
|
||||||
|
match lines.next_line().await {
|
||||||
|
Ok(Some(line)) => {
|
||||||
|
if let Some(value) = parse_cache_response(&line, request_id) {
|
||||||
|
break value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Socket closed or unreadable: mpv is gone.
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cache = cache.unwrap_or(f64::NAN);
|
||||||
|
let next = catchup_speed(cache, speed);
|
||||||
|
// A proportional law would otherwise re-send on every wobble of the
|
||||||
|
// reading; only a change worth hearing is worth a round trip.
|
||||||
|
if (next - speed).abs() > SPEED_EPSILON {
|
||||||
|
speed = next;
|
||||||
|
request_id += 1;
|
||||||
|
let set = format!("{}\n", set_speed_request(request_id, speed));
|
||||||
|
if write_half.write_all(set.as_bytes()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"livesync: cache {cache:.2}s -> playback speed {speed}x"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
|
||||||
|
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
|
||||||
|
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_rate_scales_with_how_far_behind_we_are() {
|
||||||
|
// The point of the proportional law: a small excursion gets a gentle
|
||||||
|
// nudge, a deep backlog gets real recovery.
|
||||||
|
let small = catchup_speed(1.5, NORMAL_SPEED);
|
||||||
|
let large = catchup_speed(4.0, NORMAL_SPEED);
|
||||||
|
assert!(
|
||||||
|
large > small,
|
||||||
|
"deeper buffer must drain faster: {small} vs {large}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(small - 1.05).abs() < 1e-9,
|
||||||
|
"1.5s buffer -> 1.05x, got {small}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
|
||||||
|
// The ~6 s standing buffer measured on the netem rig, and far worse.
|
||||||
|
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||||
|
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hysteresis_band_holds_the_current_speed() {
|
||||||
|
// Between the marks nothing changes, whichever side we came from —
|
||||||
|
// this is what stops the rate (and audio pitch) oscillating.
|
||||||
|
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
|
||||||
|
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
|
||||||
|
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_cache_holds_the_current_speed() {
|
||||||
|
assert_eq!(
|
||||||
|
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
|
||||||
|
MAX_CATCHUP_SPEED
|
||||||
|
);
|
||||||
|
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_handover_cycle_drains_then_settles() {
|
||||||
|
// Buffer grows through a loss burst, then drains as we play faster.
|
||||||
|
let mut speed = NORMAL_SPEED;
|
||||||
|
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
|
||||||
|
speed = catchup_speed(cache, speed);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
speed, NORMAL_SPEED,
|
||||||
|
"should be back at realtime once drained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requests_are_valid_json_with_their_ids() {
|
||||||
|
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
|
||||||
|
assert_eq!(get["request_id"], 7);
|
||||||
|
assert_eq!(get["command"][0], "get_property");
|
||||||
|
assert_eq!(get["command"][1], CACHE_PROPERTY);
|
||||||
|
|
||||||
|
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
|
||||||
|
assert_eq!(set["request_id"], 8);
|
||||||
|
assert_eq!(set["command"][0], "set_property");
|
||||||
|
assert_eq!(set["command"][1], "speed");
|
||||||
|
assert_eq!(set["command"][2], 1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_our_reply_only() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
|
||||||
|
Some(Some(1.25))
|
||||||
|
);
|
||||||
|
// Another command's reply, and an unsolicited event, are not ours.
|
||||||
|
assert_eq!(
|
||||||
|
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(parse_cache_response("not json", 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reply_without_a_usable_number_is_ours_but_empty() {
|
||||||
|
// mpv before playback starts, and while the demuxer has no cache.
|
||||||
|
assert_eq!(
|
||||||
|
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket_path_is_scoped_to_its_token() {
|
||||||
|
let a = socket_path(Path::new("/run/user/1000"), 42);
|
||||||
|
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
|
||||||
|
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
-33
@@ -21,6 +21,10 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
|
use crate::audio::ownership;
|
||||||
|
|
||||||
|
pub mod livesync;
|
||||||
|
|
||||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||||
|
|
||||||
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||||
@@ -45,6 +49,12 @@ const MAX_TICKET_LEN: usize = 512;
|
|||||||
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||||
const MAX_APP_NAME_LEN: usize = 256;
|
const MAX_APP_NAME_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
|
||||||
|
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
|
||||||
|
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
|
||||||
|
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
|
||||||
|
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
|
||||||
|
|
||||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||||
@@ -80,6 +90,21 @@ pub enum PixelpassEvent {
|
|||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the host's stdout drain forwards to the core over the notice channel.
|
||||||
|
///
|
||||||
|
/// `Eof` is **synthesized here**, not parsed: pixelpass has no "I died" event,
|
||||||
|
/// and a crash can abort across `extern "C"` before any JSON line is written,
|
||||||
|
/// so the stream ending is the only reliable death signal. A read *error*
|
||||||
|
/// counts too — either way the event stream is gone and the host must be
|
||||||
|
/// treated as over.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HostNotice {
|
||||||
|
/// A parsed pixelpass event line.
|
||||||
|
Event(PixelpassEvent),
|
||||||
|
/// The host's stdout ended (EOF or read error). Terminal: nothing follows.
|
||||||
|
Eof,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
|
/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
|
||||||
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
@@ -360,7 +385,10 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
|||||||
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
||||||
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||||
/// drained in a background task so a full pipe can't stall the host. We do
|
/// drained in a background task so a full pipe can't stall the host. The drain
|
||||||
|
/// forwards every parsed event over `notices` and — the part no share may opt
|
||||||
|
/// out of — a terminal [`HostNotice::Eof`] when the stream ends, which is the
|
||||||
|
/// caller's only reliable signal that the host died. We do
|
||||||
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
||||||
/// them, so pixelpass keeps its own defaults in the common case.
|
/// them, so pixelpass keeps its own defaults in the common case.
|
||||||
pub async fn spawn_host(
|
pub async fn spawn_host(
|
||||||
@@ -368,7 +396,7 @@ pub async fn spawn_host(
|
|||||||
audio_app: Option<&str>,
|
audio_app: Option<&str>,
|
||||||
settings: &ScreenShareSettings,
|
settings: &ScreenShareSettings,
|
||||||
quality: ShareQuality,
|
quality: ShareQuality,
|
||||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
|
||||||
) -> std::io::Result<(Child, String)> {
|
) -> std::io::Result<(Child, String)> {
|
||||||
let args = host_args(audio_app, settings, quality);
|
let args = host_args(audio_app, settings, quality);
|
||||||
// Log the exact argv we hand pixelpass so a field log can confirm which
|
// Log the exact argv we hand pixelpass so a field log can confirm which
|
||||||
@@ -423,7 +451,7 @@ pub async fn spawn_host(
|
|||||||
if let Some(stderr) = stderr {
|
if let Some(stderr) = stderr {
|
||||||
drain_stderr_in_background(stderr);
|
drain_stderr_in_background(stderr);
|
||||||
}
|
}
|
||||||
drain_in_background(lines, "host", notices);
|
drain_in_background(lines, "host", Some(notices));
|
||||||
Ok((child, ticket))
|
Ok((child, ticket))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,13 +590,15 @@ where
|
|||||||
|
|
||||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||||
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||||
/// parsed event is also forwarded to the caller (the core, which translates the
|
/// parsed event is also forwarded to the caller (the core), and when the stream
|
||||||
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
|
/// ends — EOF or read error, i.e. the child exited or its event stream broke —
|
||||||
/// stops forwarding, draining continues. The task ends on EOF (child exited).
|
/// a final [`HostNotice::Eof`] is sent so the caller learns the child is gone
|
||||||
|
/// (a host that dies must not stay advertised as sharing). A send failure
|
||||||
|
/// (receiver dropped) just stops forwarding, draining continues.
|
||||||
fn drain_in_background<R>(
|
fn drain_in_background<R>(
|
||||||
mut lines: tokio::io::Lines<BufReader<R>>,
|
mut lines: tokio::io::Lines<BufReader<R>>,
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
notices: Option<tokio::sync::mpsc::UnboundedSender<HostNotice>>,
|
||||||
) where
|
) where
|
||||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
@@ -577,10 +607,14 @@ fn drain_in_background<R>(
|
|||||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||||
if let Some(tx) = ¬ices {
|
if let Some(tx) = ¬ices {
|
||||||
let _ = tx.send(ev);
|
let _ = tx.send(HostNotice::Event(ev));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(tx) = ¬ices {
|
||||||
|
crate::log_msg(&format!("pixelpass {role}: stdout ended"));
|
||||||
|
let _ = tx.send(HostNotice::Eof);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,14 +642,27 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
/// its window closes.
|
/// its window closes.
|
||||||
///
|
///
|
||||||
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
||||||
/// passes mpv's `--untimed`, displaying frames as soon as they decode so a live
|
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
|
||||||
/// share cannot accumulate several seconds in the reliable QUIC/TCP/player
|
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
|
||||||
/// buffer chain. Smooth leaves timestamp pacing enabled, which keeps a shared
|
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
|
||||||
/// video's audio and video synchronized at the cost of extra live latency.
|
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
|
||||||
/// Hardware decoding remains opt-in: forcing `--hwdec=auto` froze some viewers
|
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
|
||||||
/// on frame 1 while audio kept playing.
|
|
||||||
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
||||||
let mpv_args = mpv_args(settings);
|
// One socket per viewer launch, so overlapping shares can't collide on it.
|
||||||
|
// Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
|
||||||
|
// speak, and an unusable socket path on the argv would help nobody.
|
||||||
|
#[cfg(unix)]
|
||||||
|
let ipc_socket = Some(livesync::socket_path(
|
||||||
|
&livesync::socket_dir(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
));
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let ipc_socket: Option<PathBuf> = None;
|
||||||
|
|
||||||
|
let mpv_args = mpv_args(settings, ipc_socket.as_deref());
|
||||||
let vlc_args = vlc_args(settings);
|
let vlc_args = vlc_args(settings);
|
||||||
let first = match settings.player {
|
let first = match settings.player {
|
||||||
SharePlayer::Mpv => ("mpv", &mpv_args),
|
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||||
@@ -626,15 +673,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
|||||||
SharePlayer::Vlc => ("mpv", &mpv_args),
|
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||||
};
|
};
|
||||||
|
|
||||||
let child = match spawn_player(first.0, first.1, url) {
|
let (launched, child) = match spawn_player(first.0, first.1, url) {
|
||||||
Ok(c) => c,
|
Ok(c) => (first.0, c),
|
||||||
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
Err(_) => (
|
||||||
|
second.0,
|
||||||
|
spawn_player(second.0, second.1, url).map_err(|_| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
"no media player found — install mpv or vlc to watch screen shares",
|
"no media player found — install mpv or vlc to watch screen shares",
|
||||||
)
|
)
|
||||||
})?,
|
})?,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only when the socket actually reached the argv: mpv (VLC has no
|
||||||
|
// equivalent IPC) in the Low latency posture. The driver ends by itself when
|
||||||
|
// the player exits, so it needs no shutdown path.
|
||||||
|
#[cfg(unix)]
|
||||||
|
if launched == "mpv"
|
||||||
|
&& settings.buffering == ShareBuffering::LowLatency
|
||||||
|
&& let Some(socket) = ipc_socket
|
||||||
|
{
|
||||||
|
tokio::spawn(livesync::drive(socket));
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = launched;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut child = child;
|
let mut child = child;
|
||||||
let _ = child.wait().await;
|
let _ = child.wait().await;
|
||||||
@@ -642,17 +706,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
/// Build the argv for an mpv viewer.
|
||||||
|
///
|
||||||
|
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
|
||||||
|
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
|
||||||
|
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
|
||||||
|
/// fight on every poll.
|
||||||
|
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
match settings.buffering {
|
match settings.buffering {
|
||||||
ShareBuffering::LowLatency => {
|
ShareBuffering::LowLatency => {
|
||||||
args.push("--profile=low-latency".to_string());
|
args.push("--profile=low-latency".to_string());
|
||||||
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP. If
|
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
|
||||||
// mpv paces a live stream even slightly slower than capture, those
|
// lossy link turns every retransmission into buffered latency that
|
||||||
// downstream buffers retain stale frames and latency grows into
|
// nothing trims back. `--untimed` does NOT fix that (measured
|
||||||
// seconds. Immediate presentation keeps this posture at the live
|
// marginally worse: it only unpaces *presentation*, while audio
|
||||||
// edge; Smooth deliberately retains timestamp pacing for A/V sync.
|
// still drains at 1x, so the backlog never shrinks) — the viewer
|
||||||
args.push("--untimed".to_string());
|
// instead drains it by playing slightly fast, see `livesync`.
|
||||||
args.push("--audio-buffer=0.2".to_string());
|
args.push("--audio-buffer=0.2".to_string());
|
||||||
args.push("--demuxer-readahead-secs=0.5".to_string());
|
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||||
}
|
}
|
||||||
@@ -661,10 +731,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
|||||||
args.push("--demuxer-readahead-secs=2".to_string());
|
args.push("--demuxer-readahead-secs=2".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
|
// The byte cap is what bounds how far behind a viewer can silently fall:
|
||||||
|
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
|
||||||
|
// measured on the netem rig) and call it a buffer. Low latency therefore
|
||||||
|
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
|
||||||
|
// catch-up has less to claw back after a bad patch of link.
|
||||||
|
let cache_mb = match settings.buffering {
|
||||||
|
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
|
||||||
|
ShareBuffering::Smooth => settings.cache_mb,
|
||||||
|
};
|
||||||
|
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
|
||||||
if settings.hardware_decode {
|
if settings.hardware_decode {
|
||||||
args.push("--hwdec=auto".to_string());
|
args.push("--hwdec=auto".to_string());
|
||||||
}
|
}
|
||||||
|
if let Some(socket) = ipc_socket
|
||||||
|
&& settings.buffering == ShareBuffering::LowLatency
|
||||||
|
{
|
||||||
|
args.push(format!("--input-ipc-server={}", socket.display()));
|
||||||
|
}
|
||||||
|
// Extra args stay last so a user override wins over everything above.
|
||||||
args.extend(split_extra_args(&settings.extra_mpv_args));
|
args.extend(split_extra_args(&settings.extra_mpv_args));
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
@@ -706,20 +791,69 @@ fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child>
|
|||||||
// and is not needed to verify the flags. Logged on each attempt, so a
|
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||||
// fallback from the preferred player to the other one is visible too.
|
// fallback from the preferred player to the other one is visible too.
|
||||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||||
Command::new(bin)
|
let mut command = Command::new(bin);
|
||||||
|
command
|
||||||
.args(args)
|
.args(args)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.kill_on_drop(false)
|
.kill_on_drop(false);
|
||||||
.spawn()
|
// Ownership tag (plan §5.1): this player is playing the *incoming*
|
||||||
|
// screenshare's audio, so it is exactly what must not be fanned back out
|
||||||
|
// if this machine also starts sharing. The role is the player binary, so
|
||||||
|
// a `pw-dump` during a field test names which one produced the node.
|
||||||
|
ownership::tag_child(command.as_std_mut(), bin);
|
||||||
|
command.spawn()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
|
||||||
|
/// actually spawns produces a live node carrying **both** ownership
|
||||||
|
/// carriers, tagged with the player's own name as the role.
|
||||||
|
///
|
||||||
|
/// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
|
||||||
|
/// gate does: the plan requires the tag to be shown "landing on a live
|
||||||
|
/// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
|
||||||
|
///
|
||||||
|
/// Live: needs PipeWire, `mpv` and `pw-dump`.
|
||||||
|
/// `cargo test --lib -- --ignored spawned_player`
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
|
||||||
|
async fn spawned_player_node_carries_both_ownership_carriers() {
|
||||||
|
use crate::audio::ownership::live_test;
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("silence.wav");
|
||||||
|
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||||
|
|
||||||
|
let mut child = spawn_player(
|
||||||
|
"mpv",
|
||||||
|
&["--no-video".to_string(), "--really-quiet".to_string()],
|
||||||
|
path.to_str().unwrap(),
|
||||||
|
)
|
||||||
|
.expect("mpv spawns");
|
||||||
|
|
||||||
|
// The role is the player binary, so this also pins that the call site
|
||||||
|
// passes `bin` and not a fixed literal.
|
||||||
|
let prefix = live_test::expected_prefix("mpv");
|
||||||
|
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||||
|
let _ = child.kill().await;
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
|
||||||
|
let (name, owned) =
|
||||||
|
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||||
|
assert!(
|
||||||
|
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||||
// A malicious "ticket" that looks like a flag must end up positional,
|
// A malicious "ticket" that looks like a flag must end up positional,
|
||||||
@@ -830,17 +964,86 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mpv_args_default_matches_low_latency_software_decode() {
|
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mpv_args(&ScreenShareSettings::default()),
|
mpv_args(&ScreenShareSettings::default(), None),
|
||||||
vec![
|
vec![
|
||||||
"--profile=low-latency",
|
"--profile=low-latency",
|
||||||
"--untimed",
|
|
||||||
"--audio-buffer=0.2",
|
"--audio-buffer=0.2",
|
||||||
"--demuxer-readahead-secs=0.5",
|
"--demuxer-readahead-secs=0.5",
|
||||||
"--demuxer-max-bytes=2M",
|
"--demuxer-max-bytes=1M",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
|
||||||
|
let args = mpv_args(
|
||||||
|
&ScreenShareSettings::default(),
|
||||||
|
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
|
||||||
|
"low latency drains a lagging buffer over mpv IPC: {args:?}"
|
||||||
|
);
|
||||||
|
// The flag that used to hold this posture at the live edge measured no
|
||||||
|
// better than pacing, and cost A/V sync — it must not come back.
|
||||||
|
assert!(!args.contains(&"--untimed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
let args = mpv_args(
|
||||||
|
&settings,
|
||||||
|
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
|
||||||
|
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
|
||||||
|
// The cache is a byte budget, so at a given bitrate it sets the
|
||||||
|
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
|
||||||
|
let generous = ScreenShareSettings {
|
||||||
|
cache_mb: 32,
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
mpv_args(&generous, None)
|
||||||
|
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
|
||||||
|
"low latency must bound how far behind the viewer can silently fall"
|
||||||
|
);
|
||||||
|
|
||||||
|
let smooth = ScreenShareSettings {
|
||||||
|
cache_mb: 32,
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
|
||||||
|
"smooth is the posture where the user asked for a deep buffer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_extra_args_still_come_last() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
extra_mpv_args: "--no-osc".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
|
||||||
|
assert_eq!(
|
||||||
|
args.last().map(String::as_str),
|
||||||
|
Some("--no-osc"),
|
||||||
|
"a user override has to win over everything we add: {args:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||||
let settings = ScreenShareSettings {
|
let settings = ScreenShareSettings {
|
||||||
@@ -852,7 +1055,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mpv_args(&settings),
|
mpv_args(&settings, None),
|
||||||
vec![
|
vec![
|
||||||
"--cache=yes",
|
"--cache=yes",
|
||||||
"--demuxer-readahead-secs=2",
|
"--demuxer-readahead-secs=2",
|
||||||
@@ -1197,4 +1400,63 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The host-fault contract, clean-exit half: events are forwarded in order
|
||||||
|
/// and the stream ending yields exactly one terminal [`HostNotice::Eof`],
|
||||||
|
/// after which the drain task drops its sender (the closed channel is what
|
||||||
|
/// ends the core's forwarder). A host that dies silently — EOF swallowed —
|
||||||
|
/// is the S2 defect: the dead share stays advertised in presence.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn drain_forwards_events_then_synthesizes_eof_when_stdout_ends() {
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let (read_half, mut write_half) = tokio::io::duplex(1024);
|
||||||
|
drain_in_background(BufReader::new(read_half).lines(), "test", Some(tx));
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
write_half
|
||||||
|
.write_all(b"{\"event\":\"app_audio\",\"state\":\"routed\"}\nnot json\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(write_half); // child exited: stdout EOF
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await,
|
||||||
|
Some(HostNotice::Event(PixelpassEvent::AppAudioRouted))
|
||||||
|
);
|
||||||
|
// The non-JSON line is dropped, not forwarded.
|
||||||
|
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
|
||||||
|
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host-fault contract, broken-stream half: a read *error* (not a tidy
|
||||||
|
/// EOF) must synthesize the same terminal `Eof` — the event stream is gone
|
||||||
|
/// either way, and only the drain task can tell the core so.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn drain_synthesizes_eof_on_a_read_error_too() {
|
||||||
|
struct BrokenPipe;
|
||||||
|
impl tokio::io::AsyncRead for BrokenPipe {
|
||||||
|
fn poll_read(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
_cx: &mut std::task::Context<'_>,
|
||||||
|
_buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::task::Poll::Ready(Err(std::io::Error::other("stream broke")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
// One good event line, then the stream breaks mid-read.
|
||||||
|
let reader =
|
||||||
|
std::io::Cursor::new(b"{\"event\":\"capture\",\"state\":\"started\"}\n".to_vec())
|
||||||
|
.chain(BrokenPipe);
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
drain_in_background(BufReader::new(reader).lines(), "test", Some(tx));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await,
|
||||||
|
Some(HostNotice::Event(PixelpassEvent::CaptureStarted))
|
||||||
|
);
|
||||||
|
assert_eq!(rx.recv().await, Some(HostNotice::Eof));
|
||||||
|
assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# Screenshare audio exclusion — ownership tagging wire contract.
|
||||||
|
#
|
||||||
|
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
|
||||||
|
# CONSUMES them as the primary taint root of the exclusion engine. Neither
|
||||||
|
# repo depends on the other, so this file is the contract: it is committed
|
||||||
|
# byte-identical in both, and each repo has a test that asserts its own named
|
||||||
|
# constants (and, on the producer side, the environment a real child Command
|
||||||
|
# would carry) match these values exactly.
|
||||||
|
#
|
||||||
|
# peerspeak/tests/fixtures/ownership-tag-contract.txt
|
||||||
|
# pixelpass/tests/fixtures/ownership-tag-contract.txt
|
||||||
|
#
|
||||||
|
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
|
||||||
|
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
|
||||||
|
# is a cross-repo breaking change: both repos must land in the same session,
|
||||||
|
# and the phase 5 matrix must be re-run.
|
||||||
|
#
|
||||||
|
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
|
||||||
|
# matches. Round 8 added the second because a property is invisible to the
|
||||||
|
# PipeWire registry `global` event and readable only via a node bind, so the
|
||||||
|
# primary taint root must not rest on one observation mechanism alone.
|
||||||
|
|
||||||
|
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
|
||||||
|
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
|
||||||
|
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
|
||||||
|
#
|
||||||
|
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
|
||||||
|
# used to accept any value other than "false"/"0", on the theory that leniency
|
||||||
|
# over-excludes and is therefore safe. It is not: leniency buys false-positive
|
||||||
|
# exclusion, and it let any process suppress a rival application's audio from
|
||||||
|
# the share with a property it did not even have to spell right. Fail-closed
|
||||||
|
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
|
||||||
|
# not about parsing.
|
||||||
|
prop_key=peerspeak.owned
|
||||||
|
prop_value=1
|
||||||
|
|
||||||
|
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
|
||||||
|
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
|
||||||
|
# Only the prefix is matched; the rest of the name is for diagnostics.
|
||||||
|
node_name_prefix=peerspeak_owned_
|
||||||
|
node_name_format=peerspeak_owned_<role>_<pid>
|
||||||
|
node_name_example=peerspeak_owned_mpv_31284
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
//! S2 exit gate: a pixelpass host that dies mid-share must be torn down —
|
||||||
|
//! reaped, pulled off presence, `ScreenShareStopped` emitted **before** the
|
||||||
|
//! explanatory error — and a host stopped *deliberately* must NOT produce that
|
||||||
|
//! error when its stdout EOF arrives late (the staleness gate).
|
||||||
|
//!
|
||||||
|
//! Drives the real core loop end to end through `CoreController`, with the
|
||||||
|
//! pixelpass override pointed at fake shell scripts: one that emits a ticket
|
||||||
|
//! and dies, one that emits a ticket and lives until signalled. This is the
|
||||||
|
//! only harness that reaches the core's fault handler — the command loop has
|
||||||
|
//! no unit seam — so these two halves are what kill the "forwarder drops the
|
||||||
|
//! Eof" and "handler ignores the generation" mutants.
|
||||||
|
//!
|
||||||
|
//! Live: joins a real (solo) room, so it needs a working audio backend and
|
||||||
|
//! network access for the endpoint bind.
|
||||||
|
//! `cargo test --test screenshare_host_fault -- --ignored`
|
||||||
|
|
||||||
|
#![cfg(unix)]
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use peerspeak::core::CoreController;
|
||||||
|
use peerspeak::core::messages::{CoreCommand, UiEvent};
|
||||||
|
|
||||||
|
const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
/// How long to listen for events that must NOT arrive. Comfortably past the
|
||||||
|
/// fake host's exit plus the drain/forwarder hop, so a stale fault that WOULD
|
||||||
|
/// be mishandled has arrived by the end of it.
|
||||||
|
const QUIET_WINDOW: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Removes the fake-pixelpass dir even when an assertion panics mid-test
|
||||||
|
/// (a plain trailing `remove_dir_all` never runs on an unwind).
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.0).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_fake_pixelpass(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, body).expect("write fake pixelpass");
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.expect("chmod fake pixelpass");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip events until `pick` matches, panicking after [`EVENT_TIMEOUT`].
|
||||||
|
/// Unrelated events (identity, presence, chat plumbing) flow on this channel
|
||||||
|
/// too, so gates scan rather than assert exact sequences.
|
||||||
|
async fn wait_for<T>(
|
||||||
|
rx: &mut tokio::sync::mpsc::Receiver<UiEvent>,
|
||||||
|
what: &str,
|
||||||
|
mut pick: impl FnMut(&UiEvent) -> Option<T>,
|
||||||
|
) -> T {
|
||||||
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let ev = tokio::time::timeout_at(deadline, rx.recv())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("timed out waiting for {what}"))
|
||||||
|
.unwrap_or_else(|| panic!("ui channel closed waiting for {what}"));
|
||||||
|
if let Some(v) = pick(&ev) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live: joins a real solo room (audio backend + network bind)"]
|
||||||
|
async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
|
||||||
|
let dir_guard =
|
||||||
|
TempDir(std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id())));
|
||||||
|
let dir = dir_guard.0.clone();
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// Half 1's host: emits its ticket, then dies on its own — the S2 defect
|
||||||
|
// scenario. Plain `sleep` (no exec) so the shell itself exits and closes
|
||||||
|
// stdout with no orphan holding the pipe.
|
||||||
|
let dying_host = write_fake_pixelpass(
|
||||||
|
&dir,
|
||||||
|
"pixelpass-dies",
|
||||||
|
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-dies\"}'\nsleep 1\n",
|
||||||
|
);
|
||||||
|
// Half 2's host: lives until signalled. `exec` so the SIGINT from Stop
|
||||||
|
// Share hits the sleep itself — the process dies AND its stdout closes,
|
||||||
|
// which is exactly what makes the late Eof arrive and exercise the
|
||||||
|
// staleness gate rather than vacuously never sending a fault.
|
||||||
|
let living_host = write_fake_pixelpass(
|
||||||
|
&dir,
|
||||||
|
"pixelpass-lives",
|
||||||
|
"#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-lives\"}'\nexec sleep 600\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let controller = CoreController::new(ui_tx);
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||||
|
dying_host.to_string_lossy().into_owned()
|
||||||
|
))));
|
||||||
|
assert!(controller.send(CoreCommand::Join {
|
||||||
|
name: "host-fault-gate".into(),
|
||||||
|
ticket: "create".into(),
|
||||||
|
room_name: "s2".into(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: false,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||||
|
UiEvent::RoomJoined { .. } => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// ── Half 1: the host dies mid-share ─────────────────────────────────────
|
||||||
|
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||||
|
audio_app: None,
|
||||||
|
settings: Default::default(),
|
||||||
|
quality: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStarted (dying host)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStarted => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("share start failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The fake host exits ~1s in. The contract: ScreenShareStopped FIRST (it
|
||||||
|
// clears the UI's sharing state), the explanatory error only after.
|
||||||
|
wait_for(&mut ui_rx, "ScreenShareStopped after host death", |ev| {
|
||||||
|
match ev {
|
||||||
|
UiEvent::ScreenShareStopped => Some(()),
|
||||||
|
// An error arriving first is the exact ordering defect S2 fixes:
|
||||||
|
// the UI would show "sharing" next to the explanation.
|
||||||
|
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
|
||||||
|
UiEvent::Error(e) => Some(e.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
err.contains("unexpectedly"),
|
||||||
|
"the error should say the share ended unexpectedly, got: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Half 2: a deliberate stop must stay clean ───────────────────────────
|
||||||
|
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||||
|
living_host.to_string_lossy().into_owned()
|
||||||
|
))));
|
||||||
|
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||||
|
audio_app: None,
|
||||||
|
settings: Default::default(),
|
||||||
|
quality: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStarted (living host)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStarted => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("second share start failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::StopScreenShare));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStopped after Stop Share",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStopped => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("clean stop produced an error: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The stopped host's stdout EOF is arriving about now as a *stale* fault
|
||||||
|
// (its generation was retired when Stop Share cleared the share). Without
|
||||||
|
// the staleness gate the handler would emit a second ScreenShareStopped
|
||||||
|
// and a spurious "ended unexpectedly" error — listen long enough for that
|
||||||
|
// mishandling to have shown up, and require silence.
|
||||||
|
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||||
|
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||||
|
match ev {
|
||||||
|
UiEvent::ScreenShareStopped => {
|
||||||
|
panic!("stale host fault re-emitted ScreenShareStopped after a clean stop")
|
||||||
|
}
|
||||||
|
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||||
|
panic!("stale host fault surfaced as an error after a clean stop: {e}")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Half 3: a failed room switch while sharing must not cry "crash" ─────
|
||||||
|
// Join tears the old session down (killing the host, deliberately) BEFORE
|
||||||
|
// it validates the ticket, so an invalid ticket exits the Join arm early.
|
||||||
|
// The share must be retired at the teardown itself — left advertised, the
|
||||||
|
// killed host's EOF passes the staleness gate and a spurious "ended
|
||||||
|
// unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
|
||||||
|
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||||
|
audio_app: None,
|
||||||
|
settings: Default::default(),
|
||||||
|
quality: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStarted (before failed switch)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStarted => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("third share start failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(controller.send(CoreCommand::Join {
|
||||||
|
name: "host-fault-gate".into(),
|
||||||
|
ticket: "definitely-not-a-ticket".into(),
|
||||||
|
room_name: "s2".into(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: false,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(&mut ui_rx, "the invalid-ticket error", |ev| match ev {
|
||||||
|
UiEvent::Error(e) if e.contains("invalid room ticket") => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("unexpected error before the ticket error: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// The deliberately-killed host's EOF is arriving about now; it must be
|
||||||
|
// dropped as stale, not reported as a crash.
|
||||||
|
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||||
|
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||||
|
match ev {
|
||||||
|
UiEvent::ScreenShareStopped => {
|
||||||
|
panic!("failed room switch re-emitted ScreenShareStopped for the torn-down share")
|
||||||
|
}
|
||||||
|
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||||
|
panic!("deliberate teardown during a failed room switch reported as a crash: {e}")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S2 presence gate: a host fault must pull the share ticket off PRESENCE —
|
||||||
|
/// what remote peers actually see — and must do it BEFORE the reap wait, not
|
||||||
|
/// after. Nothing on the sharer's own `UiEvent` channel can witness either
|
||||||
|
/// half (presence is only observable from another node), so this test runs a
|
||||||
|
/// real second core as an OBSERVER and asserts the sharer's `PeerState.sharing`
|
||||||
|
/// goes `Some` → `None` on fault.
|
||||||
|
///
|
||||||
|
/// The observer runs in a SEPARATE PROCESS (`presence_probe_helper`, this same
|
||||||
|
/// test binary re-invoked): two in-process cores would load the same
|
||||||
|
/// `identity.key` and collapse into one node id, and swapping `XDG_CONFIG_HOME`
|
||||||
|
/// between spawns in-process races other threads' getenv.
|
||||||
|
///
|
||||||
|
/// The fake host is a WEDGE — it closes stdout (the fault) but ignores SIGINT
|
||||||
|
/// and lives until the SIGKILL fallback — so `stop_host` burns the full 2 s
|
||||||
|
/// grace and TIME becomes the discriminator, exactly like the SIGINT gate:
|
||||||
|
/// with presence-removal-first the observer sees the ticket clear ~1 s after
|
||||||
|
/// it appeared (the wedge's pre-fault lifetime); with the old
|
||||||
|
/// reap-then-presence ordering, only after ~3 s. The bound also makes the
|
||||||
|
/// "presence removal deleted" mutant fail by timeout instead of passing
|
||||||
|
/// vacuously.
|
||||||
|
///
|
||||||
|
/// Live: two real solo-room cores (audio backend + network bind each).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live: two real cores in one room (audio backend + network bind), observer subprocess"]
|
||||||
|
async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
|
||||||
|
/// Mirrors `core::teardown::STOP_GRACE` (private): the wait the wedge
|
||||||
|
/// forces before the SIGKILL fallback reaps it.
|
||||||
|
const STOP_GRACE_MS: u128 = 2000;
|
||||||
|
|
||||||
|
let dir_guard = TempDir(
|
||||||
|
std::env::temp_dir().join(format!("peerspeak-presence-gate-{}", std::process::id())),
|
||||||
|
);
|
||||||
|
let dir = dir_guard.0.clone();
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// Emits its ticket, shares for ~1 s, then closes stdout (the fault) while
|
||||||
|
// staying alive and ignoring SIGINT, so the reap must wait out the grace.
|
||||||
|
// The trailing sleep is NOT exec'd on purpose: it forks after stdout is
|
||||||
|
// closed, so it holds no pipe (the vacuous-staleness trap doesn't apply),
|
||||||
|
// and it merely idles out after the SIGKILL reaps the shell.
|
||||||
|
//
|
||||||
|
// The fake ticket must pass `screenshare::sanitize_ticket` (`endpoint` +
|
||||||
|
// alphanumerics): the OBSERVER's gossip ingest sanitizes peer-advertised
|
||||||
|
// tickets, and a garbage one is nulled to `sharing: None` there — the
|
||||||
|
// probe would never see the share appear and the gate would go vacuous.
|
||||||
|
let wedged_host = write_fake_pixelpass(
|
||||||
|
&dir,
|
||||||
|
"pixelpass-wedges",
|
||||||
|
"#!/bin/sh\ntrap '' INT\n\
|
||||||
|
echo '{\"event\":\"ticket\",\"value\":\"endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm\"}'\n\
|
||||||
|
sleep 1\nexec 1>&-\nsleep 30\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let controller = CoreController::new(ui_tx);
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
|
||||||
|
wedged_host.to_string_lossy().into_owned()
|
||||||
|
))));
|
||||||
|
assert!(controller.send(CoreCommand::Join {
|
||||||
|
name: "presence-gate".into(),
|
||||||
|
ticket: "create".into(),
|
||||||
|
room_name: "s2-presence".into(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: false,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}));
|
||||||
|
let room_ticket = wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||||
|
UiEvent::RoomJoined { ticket, .. } => Some(ticket.clone()),
|
||||||
|
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The observer, in its own process with its own config dir (fresh
|
||||||
|
// identity). It prints `PROBE …` lines this test parses.
|
||||||
|
let probe_config = dir.join("probe-config");
|
||||||
|
std::fs::create_dir_all(&probe_config).unwrap();
|
||||||
|
let probe = tokio::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.args([
|
||||||
|
"presence_probe_helper",
|
||||||
|
"--exact",
|
||||||
|
"--ignored",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("PEERSPEAK_PROBE_TICKET", &room_ticket)
|
||||||
|
.env("XDG_CONFIG_HOME", &probe_config)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn the presence probe");
|
||||||
|
|
||||||
|
// Only share once the probe is in the room, so it witnesses the ticket
|
||||||
|
// APPEARING before the fault clears it (otherwise `Some` → `None` could
|
||||||
|
// both predate its join and the gate would go vacuous).
|
||||||
|
wait_for(&mut ui_rx, "the probe's PeerJoined", |ev| match ev {
|
||||||
|
UiEvent::PeerJoined { .. } => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("waiting for the probe: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||||
|
audio_app: None,
|
||||||
|
settings: Default::default(),
|
||||||
|
quality: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStarted (wedged host)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStarted => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("share start failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Sharer-side contract, unchanged by the reorder: Stopped first, the
|
||||||
|
// explanatory error only after.
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStopped after the wedge faults",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStopped => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
|
||||||
|
UiEvent::Error(e) => Some(e.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
err.contains("unexpectedly"),
|
||||||
|
"the error should say the share ended unexpectedly, got: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = tokio::time::timeout(Duration::from_secs(60), probe.wait_with_output())
|
||||||
|
.await
|
||||||
|
.expect("probe process outlived its budget")
|
||||||
|
.expect("probe process wait");
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"probe failed ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}",
|
||||||
|
out.status
|
||||||
|
);
|
||||||
|
let cleared_ms: u128 = stdout
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("PROBE sharing-cleared "))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
panic!("probe never saw the ticket clear from presence.\nstdout:\n{stdout}")
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.expect("probe delta should be integer millis");
|
||||||
|
// Presence-removal-first: ~1000 ms (the wedge's pre-fault lifetime).
|
||||||
|
// Reap-then-presence: ~3000 ms (lifetime + the full stop grace). The
|
||||||
|
// grace itself splits them with ~1 s of jitter headroom on each side.
|
||||||
|
assert!(
|
||||||
|
cleared_ms < STOP_GRACE_MS,
|
||||||
|
"presence kept advertising the dead share for {cleared_ms} ms after it appeared — \
|
||||||
|
at or past the wedge lifetime + stop grace, i.e. the ticket was only removed \
|
||||||
|
AFTER the reap wait instead of before it"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::Leave));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observer half of `a_host_fault_pulls_the_ticket_off_presence_within_the_grace`,
|
||||||
|
/// run BY that test as a subprocess. Standalone (no `PEERSPEAK_PROBE_TICKET` in
|
||||||
|
/// the env — e.g. a plain `--ignored` sweep) it is a no-op pass.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "helper: spawned by the presence gate as a subprocess; standalone it no-ops"]
|
||||||
|
async fn presence_probe_helper() {
|
||||||
|
let Ok(room_ticket) = std::env::var("PEERSPEAK_PROBE_TICKET") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let controller = CoreController::new(ui_tx);
|
||||||
|
assert!(controller.send(CoreCommand::Join {
|
||||||
|
name: "presence-probe".into(),
|
||||||
|
ticket: room_ticket,
|
||||||
|
room_name: String::new(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: false,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(&mut ui_rx, "RoomJoined (probe)", |ev| match ev {
|
||||||
|
UiEvent::RoomJoined { .. } => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("probe join failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Watch the sharer's presence: record when its `sharing` ticket appears,
|
||||||
|
// report the delta when it clears. Timings on both ends are local-loopback
|
||||||
|
// arrival times, so the parent's bound compares like with like.
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||||
|
let mut seen_at: Option<std::time::Instant> = None;
|
||||||
|
loop {
|
||||||
|
let ev = tokio::time::timeout_at(deadline, ui_rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("probe timed out watching for the sharing transition")
|
||||||
|
.expect("probe ui channel closed");
|
||||||
|
let sharing = match &ev {
|
||||||
|
UiEvent::PeerJoined { state, .. } | UiEvent::PeerUpdated { state, .. } => {
|
||||||
|
state.sharing.is_some()
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
match (&seen_at, sharing) {
|
||||||
|
(None, true) => {
|
||||||
|
seen_at = Some(std::time::Instant::now());
|
||||||
|
println!("PROBE sharing-seen");
|
||||||
|
}
|
||||||
|
(Some(t0), false) => {
|
||||||
|
println!("PROBE sharing-cleared {}", t0.elapsed().as_millis());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(controller.send(CoreCommand::Leave));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The long-owed Stop Share SIGINT gate (0c half (ii)), against the REAL
|
||||||
|
/// pixelpass binary: a Stop Share must end the host through the graceful
|
||||||
|
/// SIGINT path — child exits within [`STOP_GRACE`], no SIGKILL fallback, no
|
||||||
|
/// "couldn't confirm" warning — because SIGKILL would skip pixelpass's own
|
||||||
|
/// teardown (it unloads its capture sink on the way out in sink-owning modes).
|
||||||
|
///
|
||||||
|
/// The fallback is indistinguishable from success in the event stream (both
|
||||||
|
/// end in a confirmed reap), so the discriminator is TIME: the fallback path
|
||||||
|
/// first waits out the full 2 s grace, while a host honouring SIGINT exits in
|
||||||
|
/// milliseconds. The bound asserts the stop completed inside the grace.
|
||||||
|
///
|
||||||
|
/// Live: needs `pixelpass` on `$PATH` plus a real solo room (audio + network).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "live: real pixelpass host + a real solo room (audio backend, network bind)"]
|
||||||
|
async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() {
|
||||||
|
/// Mirrors `core::teardown::STOP_GRACE` (private): the graceful wait
|
||||||
|
/// before the SIGKILL fallback.
|
||||||
|
const STOP_GRACE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let controller = CoreController::new(ui_tx);
|
||||||
|
|
||||||
|
// No override: resolve the real binary from $PATH.
|
||||||
|
assert!(controller.send(CoreCommand::SetPixelpassPath(None)));
|
||||||
|
assert!(controller.send(CoreCommand::Join {
|
||||||
|
name: "sigint-gate".into(),
|
||||||
|
ticket: "create".into(),
|
||||||
|
room_name: "s2".into(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: false,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
|
||||||
|
UiEvent::RoomJoined { .. } => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("join failed: {e}"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Whole-desktop share: no viewers ever connect, so the real host sits idle
|
||||||
|
// after its ticket (capture starts on first viewer) — exactly the state a
|
||||||
|
// Stop Share most often hits.
|
||||||
|
assert!(controller.send(CoreCommand::StartScreenShare {
|
||||||
|
audio_app: None,
|
||||||
|
settings: Default::default(),
|
||||||
|
quality: Default::default(),
|
||||||
|
}));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStarted (real pixelpass)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStarted => Some(()),
|
||||||
|
UiEvent::Error(e) => panic!("real pixelpass host failed to start: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let stop_started = std::time::Instant::now();
|
||||||
|
assert!(controller.send(CoreCommand::StopScreenShare));
|
||||||
|
wait_for(
|
||||||
|
&mut ui_rx,
|
||||||
|
"ScreenShareStopped (real pixelpass)",
|
||||||
|
|ev| match ev {
|
||||||
|
UiEvent::ScreenShareStopped => Some(()),
|
||||||
|
// An Unconfirmed reap surfaces exactly this way; it means the
|
||||||
|
// SIGINT AND the SIGKILL both failed to end the host.
|
||||||
|
UiEvent::Error(e) => panic!("stop of the real host was not clean: {e}"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let elapsed = stop_started.elapsed();
|
||||||
|
assert!(
|
||||||
|
elapsed < STOP_GRACE,
|
||||||
|
"stop took {elapsed:?} — at or past the {STOP_GRACE:?} grace, i.e. the \
|
||||||
|
SIGKILL fallback fired instead of pixelpass honouring SIGINT"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the late stdout EOF from the SIGINTed host must stay silent (same
|
||||||
|
// staleness contract the fake-host half pins).
|
||||||
|
let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
|
||||||
|
while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
||||||
|
match ev {
|
||||||
|
UiEvent::ScreenShareStopped => {
|
||||||
|
panic!("stale fault from the SIGINTed real host re-emitted ScreenShareStopped")
|
||||||
|
}
|
||||||
|
UiEvent::Error(e) if e.contains("unexpectedly") => {
|
||||||
|
panic!("stale fault from the SIGINTed real host surfaced as an error: {e}")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(controller.send(CoreCommand::Leave));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user