Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de |
@@ -4,6 +4,53 @@ All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- **Chat message sounds.** Successful outgoing messages and admitted incoming
|
||||
messages now have distinct notification chimes, each with its own enable
|
||||
toggle and optional custom WAV path in Notifications settings.
|
||||
- **Contact presence sounds.** The home-screen contacts list now announces a
|
||||
contact becoming online or offline. Initial online contacts are announced;
|
||||
initial offline results stay silent. Both events have independent toggles and
|
||||
optional custom WAV paths.
|
||||
- **Notification sound browser.** Every notification event now has a native
|
||||
Browse button for choosing a custom WAV instead of typing its path manually.
|
||||
|
||||
### Changed
|
||||
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
|
||||
for each participant now live behind an **"Advanced audio"** foldout instead
|
||||
of being expanded all the time, so a call with several people no longer fills
|
||||
the panel with sliders. The controls themselves are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
||||
timestamp pacing could let stale frames accumulate across the reliable
|
||||
PixelPass transport until a share was 7–10 seconds behind. Low-latency mode
|
||||
now presents decoded frames immediately; Smooth mode retains timestamp pacing
|
||||
when keeping shared-video audio and video synchronized matters more.
|
||||
|
||||
## [0.6.4] — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4883,6 +4883,7 @@ dependencies = [
|
||||
"image",
|
||||
"iroh",
|
||||
"iroh-gossip",
|
||||
"libc",
|
||||
"opus",
|
||||
"pipewire",
|
||||
"rand 0.10.1",
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
license = "MIT"
|
||||
@@ -109,3 +109,10 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -76,6 +76,14 @@ CHIMES = {
|
||||
"mic-toggle.wav": [(E5, 0.08)],
|
||||
# Reconnect gave up: disappointing low two-note fall.
|
||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
||||
# Our chat message entered the room: a tiny bright acknowledgement.
|
||||
"chat-sent.wav": [(1046.50, 0.06)],
|
||||
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
|
||||
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
|
||||
# A saved contact came online: a light, higher two-note arrival.
|
||||
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
|
||||
# A saved contact went offline: the same tonal family falling away.
|
||||
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
# Implementation plan: whole-desktop screen-share audio without self-echo
|
||||
|
||||
**Status:** 🟢 **v4 — three review rounds applied. Approved to start Phase 0a.**
|
||||
**Date:** 2026-07-21
|
||||
**Design of record:** [`screenshare-audio-exclusion-plan.md`](screenshare-audio-exclusion-plan.md) v3.4 (`8768cd2`), converged round 7.
|
||||
**Scope:** *ordering, gates and acceptance criteria only.*
|
||||
|
||||
**Reference convention.** `v3.4 §N` = the design doc. `plan §N` = this document. The two
|
||||
numbering schemes collide (both have a §11 and a §12) and an unqualified reference in v2 sent
|
||||
Phase 6's most important gate to a section that does not exist. Every cross-reference below is
|
||||
qualified.
|
||||
|
||||
**Review history**
|
||||
| Round | Findings | Outcome |
|
||||
| --- | --- | --- |
|
||||
| 1 | 7 P1 + 5 P2 | 12 accepted, 1 half-rejected → v2. `~/Documents/handoff-docs/Codex/peerspeak/review-2026-07-21-impl-plan-round1.md` |
|
||||
| 2 | 3 P1 + 7 P2 + 1 P3 | all accepted → v3. `…/review-2026-07-21-impl-plan-round2.md` |
|
||||
| 3 | verification pass: 4 of 7 edits landed, 3 partial; 2 P1 + 2 P2 + 1 P3 | all accepted → v4. Approved to start Phase 0a. `…/review-2026-07-21-impl-plan-round3.md` |
|
||||
|
||||
Adjudication in plan §10. Two of my own claims were refuted by Codex with source evidence and
|
||||
two of its claims were refuted or narrowed by mine; both are recorded there rather than
|
||||
quietly dropped.
|
||||
|
||||
---
|
||||
|
||||
## 0. What this plan is optimising for
|
||||
|
||||
The design is converged; the risk has moved from "is it right?" to "will it be built in an
|
||||
order where each mistake is caught while it is still cheap." Three properties drive every
|
||||
ordering decision:
|
||||
|
||||
1. **Nothing that can create an echo runs before the thing that decides eligibility has been
|
||||
validated against a real graph.** The taint engine is the hull. It is built, unit-tested,
|
||||
and floated empty (Phase 5, dry-run) before a single link is created.
|
||||
2. **Every path to unsafe audio is closed structurally before the machinery that could take
|
||||
it is written.** There are **two** such paths, not one — the capture *source* and the
|
||||
capture sink's *inputs*. Both are closed in Phase 0d.
|
||||
3. **Every phase ends in a state that is shippable or trivially revertible**, and every gate
|
||||
is one a broken implementation can *fail*. A gate that cannot fail is not a gate; where a
|
||||
gate asserts only that bad things are absent, it must also assert that good things are
|
||||
present, or "captures nothing at all" passes it.
|
||||
|
||||
The corollary, stated plainly because it is the most likely way this goes wrong: **the
|
||||
temptation will be to write the link manager early**, because it is the visible feature.
|
||||
Fan-out is roughly 400 lines and demos beautifully with a hand-picked node. It is also the
|
||||
component that, shipped ahead of a validated engine, produces exactly the bug this feature
|
||||
exists to prevent — in front of Joe.
|
||||
|
||||
## 0.1 Cross-repo reality
|
||||
|
||||
Two repos, no Cargo dependency; the contract is pixelpass's CLI plus its `--output json`
|
||||
event stream (`peerspeak/src/screenshare/mod.rs:1-14`). The bulk of the work — graph engine,
|
||||
link manager, AEC state machine — is **pixelpass**. peerspeak's share is tagging, argv,
|
||||
capability gating, teardown ordering and the user-visible status surface.
|
||||
|
||||
**Hard ship-order constraint.** peerspeak spawns whatever `pixelpass` resolves on `PATH`. If
|
||||
peerspeak passes `--aec=…` to a pixelpass that predates the flag, clap rejects it and **the
|
||||
share hard-fails** — the documented A23 / audit-P2 skew failure that already governs
|
||||
`--strict-audio` (`screenshare/mod.rs:249-262`).
|
||||
|
||||
> **pixelpass ships the capability first (Phase 7), including the old-peerspeak/new-pixelpass
|
||||
> golden test. peerspeak only passes the new flags to a binary that advertised support
|
||||
> (Phase 8), which owns the new-peerspeak/old-pixelpass golden test. Never "flag present or
|
||||
> absent" as the protocol — v3.4 D5.**
|
||||
|
||||
⚠️ **Capability is resolved twice, against two independently-resolved binaries.**
|
||||
`ListAudioApps` resolves pixelpass and probes it at `core/mod.rs:3375-3378`; `StartScreenShare`
|
||||
resolves it *again* at `:3409-3419`. Between those moments `PATH` or the override can change.
|
||||
Verified in source. Phase 8 must bind the capability result to the resolved path and re-probe
|
||||
if it differs, failing closed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase map and dependency DAG
|
||||
|
||||
| # | Phase | Repo | Mutates graph? | Exit gate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0a | `object.serial` u64 fix | pixelpass | no | boundary parse tests |
|
||||
| 0b | Explicit teardown + drop ordering | peerspeak | no | **four independent mutations** (plan §2; revised from five, §10 r14) ✅ built |
|
||||
| 0c | Graceful stop + connection-owned capture sink | both | sink ownership | two-host SIGKILL live gate **+ SIGINT-first gate** |
|
||||
| 0d | **Typed capture plan + internal mode input** | pixelpass | no | mode matrix; neither unsafe source nor unsafe sink input constructible |
|
||||
| 1 | peerspeak ownership tagging | peerspeak | no | tag on live nodes; literal pinned in plan §3 |
|
||||
| 2 | Graph model + taint engine (pure) | pixelpass | **no PipeWire at all** | v3.4 §12 fixture matrix + degenerate-snapshot case |
|
||||
| 3 | Registry observer + readiness epoch | pixelpass | read-only | six-part gate incl. **PID derivation** |
|
||||
| **3r** | **Observer revision — bind every Node/Device (v3.5 §6.7)** | pixelpass | read-only | **four-part gate (plan §4 "Phase 3 revision")** |
|
||||
| 4 | AEC identity validation state machine | pixelpass | read-only | fake-clock transition matrix |
|
||||
| 5 | **Dry-run audit mode** | pixelpass | read-only | 🚦 **MAJOR GATE** — exact decision partitions (plan §5) |
|
||||
| 6 | Link manager + status events, driven through the real host path | pixelpass | **yes — first mutation** | link-manager matrix (plan §6.1) + live dynamic matrix |
|
||||
| 7 | **Public mode selector** + capability advertisement | pixelpass | no | old-peerspeak/new-pixelpass golden test |
|
||||
| 8 | peerspeak integration (mode flag, argv, picker, UI, status) | peerspeak | no | new/new argv golden (mode **and** `--aec`); new-peerspeak/old-pixelpass golden; causal status delivery |
|
||||
| 9 | Rig upgrade + field matrix | both | yes | 🚦 **SHIP GATE** — plan §7 |
|
||||
|
||||
**Landing DAG** (development may be concurrent; *landing* order may not):
|
||||
|
||||
```
|
||||
0a ──────────────────► 2 ──► 3 ──► 4 ──► 5 ──► 3r ──► 5 (re-run) ──► 6 ──► 7 ──► 8 ──► 9
|
||||
▲
|
||||
0b ──────────────────────────────────────────────────────────────────┤
|
||||
0c ──► 0d ───────────────────────────────────────────────────────────┘
|
||||
1 (r8 carriers) ──────────────────────────────────────► 5 (re-run)
|
||||
```
|
||||
|
||||
⚠️ **Status 2026-07-25 (evening): 3r is BUILT AND MERGED; the re-run has not happened yet.**
|
||||
The phase-5 gate failed on its first live run and put 3r into the DAG; 3r's own four-part
|
||||
gate now passes, including the live prop-recovery row on this host. Phase 5's machinery is
|
||||
built and correct — it is the audit that found the defect, twice — so "5 (re-run)" is a
|
||||
*re-run of the matrix*, not a rebuild. **Phase 6 still does not start** until a passing
|
||||
results file exists. **Phase 1 is a hard prerequisite of the re-run for both carriers**
|
||||
(plan §3).
|
||||
|
||||
⚠️ **A smoke run of the audit against the fixed observer immediately found a second defect
|
||||
(design v3.6 §6.8): a fail-closed `unresolved-ancestry` mark was being promoted to permanent
|
||||
sticky taint.** Fixed in the taint engine (evidence-only sticky pass, 3 new tests,
|
||||
mutation-verified) and merged. Decisions were unaffected — all 57 phase-2 tests passed
|
||||
untouched — so this is a change to what stickiness *remembers*, not to what it *decides*.
|
||||
Note the pattern for the re-run: the matrix rows assert exact partitions, and a stale sticky
|
||||
entry from enumeration would have contaminated every one of them.
|
||||
|
||||
- **0b strictly precedes 6.** v2/v3 drew 0b with no continuing edge. Phase 6 is the first phase
|
||||
that creates objects whose lifetime is tied to pixelpass being alive, so the teardown-ordering
|
||||
guarantee must exist before it: without it, the AEC can unload while a fanning-out pixelpass
|
||||
still holds link proxies and a stale module index (v3.4 §7.1).
|
||||
- **0a strictly precedes 2**: the taint engine's lifetime-awareness (v3.4 §6.1.3) is keyed on
|
||||
`object.serial`; building the model against the current lossy `u32`
|
||||
(`pixelpass/src/host/audio.rs:534-540`; `RouterState::sink_serial` at `:594-598`) means a
|
||||
cross-cutting migration later.
|
||||
- **0c strictly precedes 0d** (round-2): 0d's types must be built around the *final*
|
||||
connection-owned bare sink, not today's `Routing`. Building the type boundary against the
|
||||
legacy pactl sink means rebuilding it when 0c lands.
|
||||
- **1 strictly precedes 5**: without tags the taint engine has no roots and the dry-run can
|
||||
only exercise the forwarder half of the problem.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 0 — prerequisites
|
||||
|
||||
### 0a. `object.serial` u32 truncation — pixelpass
|
||||
Parse as `u64` throughout; audit the other `parse::<u32>` at `:369` (determine whether it is a
|
||||
serial or a genuinely-32-bit value before changing it). Tests: value > `u32::MAX`, and the
|
||||
boundary.
|
||||
|
||||
### 0b. Explicit teardown + drop ordering — peerspeak
|
||||
v3.4 §7.2, decision D4. All **three** of v3.4's fixes:
|
||||
|
||||
1. Replace the implicit-drop path at both channel-close sites. **Citation correction:** v3.4
|
||||
says `core/mod.rs:1516`; the actual `None => break` arms are at **`:1514`** and **`:1532`**.
|
||||
Take the session and `shutdown().await` it.
|
||||
2. Move `echo_cancel` (currently `:682`) to the last declared field, after `screenshare_host`
|
||||
(`:685`), with a comment naming the invariant.
|
||||
3. **Last-ditch drop wrapper**: `start_kill` + a bounded `try_wait` reap on the host before the
|
||||
AEC guard unloads. This is the *only* protection on the panic/unwind path, and unwind is
|
||||
reachable — the core has numerous `unwrap()` sites and no `panic=abort` profile.
|
||||
|
||||
> ✅ **0b IMPLEMENTED 2026-07-26** (peerspeak branch `phase-0b-teardown`). The ordering
|
||||
> defect was live: `echo_cancel` was declared *ahead* of `screenshare_host`, so any unwind
|
||||
> unloaded the AEC while the host was still fanning out. Fields moved into
|
||||
> `src/core/teardown.rs` with `echo_cancel` declared last, and `ReapOnDrop` added because
|
||||
> `kill_on_drop(true)` only *signals* — it hands the child to the runtime's orphan queue,
|
||||
> which an unwinding runtime may never drain. Matrix revised to four mutations; see §10
|
||||
> round 14, and round 15 for the two blocking review findings that followed.
|
||||
>
|
||||
> **Owed to phase 9:** an explicit lifecycle row — *drop the controller / close the command
|
||||
> channel while sharing* — which is the live proof for the hoisted teardown call site.
|
||||
|
||||
⚠️ **Mutation testing: five mutations, each independently breaking a named test.**
|
||||
⚠️ **SUPERSEDED by §10 round 14 — mutation 2 is vacuous and the matrix is now four.** v1 demanded
|
||||
a mutation that targeted the wrong defense; v2 fixed that but bundled two defenses into one
|
||||
combined mutant, which proves neither. Final form:
|
||||
|
||||
| # | Mutation | Must break |
|
||||
| --- | --- | --- |
|
||||
| 1 | remove `shutdown().await` at `:1514` | close-arm-A teardown test |
|
||||
| 2 | remove `shutdown().await` at `:1532` | close-arm-B teardown test |
|
||||
| 3 | remove the explicit **wait** after host kill | explicit-ordering test (host kill+wait strictly precedes AEC unload) |
|
||||
| 4 | reverse the field order | panic/unwind ordering test |
|
||||
| 5 | remove the wrapper reap | panic/unwind ordering test (distinct assertion from #4) |
|
||||
|
||||
Both channel-close arms get their own test; a single "closes the command channel" test can
|
||||
exercise one arm and leave the other unsafe.
|
||||
|
||||
### 0c. Graceful stop + connection-owned capture sink — both
|
||||
|
||||
> ✅ **MECHANISM PROBE PASSED on this host, 2026-07-26** (PipeWire 1.6.8). Run *before* any
|
||||
> structural work, on the reviewer's insistence, because a single unverified assumption could
|
||||
> have invalidated the entire approach: whether a hand-created adapter is visible to
|
||||
> pipewire-pulse under the name pixelpass's capture path depends on. It is.
|
||||
>
|
||||
> ```
|
||||
> pw-cli> create-node adapter factory.name=support.null-audio-sink \
|
||||
> node.name=pixelpass_probe_<pid> media.class=Audio/Sink \
|
||||
> audio.channels=2 audio.position=[FL,FR] node.virtual=true \
|
||||
> monitor.channel-volumes=true object.linger=false
|
||||
> ```
|
||||
>
|
||||
> Five gates, all green:
|
||||
> 1. `pactl list short sinks` shows the sink under the **exact** `node.name`.
|
||||
> 2. `pactl list short sources` shows **`<node.name>.monitor`** — the derived monitor name is
|
||||
> a pipewire-pulse contract, not a property of Pulse-created sinks. This was the one that
|
||||
> could have sunk the approach.
|
||||
> 3. `gst-launch-1.0 pulsesrc device=<node.name>.monitor num-buffers=40 ! fakesink` pulled its
|
||||
> buffers and exited clean, and a real recording stream attached — so the pixelpass capture
|
||||
> path works against it unchanged.
|
||||
> 4. **No null-sink module was loaded** (`pactl list short modules | grep -c null-sink` stayed
|
||||
> at its baseline of 3). It is genuinely not a Pulse module.
|
||||
> 5. **SIGKILL of the owning connection removed both Pulse-visible names**, with zero residue
|
||||
> anywhere in `pw-dump`. That is the entire point of 0c, demonstrated on the real graph.
|
||||
>
|
||||
> The default sink never moved, so this is also safe to run on a live desktop.
|
||||
> **Every O1 stop condition listed for 0c is retired.** `object.linger=false` is load-bearing:
|
||||
> the bundled pipewire-rs example sets `linger=1` for the opposite behaviour.
|
||||
>
|
||||
> ⚠️ **`--repair`'s job does not shrink — it BREAKS.** Discovery derives dead host PIDs
|
||||
> **only** from `module-null-sink` entries (`pixelpass/src/repair.rs`), and only then matches
|
||||
> loopbacks against that PID set. The native sink is scoped to **every mode that owns a
|
||||
> capture sink**, not just `DesktopExcluding`, so legacy Pulse loopbacks will coexist with a
|
||||
> connection-owned sink; when that host dies the sink vanishes automatically and its loopbacks
|
||||
> become **undiscoverable orphans**. Candidate PIDs must be derived independently from all
|
||||
> three module shapes (`null-sink sink_name=`, `loopback sink=`, `loopback source=…monitor`),
|
||||
> with a liveness recheck immediately before each destructive unload. This makes the repair
|
||||
> rework **load-bearing, not defensive**.
|
||||
v3.4 §7.4. The **largest hidden cost in Phase 0**: moving the null sink off `pactl load-module`
|
||||
(`pixelpass/src/host/audio.rs:69`, cleaned up only in `Routing::cleanup` at `:259-260`, which
|
||||
SIGKILL skips) onto a connection-owned PipeWire object.
|
||||
|
||||
- peerspeak: SIGINT (**not** SIGTERM — pixelpass installs only `ctrl_c()`,
|
||||
`pixelpass/src/common/signal.rs:6`), bounded wait, SIGKILL fallback, at `core/mod.rs:699`
|
||||
and `:3480`.
|
||||
- pixelpass: connection-owned sink; `--repair` (`src/repair.rs:15-63`) extended and proven safe
|
||||
with a second live host.
|
||||
|
||||
**Exit gate — two halves. The round-2 finding was that v2 gated only the first.**
|
||||
|
||||
*(i) Ownership,* a live two-host test — connection-ownership is a runtime property no unit test
|
||||
can establish:
|
||||
|
||||
```bash
|
||||
pactl list short sinks | rg 'pixelpass_capture_'
|
||||
pw-dump | jq -r '.[] | select(.type=="PipeWire:Interface:Node") | .info.props as $p
|
||||
| select(($p["node.name"] // "") | startswith("pixelpass_capture_"))
|
||||
| [$p["object.serial"], $p["node.name"]] | @tsv'
|
||||
kill -KILL <first-pixelpass-pid>
|
||||
# re-run both: the killed host's object GONE, the second host's REMAINS
|
||||
pixelpass --repair # the live host must be untouched
|
||||
```
|
||||
|
||||
*(ii) Graceful stop,* which the above does not touch at all — it exercises only external
|
||||
SIGKILL, so Stop Share could remain `child.kill().await` (`core/mod.rs:3480`, still true today)
|
||||
and every command above would pass:
|
||||
- a fake-child signal-order test: SIGINT first, SIGKILL only after the bound expires;
|
||||
- a live Stop Share run: SIGINT sent, child exits within the declared bound, **no fallback
|
||||
kill on the normal path**.
|
||||
|
||||
**O1 is closed: no demotion path.** v1 pre-authorised moving 0c after Phase 6 if it ballooned.
|
||||
That is a waiver of settled decision v3.4 D6 hidden in a sequencing document, which is how a
|
||||
converged design quietly decays. If 0c balloons, **stop and reopen D6 as design round 8.**
|
||||
|
||||
### 0d. Typed capture plan + internal mode input — pixelpass
|
||||
|
||||
Today `setup_audio` returns `(Option<Routing>, String)` (`pipeline.rs:123-142`) and the `String`
|
||||
flows untyped into `build_args` (`:151-157`). There are **two** unsafe paths into the capture,
|
||||
and v2 closed only the first:
|
||||
|
||||
**Path 1 — the source string.** `default_audio_monitor()` has exactly one call site,
|
||||
`pipeline.rs:138`. That single line hands `pulsesrc` the real default monitor.
|
||||
|
||||
**Path 2 — the sink's inputs (round-2 P1, the defect v2 missed).** Even with a type-safe source,
|
||||
`Routing::start` loads `module-loopback source=@DEFAULT_SINK@.monitor → pixelpass_capture_*`
|
||||
whenever it runs outside strict-app mode (`audio.rs:80-92`), and it runs whenever
|
||||
`PIXELPASS_AUDIO_VIA_NULL_SINK` is set (`pipeline.rs:124-125`). So `DesktopExcluding` could
|
||||
correctly read *its own* sink's monitor while the legacy loopback has already filled that sink
|
||||
with the whole-desktop mix — **full echo, with no source switch anywhere.** v3.4 §3 states this
|
||||
loopback must never load in the new mode; nothing structurally enforced it.
|
||||
|
||||
Both are closed by construction:
|
||||
|
||||
- `LegacyDesktop` — the only variant that can produce `DefaultMonitor`.
|
||||
- `PerApp { routing }` — legacy `Routing`, unchanged.
|
||||
- `DesktopExcluding { capture_sink }` — owns a **bare** connection-owned sink type (0c) whose
|
||||
API **cannot construct the legacy loopback at all**. Not "does not call it": the constructor
|
||||
is not reachable from this variant.
|
||||
- **Conflict policy, pinned here rather than discovered later:** the new mode combined with
|
||||
`--app` or `PIXELPASS_AUDIO_VIA_NULL_SINK` **rejects at CLI parse time**. It must never fall
|
||||
through to legacy `Routing`, and it must never silently ignore an input the user set.
|
||||
- **Internal mode input lands here too** (round-2 P1): a non-advertised `HostOpts` field plus a
|
||||
hidden trigger, so the variant is reachable through the real host/spawn path before Phase 6
|
||||
needs to measure through it. `HostOpts` has no mode field today and `setup_audio` selects
|
||||
solely on `app` + the env override.
|
||||
|
||||
**Why 0d is a prerequisite rather than a Phase 6 deliverable** (my divergence from Codex's
|
||||
round-1 suggestion; it agreed in round 2): this is a pure non-mutating refactor of one function,
|
||||
and landing it in Phase 6 means the link manager is written against the untyped API and then
|
||||
refactored underneath itself, while the two most dangerous paths in the codebase stay unguarded
|
||||
through four phases of active work around them. Guardrails go up before the scaffolding.
|
||||
|
||||
**Exit gate:**
|
||||
- mode matrix over every `(app, strict_audio, mode, env-override)` combination asserting the
|
||||
resulting capture plan, including every conflict combination rejecting;
|
||||
- type-level: `DesktopExcluding` can name neither the default monitor nor the legacy loopback;
|
||||
- **graph assertion**: with the new mode active, no default-monitor link or module feeds the
|
||||
capture sink;
|
||||
- legacy behaviour byte-identical.
|
||||
|
||||
A constructible-but-not-yet-public variant is acceptable for the interval between 0d and Phase
|
||||
6 provided it is unit-tested and reachable by the hidden trigger.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1 — peerspeak ownership tagging (v3.4 §5.1)
|
||||
|
||||
Zero behaviour change; it is what makes Phase 5 observable.
|
||||
|
||||
- Native playback: prop on the stream dict, `src/audio/pipewire_impl.rs:374-388`.
|
||||
- mpv/VLC spawn (`src/screenshare/mod.rs:768-775`), notification spawn (`src/notify.rs:265-272`):
|
||||
`PULSE_PROP` + `PIPEWIRE_PROPS` on the `Command`.
|
||||
|
||||
⚠️ **The literal is a cross-repo wire contract and is pinned HERE, before Phase 1 starts** — not
|
||||
deferred with the v3.4 §11 product naming, which is a separate and genuinely user-facing question.
|
||||
|
||||
```
|
||||
key: peerspeak.owned
|
||||
value: 1
|
||||
```
|
||||
|
||||
⚠️ **Round 8 — a SECOND carrier is required, and its literal is pinned here too** (v3.5 §5.1).
|
||||
`peerspeak.owned` is invisible to the registry `global` event and readable only via a node
|
||||
bind (v3.5 §6.7); the prefix below is announced by the registry and needs no bind, so the
|
||||
primary taint root no longer rests on a single observation mechanism.
|
||||
|
||||
```
|
||||
key: node.name
|
||||
format: peerspeak_owned_<role>_<pid> e.g. peerspeak_owned_mpv_31284
|
||||
prefix: peerspeak_owned_ ← the matched literal
|
||||
```
|
||||
|
||||
- **Both carriers are set at every tagging site.** A node is owned if **either** matches —
|
||||
union, the fail-closed direction. The engine's tag root is `peerspeak.owned == 1` **OR**
|
||||
`node.name` starts with `peerspeak_owned_`.
|
||||
- **`node.description` is NOT touched**, so mixers still show "mpv". Only `node.name`, which
|
||||
is the internal identifier, carries the prefix.
|
||||
- The prefix mechanism is already proven here: `pixelpass_capture_*` is matched on
|
||||
`node.name` and was the only root that kept working under the F1 defect.
|
||||
- Same three requirements as the property literal: one named constant per repo, the
|
||||
black-box cross-repo test driven from a shared fixture, and phase 5 as the real proof.
|
||||
- ⚠️ Native call playback sets both on its own stream dict. The child spawns set the prefix
|
||||
through the same `PULSE_PROP` / `PIPEWIRE_PROPS` env that carries the property —
|
||||
`node.name` is settable there, and **the phase-1 exit gate must show it landing on a live
|
||||
mpv node**, not just in the env.
|
||||
|
||||
**A per-repo literal test is not a contract test.** Two tests, one per repo, each maintained
|
||||
beside its own implementation, get updated in lockstep with a rename and prove nothing. Required:
|
||||
|
||||
1. The literal appears **once** per repo as a named constant, commented with a pointer to this
|
||||
section and to the other repo's constant.
|
||||
2. A **black-box cross-repo test**: peerspeak constructs the child `Command`, the test reads the
|
||||
env it would set, and asserts it produces the exact property string pixelpass's engine
|
||||
matches on — driven from a single shared fixture string committed in both repos.
|
||||
3. The real proof is the **Phase 5 dry-run**, which requires pixelpass to classify all three
|
||||
live peerspeak playback paths as `NotEligible` *for the tag reason*. Emission alone proves
|
||||
only that peerspeak talks, not that pixelpass listens.
|
||||
|
||||
**Exit gate:** `pw-dump` shows the tag on a native call playback node, an mpv node and a
|
||||
notification node on this box. (Consumption is gated in Phase 5.)
|
||||
|
||||
**Non-goal:** the known grandchild-inheritance leak (v3.4 §5.1) stays accepted in v1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phases 2–4 — the engine (pixelpass)
|
||||
|
||||
### Phase 2 — graph model + taint engine, pure
|
||||
All of v3.4 §6.1–§6.1.3, **with no PipeWire types in any signature**:
|
||||
|
||||
```
|
||||
fn evaluate(snapshot: &GraphSnapshot, ctx: &ExclusionCtx, prior: &StickyState)
|
||||
-> (Decisions, StickyState)
|
||||
```
|
||||
|
||||
- `GraphSnapshot` = plain owned Node/Port/Link/Client structs keyed on `u64` serial, with the
|
||||
recyclable id retained only as a lookup key, never as identity (v3.4 §6.1.3).
|
||||
- `Decisions` carries `Eligibility::NotEligible { reason }` with **stable reason codes**, not
|
||||
prose. That code is the Phase 5 dry-run output, the Phase 6 JSON status event, and the
|
||||
eventual "why isn't this app shared" answer. Design it once, here.
|
||||
- `StickyState` threaded explicitly, so stickiness is testable as a snapshot sequence.
|
||||
|
||||
**Every row of the v3.4 §12 taint-engine table is a required deliverable**, plus one addition:
|
||||
an **empty/degenerate snapshot must yield "nothing eligible", not "everything eligible"** — the
|
||||
fail-closed default asserted at the boundary.
|
||||
|
||||
**Exit gate:** fixture matrix green; the engine has never been linked against libpipewire.
|
||||
|
||||
### Phase 3 — registry observer + readiness epoch, read-only
|
||||
v3.4 §6.3 and §6.4. Replaces (not extends) the existing router, which watches Node and Metadata
|
||||
adds, forwards raw removals, and binds no graph (`src/host/audio.rs:523-585`).
|
||||
|
||||
> ⚠️ **Built and merged, then superseded in part by "Phase 3 revision (round 8)" below.** This
|
||||
> section's node-property requirements assume the registry `global` event carries them. It does
|
||||
> not (v3.5 §6.7). Everything here about removals, the readiness epoch, PID derivation and the
|
||||
> Link path is unaffected and still holds.
|
||||
|
||||
- Node, Port, Link **and Client** globals; adds **and removes**.
|
||||
- Link endpoint props from the global are the **optimisation**; the bind-`LinkInfoRef` fallback
|
||||
is the correctness path.
|
||||
- Readiness = `core.sync()`/`done` **plus** no outstanding required observations, fail-closed
|
||||
timeout. Log which condition released the epoch.
|
||||
- pipewire-pulse PID derivation (v3.4 §6.1.2): consistent `pipewire.sec.pid` across Pulse
|
||||
clients, validated against `/proc/<pid>/comm`.
|
||||
|
||||
**Exit gate — six parts.** A one-time `pw-dump` diff passes while Port observation is absent,
|
||||
removals are ignored, the fallback is dead code, and readiness releases early:
|
||||
|
||||
| gate | proves |
|
||||
| --- | --- |
|
||||
| adapter tests: add **and remove** of all four object types | removal handling exists |
|
||||
| forced-absent Link endpoint props | the bind fallback is live, not decorative |
|
||||
| unresolved-observer timeout test | readiness fails closed |
|
||||
| readiness does not release with an observation outstanding | the epoch means something |
|
||||
| **PID derivation matrix** (round-2): consistent valid PID · inconsistent PIDs · missing client property · `/proc` entry missing · `comm` mismatch · PID reuse — **every failure makes owner-bridge key 4 unusable** | the pure engine can be correct on a wrong context; this is where the context is built |
|
||||
| **live**: create and destroy a controlled node/link topology; diff Nodes, **Ports**, Links and Clients before/during/after | the adapter tracks a *changing* graph, not a static one |
|
||||
|
||||
### Phase 3 revision (round 8) — bind every Node and Device ✅ BUILT AND MERGED 2026-07-25
|
||||
v3.5 §6.7. Phase 3 shipped reading node properties off the registry `global` event, where
|
||||
**eight of them are never announced**. This is the fix. Scope is the observer only — phases 2
|
||||
and 4 are unaffected, and the phase-5 audit machinery is already correct.
|
||||
|
||||
> **🟢 Done.** Pure core + adapter, split-seam with mutual review as in phase 3 (mine and
|
||||
> Codex's respectively, each reviewing the other). All four gate rows below pass, the live
|
||||
> row on this host. Codex's review of the core found no certain P1; two findings taken and
|
||||
> mutation-verified (a `device_props` ambiguity test that checked for one live *Device*
|
||||
> rather than one live *global*, and `device.api` corroborating by presence). Two findings
|
||||
> left open as design items, both pre-existing — hardware playback-to-capture paths and the
|
||||
> readiness-budget calibration, both recorded in design §6.8.
|
||||
>
|
||||
> **Added beyond the spec: a second live gate for the Device-side path.** Row 1's
|
||||
> `session_device` assertion is satisfied by a union, and WirePlumber 0.5.15 copies
|
||||
> `device.api`/`alsa.driver_name` onto ALSA nodes on this host — so row 1 passes through the
|
||||
> node fallback and would keep passing if the Device bind delivered nothing at all, leaving
|
||||
> §6.7 decision 4 ungated on the development machine. Verified by mutation: breaking the
|
||||
> Device-side driver read fails the new test while row 1 still passes.
|
||||
|
||||
**Requirements.**
|
||||
|
||||
1. **Bind every `Node` global**, unconditionally, no `media.class` filter. Retain the proxy
|
||||
and its `info` listener in that global's slot in the existing per-id FIFO
|
||||
(`LiveGlobal.bound_link` generalises to a bound-proxy slot).
|
||||
⚠️ The phase-3 review's finding 3 — record the id and apply the add as **one** step, so
|
||||
the proxy FIFO stays lockstep with the model's `live_ids` — now applies on the **hottest**
|
||||
path in the observer. A recycled Node id must not pop another generation's proxy.
|
||||
2. **The global is an index; `info` is the source of truth.** Read from the global only what
|
||||
must exist before the bind resolves: `object.serial` (identity), the object's id, and
|
||||
`device.id`/`node.id` linkage. **Every** taint-relevant property — including `node.name`
|
||||
and `media.class`, so there is exactly one source — comes from the bound `info` props.
|
||||
3. **A node with no `info` yet is WITHHELD from the snapshot and is a readiness obligation**
|
||||
(`pending_nodes`, beside `withheld` and `pending_links`). `graph_ready` false while any is
|
||||
outstanding; the existing bounded deadline makes an unresolvable bind sticky-`TimedOut`,
|
||||
fail closed. No provisional-ownership admission, ever (v3.4 §6.1.3).
|
||||
4. **Track props for the node's lifetime.** On a later `info` with `PROPS` in `change_mask`,
|
||||
re-read, re-classify, and apply a `NodePropsUpdated` event.
|
||||
⚠️ **Suppression rule:** a prop update may be dropped **only** when the resulting
|
||||
`Projection` is identical to the current one. Anything looser breaks phase 4's
|
||||
no-coalescing contract; anything stricter (emitting on every `info`, including
|
||||
state-only changes) inflates the O5 event rate with non-events.
|
||||
5. **Bind every `Device` global** and read `device.api` **and** `alsa.driver_name` from its
|
||||
`info` props — authoritative, and the phase-3 review's owed fix (on PipeWire ≥ 1.2.6 with
|
||||
WirePlumber < 0.5.13 the driver name is not copied to the node, and the fail-closed
|
||||
absent-driver rule would over-exclude real cards). `factory.name` exists only on the node.
|
||||
`classify` takes both sides; node values are the fallback, Device values win.
|
||||
6. **Ports are NOT bound in v1 — an explicit accepted limitation.** `port.exclusive` is the
|
||||
only port property missing from the global, and it guards a *mutation* (don't fan out into
|
||||
an exclusive port), not echo: an exclusive port rejects the second link, so phase 6 sees a
|
||||
clean link-create failure it must handle correctly anyway. Binding ~21 more objects at
|
||||
rest to pre-empt an error that surfaces safely is not worth the obligation surface in v1.
|
||||
**Revisit trigger:** any phase-6 link-matrix row where an exclusive-port link failure is
|
||||
not cleanly recoverable. (`node.passthrough`, the *other* half of that §6.2 row, is a node
|
||||
property and **is** recovered by this revision.)
|
||||
|
||||
**Exit gate — four parts.** The first is the direct inverse of the F1 finding.
|
||||
|
||||
| gate | proves |
|
||||
| --- | --- |
|
||||
| **live prop recovery**: a `module-null-sink` tagged `peerspeak.owned=true` plus a `module-loopback` reading its monitor — assert the projection carries `peerspeak.owned`, `pulse.module.id`, `node.link-group` **and** `factory.name`/`device.api`/`alsa.driver_name` on a real ALSA node | the eight properties actually arrive — F1 cannot recur silently |
|
||||
| **pure-model prop-update matrix**: props-changed → re-classified; identical props → suppressed; a `session_device`-relevant change flips classification. (A *live* prop mutation has no reliable CLI trigger — the pure test is the gate, a live sighting is opportunistic) | the lifetime-tracking path exists and its suppression rule is exact |
|
||||
| **readiness with node binds**: no projection reports `graph_ready` while a node bind is outstanding; an `info` that never arrives ends in sticky `TimedOut` | withholding and fail-closed timeout still hold with the new obligation class |
|
||||
| **recycled Node id under churn**: repeated add/remove of the same id; no proxy leak, no cross-generation misattribution | the FIFO lockstep rule survives being moved to the hot path |
|
||||
|
||||
**Then re-run the whole phase-5 §5.1 matrix and re-measure O5** with bind I/O included — the
|
||||
existing numbers were taken on the degraded graph and inherit nothing.
|
||||
|
||||
### Phase 4 — AEC identity validation state machine, read-only
|
||||
v3.4 §5.3 verbatim: `NotConfigured / Validating / Validated / Failed / Revoked`;
|
||||
`--aec=off|pulse-module:<idx>` parsing (D5); bounded deadline; **no fan-out while `Validating`**;
|
||||
revocation = loss of *all* nodes bearing the index, never one leg corking. Foreign
|
||||
`echo-cancel-*` groups: warn and exclude (D3).
|
||||
|
||||
**Moved ahead of the dry-run gate (round-1 P1).** v1 put this after the dry-run while the
|
||||
dry-run checklist required AEC validation and revocation semantics — a circular dependency that
|
||||
made the major gate uncompletable as written.
|
||||
|
||||
**Exit gate — a fake-clock/event-sequence transition matrix**, because these are timing
|
||||
semantics a live poke cannot cover: `Validating → Failed` on deadline expiry; `Validating →
|
||||
Validated` on first matching node; partial-node disappearance ⇒ **stays `Validated`**; all
|
||||
nodes gone ⇒ `Revoked`; `Revoked` stops fan-out and drops proxies; a retained stale index does
|
||||
not alias onto a reloaded module (v3.4 §5.2 correction 3 — indices *are* reused). Parsing: JSON
|
||||
number and string forms, `> u32::MAX`, absent, malformed.
|
||||
|
||||
Then wire the state machine's output into the dry-run so `Validating`/`Failed`/`Revoked` are
|
||||
observable in Phase 5 before they gate anything real.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
|
||||
|
||||
> **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results:
|
||||
> `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible
|
||||
> half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute
|
||||
> 67 µs; readiness 1–2 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13)
|
||||
> and three findings are recorded as non-blocking.
|
||||
>
|
||||
> **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was
|
||||
> unresolvable on this host *permanently*, because stage 1 of the derivation required exactly
|
||||
> one repeated `sec_pid` and **WirePlumber repeats one too** (two Clients, one PID). Key 4's
|
||||
> suppression therefore never fired and every Pulse-emulated node fused into one owner. Fixed
|
||||
> in pixelpass `91c4ded`: probe every distinct `sec_pid` and let `/proc/<pid>/comm` decide.
|
||||
> **The eligible half of row 1 is the only thing that exposed it** — the verdict was
|
||||
> fail-closed and silent.
|
||||
>
|
||||
> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 was the other gate and is now
|
||||
> **closed** (2026-07-26, pixelpass `c78eb2d`: key 4 bounds an owner only when the node's
|
||||
> Client resolves; measured cost on the live graph, zero — see the results file). Phases
|
||||
> 0b/0c/0d and the "Stereo Mix" design call still precede phase 6.
|
||||
>
|
||||
> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a
|
||||
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
|
||||
> row, which reads as a failure that is really a harness error), and **start the audit BEFORE
|
||||
> building the fixture**. Fixture-first makes the whole graph arrive as one enumeration burst,
|
||||
> so every node is first tainted while `graph_ready` is false; that partial-graph taint enters
|
||||
> sticky state and the keyless sticky reason then wins over the evidence-derived one, so a row
|
||||
> cannot assert its own key. Read keys at *derivation* (first non-sticky appearance).
|
||||
|
||||
**Adds no capability. Its entire purpose is to be wrong loudly and safely.**
|
||||
|
||||
A hidden trigger (`PIXELPASS_AUDIO_AUDIT=1`) running Phases 2–4 against the live graph on every
|
||||
graph event, emitting per `Stream/Output/Audio` node: serial, name, decision, stable reason
|
||||
code, graph epoch. It creates **no links**. Output goes to **stderr or a defined JSON event** —
|
||||
never unstructured prose into `--output json`, which peerspeak parses
|
||||
(`screenshare/mod.rs:92`).
|
||||
|
||||
Why this is the gate: the C2/C3-class defects are graph-*reasoning* defects. A fixture proves
|
||||
the code matches my model of PipeWire; only a live run proves my model matches PipeWire. A wrong
|
||||
answer here costs a log line; the same wrong answer in Phase 6 costs an echo.
|
||||
|
||||
### 5.1 Every row asserts an exact partition, not a spot check
|
||||
|
||||
Round 2's sharpest structural point: checking only named targets constrains nothing about
|
||||
everything else, so **each row must assert the complete candidate universe partitioned into
|
||||
exact eligible and excluded sets, with reason codes on the excluded side.** That single
|
||||
requirement is also the answer to O7 — it is the over-exclusion gate, because an
|
||||
exclude-everything implementation fails the eligible half of every row.
|
||||
|
||||
| # | Scenario | Excluded (with reason code) | Eligible |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `module-null-sink` + `module-loopback` forwarder (the v3.4 §6.1 measured shape) | output leg, reason = **owner bridge**, naming the key — *not* a Link walk | same forwarder shape with **no** tainted input |
|
||||
| 1b | *opportunistic, non-gating:* Sunshine's null-sink topology while it is routing desktop audio | its forwarder leg, if a re-emitting leg exists | — |
|
||||
| 2 | `gst-launch pulsesrc ! pulsesink` split clients, input **explicitly rooted on a tainted monitor** | output leg via key 4 | the same process reading an **untainted** source |
|
||||
| 3 | **two** Pulse modules; **one** tainted input | the tainted module's output only | **the other module's output must be ELIGIBLE** — this is what makes wrong pipewire-pulse-PID fusion observable |
|
||||
| 4 | peerspeak native call playback | that node, reason = tag | — |
|
||||
| 5 | peerspeak-spawned **mpv** (watched share) | that node, reason = tag | mpv launched by hand |
|
||||
| 6 | peerspeak **notification** sound | that node, reason = tag | — |
|
||||
| 7 | a **second** pixelpass host's capture sink, **plus a controlled forwarder reading that sink's monitor** | the forwarder's **named output serial** (cycle prevention, v3.4 §6.2) | — |
|
||||
| 8 | EasyEffects running | combined output leg | EasyEffects stopped ⇒ ordinary streams |
|
||||
| 9 | Firefox: music only / mic on untainted source / capturing a tainted monitor | the third only (v3.4 §6.1.1) | the first two |
|
||||
| 10 | sticky taint: tainted input leg removed, output leg lives | still excluded | after full owner teardown + restart |
|
||||
| 11 | recycled serial/index/link-group after teardown | — | must **not** inherit taint |
|
||||
| 12 | AEC loaded, then unloaded | four nodes; then `Revoked` | — |
|
||||
| 13 | `Audio/Duplex` device | over-taints, recorded as **known accepted** (v3.4 §6.1 caveat) | — |
|
||||
|
||||
Rows 3 and 7 were vacuous in v2: row 3 had no tainted module, so incorrect fusion of all
|
||||
pipewire-pulse modules changed no emitted decision; row 7 observed a capture sink without naming
|
||||
a downstream candidate, so recognising `pixelpass_capture_*` as a mere sink name would pass
|
||||
without any transitive propagation.
|
||||
|
||||
### 5.2 Also record, per O5
|
||||
Graph-event rate, recompute duration **distribution and maximum**, and whether events queue
|
||||
behind recompute/logging. v3.4 §6.4's "full recompute is fine for v1" then rests on measured
|
||||
headroom and epoch lag rather than on a node count.
|
||||
|
||||
### 5.3 ⚠️ Do not build a gate on a transient topology
|
||||
v1 leaned on v3.4 §6.1.0's "the hazard is LIVE on this machine right now." **Measured
|
||||
2026-07-21 ~14:55 — no longer true**, six hours after it was written: `Default Sink` is
|
||||
`alsa_output.pci-0000_10_00.6.analog-stereo` (IDLE), all three `sink-sunshine-*` null sinks
|
||||
SUSPENDED. Sunshine is still running (pid 4104) and still reads a monitor — but the hardware
|
||||
sink's, via active link `56 → 95`, not a null sink's. So Sunshine running is **not** sufficient
|
||||
for the topology to be present; see plan §11.
|
||||
|
||||
The **controlled fixture (row 1) is authoritative** — deterministic and always available. But
|
||||
the wild sample is not therefore unnecessary: a fixture I build tests my model against my own
|
||||
assumptions, whereas Sunshine is an uncontrived third-party forwarder nobody designed for this
|
||||
test. It stays as row 1b, **opportunistic and non-gating**, because it cannot be relied on to
|
||||
be present.
|
||||
|
||||
**Any surprise here goes back to the design doc as round 8. Phase 6 does not start until this
|
||||
results file exists.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Phases 6–8 — mutation, capability, integration
|
||||
|
||||
### Phase 6 — link manager + status events, driven through the real host path
|
||||
v3.4 §4.2 + §6.2 + §6.3 items 3–4. Non-lingering links (rig gotcha: `object.linger=false` is
|
||||
*ignored* by `pw-link --props` and `pw-cli create-link`; only `pw-link -m` yields one), proxies
|
||||
retained for the life of the share, per-port link sets, "captured" only when **every** required
|
||||
link is `ACTIVE`, same-epoch revalidation immediately before each creation, proxy drop on
|
||||
ancestry becoming unsafe.
|
||||
|
||||
Failure ⇒ report the stream unsupported. **Never** fall back to the default monitor — and after
|
||||
0d that fallback is unconstructible in this mode, by either path.
|
||||
|
||||
**Everything here is measured through the real selector → sink → link manager → `pulsesrc`
|
||||
path**, using 0d's hidden trigger. A harness-only measurement would pass while the production
|
||||
CLI still reaches only legacy branches.
|
||||
|
||||
**Status events land here** (round-1/2: no phase owned them). pixelpass's event enum
|
||||
(`src/common/output.rs:36-66`) has nothing for exclusion status, and capture-spawn failure
|
||||
(`host/mod.rs:309-312`) replies to the viewer while emitting no event at all. Required as
|
||||
**versioned wire-shaped events**, not stderr lines. Without them the safe failure mode is
|
||||
unexplained silence after the first viewer connects — and a sharer who cannot see why will
|
||||
switch back to unsafe whole-desktop audio.
|
||||
|
||||
There are **four** production causes, and each needs an **exact JSON golden plus a cause →
|
||||
emission test** — not a shared "an event is emitted" assertion, which passes while three of the
|
||||
four remain unwired:
|
||||
|
||||
| cause | event | trigger under test |
|
||||
| --- | --- | --- |
|
||||
| per-stream link failure | `stream_unsupported` | link-matrix row 8c |
|
||||
| AEC validation deadline | `aec_failed` | Phase 4 `Validating → Failed` |
|
||||
| AEC identity lost mid-share | `aec_revoked` | Phase 4 `Validated → Revoked` |
|
||||
| foreign `echo-cancel-*` present (D3) | `foreign_aec_warning` | a second AEC module loaded |
|
||||
|
||||
Phase 8 owns the other half of each: parse, traverse the **new mode's** notice channel, and
|
||||
reach the intended UI state. The channel is currently created only for `audio_app`
|
||||
(`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`).
|
||||
|
||||
#### 6.1 Link-manager matrix (local anchor — v3.4 §12 has only a one-line bullet)
|
||||
|
||||
v2 pointed its most important gate at a "v3.4 §12 bookkeeping matrix" that does not exist.
|
||||
Here it is. Each row is a deterministic test with an injected graph, not a live observation:
|
||||
|
||||
| # | Case | Assertion |
|
||||
| --- | --- | --- |
|
||||
| 1 | graph mutated to tainted **between evaluation and `create_link`** | **zero unsafe `create_link` calls** — not "eventually cleaned up" |
|
||||
| 2 | per-port enumeration | exact set of attempted links and their states |
|
||||
| 3 | partial activation (FL `ACTIVE`, FR not) | **not** reported captured |
|
||||
| 4 | duplicate enumeration of the same node | idempotent; no second link set |
|
||||
| 5 | ancestry becomes unsafe after `ACTIVE` | owned proxies dropped |
|
||||
| 6a | `port.exclusive` port | refused, reason code emitted |
|
||||
| 6b | encoded stream | refused, reason code emitted |
|
||||
| 6c | passthrough (IEC958) stream | refused, reason code emitted |
|
||||
| 7 | capture sink replaced | relink **succeeds** — every required port back to `ACTIVE` and the node reported captured again; stale proxies dropped |
|
||||
| 8a | AEC `Failed` (validation deadline) | plan stays `DesktopExcluding`; no capture |
|
||||
| 8b | AEC `Revoked` mid-share | plan stays `DesktopExcluding`; fan-out stops |
|
||||
| 8c | link creation error | plan stays `DesktopExcluding`; that stream reported unsupported |
|
||||
| 8d | capture-sink creation failure | plan stays `DesktopExcluding`; mode fails, does not degrade |
|
||||
| 8e | readiness-epoch timeout | plan stays `DesktopExcluding`; fail closed |
|
||||
| 9 | an **eligible** late-arriving node | **positively captured** — the over-exclusion counterpart to row 1 |
|
||||
|
||||
Rows 6a–6c were one combined fixture in v3: a single working refusal predicate would have
|
||||
masked two missing ones. Row 7 required only "relink attempted", which a permanently-failing
|
||||
attempt satisfies while v3.4 §4.2 requires a live owner to actually restore links after sink
|
||||
recreation. Rows 8a–8e replace an unenumerated "any failure path", under which testing one
|
||||
handler passes while another silently swaps the plan to `LegacyDesktop`.
|
||||
|
||||
Row 1 is the one v2 could not falsify: "clean → tainted mid-share ⇒ links dropped" can pass by
|
||||
observing eventual removal, while an unsafe link genuinely existed for a window. Row 8 is O8's
|
||||
answer: 0d's enum prevents a `DesktopExcluding` value from *containing* `DefaultMonitor`, but
|
||||
not a failure handler from replacing the whole plan with `LegacyDesktop`, so this needs a
|
||||
release-mode integration test per failure transition. A `debug_assert!` is cheap and worth
|
||||
adding, but it is not a gate.
|
||||
|
||||
Plus the live dynamic matrix (SIGKILL removes owned links; node appearing after share start;
|
||||
sink recreation) and the three-arm leak measurement re-run through the production path with
|
||||
v3.4 §12's rig discipline (`media.class` filter first, never drop stderr, verify the link is
|
||||
in-graph, `parec -d <sink>.monitor`). The deliberately-naive predicate used as that
|
||||
measurement's positive control lives in a **test-only injected implementation**, never a
|
||||
shippable runtime override.
|
||||
|
||||
### Phase 7 — public mode selector + capability advertisement (pixelpass ships first)
|
||||
|
||||
⚠️ **Round-3 P1: nothing in v3 ever promoted the hidden trigger to a public flag.** 0d added an
|
||||
internal mode input; Phase 7 advertised capability and naming; Phase 8 added `--aec`, the picker
|
||||
and status. No phase required the actual **mode selector** to exist publicly or to be passed.
|
||||
The result would be a capability-gated picker entry that, when chosen, still spawns legacy
|
||||
whole-desktop capture — the feature appearing to ship while doing nothing. Reachable: peerspeak's
|
||||
host argv has no mode parameter (`screenshare/mod.rs:152`) and pixelpass's `HostOpts` has no mode
|
||||
field (`cli.rs:153`); v3.4 §11 requires a distinct mode selector.
|
||||
|
||||
So Phase 7 lands **both**:
|
||||
1. the public mode flag (naming per v3.4 §11), replacing the 0d hidden trigger as the production
|
||||
entry point — the hidden trigger may remain for testing;
|
||||
2. D2's **versioned machine-readable** capability response or bitset. Must **not** overload
|
||||
`app_audio_supported: bool` — per-app-strict and desktop-excluding are independent
|
||||
capabilities. `--help` substring probing survives only as the legacy fallback.
|
||||
|
||||
**Old-peerspeak + new-pixelpass golden test lands here, before pixelpass ships**: behaviour
|
||||
byte-identical, absent `--aec` still accepted.
|
||||
|
||||
v3.4 §11 public naming is a **blocking user input at the start of this phase**. Internal typed
|
||||
variant names (0d) do not block on it.
|
||||
|
||||
### Phase 8 — peerspeak integration
|
||||
- `EchoCancelGuard::module_index()` accessor (currently private; only `source_name()` /
|
||||
`sink_name()` exist).
|
||||
- **Emit the public mode flag** when the new picker choice is selected. Gated by an **exact
|
||||
new/new argv golden** that requires *both* the mode flag and `--aec=…` to be present — the
|
||||
round-3 P1. An argv test that checks only `--aec` passes while the mode flag is never sent and
|
||||
pixelpass silently runs legacy capture.
|
||||
- Always pass `--aec=off|pulse-module:<idx>` — absence is not a protocol state (D5).
|
||||
- **Bind capability to the resolved binary path**; re-probe immediately before constructing
|
||||
new-mode argv if resolution differs from the probe's; fail closed. Closes the `:3375-3378`
|
||||
vs `:3409-3419` double-resolution gap.
|
||||
- **New-peerspeak + old-pixelpass golden test** (round-2: the phase map promised "both
|
||||
directions" and only one was specified) — against an old-capability response and an old fake
|
||||
binary: the new picker entry stays absent and **no new flags are emitted**. Path rebinding
|
||||
alone does not test the failure policy.
|
||||
- Parse and surface the Phase 6 status events, with a **causal** test: an event emitted by
|
||||
pixelpass must reach the UI. Matching enums defined independently in both repos would
|
||||
otherwise pass. The notice channel is currently created only when `audio_app` is set
|
||||
(`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`);
|
||||
everything else is logged and lost — so the new mode needs its own channel creation path.
|
||||
- Capability-gated picker entry; wording per v3.4 §11.
|
||||
- Regression: existing `--app` / `--strict-audio` argv byte-identical to today.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 9 — rig upgrade and field tests 🚦 SHIP GATE
|
||||
|
||||
v3.4 §9.2's rig upgrade is **owed before any exclusion claim is published**: two orthogonal
|
||||
PN/MLS probes, windowed per-channel normalised cross-correlation reporting max per-window
|
||||
correlation, plus xrun telemetry. Until it exists the only defensible claim is the gross-leak
|
||||
distinction, in v3.4 §9.2's exact wording.
|
||||
|
||||
**Every row gets a declared pass/fail threshold before the run, not after.** Baseline for all
|
||||
rows: excluded probe ≤ the declared rig criterion; **eligible control audio present**; original
|
||||
playback routes intact; zero surviving owned links or capture sinks after teardown; xrun and CPU
|
||||
within recorded bounds.
|
||||
|
||||
The **full** v3.4 §12 matrix — v1 silently dropped rows 2 and 7:
|
||||
|
||||
1. Sharer in a call while sharing, AEC on **and** off.
|
||||
2. **Sharer simultaneously viewing another share while sharing** (restored). Highest-value test
|
||||
of the child-tag path: mpv playing a watched share while hosting. Reachable —
|
||||
`StartScreenShare` stores a host at `core/mod.rs:3458`, `ViewShare` stores viewer children at
|
||||
`:3525`, no mutual exclusion.
|
||||
3. Lifecycle, each separately: Stop, room leave, UI crash, pixelpass panic, SIGINT, SIGTERM,
|
||||
SIGKILL, last viewer, pipewire-pulse restart, PipeWire daemon restart, `--repair`.
|
||||
4. EasyEffects running for the whole share.
|
||||
5. Output-device switch mid-share via the real `Ctrl+Meta+F` / `Ctrl+Meta+S` scripts.
|
||||
6. Two concurrent hosts; notification mid-share; app that starts playing after the share.
|
||||
7. **Sample-rate / channel / passthrough behaviour on real sinks, and CPU cost** (restored).
|
||||
|
||||
⚠️ **Mid-share taint-root arrival — v3.4 §6.1.4 names an unreachable case, and so did my first
|
||||
replacement.** v3.4 says "AEC-load-mid-share is the case to test": unreachable, because there is
|
||||
exactly one `echo_cancel::enable` site at session join (`core/mod.rs:1850`), the guard moves
|
||||
into `ActiveSession` at `:2729`, and `StartScreenShare` rejects `active_session == None` at
|
||||
`:3397-3404` ("Join a call before sharing your screen") — so the AEC always predates the share.
|
||||
My proposed replacement, "a peer joining creates their playback node," is **also wrong**:
|
||||
peerspeak starts **one mixed playback stream** at session construction (the sole core
|
||||
`start_playback`, `core/mod.rs:1900`), and `PeerJoined` (`:2396-2409`) only admits and connects
|
||||
the sender. No per-peer node is ever created.
|
||||
|
||||
The reachable newly-created mid-share taint roots are: **a notification sound played mid-share**
|
||||
(`notify.rs:265-272`), and **starting to view another share mid-share**, which spawns a tagged
|
||||
mpv/VLC (`screenshare/mod.rs:768-775`). Those are the transition-window field tests. Owned-AEC
|
||||
mid-share load stays a **synthetic** test until a second `enable` site or hot reload arms it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions — final status
|
||||
|
||||
| # | Question | Status |
|
||||
| --- | --- | --- |
|
||||
| O1 | Is 0c a true blocker? | **CLOSED — yes, no demotion path.** The *echo* argument for demoting it is sound and irrelevant: D6 settled it. Balloon ⇒ round 8. |
|
||||
| O2 | Ship the dry-run mode? | **CLOSED — keep**, env-gated, stable reason codes + epoch + serial, stderr or defined JSON event so `--output json` stays clean. |
|
||||
| O3 | Naming | **SPLIT.** The `peerspeak.owned` wire literal is pinned in plan §3 now (a contract, not product wording). Public mode/picker wording remains the **user's call**, blocking at the start of Phase 7 only. |
|
||||
| O4 | Sticky state: engine or observer? | **CLOSED — pure engine.** Observer supplies lifetime-bearing membership/removal facts; the engine decides. |
|
||||
| O5 | Is full recompute really fine? | **CLOSED — measure it in Phase 5**: duration distribution, maximum, and queueing, not a recompute count. |
|
||||
| O6 | Can the default-monitor fallback be made structurally impossible? | **CLOSED — yes, but it took two closures, not one.** Source path *and* sink-input path, both in 0d. Placement 0d rather than Phase 6 was my divergence; Codex agreed in round 2 with the added constraint `0c → 0d`. |
|
||||
| O7 | Does over-exclusion need its own gate? | **CLOSED — subsumed.** Codex correctly narrowed my premise: an exclude-everything build already fails the eligible controls in six Phase 5 rows *provided they are asserted*. Fix is the exact-partition requirement (plan §5.1) plus link-matrix row 9's positive capture assertion. |
|
||||
| O8 | Runtime assertion for "no source switch on failure"? | **CLOSED — 0d's enum is insufficient.** It stops a `DesktopExcluding` value containing `DefaultMonitor`, not a failure handler swapping the whole plan for `LegacyDesktop`. Release-mode integration test per failure transition (link-matrix row 8); `debug_assert!` in addition, but it is not the gate. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risk register
|
||||
|
||||
| Risk | Where it bites | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Engine correct, **source** wrong | full echo, engine bypassed | 0d path 1 — typed capture plan |
|
||||
| Engine correct, **sink inputs** poisoned | full echo, no source switch anywhere | 0d path 2 — bare sink type + conflict rejection + graph assertion |
|
||||
| Taint engine subtly wrong about real PipeWire | Phase 6 leaks the call into the share | Phase 5 exact-partition gate with negative controls |
|
||||
| Unsafe link exists briefly, then is cleaned up | a real leak that "eventual cleanup" tests score as a pass | link-matrix row 1: zero unsafe `create_link` calls |
|
||||
| Link manager built before the engine is validated | same, discovered in front of a viewer | strict DAG; plan §0's stated temptation |
|
||||
| Tag literal mismatch across repos | v3.4 §5.1 silently does nothing, *quietly* | literal pinned in plan §3; cross-repo black-box test; consumption gated in Phase 5 rows 4–6 |
|
||||
| Cross-repo skew | share hard-fails on spawn | pixelpass-first; a golden test in **each** direction; capability bound to resolved path |
|
||||
| Fail-closed with no explanation | user switches back to unsafe whole-desktop audio | causal status-delivery test, Phase 6 → Phase 8 |
|
||||
| Over-exclusion ships as "working" | mode captures silence, all gates pass | exact partitions (plan §5.1) + link-matrix row 9 — **🟢 FIRED 2026-07-25 and worked**: the build *was* the exclude-everything degenerate case, and the empty eligible half is what exposed it |
|
||||
| **A property the engine reads is silently absent at the observation boundary** | engine correct, context permanently `None`; fails in *both* directions at once (F1: no taint root ⇒ echo; F2: no owner key ⇒ exclude everything) | **v3.5 §6.7 — never read node/device props off a registry global.** Phase 3r's live prop-recovery gate asserts each one arrives. General form: `pw-dump` is a **bound** view; the registry is not, and the difference is silent |
|
||||
| Wrong pipewire-pulse PID | mass over-exclusion from a correct engine on a wrong context | Phase 3 PID-derivation matrix |
|
||||
| 0c balloons | prerequisites eat the schedule | reopen D6 as round 8 — no silent waiver |
|
||||
| "It works on my box" | the only box is this box | two-machine field test is the ship gate |
|
||||
|
||||
## 10. Adjudication record
|
||||
|
||||
**Round 14 (2026-07-26) — 0b's five-mutation matrix is revised to four, and one pinned
|
||||
mutation is retired as vacuous.** Reached independently by both reviewers, then agreed.
|
||||
|
||||
- **Mutation 2 cannot be killed by any test, because its site cannot execute.** The
|
||||
best-effort wake arm (`core/mod.rs`, the `besteffort_wake_rx` close arm) is unreachable
|
||||
**by construction, twice over**: (i) `run_core_loop` owns a clone of `besteffort_wake_tx`
|
||||
— created at `CoreController::new` and used for the `has_more` re-arm inside the loop —
|
||||
and a tokio `Receiver::recv()` yields `None` only once *every* sender is dropped; (ii)
|
||||
even without that clone, both `CoreController` and `CoreCommandSender` hold `reliable_tx`
|
||||
alongside the wake sender, and the `select!` is `biased` with the reliable arm first, so
|
||||
the reliable arm always wins the race to exit. Writing teardown there would be code that
|
||||
provably never runs, dressed as a tested path.
|
||||
- **Mutation 1's site is reachable but not unit-testable.** It sits inside `run_core_loop`,
|
||||
which builds a real iroh endpoint and loads identity; no unit test can drive it.
|
||||
- **Decision: (b) + (c).** Teardown is **hoisted to one unconditional site after the loop**,
|
||||
so every `break` is covered structurally, including any added later — strictly better than
|
||||
duplicating teardown across one live arm and one dead one. The **seam-level mutation gates
|
||||
are the real ordering proof**, and the call site's live proof is owed to **phase 9**, which
|
||||
gains an explicit row: *drop the controller / close the command channel while sharing*.
|
||||
"UI crash" is not precise enough to serve as that row.
|
||||
- **Rejected: an integration test built to preserve the number five.** It would pay for a
|
||||
full iroh core plus test-only observability and prove only that a method was called — not
|
||||
the ordering invariant, which is the thing that actually breaks.
|
||||
- The 0b gate is therefore **four mutations**, enumerated exactly (round 16 P3-5 — the earlier
|
||||
wording said "three plus the 0c pair", which reads as five and blurred what 0b owns):
|
||||
|
||||
| # | mutation | killed by | status |
|
||||
|---|----------|-----------|--------|
|
||||
| 1 (old gate 3) | remove the wait after the host kill | `explicit_shutdown_reaps_the_host_before_the_aec_can_unload` | killed now |
|
||||
| 2 (old gate 4) | reverse `ScreenshareTeardown`'s field declaration order | `the_aec_unloads_after_the_children_on_the_drop_path` | killed now |
|
||||
| 3 (old gate 5) | remove the reap loop from `ReapOnDrop::drop` | `dropping_a_guard_kills_and_then_reaps_the_child` | killed now |
|
||||
| 4 (old 1) | remove the teardown at the hoisted post-loop call site | — | **deferred to the phase-9 row** *drop the controller / close the command channel while sharing* |
|
||||
|
||||
4-vs-5 separation verified: reversing the field order leaves the reap test green, and
|
||||
removing the reap loop leaves the ordering test green. **0c's own pair (no SIGINT · no
|
||||
SIGKILL fallback) is counted under 0c, not here**, along with the round-15/16 additions
|
||||
(disarm the wrapper at entry · disarm it between the waits · treat a wait error as a reap ·
|
||||
report an unconfirmed stop as clean · zero the grace).
|
||||
|
||||
**Round 15 (2026-07-26) — review of the 0b/0c-peerspeak implementation returned two blocking
|
||||
findings, both accepted.** Recorded because both are the same shape: a defence that existed
|
||||
but was disarmed exactly when it was needed.
|
||||
|
||||
- **The drop fallback was disarmed across its own wait.** `shutdown` took the child out of
|
||||
the wrapper before the first `.await`; a cancellation or unwind during the wait left the
|
||||
raw child to drop with `kill_on_drop` (which signals without reaping) while `Drop` found
|
||||
`None`. The child now stays owned until the reap is **confirmed**.
|
||||
- **A failed wait was reported as a reap, and the hard-kill wait was unbounded.** The
|
||||
`io::Result` was discarded, so a wait error returned "reaped"; and a process in
|
||||
uninterruptible sleep after SIGKILL could wedge the core loop forever. Both waits are now
|
||||
bounded and the conflict case has a written policy: availability wins, the child stays
|
||||
owned so the bounded `Drop` retry stays armed, and the residual risk is logged.
|
||||
- **A gate of mine was vacuous and the review's fourth test-double point caught it.** The
|
||||
elapsed-time assertion compared against `STOP_GRACE` itself, so zeroing the constant left
|
||||
it trivially true. `the_grace_is_a_real_interval` now pins the constant to a band.
|
||||
|
||||
**Round 16 (2026-07-26) — the re-review of the 0b/0c-peerspeak fixes returned *approve with
|
||||
follow-ups*: no blocking findings, five P3s, all five applied before the merge.** The two that
|
||||
carry design content:
|
||||
|
||||
- **An unconfirmed stop was reported to the user as a clean one.** `stop_host` returned a bare
|
||||
"was sharing" bool, so the one case where availability-first gives up (SIGKILL queued, reap
|
||||
never confirmed) still emitted `ScreenShareStopped` with no warning — the UI would say
|
||||
sharing ended while pixelpass might still be fanning out. `ReapOnDrop::shutdown` now returns
|
||||
`StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an `Unconfirmed` user-initiated
|
||||
stop raises a UI error naming the stray process. Session/viewer teardown discards the outcome
|
||||
on purpose: no user is waiting on an answer there and the risk is already logged.
|
||||
- **Cancellation coverage only reached the graceful wait.** The mid-wait test could not kill a
|
||||
mutant that disarmed the wrapper *between* the two waits. Verified: the naive form of that
|
||||
mutant does not compile (the child is borrowed from `self`), but the restructured form —
|
||||
`self.child.take()` once cooperation has failed — compiles, and the pre-existing test passes
|
||||
it. `cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.
|
||||
|
||||
**Deferred item — aggregate teardown latency (round 16 P3-4).** Bounds are per child, not per
|
||||
teardown. Sequential drain gives `2 × STOP_GRACE` per unconfirmed child inline (≈4 s), plus
|
||||
`REAP_BUDGET` (250 ms) per child on the `Drop` path: three wedged children ≈6 s of command-loop
|
||||
stall, ≈12.75 s worst case including drop retries. Accepted as-is for 0b — one host plus one or
|
||||
two viewers is the real shape, and concurrency here would mean detaching children from the
|
||||
session that owns the AEC's lifetime. **Trigger to revisit: a fourth tracked child becomes
|
||||
routine, or a measured teardown exceeds 5 s.** The fix, when triggered, is to drain viewers
|
||||
concurrently while still owned by `shutdown_children` — not to detach them.
|
||||
|
||||
**Round 1 — 13 items, 12 accepted.** Phase reorder (AEC machine before dry-run); typed capture
|
||||
plan (accepted, moved *earlier* than proposed); Phase 3 five-part gate; tag-consumption gating;
|
||||
Phase 6 matrix mandatory; 0b unwind backstop restored **and my mutation test corrected — it
|
||||
targeted the wrong mutation**; Phase 9 rows restored with pre-declared thresholds; DAG stated;
|
||||
O1 demotion language removed; 0c two-host gate; skew tests moved to Phase 7; status events
|
||||
assigned. **Half-rejected:** "the Sunshine topology is unverified and unnecessary" — *unverified*
|
||||
was right and it has since flipped; *unnecessary* rejected, retained as non-gating row 1b.
|
||||
|
||||
**Round 2 — 11 items, all accepted.** The three that mattered:
|
||||
|
||||
- **P1, the sink-input path.** My 0d closed the source and left the capture sink's inputs open;
|
||||
`PIXELPASS_AUDIO_VIA_NULL_SINK` + `Routing::start` would have filled the owned sink with the
|
||||
whole-desktop mix and produced full echo with no source switch. This is the "at least one
|
||||
comparable error" I asked round 2 to find, and it was in the fix for round 1's headline P1.
|
||||
- **P2, my v3.4 §6.1.4 replacement was also unreachable.** I claimed a peer joining creates their
|
||||
playback node; verified false — one mixed playback stream at session construction
|
||||
(`core/mod.rs:1900`), `PeerJoined` only admits the sender. Replaced with notification sound and
|
||||
watched-share start, both reachable.
|
||||
- **P1, no invocable production selector**, so Phase 6's "production path" measurement would have
|
||||
run through a harness. Internal mode input moved into 0d.
|
||||
|
||||
**Round 3 — verification pass, 5 items, all accepted.** It confirmed 4 of the 7 round-2 edits
|
||||
landed and 3 were partial, which is the reason to run a verification round at all rather than
|
||||
declaring the fixes done. The one that mattered:
|
||||
|
||||
- **P1, the public mode selector was never assigned to any phase.** 0d added a hidden trigger,
|
||||
Phase 7 added capability + naming, Phase 8 added `--aec` — and nothing required the mode flag
|
||||
itself to exist publicly or be passed. A capability-gated picker entry would have appeared and,
|
||||
when chosen, spawned legacy whole-desktop capture: the feature shipping while doing nothing,
|
||||
with the echo intact. Fixed in Phase 7 (flag) and Phase 8 (emission + new/new argv golden).
|
||||
- Three link-matrix rows I had just written were insufficiently falsifiable — a combined
|
||||
exclusive/encoded/passthrough fixture (one working predicate masks two missing), "relink
|
||||
attempted" (a permanently-failing attempt passes), and an unenumerated "any failure path".
|
||||
Split into 6a–6c, a success assertion, and 8a–8e.
|
||||
- Status delivery was gated by one generic causal test that passes while three of the four
|
||||
events stay unwired. Now four exact JSON goldens with named triggers.
|
||||
- `0b` was drawn in the DAG with no outgoing edge; it now explicitly precedes Phase 6.
|
||||
|
||||
**Codex's positions I narrowed:** it agreed the Sunshine sample is worth keeping as non-gating,
|
||||
and corrected my wording — the topology appears when Sunshine *routes desktop audio through its
|
||||
null-sink topology*, not merely whenever Sunshine is running, since I measured it running
|
||||
without that topology. It also correctly narrowed O7's premise (an exclude-everything build does
|
||||
already fail six rows' eligible controls, *if* asserted) while agreeing the exact-partition fix
|
||||
is right.
|
||||
|
||||
**Verified by me before accepting:** the single `default_audio_monitor` call site
|
||||
(`pipeline.rs:138`); the double binary resolution (`core/mod.rs:3375-3378` vs `:3409-3419`); the
|
||||
no-session guard on `StartScreenShare` (`:3397-3404`); the sole core `start_playback` (`:1900`)
|
||||
and `PeerJoined`'s scope (`:2396-2409`); and the current default-sink/Sunshine graph state.
|
||||
|
||||
## 11. Corrections owed to the design doc — ✅ APPLIED in v3.5 (round 8, 2026-07-25)
|
||||
|
||||
Both are now in the design doc (§6.1.0 and §6.1.4 respectively), alongside round 8's own
|
||||
finding (§6.7, the observation boundary). Kept here as the record of what was owed and why:
|
||||
|
||||
1. **v3.4 §6.1.0's "🔴 the hazard is LIVE on this machine right now" is time-dependent and has
|
||||
already flipped.** Measured 2026-07-21 ~14:55 (details in plan §5.3). The reachability
|
||||
argument is unaffected — the topology appears **when Sunshine routes desktop audio through
|
||||
its null-sink topology**, which is narrower than "whenever Sunshine is running," since it was
|
||||
measured running without it. Nothing should gate on its presence.
|
||||
2. **v3.4 §6.1.4's nominated test case is unreachable, and so was my first replacement.**
|
||||
Details in plan §7. The conclusion (the transition window exists only for newly-created
|
||||
roots) stands; the example must become the notification sound or watched-share start.
|
||||
|
||||
## 12. Not in this plan
|
||||
|
||||
**Round 8 additions:** **port binding** (so `port.exclusive` is never observed — plan §4 "Phase
|
||||
3 revision" item 6, with its revisit trigger), **per-node quarantine** (an unresolvable node
|
||||
bind fails the whole readiness epoch closed instead of isolating that one node — v3.5 §6.7
|
||||
decision 3), and the **serial-continuity signal** for the AEC validator's no-coalescing
|
||||
contract (phase 4's owed F4 hardening).
|
||||
|
||||
Everything v3.4 §14 lists as out of v1 — port-granular taint, timed drain, hot-AEC-reload epoch
|
||||
protocol, native PipeWire AEC, incremental dirty-set, seamless daemon-restart recovery — plus
|
||||
v3.4 §10 items 2 and 3 (per-app debt; D6 says they do not block Option C), the v3.4 §5.1
|
||||
grandchild leak, and the general "AEC binds to the default sink when no device is pinned" defect
|
||||
(v3.4 §6.1.0, resolved for this user, own task).
|
||||
@@ -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
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.4"
|
||||
#define MyAppVersion "0.6.6"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+363
-92
@@ -700,6 +700,8 @@ pub enum AppMessage {
|
||||
PeerPanChanged(EndpointId, f32),
|
||||
PeerGateChanged(EndpointId, f32),
|
||||
PeerEqChanged(EndpointId, EqBand, f32),
|
||||
/// Show or hide the secondary audio controls on one participant card.
|
||||
TogglePeerAdvancedAudio(EndpointId),
|
||||
/// Toggle local mute of a peer (silence them just for us).
|
||||
TogglePeerMute(EndpointId),
|
||||
InputDeviceSelected(AudioDevice),
|
||||
@@ -758,6 +760,10 @@ pub enum AppMessage {
|
||||
ToggleNotifications(bool),
|
||||
ToggleEchoCancellation(bool),
|
||||
CustomSoundPathChanged(Sound, String),
|
||||
/// Open a native WAV picker for one notification event.
|
||||
BrowseCustomSound(Sound),
|
||||
/// Result of the notification WAV picker (`None` = cancelled).
|
||||
CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
|
||||
/// Toggle the per-sound enable flag for a single chime (W6).
|
||||
ToggleSoundEnabled(Sound, bool),
|
||||
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
||||
@@ -1047,6 +1053,9 @@ pub struct AppState {
|
||||
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
locally_muted: HashSet<EndpointId>,
|
||||
/// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
|
||||
/// a fresh room starts compact, regardless of the previous room's UI state.
|
||||
peer_audio_expanded: HashSet<EndpointId>,
|
||||
/// When we joined the current room, for the in-room call-duration timer.
|
||||
call_started: Option<std::time::Instant>,
|
||||
/// Whether a local call recording is in progress (confirmed by the core).
|
||||
@@ -1240,6 +1249,7 @@ impl AppState {
|
||||
self.audio_levels.clear();
|
||||
self.conn_stats.clear();
|
||||
self.locally_muted.clear();
|
||||
self.peer_audio_expanded.clear();
|
||||
self.chat_messages.clear();
|
||||
// Unsent queue + retry bytes die with the room's transcript. The pacer
|
||||
// and id counter deliberately survive: receivers' per-author buckets
|
||||
@@ -1333,16 +1343,20 @@ impl AppState {
|
||||
/// so Retry can re-dispatch, unless the entry is already gone (history
|
||||
/// eviction / room reset), in which case the payload is dropped so its map
|
||||
/// can't leak. Either way an id with no matching entry is a harmless no-op.
|
||||
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
|
||||
/// Returns `true` only when a successful result matched a live local echo,
|
||||
/// which is the boundary used for the outgoing-message notification.
|
||||
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
|
||||
match error {
|
||||
None => {
|
||||
self.set_send_status(local_id, SendStatus::Broadcast);
|
||||
let matched = self.set_send_status(local_id, SendStatus::Broadcast);
|
||||
self.send_payloads.remove(&local_id);
|
||||
matched
|
||||
}
|
||||
Some(e) => {
|
||||
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
||||
self.send_payloads.remove(&local_id);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1373,9 +1387,30 @@ impl AppState {
|
||||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
||||
Sound::ChatSent => &self.config.custom_sound_chat_sent,
|
||||
Sound::ChatReceived => &self.config.custom_sound_chat_received,
|
||||
Sound::ContactOnline => &self.config.custom_sound_contact_online,
|
||||
Sound::ContactOffline => &self.config.custom_sound_contact_offline,
|
||||
};
|
||||
opt.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
|
||||
match sound {
|
||||
Sound::SelfJoin => self.config.custom_sound_self_join = path,
|
||||
Sound::PeerJoin => self.config.custom_sound_peer_join = path,
|
||||
Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
|
||||
Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
|
||||
Sound::Reconnected => self.config.custom_sound_reconnected = path,
|
||||
Sound::SelfLeave => self.config.custom_sound_self_leave = path,
|
||||
Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
|
||||
Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
|
||||
Sound::ChatSent => self.config.custom_sound_chat_sent = path,
|
||||
Sound::ChatReceived => self.config.custom_sound_chat_received = path,
|
||||
Sound::ContactOnline => self.config.custom_sound_contact_online = path,
|
||||
Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
@@ -1502,6 +1537,7 @@ impl Default for AppState {
|
||||
audio_levels: HashMap::new(),
|
||||
conn_stats: HashMap::new(),
|
||||
locally_muted: HashSet::new(),
|
||||
peer_audio_expanded: HashSet::new(),
|
||||
call_started: None,
|
||||
recording: false,
|
||||
recording_started: None,
|
||||
@@ -1864,6 +1900,47 @@ fn reconnected_chime(
|
||||
was_reconnect.then_some(Sound::Reconnected)
|
||||
}
|
||||
|
||||
/// Return the landing-page contact chime for one definitive presence update.
|
||||
/// An initial online result is an arrival (so contacts already online at app
|
||||
/// startup are announced), while an initial offline result is silent. Online
|
||||
/// includes both plain `Online` and `InRoom`; moving between those two states is
|
||||
/// not a connection transition. Updates continue to populate the presence map
|
||||
/// off-home, but notification sounds are intentionally limited to the home page.
|
||||
fn friend_presence_notification(
|
||||
screen: Screen,
|
||||
previous: Option<&crate::presence::FriendPresence>,
|
||||
next: &crate::presence::FriendPresence,
|
||||
) -> Option<Sound> {
|
||||
if screen != Screen::Home {
|
||||
return None;
|
||||
}
|
||||
|
||||
let online = |presence: &crate::presence::FriendPresence| {
|
||||
matches!(
|
||||
presence,
|
||||
crate::presence::FriendPresence::Online
|
||||
| crate::presence::FriendPresence::InRoom { .. }
|
||||
)
|
||||
};
|
||||
match (previous.map(online), online(next)) {
|
||||
(None | Some(false), true) => Some(Sound::ContactOnline),
|
||||
(Some(true), false) => Some(Sound::ContactOffline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a native picker result into the persisted notification path. The
|
||||
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
|
||||
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
|
||||
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
|
||||
let path = picked?;
|
||||
let is_wav = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
|
||||
is_wav.then(|| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn in_call(state: &AppState) -> bool {
|
||||
!state.ticket.is_empty()
|
||||
}
|
||||
@@ -2217,6 +2294,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
state.locally_muted.remove(&id);
|
||||
state.peer_audio_expanded.remove(&id);
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.remove(&id);
|
||||
if state.music_listening_to == Some(id) {
|
||||
@@ -2238,6 +2316,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
state.locally_muted.remove(&id);
|
||||
state.peer_audio_expanded.remove(&id);
|
||||
state.connecting.remove(&id);
|
||||
state.ever_connected.remove(&id);
|
||||
notify::play(
|
||||
@@ -2301,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
// failure it's retained for Retry — unless the entry is gone
|
||||
// (history eviction / room reset), in which case drop it so
|
||||
// the payload map can't leak.
|
||||
state.apply_send_result(local_id, error);
|
||||
if state.apply_send_result(local_id, error) {
|
||||
notify::play(
|
||||
Sound::ChatSent,
|
||||
state.config.custom_sound_chat_sent.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
UiEvent::ChatMessage {
|
||||
from,
|
||||
@@ -2334,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
local_send: None,
|
||||
},
|
||||
);
|
||||
notify::play(
|
||||
Sound::ChatReceived,
|
||||
state.config.custom_sound_chat_received.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
UiEvent::AttachmentReady { from, id, data } => {
|
||||
@@ -2503,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.friends_read_only = read_only;
|
||||
}
|
||||
UiEvent::FriendPresence { id, presence } => {
|
||||
let sound = friend_presence_notification(
|
||||
state.current_screen,
|
||||
state.friend_presence.get(&id),
|
||||
&presence,
|
||||
);
|
||||
state.friend_presence.insert(id, presence);
|
||||
if let Some(sound) = sound {
|
||||
notify::play(sound, Some(state.custom_sound_path(sound)));
|
||||
}
|
||||
}
|
||||
UiEvent::FriendsRescanned => {
|
||||
// The manual pass finished. Stamp the time for the live "scanned
|
||||
@@ -2588,6 +2684,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
|
||||
}
|
||||
AppMessage::TogglePeerAdvancedAudio(id) => {
|
||||
if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
|
||||
state.peer_audio_expanded.insert(id);
|
||||
}
|
||||
}
|
||||
AppMessage::TogglePeerMute(id) => {
|
||||
let now_muted = if state.locally_muted.contains(&id) {
|
||||
state.locally_muted.remove(&id);
|
||||
@@ -2873,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
} else {
|
||||
Some(path)
|
||||
};
|
||||
match sound {
|
||||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
||||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
||||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
||||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
||||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
||||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
||||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
||||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
||||
state.set_custom_sound_path(sound, path_opt);
|
||||
}
|
||||
AppMessage::BrowseCustomSound(sound) => {
|
||||
let initial_dir = {
|
||||
let current = state.custom_sound_path(sound);
|
||||
(!current.trim().is_empty())
|
||||
.then(|| notify::expand_tilde(current))
|
||||
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
|
||||
.filter(|path| path.is_dir())
|
||||
};
|
||||
return Task::perform(
|
||||
async move {
|
||||
let mut dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter("WAV audio", &["wav"])
|
||||
.set_title("Choose a notification sound");
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
dialog
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| handle.path().to_path_buf())
|
||||
},
|
||||
move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
|
||||
);
|
||||
}
|
||||
AppMessage::CustomSoundFilePicked(sound, picked) => {
|
||||
if let Some(path) = selected_wav_path(picked) {
|
||||
state.set_custom_sound_path(sound, Some(path));
|
||||
state.config.save();
|
||||
}
|
||||
}
|
||||
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||||
@@ -5268,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
row![
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill),
|
||||
button(text("Browse…").size(11))
|
||||
.on_press(AppMessage::BrowseCustomSound(sound))
|
||||
.style(b_style(color_surface, color_blue, color_text, 5.0))
|
||||
.padding([8, 10]),
|
||||
]
|
||||
.spacing(6)
|
||||
.width(iced::Length::Fill)
|
||||
]
|
||||
.spacing(4)
|
||||
.width(iced::Length::Fill)
|
||||
@@ -6021,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
path_field("Mic Toggle", Sound::MicToggle),
|
||||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
row![
|
||||
path_field("Chat Sent", Sound::ChatSent),
|
||||
path_field("Chat Received", Sound::ChatReceived),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
row![
|
||||
path_field("Contact Online", Sound::ContactOnline),
|
||||
path_field("Contact Offline", Sound::ContactOffline),
|
||||
].spacing(20).width(iced::Length::Fill),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -6748,15 +6887,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
// Peer volume slider
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
|
||||
let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" };
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
button(
|
||||
row![
|
||||
text(foldout_symbol).size(13).color(color_subtext),
|
||||
text("Advanced audio").size(12).color(color_text),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding([6, 8])
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
|
||||
if advanced_audio_open {
|
||||
let peer_key = peer_id.to_string();
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||||
let current_gate = state
|
||||
.config
|
||||
.peer_gate
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
let gate_label = if current_gate <= 0.0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
||||
)
|
||||
};
|
||||
|
||||
let volume_row = row![
|
||||
text("Vol:").size(12).color(color_subtext),
|
||||
slider(0.0..=2.0, current_vol, move |v| {
|
||||
AppMessage::PeerVolumeChanged(peer_id_clone, v)
|
||||
@@ -6765,47 +6937,24 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_release(AppMessage::PersistConfig)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
let peer_key = peer_id.to_string();
|
||||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
let pan_row = row![
|
||||
text("Pan:").size(12).color(color_subtext),
|
||||
container(text(pan_label(current_pan)).size(11).color(color_subtext))
|
||||
.width(iced::Length::Fixed(58.0)),
|
||||
slider(
|
||||
-1.0..=1.0,
|
||||
current_pan,
|
||||
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||
)
|
||||
slider(-1.0..=1.0, current_pan, move |v| {
|
||||
AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||
})
|
||||
.step(0.05)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
// Peer noise gate: suppress this peer's background noise on our end.
|
||||
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
|
||||
// mic gate; 0 = off.
|
||||
let current_gate = state
|
||||
.config
|
||||
.peer_gate
|
||||
.get(&peer_key)
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
let gate_label = if current_gate <= 0.0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
||||
)
|
||||
};
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
// Peer noise gate: suppress this peer's background noise on our
|
||||
// end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
|
||||
let gate_row = row![
|
||||
text("Gate:").size(12).color(color_subtext),
|
||||
container(text(gate_label).size(11).color(color_subtext))
|
||||
.width(iced::Length::Fixed(58.0)),
|
||||
@@ -6816,38 +6965,45 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
card_content = card_content.push(
|
||||
column![
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
let advanced_audio = column![
|
||||
volume_row,
|
||||
pan_row,
|
||||
gate_row,
|
||||
text("EQ").size(11).color(color_subtext),
|
||||
eq_row("Low", EqBand::Low, eq.low_gain_db),
|
||||
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
|
||||
eq_row("High", EqBand::High, eq.high_gain_db),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
.spacing(6);
|
||||
card_content = card_content.push(
|
||||
container(advanced_audio)
|
||||
.style(c_style(color_crust, color_surface, 6.0))
|
||||
.padding(10)
|
||||
.width(iced::Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
let card = container(card_content)
|
||||
.style(c_style(
|
||||
@@ -9513,12 +9669,12 @@ mod tests {
|
||||
use super::sendqueue::{self, LocalSend, SendStatus};
|
||||
use super::{
|
||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
|
||||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
|
||||
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||||
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
|
||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
|
||||
update,
|
||||
format_relative_ago, friend_presence_notification, initial_window_position,
|
||||
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
|
||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
use std::collections::VecDeque;
|
||||
@@ -9776,6 +9932,7 @@ mod tests {
|
||||
);
|
||||
state.audio_levels.insert(peer, 0.5);
|
||||
state.locally_muted.insert(peer);
|
||||
state.peer_audio_expanded.insert(peer);
|
||||
state.chat_messages.push(ChatEntry {
|
||||
name: "Peer".to_string(),
|
||||
text: "old room".to_string(),
|
||||
@@ -9837,6 +9994,7 @@ mod tests {
|
||||
assert!(state.peers.is_empty());
|
||||
assert!(state.audio_levels.is_empty());
|
||||
assert!(state.locally_muted.is_empty());
|
||||
assert!(state.peer_audio_expanded.is_empty());
|
||||
assert!(state.chat_messages.is_empty());
|
||||
assert!(state.chat_input.is_empty());
|
||||
assert!(state.attachments.len() == 0);
|
||||
@@ -9886,6 +10044,34 @@ mod tests {
|
||||
panic!("clip player did not stop during room reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
|
||||
let mut state = AppState::default();
|
||||
let peer = SecretKey::generate().public();
|
||||
state.peers.insert(
|
||||
peer,
|
||||
crate::network::PeerState {
|
||||
name: "Peer".to_string(),
|
||||
is_muted: false,
|
||||
addr: iroh::EndpointAddr::from(peer),
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
music: None,
|
||||
},
|
||||
);
|
||||
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
|
||||
assert!(state.peer_audio_expanded.contains(&peer));
|
||||
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
|
||||
assert!(!state.peer_audio_expanded.contains(&peer));
|
||||
|
||||
let stale = SecretKey::generate().public();
|
||||
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
|
||||
assert!(!state.peer_audio_expanded.contains(&stale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
||||
let mut state = AppState::default();
|
||||
@@ -10579,6 +10765,91 @@ mod tests {
|
||||
|
||||
const W: f32 = 200.0;
|
||||
|
||||
#[test]
|
||||
fn initial_contact_presence_announces_only_online() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
|
||||
Some(Sound::ContactOnline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_presence_chimes_only_on_online_boundary() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
let in_room = FriendPresence::InRoom {
|
||||
name: "Game night".to_string(),
|
||||
ticket: "ticket".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
|
||||
Some(Sound::ContactOnline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Home,
|
||||
Some(&FriendPresence::Online),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
Some(Sound::ContactOffline)
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Home,
|
||||
Some(&FriendPresence::Offline),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_presence_is_silent_away_from_landing_page() {
|
||||
use crate::presence::FriendPresence;
|
||||
|
||||
assert_eq!(
|
||||
friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
friend_presence_notification(
|
||||
Screen::Settings,
|
||||
Some(&FriendPresence::Online),
|
||||
&FriendPresence::Offline,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
|
||||
use std::path::PathBuf;
|
||||
|
||||
assert_eq!(selected_wav_path(None), None);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
|
||||
Some("/tmp/notify.wav".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
|
||||
Some("/tmp/notify.WAV".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_drag_maps_left_edge_to_zero() {
|
||||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
||||
@@ -10862,7 +11133,7 @@ mod tests {
|
||||
// Empty queue + a fresh full pacer → dispatched immediately.
|
||||
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
||||
assert!(state.send_payloads.contains_key(&id));
|
||||
state.apply_send_result(id, None);
|
||||
assert!(state.apply_send_result(id, None));
|
||||
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
|
||||
// A completed send releases its retry payload.
|
||||
assert!(!state.send_payloads.contains_key(&id));
|
||||
@@ -10873,7 +11144,7 @@ mod tests {
|
||||
let mut state = AppState::default();
|
||||
let id = push_own(&mut state, "yo");
|
||||
state.submit_send(id, PendingSend::Text("yo".to_string()));
|
||||
state.apply_send_result(id, Some("not in a room".to_string()));
|
||||
assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
|
||||
assert_eq!(
|
||||
status_of(&state, id),
|
||||
Some(SendStatus::Failed("not in a room".to_string()))
|
||||
@@ -10889,11 +11160,11 @@ mod tests {
|
||||
state.submit_send(a, PendingSend::Text("a".to_string()));
|
||||
let b = push_own(&mut state, "b");
|
||||
state.submit_send(b, PendingSend::Text("b".to_string()));
|
||||
state.apply_send_result(a, None);
|
||||
assert!(state.apply_send_result(a, None));
|
||||
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
|
||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||
// A result for an id with no matching entry is a harmless no-op.
|
||||
state.apply_send_result(9999, None);
|
||||
assert!(!state.apply_send_result(9999, None));
|
||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||
}
|
||||
|
||||
@@ -10907,7 +11178,7 @@ mod tests {
|
||||
state
|
||||
.chat_messages
|
||||
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
|
||||
state.apply_send_result(id, Some("dead".to_string()));
|
||||
assert!(!state.apply_send_result(id, Some("dead".to_string())));
|
||||
// No entry to mark → the payload must not leak.
|
||||
assert!(!state.send_payloads.contains_key(&id));
|
||||
}
|
||||
@@ -10922,7 +11193,7 @@ mod tests {
|
||||
assert!(state.send_queue.is_empty());
|
||||
assert!(state.send_payloads.is_empty());
|
||||
// A late result for the pre-reset send touches nothing and adds no entry.
|
||||
state.apply_send_result(id, None);
|
||||
assert!(!state.apply_send_result(id, None));
|
||||
assert!(state.chat_messages.is_empty());
|
||||
assert!(state.send_payloads.is_empty());
|
||||
}
|
||||
|
||||
@@ -328,4 +328,54 @@ mod tests {
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
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 limiter;
|
||||
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;
|
||||
// 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
use crate::audio::ownership;
|
||||
use crate::audio::{AudioBackend, AudioError};
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -371,6 +372,11 @@ fn run_playback(
|
||||
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! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -379,6 +385,19 @@ fn run_playback(
|
||||
// buffer — the real fix is the explicit Buffers param below — but it
|
||||
// expresses the intended quantum for any node that honours it.
|
||||
*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 {
|
||||
props.insert("node.target", target);
|
||||
@@ -637,6 +656,62 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
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]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
|
||||
@@ -478,6 +478,14 @@ pub struct AppConfig {
|
||||
pub custom_sound_mic_toggle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_reconnect_failed: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_chat_sent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_chat_received: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_contact_online: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_sound_contact_offline: Option<String>,
|
||||
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
||||
/// gates ALL chimes; these let the user silence individual events while the
|
||||
/// master stays on. A chime plays only if the master AND its flag are true.
|
||||
@@ -498,6 +506,14 @@ pub struct AppConfig {
|
||||
pub sound_mic_toggle_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_reconnect_failed_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_chat_sent_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_chat_received_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_contact_online_enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub sound_contact_offline_enabled: bool,
|
||||
/// Optional override for the `pixelpass` binary location (screen share).
|
||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||
#[serde(default)]
|
||||
@@ -601,6 +617,10 @@ impl Default for AppConfig {
|
||||
custom_sound_self_leave: None,
|
||||
custom_sound_mic_toggle: None,
|
||||
custom_sound_reconnect_failed: None,
|
||||
custom_sound_chat_sent: None,
|
||||
custom_sound_chat_received: None,
|
||||
custom_sound_contact_online: None,
|
||||
custom_sound_contact_offline: None,
|
||||
sound_self_join_enabled: true,
|
||||
sound_peer_join_enabled: true,
|
||||
sound_peer_leave_enabled: true,
|
||||
@@ -609,6 +629,10 @@ impl Default for AppConfig {
|
||||
sound_self_leave_enabled: true,
|
||||
sound_mic_toggle_enabled: true,
|
||||
sound_reconnect_failed_enabled: true,
|
||||
sound_chat_sent_enabled: true,
|
||||
sound_chat_received_enabled: true,
|
||||
sound_contact_online_enabled: true,
|
||||
sound_contact_offline_enabled: true,
|
||||
pixelpass_path: None,
|
||||
screen_share: ScreenShareSettings::default(),
|
||||
recents: Vec::new(),
|
||||
@@ -639,6 +663,10 @@ impl AppConfig {
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
|
||||
Sound::ChatSent => self.sound_chat_sent_enabled,
|
||||
Sound::ChatReceived => self.sound_chat_received_enabled,
|
||||
Sound::ContactOnline => self.sound_contact_online_enabled,
|
||||
Sound::ContactOffline => self.sound_contact_offline_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +681,10 @@ impl AppConfig {
|
||||
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
|
||||
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
|
||||
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
|
||||
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
|
||||
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +972,10 @@ mod tests {
|
||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
||||
assert!(deserialized.custom_sound_chat_sent.is_none());
|
||||
assert!(deserialized.custom_sound_chat_received.is_none());
|
||||
assert!(deserialized.custom_sound_contact_online.is_none());
|
||||
assert!(deserialized.custom_sound_contact_offline.is_none());
|
||||
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
|
||||
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
||||
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
||||
|
||||
+80
-36
@@ -4,6 +4,7 @@ pub mod fetchbudget;
|
||||
pub mod jitter;
|
||||
pub mod messages;
|
||||
mod recovery;
|
||||
mod teardown;
|
||||
|
||||
use crate::audio::eq::{Eq, EqSettings};
|
||||
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
||||
@@ -677,31 +678,33 @@ struct ActiveSession {
|
||||
recovery_terminal_task: tokio::task::JoinHandle<()>,
|
||||
grace_timers: GraceTimers,
|
||||
transport: Arc<IrohTransport>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||
/// also dies if the session is dropped without an explicit stop).
|
||||
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 screen-share children and the echo-cancel module, held together
|
||||
/// because their **destruction order** is load-bearing: the AEC module must
|
||||
/// not unload while a pixelpass host is alive and fanning out (design v3.4
|
||||
/// §7.1). `teardown` owns that ordering; see `core::teardown`.
|
||||
teardown: SessionTeardown,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
||||
crate::log_msg("ActiveSession::shutdown started");
|
||||
// Tear down any screen-share children first so the host stops streaming
|
||||
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
||||
// see the stream end without waiting on drop ordering).
|
||||
if let Some(mut host) = self.screenshare_host.take() {
|
||||
let _ = host.kill().await;
|
||||
}
|
||||
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
|
||||
let _ = viewer.kill().await;
|
||||
}
|
||||
// promptly, and so they are dead *and reaped* well before the AEC guard
|
||||
// unloads at the end of this function (design v3.4 §7.1). Drop ordering
|
||||
// is the backstop for the unwind path; this is the path we control.
|
||||
self.teardown.shutdown_children().await;
|
||||
self.datagram_task.abort();
|
||||
self.mixer_task.abort();
|
||||
self.event_task.abort();
|
||||
@@ -726,8 +729,9 @@ impl ActiveSession {
|
||||
|
||||
// Unload the echo-cancel module now that the audio streams releasing its
|
||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||
#[cfg(target_os = "linux")]
|
||||
drop(self.echo_cancel);
|
||||
// The screen-share children were killed *and reaped* at the top of this
|
||||
// function, so nothing pixelpass-side is alive to see the module vanish.
|
||||
drop(self.teardown);
|
||||
|
||||
crate::log_msg("Leaving room...");
|
||||
let _ = self.room_state.leave().await;
|
||||
@@ -1511,6 +1515,8 @@ async fn run_core_loop(
|
||||
biased;
|
||||
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
|
||||
Some(cmd) => cmd,
|
||||
// Every `CoreController`/`CoreCommandSender` is gone — the UI has
|
||||
// dropped the core. Teardown happens once, after the loop.
|
||||
None => break,
|
||||
},
|
||||
maybe_wake = besteffort_wake_rx.recv() => match maybe_wake {
|
||||
@@ -1529,6 +1535,18 @@ async fn run_core_loop(
|
||||
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,
|
||||
},
|
||||
game_change = next_game_change(&mut game_rx) => {
|
||||
@@ -2726,9 +2744,9 @@ async fn run_core_loop(
|
||||
grace_timers,
|
||||
transport: transport.clone(),
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: echo_cancel_guard,
|
||||
screenshare_host: None,
|
||||
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
|
||||
teardown: SessionTeardown::new(echo_cancel_guard),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
teardown: SessionTeardown::new(None),
|
||||
};
|
||||
|
||||
let self_id = endpoint.id().to_string();
|
||||
@@ -3403,7 +3421,7 @@ async fn run_core_loop(
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
if session.screenshare_host.is_some() {
|
||||
if session.teardown.is_sharing() {
|
||||
continue; // already sharing
|
||||
}
|
||||
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
|
||||
@@ -3455,7 +3473,7 @@ async fn run_core_loop(
|
||||
{
|
||||
Ok((child, ticket)) => {
|
||||
crate::log_msg("Screen share host started");
|
||||
session.screenshare_host = Some(child);
|
||||
session.teardown.set_host(child);
|
||||
current_sharing = Some(ticket.clone());
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
@@ -3476,9 +3494,24 @@ async fn run_core_loop(
|
||||
CoreCommand::StopScreenShare => {
|
||||
current_sharing = None;
|
||||
if let Some(session) = &mut active_session {
|
||||
if let Some(mut child) = session.screenshare_host.take() {
|
||||
let _ = child.kill().await;
|
||||
crate::log_msg("Screen share host stopped");
|
||||
match session.teardown.stop_host().await {
|
||||
None => {}
|
||||
Some(teardown::StopOutcome::Reaped) => {
|
||||
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(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
@@ -3505,16 +3538,12 @@ async fn run_core_loop(
|
||||
if let Some(session) = &mut active_session {
|
||||
// Drop viewers whose player window has already closed so the
|
||||
// list only tracks live players.
|
||||
session
|
||||
.screenshare_viewers
|
||||
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
|
||||
session.teardown.sweep_exited_viewers();
|
||||
// One player per share: a second Watch click on a share we're
|
||||
// already viewing is a retry (usually because the first window
|
||||
// froze), so replace the existing player rather than stacking a
|
||||
// second mpv — two players would double the shared audio.
|
||||
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
|
||||
let (_, mut old) = session.screenshare_viewers.remove(pos);
|
||||
let _ = old.kill().await;
|
||||
if session.teardown.replace_viewer(&ticket).await {
|
||||
crate::log_msg("Screen share viewer replaced (re-watch)");
|
||||
}
|
||||
}
|
||||
@@ -3522,7 +3551,7 @@ async fn run_core_loop(
|
||||
Ok(child) => {
|
||||
crate::log_msg("Screen share viewer started");
|
||||
if let Some(session) = &mut active_session {
|
||||
session.screenshare_viewers.push((ticket, child));
|
||||
session.teardown.push_viewer(ticket, child);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -3535,6 +3564,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
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() {
|
||||
eprintln!("Error running GUI: {:?}", e);
|
||||
}
|
||||
|
||||
+81
-4
@@ -10,6 +10,8 @@
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use crate::audio::ownership;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -80,6 +82,14 @@ pub enum Sound {
|
||||
MicToggle,
|
||||
/// Reconnect failed / peer evicted.
|
||||
ReconnectFailed,
|
||||
/// One of our chat messages was broadcast to the room.
|
||||
ChatSent,
|
||||
/// A chat message from another participant was admitted.
|
||||
ChatReceived,
|
||||
/// A saved contact was detected online on the home screen.
|
||||
ContactOnline,
|
||||
/// A saved contact previously seen online went offline on the home screen.
|
||||
ContactOffline,
|
||||
}
|
||||
|
||||
impl Sound {
|
||||
@@ -93,10 +103,14 @@ impl Sound {
|
||||
Sound::SelfLeave,
|
||||
Sound::MicToggle,
|
||||
Sound::ReconnectFailed,
|
||||
Sound::ChatSent,
|
||||
Sound::ChatReceived,
|
||||
Sound::ContactOnline,
|
||||
Sound::ContactOffline,
|
||||
];
|
||||
|
||||
/// Number of distinct notification events.
|
||||
pub const COUNT: usize = 8;
|
||||
pub const COUNT: usize = 12;
|
||||
|
||||
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
||||
fn index(self) -> usize {
|
||||
@@ -109,6 +123,10 @@ impl Sound {
|
||||
Sound::SelfLeave => 5,
|
||||
Sound::MicToggle => 6,
|
||||
Sound::ReconnectFailed => 7,
|
||||
Sound::ChatSent => 8,
|
||||
Sound::ChatReceived => 9,
|
||||
Sound::ContactOnline => 10,
|
||||
Sound::ContactOffline => 11,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +141,10 @@ impl Sound {
|
||||
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
||||
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
||||
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
|
||||
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
|
||||
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
|
||||
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
|
||||
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +159,10 @@ impl Sound {
|
||||
Sound::SelfLeave => "self-leave",
|
||||
Sound::MicToggle => "mic-toggle",
|
||||
Sound::ReconnectFailed => "reconnect-failed",
|
||||
Sound::ChatSent => "chat-sent",
|
||||
Sound::ChatReceived => "chat-received",
|
||||
Sound::ContactOnline => "contact-online",
|
||||
Sound::ContactOffline => "contact-offline",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
let mut command = Command::new(player);
|
||||
command
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.stderr(Stdio::null());
|
||||
// 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
|
||||
// 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.
|
||||
@@ -289,6 +323,49 @@ mod tests {
|
||||
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]
|
||||
fn test_should_play_truth_table() {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
+210
-25
@@ -21,6 +21,10 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::audio::ownership;
|
||||
|
||||
pub mod livesync;
|
||||
|
||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||
|
||||
/// 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.
|
||||
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
|
||||
/// 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.
|
||||
@@ -607,16 +617,28 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||
/// its window closes.
|
||||
///
|
||||
/// The flags keep latency low while preserving A/V sync. We deliberately do
|
||||
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
|
||||
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
|
||||
/// progressively out of sync with its audio. Pacing to the audio clock costs a
|
||||
/// little latency (negligible for pointing at a desktop) and keeps a shared
|
||||
/// video in sync. We also leave hwdec at the `low-latency` default (software
|
||||
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
|
||||
/// kept playing.
|
||||
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
||||
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
|
||||
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
|
||||
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
|
||||
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
|
||||
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
|
||||
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 first = match settings.player {
|
||||
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||
@@ -627,15 +649,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||
};
|
||||
|
||||
let child = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => c,
|
||||
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no media player found — install mpv or vlc to watch screen shares",
|
||||
)
|
||||
})?,
|
||||
let (launched, child) = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => (first.0, c),
|
||||
Err(_) => (
|
||||
second.0,
|
||||
spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"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 {
|
||||
let mut child = child;
|
||||
let _ = child.wait().await;
|
||||
@@ -643,11 +682,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
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();
|
||||
match settings.buffering {
|
||||
ShareBuffering::LowLatency => {
|
||||
args.push("--profile=low-latency".to_string());
|
||||
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
|
||||
// lossy link turns every retransmission into buffered latency that
|
||||
// nothing trims back. `--untimed` does NOT fix that (measured
|
||||
// marginally worse: it only unpaces *presentation*, while audio
|
||||
// still drains at 1x, so the backlog never shrinks) — the viewer
|
||||
// instead drains it by playing slightly fast, see `livesync`.
|
||||
args.push("--audio-buffer=0.2".to_string());
|
||||
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||
}
|
||||
@@ -656,10 +707,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<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 {
|
||||
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
|
||||
}
|
||||
@@ -701,20 +767,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
|
||||
// fallback from the preferred player to the other one is visible too.
|
||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||
Command::new(bin)
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.kill_on_drop(false);
|
||||
// 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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||
// A malicious "ticket" that looks like a flag must end up positional,
|
||||
@@ -825,16 +940,86 @@ mod tests {
|
||||
#[test]
|
||||
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||
assert_eq!(
|
||||
mpv_args(&ScreenShareSettings::default()),
|
||||
mpv_args(&ScreenShareSettings::default(), None),
|
||||
vec![
|
||||
"--profile=low-latency",
|
||||
"--audio-buffer=0.2",
|
||||
"--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]
|
||||
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||
let settings = ScreenShareSettings {
|
||||
@@ -846,7 +1031,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mpv_args(&settings),
|
||||
mpv_args(&settings, None),
|
||||
vec![
|
||||
"--cache=yes",
|
||||
"--demuxer-readahead-secs=2",
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Screenshare audio exclusion — ownership tagging wire contract.
|
||||
#
|
||||
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
|
||||
# CONSUMES them as the primary taint root of the exclusion engine. Neither
|
||||
# repo depends on the other, so this file is the contract: it is committed
|
||||
# byte-identical in both, and each repo has a test that asserts its own named
|
||||
# constants (and, on the producer side, the environment a real child Command
|
||||
# would carry) match these values exactly.
|
||||
#
|
||||
# peerspeak/tests/fixtures/ownership-tag-contract.txt
|
||||
# pixelpass/tests/fixtures/ownership-tag-contract.txt
|
||||
#
|
||||
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
|
||||
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
|
||||
# is a cross-repo breaking change: both repos must land in the same session,
|
||||
# and the phase 5 matrix must be re-run.
|
||||
#
|
||||
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
|
||||
# matches. Round 8 added the second because a property is invisible to the
|
||||
# PipeWire registry `global` event and readable only via a node bind, so the
|
||||
# primary taint root must not rest on one observation mechanism alone.
|
||||
|
||||
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
|
||||
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
|
||||
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
|
||||
#
|
||||
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
|
||||
# used to accept any value other than "false"/"0", on the theory that leniency
|
||||
# over-excludes and is therefore safe. It is not: leniency buys false-positive
|
||||
# exclusion, and it let any process suppress a rival application's audio from
|
||||
# the share with a property it did not even have to spell right. Fail-closed
|
||||
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
|
||||
# not about parsing.
|
||||
prop_key=peerspeak.owned
|
||||
prop_value=1
|
||||
|
||||
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
|
||||
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
|
||||
# Only the prefix is matched; the rest of the name is for diagnostics.
|
||||
node_name_prefix=peerspeak_owned_
|
||||
node_name_format=peerspeak_owned_<role>_<pid>
|
||||
node_name_example=peerspeak_owned_mpv_31284
|
||||
Reference in New Issue
Block a user