Compare commits

...
49 Commits
Author SHA1 Message Date
mollusk 774922c6a9 build(nix): add a devShell so peerspeak builds on NixOS
CI / check (push) Waiting to run
The repo assumed a distro with a system-wide Rust, which NixOS does not
provide. This adds a flake devShell carrying the full dependency surface:

- Build: rustc/cargo/clippy/rustfmt, pkg-config, clang (pipewire-sys and
  libspa-sys need a real libclang for bindgen, via LIBCLANG_PATH), cmake
  (audiopus_sys's vendored-libopus fallback), and git (build.rs stamps
  PEERSPEAK_GIT_SHORT from `git rev-parse`).
- Link: alsa-lib, libopus, pipewire.
- dlopen'd at runtime: vulkan-loader, libxkbcommon, wayland and the X11 libs.
  Nothing links these, so they never land in the binary's rpath and are
  reachable only through LD_LIBRARY_PATH. Omitting them builds fine and then
  fails at window creation, which is a confusing way to find out.
- The supply-chain gates CI runs (cargo-deny, cargo-audit) plus cargo-deb.
  These were `cargo install`ed on the CachyOS side, which does not carry
  over — those binaries link that distro's glibc.

It also carries the screen-share tools (GStreamer + plugin search path,
pactl, mpv). Those look like they belong only to pixelpass, but
tests/screenshare_host_fault.rs starts a REAL pixelpass host, which aborts at
its own preflight without them — so they are a dependency of this test suite.
They are duplicated from pixelpass's flake rather than imported: the two
projects are mutually optional by design, and having one flake consume the
other would reintroduce the build-level dependency that rule prevents.

nixpkgs is pinned to nixos-26.05, the same channel the hosts run, so the
libraries here match the running PipeWire daemon and Vulkan ICD.

Verified: 640 lib tests pass, all 4 screenshare_host_fault live gates pass
(real audio backend, real network bind, real pixelpass child), fmt clean.
Known delta: clippy 1.95.0 (nixpkgs 26.05) flags one collapsible_match in
src/widget/selectable_text.rs that clippy 1.97.1 on CachyOS did not.
2026-08-07 13:46:18 -04:00
molluskandClaude Opus 5 63c246d976 docs: record the jitter buffer's unreachable shrink path as a known bug
CI / check (push) Waiting to run
`target_delay` grows +1 per disruption to MAX_DELAY_FRAMES (240 ms) but only
shrinks after 250 consecutive clean frames — 5 s of unbroken audio. Two of the
five `clean_run` resets fire on every natural pause in speech (jitter.rs:201
benign underrun, jitter.rs:181 re-prime), and the sender stops transmitting
outright while the gate is closed (core/mod.rs:2041). The AIMD decrease half is
therefore unreachable under conversational voice: one early jitter burst pins
the extra latency for the rest of the session.

Found by code review; not yet reproduced live. Pairs with field-test debt #5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:38:26 -04:00
molluskandClaude Fable 5 52d842160d Merge s2-host-fault: the screenshare host-fault path (S2)
A pixelpass host that dies mid-share is now torn down instead of
staying advertised: stdout EOF is synthesized as a terminal fault,
routed back into the core on a dedicated channel behind the reliable
arm of the biased select, gated by the ActiveShare generation so a
reaped child's late EOF is dropped as stale, and handled by retiring
the share — presence ticket removal and ScreenShareStopped ahead of
the reap wait, the explanatory error after.

Reviewed by Gemini (three rounds: branch review, full-range merge
review, fix verification round). Its P2s — Join's early-exit ordering
hole, the reap-then-presence advertising window, and the missing
presence-side gate — are fixed and mutation-verified. 640 lib tests;
three live gates green on the desktop, including a two-process
observer gate that reads the sharer's presence from a second real
node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:53:02 -04:00
molluskandClaude Fable 5 e043810eb0 test: presence gate — a host fault clears the ticket for peers, promptly
The 🟡 S2 gap: presence-side ticket removal on fault was asserted by no
gate, and the "same code path as StopScreenShare" argument turned out
false — the fault handler duplicates the presence statements, so a
mutant deleting them passed all 644 tests. Nothing on the sharer's own
UiEvent channel can witness presence; it is only observable from
another node.

The gate runs a real second core as an observer in a SEPARATE PROCESS
(this test binary re-invoked as `presence_probe_helper`, its own
XDG_CONFIG_HOME): two in-process cores would load the same identity.key
and collapse into one node id, and swapping the env var between spawns
races other threads' getenv. The observer asserts the sharer's
PeerState.sharing goes Some → None on fault.

The fake host is a wedge — valid-shaped ticket (the OBSERVER's gossip
ingest sanitizes peer tickets; a garbage one is nulled to None and the
gate goes vacuous), ~1 s of life, then closes stdout while trapping
SIGINT — so the reap burns the full stop grace and TIME discriminates
the ordering, like the SIGINT gate: presence-first clears in ~1 s, the
old reap-then-presence ordering in ~3 s, asserted < the 2 s grace.
Mutation-verified both ways: presence removal deleted ⇒ observer times
out; old ordering restored ⇒ 3002 ms measured, assert fires.

Standalone (a plain --ignored sweep) the helper no-ops; the probe is
kill_on_drop so a parent panic can't orphan it (Gemini P3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:52:10 -04:00
molluskandClaude Fable 5 5b80a1a010 core: pull the ticket off presence before the reap wait on host fault
Gemini's merge-round review (P2, CERTAIN): the fault handler ran
`stop_host().await` first, and a host that merely closed stdout but
lives on — trapped SIGINT, wedged — makes that call burn the full 2 s
stop grace before the SIGKILL fallback. For that whole window the dead
share stayed advertised: peers could still click Watch on it, and the
sharer's own UI kept saying "sharing".

The handler now retires the share where the fault is decided, not where
the corpse is confirmed: presence ticket removal and ScreenShareStopped
are emitted before the reap wait, and only the explanatory error (which
carries the unconfirmed-reap caveat) waits for `stop_host`. The
Stopped-before-Error contract is unchanged and still gated.

StopScreenShare's identical reap-then-presence ordering predates S2 and
is deliberately left alone (user-initiated stop, lower stakes); recorded
as a follow-up note instead of churning reviewed main-line code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:51:59 -04:00
molluskandClaude Fable 5 b9803f93fb core: retire the share at Join's session teardown, not after ticket parse
Gemini's review of the S2 branch found the one hole the harness had not
covered (P2, verified reachable): Join tears the old session down —
deliberately killing the share host — BEFORE validating the ticket, and
an invalid ticket exits the arm early, skipping the late
`current_sharing = None`. The killed host's stdout EOF then passed the
staleness gate and the user got a spurious "Screen share ended
unexpectedly" on top of "invalid room ticket". Pre-S2 the stale value
was toothless on this path; the fault handler gave it teeth.

The share now dies where the session does: cleared unconditionally right
after the teardown block, ahead of every early exit. The live gate grew
a third half — share, Join with a garbage ticket, then require silence
after the ticket error — and the mutant restoring the old placement is
killed by exactly that assertion (spurious re-emitted ScreenShareStopped).

Also Gemini's P3: the test's temp dir is now dropped by a guard, so an
assertion panic no longer leaks the fake-pixelpass scripts in /tmp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 04:05:17 -04:00
molluskandClaude Fable 5 3df2378831 test: the owed Stop Share SIGINT gate, against the real pixelpass
0c half (ii) had never been field-run: SIGINT sent, child exits within
the bound, no fallback kill on the normal path. Now it's a repeatable
live gate instead of a one-off manual check: a real whole-desktop host
(idle — no viewer, so no capture) is stopped and must reach
ScreenShareStopped inside STOP_GRACE. The SIGKILL fallback is
indistinguishable from success in the event stream, so time is the
discriminator: the fallback first waits out the full 2 s grace, while a
host honouring SIGINT exits in milliseconds.

Also holds the SIGINTed host's late stdout EOF to the same staleness
contract as the fake-host gate. Verified green on this desktop; no
stray pixelpass processes after the run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:10:11 -04:00
molluskandClaude Fable 5 81c230a09c test: live S2 exit gate — dead host torn down, clean stop stays clean
Drives the real core loop through CoreController with the pixelpass
override pointed at fake shell scripts (a host that emits its ticket and
dies; one that lives until signalled). The command loop has no unit
seam, so this is the only harness reaching the fault handler.

Half 1 pins the whole death path: ScreenShareStopped arrives BEFORE the
"ended unexpectedly" error. Half 2 stops a share deliberately and then
requires silence while the retired host's late stdout EOF lands as a
stale fault. The `exec sleep` in the living host is load-bearing: it
makes SIGINT close stdout so the stale fault actually arrives, keeping
the staleness assertion non-vacuous.

Both core-side mutants verified killed: swallowing the forwarder's fault
times out half 1; disabling the staleness gate panics half 2 with the
spurious re-emitted ScreenShareStopped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:08:13 -04:00
molluskandClaude Fable 5 be3740f5f9 screenshare/core: a host that dies is no longer advertised as sharing (S2)
The defect: pixelpass's stdout EOF was silently discarded, the notice
channel existed only for app-audio shares, and nothing cleared the host
from the teardown slot or the ticket from presence — so a crashed host
stayed advertised in the room and the UI kept saying "sharing".

Every share now gets a notice channel. The drain task synthesizes a
terminal HostNotice::Eof when the stream ends (EOF or read error — a
crash can abort across `extern "C"` before any JSON line is written, so
the stream ending is the only reliable death signal). The core's
forwarder turns that into a ScreenShareHostFault scoped to the spawn's
generation; a stale fault (already stopped, or a newer share running) is
dropped. The handler reaps the child through the existing confirmed-reap
path, pulls the ticket off presence, and emits ScreenShareStopped BEFORE
the error, so the UI never shows "sharing" next to the explanation.

The ticket and its generation live in one ActiveShare value on purpose:
they must appear and vanish together, or the staleness gate drifts.

Both drain gates are mutation-verified: swallowing the Eof fails both
tests; skipping it only on the read-error path fails exactly the
error-path test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:02:41 -04:00
molluskandClaude Opus 5 0d836d14c2 docs: round 18 — repair moves to libpulse; two reviews, three reusable lessons
Rounds 17c and 4 of the repair review, recorded together because they resolve to
one decision: `pactl`'s text output cannot carry the guarantees repair claims, so
observation and unloading now go through libpulse introspection over a single
verified-local connection. The dependency was taken with the user's sign-off after
vetting (details beside the dep in pixelpass Cargo.toml).

Three lessons that generalise beyond this phase:

- A *prescription* can fail reachability just as a finding can. "Use `pactl -f json
  list modules`" is sound reasoning against an API that does not exist — those
  records carry no module index, and `unload-module` accepts only an index.
- Auditing my own fixes paid a third time: two of the four fixes applied in round
  17a were themselves defective, including a correlation scheme that is unsound
  whenever module names repeat.
- The live field test caught a bug unit tests structurally cannot reach, and it was
  phase 0b's bug one layer down: fields drop in declaration order, the Pulse
  context's teardown frees IO events owned by the mainloop, and declaring the
  mainloop first turned a fully successful repair into SIGABRT and exit 134.

Also recorded: the newline defect needed no adversary and was confirmed on the live
server, and the remaining namespace hole is left open with its trade stated — an
owner token would close it but would make orphans from older builds uncleanable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:24:46 -04:00
molluskandClaude Opus 5 76c1a13e11 docs: round 17 — the repair review, the 0c actor review, and 0c's slicing
Two reviews in one round. The repair planner returned changes-requested (no P1s,
four reachable P2s, all applied in pixelpass `9145b2a`); the 0c actor design
returned four blocking issues, all accepted, plus a concession on epoch.

Recorded because three of them generalise:

- A prescribed fix was not implementable as written. "Use `pactl -f json list
  modules`" is sound reasoning against an API that does not exist: on pactl 17
  those records carry no module index, and `unload-module` takes only an index.
  The reachability rule now applies to prescriptions, not just findings.
- The actor's bounded join would have disarmed `Drop` by moving the thread handle
  into `spawn_blocking` — the same defect shape as round 15's, a defence disarmed
  exactly when needed.
- Epoch was over-specified and my vacuity instinct was right: serial equality is
  the entire identity guarantee, so epoch is diagnostic and explicitly not a gate.

Measured on the live graph rather than argued: recorded module arguments are
byte-exact with `@DEFAULT_SINK@` unresolved (both load-bearing for exact-form
matching), and two sinks may share one `node.name` with capture attaching to the
OLDER one in 3 of 3 trials — so a surviving wedged owner silently steals the next
session's capture instead of merely risking a collision.

0c step 2 is sliced into S2–S5 so each lands reviewed. Nothing reopens D6: the
connection-owned-sink design is unchanged, and a material part of the growth is
pre-existing debt 0c forced into the light.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:33:57 -04:00
mollusk aa0515af1c Merge phase 0b + the peerspeak half of 0c: teardown ordering, reaping, graceful stop
Two invariants land here, both of which phase 6 depends on:

1. The echo-cancel module cannot unload while a pixelpass host is alive. The
   ordering-critical fields moved into `ScreenshareTeardown` with `echo_cancel`
   declared LAST, and killing is no longer taken for reaping — `kill_on_drop`
   only signals, so `ReapOnDrop` blocks on a bounded poll until the child is
   actually gone. The defect was real, not theoretical: `echo_cancel` sat ahead
   of `screenshare_host` in declaration order, so any unwind unloaded the AEC
   first, and unwind is reachable (no `panic=abort`, many `unwrap()`s).

2. Stop Share asks before it insists — SIGINT, a bounded grace, then SIGKILL —
   so pixelpass runs its own cleanup instead of leaking a null-sink module every
   time. SIGINT specifically: pixelpass installs only a `ctrl_c()` handler.

Reviewed by Codex across two rounds: changes-requested (two blocking findings,
both real, both the same shape — a defence disarmed exactly when it was needed)
then approve-with-follow-ups (five P3s, all applied). The mutation matrix was
revised from five to four after one pinned mutation was proved unreachable by
construction, and teardown was hoisted to one unconditional post-loop site so
every loop exit is covered structurally.

Owed and recorded: the live Stop Share SIGINT gate has never been field-run, and
the hoisted call site's live proof belongs to the phase-9 lifecycle row.

638 lib tests, clippy clean, fmt clean.
2026-07-26 19:43:17 -04:00
molluskandClaude Opus 5 9f06741b99 core/teardown: an unconfirmed stop is not a clean stop
Codex's re-review of the branch returned "approve with follow-ups" — no
blocking findings, five P3s. All five are applied here rather than carried as
debt, since each is a few lines.

The one with user-visible consequences: `stop_host` returned a bare "was
sharing" bool, so the single case where the availability-first policy gives up
(SIGKILL queued, reap never confirmed) still sent `ScreenShareStopped` with
nothing else. The UI would say sharing had ended while pixelpass might still be
alive and fanning out — a claim the user cannot see through. `shutdown` now
returns `StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an
unconfirmed *user-initiated* stop raises a UI error naming the stray process.
Session and viewer teardown discard the outcome deliberately: nobody is waiting
on an answer there, and the residual risk is already logged.

Also: the three failure diagnoses in `shutdown` (the signal never left, the
child ignored it, the wait itself broke) were collapsed into one log line and
are now distinct — they mean different things to whoever reads the log.

The second cancellation gate is the one worth keeping. The review pointed out
that all cancellation coverage sat in the *graceful* wait, so a mutant that
disarmed the wrapper between the two waits would survive. It was right, with a
wrinkle: the naive mutant does not compile, because the child is borrowed from
`self` for the whole function — the borrow checker is doing real work here. The
restructured form (`self.child.take()` once cooperation has failed) does
compile, and the pre-existing mid-wait test passes it.
`cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.

Mutation-verified, both new gates: reporting an unconfirmed stop as `Reaped`
fails exactly `a_failed_wait_is_not_treated_as_a_confirmed_reap`; disarming
between the waits fails the new cancellation test (and the failed-wait test,
which also asserts armedness) while leaving the old mid-wait test green — which
is the proof the new test is not redundant. The logging split is diagnostics
only and has no gate; said plainly rather than dressed up as covered.

Docs: the "four mutations" line is now an explicit table naming each target and
its test, with 0c's pair counted under 0c; and the aggregate teardown latency is
recorded as a deferred item with a trigger (a fourth routine child, or a
measured teardown over 5 s) instead of an unwritten known cost.

638 lib tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:43:04 -04:00
molluskandClaude Opus 5 3aa768af52 docs: the 0b DAG row says four mutations, matching §10 round 14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:19 -04:00
molluskandClaude Opus 5 1cfa932fbe docs: record the 0c mechanism probe, and revise 0b's mutation matrix
The 0c mechanism probe was run on this host before any structural work, because
one unverified assumption could have invalidated the whole approach: whether a
hand-created `adapter` node is visible to pipewire-pulse under the name the
capture path depends on. It is. Five gates green, including the two that
mattered — `<node.name>.monitor` is exposed as a Pulse source, and SIGKILL of
the owning connection removes both Pulse-visible names with zero graph residue.
No null-sink module is involved at any point. Every O1 stop condition for 0c is
retired, and the default sink never moved, so the probe is safe on a live
desktop.

The probe also settled the native-sink scope question: it applies to every mode
that owns a capture sink, not only `DesktopExcluding`. That makes the `--repair`
rework load-bearing rather than defensive — repair derives dead PIDs only from
`module-null-sink` entries, so once the sink is native its loopbacks become
undiscoverable orphans.

§10 gains rounds 14 and 15: the 0b matrix drops to four mutations because the
best-effort wake arm is unreachable by construction (the loop owns a sender, and
the biased select would win anyway), and teardown is hoisted to one unconditional
post-loop site instead of being duplicated across one live arm and one dead one.
Round 15 records the two blocking implementation-review findings and the vacuous
gate of my own that the review's test-double critique exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:06 -04:00
molluskandClaude Opus 5 d8b8fd79cf core/teardown: stay armed across the wait, and never call a failed wait a reap
Codex's review of the two commits below returned "changes requested" with two
blocking findings. Both were real.

1. `ReapOnDrop` disarmed itself across the async wait. `shutdown` moved the
   child out of the wrapper with `take()` before the first `.await`, so if that
   future was cancelled or unwound mid-wait, the raw child dropped with nothing
   but `kill_on_drop` (signals, does not reap) while `Drop` found `None` and did
   nothing — the AEC could then unload over a live child. That is precisely the
   hole the type exists to close, left open for the duration of every wait. The
   child now stays owned by `self` across every await and is released only on a
   *confirmed* reap.

2. A failed wait was silently converted into success, and the hard-kill path was
   unbounded. `wait_reaped` discarded `io::Result`, so a wait error made the
   timeout return `Ok` and shutdown returned as though the reap were confirmed;
   meanwhile a process stuck in uninterruptible sleep after SIGKILL could wedge
   the core command loop forever. The trait now preserves the result, both waits
   are bounded, and the conflict case has an explicit written policy: we choose
   availability, leave the child owned so the bounded Drop retry stays armed,
   and log the residual risk rather than hiding it.

Codex also showed the test double was flattering the implementation in four
ways. All four are closed: the fake can now be cancelled mid-wait, can fail its
wait, and can take several polls to die, and the grace is pinned independently.

That last one caught a flaw in my own gate. The elapsed-time assertion compares
against `STOP_GRACE` itself, so setting the constant to zero leaves it vacuously
true — both sides move together. `the_grace_is_a_real_interval` pins the
constant to a band instead, and now kills that mutation directly.

Mutation-verified again, five mutants, each killed by its own gate: disarming
the wrapper (cancellation test), treating a wait error as success (failed-wait
test, exactly one), a zero grace (the new band test), a single poll instead of
the drop loop (delayed-reap test, exactly one), reversed field order (the two
ordering tests).

Also applies the matrix adjudication, which Codex and I reached independently:
teardown moves out of the reliable close arm to ONE unconditional site after the
loop, so every `break` is covered structurally — including any added later —
instead of duplicating teardown across one live arm and one provably dead one.

637 lib tests, clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:09:58 -04:00
molluskandClaude Opus 5 92a64465a4 core/teardown: ask pixelpass to stop before killing it
The peerspeak half of phase 0c (design v3.4 §7.4 item 1). Stop Share and
session teardown both went straight to `Child::kill()`, i.e. SIGKILL, which
skips pixelpass's own cleanup and leaks one null-sink module every time.

`ReapOnDrop::shutdown` now asks first: SIGINT, a bounded 2 s wait, then SIGKILL
only if the child ignored the request. SIGINT specifically, not SIGTERM —
pixelpass installs only a `ctrl_c()` handler, so SIGTERM would take the default
disposition and be indistinguishable from SIGKILL.

Signalling by pid is safe against pid reuse here: we have not reaped the child,
so it is a zombie whose pid the kernel reserves until we wait it, and the pid
cannot name a stranger. (Same reasoning that dismissed pixelpass bug #6.)

The grace is 2 s because it is awaited inline in the core command loop, so it
is also how long a wedged child can delay other commands. A healthy pixelpass
never spends it.

The drop/unwind path deliberately stays a hard kill: `Drop` cannot await, and
there the ordering invariant (§7.1) outranks tidiness. Once the pixelpass half
of 0c lands, the capture sink is connection-owned and that path stops leaking
by construction.

`libc` becomes a direct unix-only dependency, pinned to 0.2.186 — the version
already in the tree via alsa/cpal/tokio — so Cargo.lock gains one line and no
new code enters the build.

Mutation-verified, five mutations, each killing its own gate: no wait (6 fail),
reversed field order (2, reap test green), no reap loop (2, ordering test
green), no SIGINT (6), no SIGKILL fallback (exactly 1 — the wedged-child test).
633 lib tests, clippy clean, fmt clean.

Not yet field-tested: the live Stop Share gate (SIGINT sent, child exits within
the bound, no fallback kill on the normal path) still owes a real run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:26:05 -04:00
molluskandClaude Opus 5 6ba763774d core/teardown: reap the screen-share children before the AEC unloads
Phase 0b, fixes 1-3 of design v3.4 §7.2 (decision D4). The invariant is that
the echo-cancel module must not unload while a pixelpass host is alive and
fanning out; two paths have to honour it and only one is code we get to run.

The explicit path: `ActiveSession::shutdown` now awaits
`ScreenshareTeardown::shutdown_children`, and the reliable command channel's
close arm tears the session down explicitly instead of letting it drop on the
way out of `run_core_loop`.

The drop/unwind path: the ordering-critical fields move out of `ActiveSession`
into `core::teardown::ScreenshareTeardown`, where `echo_cancel` is the LAST
declared field and therefore the last dropped. Previously it was declared
first (`:682`, ahead of `screenshare_host` at `:685`), so an unwind unloaded
the AEC while the host was still live — and unwind is reachable, the core is
full of `unwrap()` and has no `panic=abort` profile.

Killing is not enough. `kill_on_drop(true)` only signals: it hands the child to
the runtime's orphan queue and returns, which an unwinding runtime may never
drain. `ReapOnDrop` blocks on a bounded 250 ms budget until the child is really
gone, because a bounded stall beats unloading the AEC out from under a live
pixelpass.

Everything is generic over a narrow `ChildProcess` trait and over the guard
type, so ordering is unit-testable without spawning processes or loading
PipeWire modules — the seam idiom already used by `replace_viewer_index`.

Mutation-verified, and the plan's demand that mutations 4 and 5 prove
*different* defenses holds: reversing the field order fails only the
AEC-ordering tests and leaves the reap test green; removing the reap loop fails
only the reap tests and leaves the ordering test green. Removing the explicit
wait fails the explicit-path tests. 631 lib tests, clippy clean, fmt clean.

⚠️ Mutations 1 and 2 of the pinned matrix do not both exist: the best-effort
wake arm is unreachable by construction, twice over. Documented at the site;
adjudication owed in the impl plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:19:07 -04:00
molluskandClaude Opus 5 692ad677d2 docs: record F11-1 closed — boundedness needs a resolved Client
Design v3.7 §6.1.1 gains the round-13 box (the rule, the ordering that is
load-bearing in both directions, and why bridging deliberately still uses the
full union); the phase-5 results file records the close with the measurement
the deferral was waiting for; the impl plan's phase-6 gate note drops F11-1.

pixelpass c78eb2d is the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 04:46:25 -04:00
mollusk bf908adbf0 docs: phase 5 matrix PASSED (13/13) — design round 10, results run 2
The §5.1 dry-run audit gate passes. All 13 rows completed with a non-empty
eligible half in every one, and O5 is re-measured on the fixed graph: worst
recompute 67 µs across every run, 10 µs mean under deliberate churn, busy
fraction 0.0006, readiness 1-2 ms with 18 binds against a 2000 ms budget.

Round 10's finding, and it is the third of exactly the same shape: the
pipewire-pulse PID derivation required a SINGLE repeated pipewire.sec.pid.
WirePlumber repeats one too (two Clients, both sec_pid 1747), so the derivation
returned None permanently on a stock desktop, key 4's suppression never fired,
and every Pulse-emulated node fused into one owner. Row 1's CLEAN control
forwarder and Firefox were both excluded. Fixed in pixelpass 91c4ded by deleting
the heuristic: probe every distinct sec_pid and let /proc/<pid>/comm decide.

Three measured rounds now, all at the observation boundary, none in the
architecture -- and all three were fail-closed and silent, caught only because
§5.1 requires asserting what must remain ELIGIBLE. An exclusion-only checklist
would have passed every one of these builds.

Rows 4-6 are closed through peerspeak's REAL tagging sites (call, mpv, notify,
plus clip) with a hand-launched mpv staying eligible, so the cross-repo contract
is proven end to end on live nodes. Row 10 covers the full sticky lifecycle
including retirement; row 11 is provably non-vacuous (the recycled
node.link-group came back byte-identical and did not inherit taint).

Recorded and NOT claimed as passes: substitutions in rows 8, 9 and 13, and two
reporting-only findings (the audit's sticky flag is uninformative; a bridge's
named key is lost when a leg reappears under a new serial).

Phase 6 remains blocked by F11-1, phases 0b/0c/0d and the Stereo Mix design
call -- this file removes one gate, not all of them.
2026-07-26 02:59:43 -04:00
mollusk b68fca689e Merge phase 1: ownership tagging (SPA-JSON carriers via libspa)
peerspeak-side half of phase 1 of the screenshare audio-exclusion work: every
node peerspeak owns carries two registry-visible ownership carriers, so the
taint engine has a primary root that survives the registry's filtered global
event (design v3.5 section 6.7).

Reviewed by Codex over rounds 10-12; all findings verified and dispositioned.
Round 12's F12-1 (rfind('}') spliced carriers inside a trailing comment, a
fail-open) and F12-2 (depth ceiling taken from the consumer,
pw_properties_update_string, not from the spa-json-dump grammar) are fixed and
live-verified through the real ALSA plugin.

623 lib tests green, fmt clean, clippy clean.
2026-07-26 02:14:25 -04:00
molluskandClaude Opus 5 c82ef07464 audio/ownership: take the depth ceiling from the consumer, not the grammar
Round 12 review, finding 2 — filed as P2, and the interesting part is
that its author retracted it to P3 once we had measurements, while the
remedy it originally proposed would have been a fail-open.

The finding was that our validator rejects nesting `spa-json-dump -s`
accepts, and the suggested fix was a recursive sub-iterator walk to
match the dump tool. Both halves rest on the dump tool being the
reference. It is not. Nothing reads `PIPEWIRE_PROPS` or `PIPEWIRE_ALSA`
with `spa-json-dump`; `pw_properties_update_string` does, in the client
process.

Measured live on this host, against the real ALSA plugin:

    depth 513  dump accept   plugin accept   ours accept
    depth 514  dump accept   plugin accept   ours REJECT
    depth 515  dump accept   plugin REJECT   ours reject
    depth 1000 dump accept   plugin REJECT   ours reject

At 515 the plugin discards the whole object: the node came back as
`alsa_playback.aplay` with no properties at all. So matching the dump
tool would have made us splice carriers into values the consumer throws
away wholesale — losing both, which is the echo this feature exists to
prevent. Over-rejecting costs a routing preference; over-accepting costs
a carrier. Those are not the same price.

What was genuinely wrong is narrower: we sat exactly one level below the
consumer. `pw_properties_update_string` calls `spa_json_container_len`
on a container value, which enters one more sub-iterator before its flat
walk, and that single level is the entire discrepancy. Doing the same
puts the boundaries on the same number.

Codex reached the same three numbers independently by calling
`pw_properties_update_string_checked(NULL, ...)` directly, having
disassembled both call sites; I measured through the live plugin. Two
methods, one table.

The dump differential stays, but it is now labelled a *grammar* oracle
with a warning not to add deep values — it would fail by design. The
acceptance oracle is the new boundary test.

Mutation-verified: removing the container step fails the 514 assertion.
622 -> 623 lib tests, fmt clean, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:59:36 -04:00
molluskandClaude Opus 5 9eab6c118d audio/ownership: let libspa say where the object closes
Round 12 review, finding 1 — a measured fail-open, and the third
distinct door into the same failure.

A trailing comment is valid SPA-JSON and *ends the document*
(`case __COMMENT: return 0` in spa/utils/json-core.h), so an object may
close before the last `}` in the string. `merge_pipewire_props` located
the closing brace with `rfind('}')`, which is a byte scan and not a
parse, so for

    { "target.object" = "my-sink" } # trailing }

it selected the comment's brace and spliced both ownership carriers
*into the comment*. The re-validation did not catch it, because the
result parses perfectly well — as `{ target.object = "my-sink" }`, with
neither carrier present. Confirmed against `spa-json-dump -s`.

That is an untagged node, so no taint root, so echo — exactly what
rounds 10 and 11 each closed by a different route. Latent rather than
live: pixelpass's evaluate() is still audit-only, so today it corrupts
an audit classification and becomes a leak when phase 6 consumes
eligibility.

The whole thesis of round 11 was "do not re-implement someone else's
grammar". The scanner went, but this brace hunt stayed behind in the
caller, which is the same defect wearing different clothes.

So spa_object now reports the object's own closer, taken from libspa:
closing a container at depth 0 writes the brace's position back to the
parent iterator, and spa_json_enter made `outer` that parent. Read
before the trailing check, which advances past it.

Also:
- whatever followed the object is preserved, so a user's trailing
  comment survives instead of being silently deleted;
- the output check now asks whether the object closes where we put our
  brace, not merely whether the string parses. A parse-only check is
  what this finding defeated.

Mutation-verified: restoring `rfind` fails the new test, and dropping
the tail fails it on the deleted comment. Honest note in the code —
mutation cannot distinguish the closer comparison or the is-object
test; both are labelled belt-and-braces rather than presented as
tested.

621 -> 622 lib tests, fmt clean, clippy clean, and the ignored
spa-json-dump differential still agrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:51:14 -04:00
molluskandClaude Opus 5 21ba633825 audio/ownership: validate inherited SPA-JSON with libspa, not a scanner
Round 11 review, findings 2, 3 and 4.

The round-10 fix replaced a brace check with a hand-written scanner. That was
the wrong shape: a second implementation of someone else's grammar drifts in
both directions at once, and measured against `spa-json-dump -s` on this host
it did.

It ACCEPTED `{ "foo" = { garbage } }` (only brackets were balanced, contents
never validated), `{ "a" = "\é" }`, `{ "a" = é }` and `{ "a" = foo\bar }`.
Merging into those put an invalid pair before our carriers, so the daemon
stops at it and drops both -- recreating the exact fail-open the round-10 fix
existed to close. Its own test even pinned `"\é"` as a valid token.

It REJECTED `{ target.object, "my-sink" }`, `{ key == "value" }` and
CR-terminated comments, all valid -- so a user with one of those in their
environment silently lost their routing policy to an overwrite. That half
affects a running Linux user.

Now libspa's own parser validates, and the merge splices into the validated
text instead of re-emitting parsed pairs. Splicing preserves the user's bytes
exactly, which also answers the review's point that re-quoting a bare key can
invent a different one (`foo\bar` -> a string with a \b escape). Three
measured properties make the splice safe -- the last `}` is the object's, a
validated object's brace is never mid-comment, and commas are pure separators
-- and the result is validated again before it is returned.

Mutation testing then deleted the rest: every pairing and recursion check I
had written turned out to be redundant, because spa_json_next already errors
on `{ garbage }` and on nested garbage, and skips containers rather than
descending. ~60 lines of my own grammar logic removed. What remains is gated
by a new differential test against `spa-json-dump -s` over a 27-value corpus
-- the check whose absence caused this round. It found a real disagreement on
its first run (a bare document, which we reject by design, not by accident).

One mutation HUNG rather than failed: dropping the `length < 0` check makes
libspa report the same error without advancing, spinning forever. Kept, now
labelled load-bearing for termination, with a token-count bound beside it.

Finding 4: the ordering test took the first textual match of `fn main`, so a
raw-string decoy above the real function satisfied it while the real one
spawned a thread first. Now requires each of the three anchors to be unique.
Mutation-verified with the review's own decoy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:08:17 -04:00
molluskandClaude Opus 5 45b1b97dd8 audio/ownership: pin the no-lost-carrier invariant, and harden the byte scan
Verification round on the round-10 review fixes.

Adds the property the whole of finding 3 is about, stated directly: over
20,000 deterministic inputs built from the exact characters that break
SPA-JSON (braces, brackets, quotes, separators, comment marks, escapes,
newlines, multi-byte characters), the merge always emits both carriers in an
object it can read back. Either outcome — parse and rebuild, or overwrite —
has to end that way, and now nothing can quietly change which.

Also replaces two byte-index steps with character-boundary steps. Both were
correct on the ASCII input they actually see, but `index + 1` after a
reverse find would have split a multi-byte character and panicked the slice.
scan_token gains multi-byte cases for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:23 -04:00
molluskandClaude Opus 5 ae2e9de523 tests/fixtures: the ownership contract says exact-match, not truthy
Round 10 review, finding 6. The cross-repo contract still documented carrier
1 as "any value other than false/0 is truthy" after R10-4 made pixelpass
match it exactly. A future producer following the fixture could emit "true"
and silently lose the carrier.

Committed byte-identical with pixelpass's copy in the same session, as the
file's own rules require.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:57:16 -04:00
molluskandClaude Opus 5 985c63806b audio/ownership: state the playlist policy, and gate main's ordering properly
Round 10 review, findings 2 and 5.

Finding 2 — R10-2's rationale for tagging local playlist audio was factually
wrong. It claimed a local track is "already being broadcast to peers on the
same keypress", but shared listening is opt-in: music_broadcast defaults to
false, play_music_index starts local playback unconditionally, and
broadcast_track returns immediately when can_broadcast_music is false. So a
default-config playlist is not already broadcast.

The tag stays, now as an explicit policy with the real reason: the carriers
reach rodio through PIPEWIRE_ALSA, which is process-wide, and clip_player
and music_player are two ClipPlayer instances in one process — no value of
that variable can tag one and not the other. Exempting the playlist means
giving it a separately taggable stream, which is a large change for a case
with a one-step workaround (play it in any other app). Tagging is not
optional for received clips and peer music, which are the far end's own
audio.

Finding 5 — the ordering test proved only "before run_gui", which a
thread::spawn inserted above the tag still satisfies while making the
set_var a data race. It now requires the tag to be the first executable
statement in main: attributes, `unsafe` and block punctuation are stripped,
and any residue fails. Mutation-verified against a spawn, an unrelated
statement, and the call deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:51:02 -04:00
molluskandClaude Opus 5 6fc55a286d audio/ownership: parse inherited SPA-JSON instead of trusting its braces
Round 10 review, finding 3. The merge's shape check was the outer braces
only, so an inherited `PIPEWIRE_ALSA='{ garbage }'` was spliced into rather
than overwritten, producing an object the daemon does not accept.

Measured live 2026-07-25, and the failure is worse than a rejection: with
PIPEWIRE_ALSA set to the old merge's output, a real aplay node came up as
node.name=alsa_playback.aplay, no peerspeak.owned, and a junk property
`garbage = "peerspeak.owned"` — the lenient parser ate our key as their
value and stopped. Both ownership carriers lost on a live
Stream/Output/Audio node, which is an echo.

So: parse the inherited object and REBUILD it with our pairs last, rather
than splicing before the closing brace. Rebuilding is what makes the result
independent of the input's formatting — a value ending in a `#` comment
would otherwise swallow everything appended after it.

The three values the new merge emits were verified against the live daemon
(user props preserved, both carriers present) and are pinned byte-for-byte.
scan_token is gated on its own postcondition: at the object level an
unterminated string is also caught by "the object never closed", so the two
implementations only disagree at the seam.

Also parameterizes the malformed-value warning, which always named
PIPEWIRE_PROPS even when PIPEWIRE_ALSA was the malformed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:47:32 -04:00
molluskandClaude Opus 5 d63db68318 audio/ownership: apply the merge rule to the ALSA carrier too
Verification round on round 10's own fixes, not on the next layer.

R10-5 preserved a user's PULSE_PROP and PIPEWIRE_PROPS but
tag_this_process_alsa_audio still clobbered their PIPEWIRE_ALSA, which is
the same kind of routing policy and deserves the same treatment. Both it
and tag_child now merge.

MEASURED, rather than assumed, because "our pairs go last so they win"
was load-bearing for the whole merge design and was never checked:
  PIPEWIRE_PROPS='{ "node.name"="theirs_first", "media.role"="music",
                    "node.name"="ours_last" }' on pw-play
    -> node.name=ours_last, media.role preserved.
  The PULSE_PROP equivalent on paplay -> the same.
So last-wins holds on both grammars: a user who already sets node.name
cannot silently untag us, and their other keys survive.

That also makes tag_child's ALSA carrier merge from the inherited value
safely: in production main has already put this process's `clip` tag
there, and the child's own role now overrides it by coming last. The
existing row could not see this — the test binary never runs main, so it
only ever exercised the merge-into-nothing case. Added a row that drives
the real shape directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:33 -04:00
molluskandClaude Opus 5 e7923a1b5c audio/ownership: merge inherited player env vars instead of clobbering
PULSE_PROP and PIPEWIRE_PROPS can legitimately carry a user's own routing
policy — media.role, a target sink — and replacing them changes where the
user's audio goes as a side effect of a tagging mechanism that is
supposed to be behaviourally invisible.

PULSE_PROP is space-separated key=value, so merging is appending;
PIPEWIRE_PROPS is a SPA-JSON object, so it is an insert before the
closing brace. Our pairs go last in both, so they win a duplicate key —
without that, a user with node.name already set would silently untag us.
A value that does not match the expected shape is logged and overwritten:
a half-merged string that fails to parse would drop the tag silently,
which is worse than losing a routing preference. No full SPA-JSON parser,
which would be over-engineering for a case with no live consumer
(measured: neither variable is set anywhere in this user's env or config).

Also sets PIPEWIRE_ALSA on the child, with the child's own role. A player
configured for ALSA output is reached by neither of the other two
variables, so this closes a real gap rather than only a cosmetic one —
and without it such a child would inherit this process's `clip` tag from
tag_this_process_alsa_audio and report the wrong role in the audit.

Corrects a stale doc comment on OWNED_PROP_VALUE that still claimed
pixelpass accepts any truthy value; R10-4 made the match exact. Codex's
F5 was reasoned partly from a stale comment of mine, so these are worth
fixing on sight.

Codex phase-1 review F4. Round 10, R10-5.
8 new rows; 5 mutations verified (clobber PULSE_PROP, our pairs first,
naive object concat, doubled trailing comma, drop the ALSA carrier).
All 4 live ownership gates re-run green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:03:32 -04:00
molluskandClaude Opus 5 b5569fe2c6 audio: tag the fourth playback path, rodio's ClipPlayer
ClipPlayer opens a rodio default sink, which on Linux reaches the graph
through PipeWire's ALSA plugin. It was untagged through all of phase 1,
and it is a real echo path: B broadcasts music, A tunes in, A shares
their desktop, B hears their own track played back at them. Confirmed
live as `alsa_playback.peerspeak-...` with no ownership properties.

rodio exposes no way to set PipeWire node properties, so the carrier is
PIPEWIRE_ALSA, set once at the top of main while still single-threaded.

Measured, with PIPEWIRE_PROPS and PULSE_PROP unset, to establish that
setting it process-wide is safe:
  - aplay (ALSA plugin)   -> both carriers land. Confirms the mechanism.
  - pw-play (native)      -> untouched. Our own call-playback and capture
                             streams are native, so they keep their own
                             explicit tagging and are unaffected.
  - arecord (ALSA capture)-> IS tagged, on a Stream/Input/Audio. Not
                             surgical in the role dimension; harmless only
                             because R10-1 honours the carriers on
                             producers alone. This is why R10-1 lands first.

Local playlist tracks are tagged too, not just inbound peer audio. A
local track is already broadcast to peers over the call on the same
keypress, so sharing it again through the screen share would send the far
end two copies at differing latency. That is a defect, not a feature.

Codex phase-1 review F1. Round 10, R10-2.

New live exit-gate row drives the real ClipPlayer; mutation-verified
(drop the tag -> no node within 5s). The wiring guard is mutation-
verified too, and its first version was WRONG: it searched raw source and
passed against a main with the call deleted, because the comment above it
named the function. It strips comments now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:52:22 -04:00
molluskandClaude Opus 5 503f78153b audio/ownership: refuse an ambiguous contract fixture
Producer half of the same fix (Codex phase-1 review, finding 3, P2).
This side collected fixture lines into a map, so a duplicated key
silently took the last value while pixelpass took the first — both
repos green on different contracts.

Mutation-verified in both repos with a duplicated `prop_value`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:27 -04:00
molluskandClaude Opus 5 d40385f85c notify: correct a measured claim about the aplay fallback
The comment said aplay ignores PULSE_PROP/PIPEWIRE_PROPS. Measured:
it reaches the graph through PipeWire's ALSA plugin and carries both
carriers exactly like pw-play and paplay. Comment only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:07:08 -04:00
molluskandClaude Opus 5 bcf1343a55 phase 1: tag every audio node peerspeak owns, on both carriers
Zero behaviour change. This is what makes the screenshare exclusion
engine able to see us at all (plan §5.1, impl plan §3): pixelpass must
refuse to fan out our own playback, and until now it had no way to
recognise it.

Two carriers, matched by pixelpass as a union — `peerspeak.owned=1`
and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added
the second after the phase-5 audit found a node property is invisible
to the PipeWire registry `global` event and recoverable only by
binding the node; the prefix is announced directly. A union is also
the fail-closed direction: a missed tag leaks call audio into a share,
a spurious one only over-excludes.

Three tagging sites, all three verified live on this host:
  - native call playback  → props on the stream dict
  - screenshare mpv/VLC   → PULSE_PROP + PIPEWIRE_PROPS on the child
  - notification chimes   → same, on pw-play/paplay

The literals are a cross-repo wire contract, so they appear once here
as named constants and are pinned in a fixture committed byte-identical
in both repos (tests/fixtures/ownership-tag-contract.txt). The contract
test is black-box: it builds a real child `Command` and reads back the
environment it would carry, rather than testing our own formatter.

Three live `#[ignore]`d exit-gate tests drive the real call sites and
poll `pw-dump` for the resulting node — the plan requires the tag be
shown landing on a live node, not just in the env. All three
mutation-verified (drop either carrier, or the role, and the matching
gate fails).

Measured while verifying: mpv, VLC, pw-play and paplay all honour
`node.name` from those env vars. The native stream set neither
`application.name` nor a description, so a mixer fell back to
`node.name` — which the tag turns into an internal identifier. Added
an explicit `node.description = "PeerSpeak"` there, which keeps the
plan's rule (the prefix must not reach `node.description`) while
preserving its intent: mixers stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:25 -04:00
mollusk 6773a3882b docs: round 9 — uncertainty is not history (design v3.6)
Phase 3r landed §6.7 and the phase-5 audit was re-run against it immediately.
It found a second measured defect within minutes: a real hardware sink
carrying `unresolved-ancestry` permanently, from one link observed while its
output node was still unbound during enumeration. Round 8 made that
systematic rather than rare, because every node is now withheld until its
bind resolves.

New §6.8: sticky taint is a claim about history, and uncertainty is not
history. Retiring by reason code would not be enough — an unresolved node
propagates `TaintedUpstream`, which is indistinguishable from real
contamination once recorded — so the split is by provenance: the engine runs
its fixpoint twice, and only the evidence-only pass may feed sticky state.
Decisions are unchanged and still fail closed.

Also recorded in §6.8, both from Codex's round-9 review and both pre-existing:
hardware playback-to-capture paths ("Stereo Mix") defeat the `session_device`
classifier in a way the driver denylist cannot detect — a real echo path
needing a design call — and the 2 s readiness budget has no calibration
argument beyond one measurement on one idle desktop.

Impl plan: phase 3r marked built and merged with its gate results, including
the extra Device-side live gate and why row 1 alone could not cover it.
2026-07-25 18:51:21 -04:00
molluskandClaude Opus 5 1cd19b355f docs: design round 8 — the observation boundary (v3.5)
The phase-5 dry-run gate failed on its first live run: the engine built to
v3.4 could not see its own primary taint root (echo, AEC off) while excluding
every stream on the machine (silence). One cause — the PipeWire registry
`global` event carries only a filtered subset of an object's properties, and
eight the design depends on are never announced.

Design doc (v3.4 → v3.5):
- NEW §6.7 — the observation boundary. The global is an index, not a source of
  truth: bind every Node and Device, `info` props are the sole source, live
  prop tracking, one readiness obligation per unbound node, fail closed.
  Four user design calls recorded.
- §5.1 — a second, registry-visible tag carrier (`node.name` prefix) alongside
  `peerspeak.owned`, so the primary root does not rest on one mechanism.
- §6.4 — node/device props are not an optimisation to skip, they are
  unavailable from the global; the round-6 Link lesson was right and applied
  to exactly one object type.
- §6.1.0, §6.1.4 — the two corrections the impl plan owed v3.5: a
  time-dependent "hazard is LIVE" claim, and an unreachable nominated test
  case (twice over).
- §9.1 measured facts, §12 rig discipline (pw-dump binds; the registry does
  not), §14 readiness.

Impl plan:
- NEW phase 3r with a four-part exit gate, the first the direct inverse of the
  finding. Ports deliberately not bound in v1, with a revisit trigger.
- Phase 1 pins the second carrier literal as a cross-repo contract.
- Phase 5 marked GATE FAILED; matrix and O5 re-run after 3r and 1.
- Risk register: the over-exclusion row fired and worked; new row for the
  observation boundary class.

Architecture is unchanged and vindicated: fed correct properties, the engine
decided correctly in every fixture. The §5.1 exact-partition requirement is
what caught this — every exclusion was defensible and the eligible half was
empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 17:04:51 -04:00
molluskandClaude Opus 5 297f4397a7 docs: phase 5 dry-run audit results — GATE FAILED, two findings
Impl plan §5's required results file. Phase 6 does not start.

F1 (fatal): the PipeWire registry `global` event delivers only a filtered
subset of node properties, and eight of the properties the phase-3 adapter
reads are not among them — peerspeak.owned, pulse.module.id, node.link-group,
application.process.id, node.passthrough, device.api, factory.name,
alsa.driver_name (plus port.exclusive on Ports). They are silently absent, so
the primary taint root never fires, the AEC identity can never validate, and
session_device is universally false. Measured on PipeWire 1.6.8 /
WirePlumber 0.5.15, with the full announced key set for all five object types
recorded. Links and Clients are unaffected; pulse-PID derivation works.

F2: with F1 in force no node has a strong owner key, so any tainted capture
stream is an unbounded tainted reader and phase 2's fail-closed backstop
excludes every Stream/Output/Audio on the machine. Fail-closed, so silence
rather than echo — but entirely non-functional, and non-functional in a way an
exclusion-only checklist would have scored as passing. The eligible half of
the §5.1 partition is what caught it, exactly as the plan argued it would.

The fix direction is measured and recorded: binding each Node and reading its
info props recovers every missing property, which is the pattern phase 3
already built for Links. factory.id is not a shortcut — it resolves to
"adapter", not api.alsa.pcm.sink.

O5 is closed with ~4 orders of magnitude of headroom: 308 graph events in
6.5s under churn, every recompute under 50us (max 15us), busy fraction 0.0004.
Caveat recorded — measured on the degraded graph, and the F1 fix adds
per-node bind I/O this run did not measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:43:23 -04:00
molluskandClaude Opus 4.8 283d938b79 docs: sequenced implementation plan for screenshare audio exclusion
Turns the converged v3.4 design into ordered phases with falsifiable exit
gates. Three adversarial review rounds with Codex (gpt-5.6-sol, xhigh);
findings adjudicated rather than accepted wholesale, with reachability
verified against source on both sides.

Structural decisions:
- Phase 0d closes BOTH unsafe paths into the capture (source string and
  capture-sink inputs) before any machinery that could take them exists.
- Phase 5 dry-run audit mode is a hard gate: the taint engine runs against
  the live graph, creating no links, asserting exact eligible/excluded
  partitions with reason codes.
- Link manager is deliberately last among the pixelpass components.

Two measured corrections owed back to v3.4 (plan §11): §6.1.0's "hazard is
LIVE right now" has already flipped and must not be gated on, and §6.1.4
nominates an unreachable test case (as did my first replacement for it).

Design approval only. No code, nothing approved for merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:34:27 -04:00
molluskandClaude Opus 4.8 fd72e6f018 docs: close the AEC default-sink question raised by §6.1.0
Checked ~/.config/peerspeak/config.json: output_device and input_device are
both pinned to the Arctis, so echo_cancel::enable always passes sink_master
explicitly and the AEC binds to real hardware regardless of Sunshine owning
the default sink. Not live for this user.

Kept as a low-priority general defect: on "system default", the master args
are omitted (echo_cancel.rs:89-94) and module-echo-cancel binds to whatever
the default is, which on a box like this one is a null sink. Hardening would
be to resolve and validate the default before load. Own task, not this
feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 05:22:32 -04:00
molluskandClaude Opus 4.8 8768cd242c docs: v3.4 audio-exclusion — CONVERGED, ready for implementation planning
Round 7. Codex ratifies: v3.3 is ready to become the implementation plan.
Seven rounds, every blocker fixed or refuted with evidence. Design approval
only — nothing approved for merge, no code written.

Two subtle catches from the ratification round, both applied:

- The owner-key union had a wording trap that would have preserved the exact
  bug it was written to fix. "Resolves" must mean "yields a MATCH between the
  two legs", not "first property present on the node" — client.id IS present
  on both gst-launch legs but differs, so a first-present implementation stops
  at key 3, sees a mismatch, concludes "different owners" and leaks. Now
  specified as try-in-order-until-equal, with a dedicated test.
- Sticky taint must be lifetime-aware, not keyed on raw ids. client.id, node
  ids, module indices, link-groups and PIDs all recycle on this stack, so a
  bare key would hand an unrelated future app permanent inherited taint.
  Stored against live owner components, cleared only when all members vanish.

Also added: how pixelpass learns the pipewire-pulse PID itself (consistent
pipewire.sec.pid across Pulse clients, validated against /proc/<pid>/comm),
with the failure modes in both directions — safe only because unresolved
ancestry is fail-closed, which is the invariant the section rests on.

NEW LIVE FINDING (§6.1.0), the strongest reachability evidence yet and one
Codex's sandbox could not have seen: the user's CURRENT DEFAULT SINK is
sink-sunshine-stereo, a support.null-audio-sink. Every hardware sink is
SUSPENDED; the only RUNNING sink is Sunshine's virtual one, with Firefox
playing into it and sunshine reading its monitor. The hazardous forwarder
topology is live in the default audio path full time, with no EasyEffects
involved. It also means the rejected hardware-sink-only shortcut would have
captured NOTHING on this machine. Flagged separately, explicitly UNVERIFIED:
what module-echo-cancel binds to when the default sink is an app-owned null
sink.

§12 expanded with a graph-engine test surface (node-local tests cannot catch
C2/C3-class defects). §14 rewritten: convergence table, agreed v1 scope, and
what is deliberately out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 04:14:34 -04:00
molluskandClaude Opus 4.8 da72541e18 docs: v3.3 audio-exclusion — owner-key union + sticky taint
Round 6. Codex disagreed with two of my three round-5 claims and was right
about both; I had independently refuted one of them with a sharper test.

C2 REFUTED (by my own measurement): client.id is NOT an owner bridge. One
gst-launch process doing capture+playback produced TWO client objects (209
input, 210 output), no link-group, same application.process.id 20172. So
client.id bridges a *connection*, not an owner, and GStreamer — the same
framework pixelpass uses — splits them by default. Replaced with a
conservative union, strongest first: node.link-group, owned pulse.module.id,
client.id, node application.process.id, else fail closed.

With a trap Codex did not flag: application.process.id is pipewire-pulse's
PID for module-created streams, so bridging on it would fuse every Pulse
module's legs into one owner and mass-exclude tunnel/RTP/loopback audio the
user may legitimately want shared. Never bridge on that key when it equals
the pipewire-pulse PID; keys 1-2 already cover those precisely. PID thus
returns to the design in the CORRELATION role while remaining unusable in
the IDENTITY role — and in that role a wrong answer fails closed.

C3 CONCEDED: taint must be STICKY. Current-topology taint forgets buffered
audio — an app that reads a tainted monitor, buffers, then closes its input
leg would be relinked while still emitting peerspeak audio from the buffer,
and no graph event marks the drain. Taint now persists per owner until its
nodes disappear. Added §6.1.4 quantifying the arrival-side window (~10.6-21.3
ms quantum plus scheduling) and noting it is zero when taint roots already
exist, which is the common case.

C1 SUSTAINED with Codex's caveat: node-granular traversal is free for the
monitor boundary, but over-taints Audio/Duplex nodes. Fail-closed, accepted
for v1, documented as a known contradiction of the "Firefox with a mic stays
shareable" promise on duplex devices.

S1: endpoint props demoted to an optimization; bind-LinkInfo fallback is the
correctness path. S2: readiness epoch + revalidate before each link creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 04:01:37 -04:00
molluskandClaude Opus 4.8 8610ab2eb6 docs: v3.2 audio-exclusion — signal-graph taint, measured
Round 5. Codex and I converged independently on the same conclusion — a
Link-only ancestry walk does not catch the leak — it from the crate/header/
WirePlumber sources, me from the live graph. Its sandbox could not reach the
daemon (pw-dump: Operation not permitted), so the measurements are mine.

Reproduced the EasyEffects topology with module-null-sink + module-loopback
(same shape, no EasyEffects needed). Result: there is NO Link object between
a forwarder's input leg and its output leg. Walking upstream from the leaking
node over Links alone finds no inbound links at all — a dead end that reads
as "clean". The legs are related only by shared node.link-group / client.id /
pulse.module.id.

So the signal graph needs three edge types:
1. Link edges — measured: registry Links carry all four endpoint props.
2. Sink-monitor — measured FREE at node granularity: the monitor connection
   IS a real Link whose output node is the sink itself. Codex held that this
   must be modelled explicitly; that is true only for a port-granular walk.
   Taint walks at node granularity, links are created per port.
3. Owner bridge — node.link-group when present, else client.id (measured
   shared across the forwarder's legs, distinct per app). Only modules set
   link-group, so client.id is what covers ordinary apps.

New §6.1.1: bridge taint must be CONDITIONAL on the input leg being tainted.
"Client has both legs ⇒ exclude" would exclude every app using a microphone.
Firefox in a Meet call stays shareable; Firefox sharing desktop audio does not.

Also: §6.5 rejects the cheap "hardware-sink-only" predicate with a measurement
— the forwarder's output leg links directly to alsa_output, so the shortcut
passes the leak and excludes the innocent app, backwards on both halves.
§6.3 barrier corrected: core sync/done is a previous-work roundtrip, not graph
quiescence. §6.4 adds crate version, endpoint fast path + bind fallback, and
full-recompute cost. §5.2 correction 5 rewritten: application.process.id lives
on the Node and is the app's own PID; pipewire.sec.pid lives on the Client and
is pipewire-pulse's for every Pulse client. That resolves four rounds of
contradictory PID claims.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:49:09 -04:00
molluskandClaude Opus 4.8 100117085d docs: v3.1 audio-exclusion — apply Codex round-4 findings
Round 4 (review-2026-07-21-design-v3-round4.md) returned 5 findings, 3 of
them blocking. All claims re-verified against source before acceptance.

Biggest correction: eligibility is a GRAPH property, not a node property.
Exclusion does not propagate downstream — a filter-chain/loopback/combine-sink
re-emits the mix as a fresh untagged Stream/Output/Audio that passes both the
peerspeak.owned and pulse.module.id checks, re-injecting the whole call into
the share. Reachability confirmed: easyeffects IS installed on this machine
(it merely wasn't running during the fan-out spike, which is why the spike
missed it). §6 rewritten around transitive upstream reachability, tracking
Node/Port/Link globals, with a registry sync barrier and revalidation
immediately before each link creation.

Also applied:
- §5.3 is now a bounded validation state machine, not a one-shot check.
  wait_for_nodes only waits for the virtual source/sink, never the playback
  hazard leg, and pixelpass capture spawns lazily on first viewer, so the
  one-shot check raced in both directions. Revocation redefined as loss of
  the module identity, not transient absence of one leg.
- §7.2: reordering ActiveSession fields is NOT sufficient — kill_on_drop
  sends SIGKILL without waiting, so AEC can still unload while pixelpass
  lives. Fix is explicit shutdown().await at both channel-close breaks,
  field order as defence in depth, plus a fake-resource ordering test.
- §5.1 relabelled implementation sites; none of them tag anything today.
- Stop Share citation corrected to :699/:3480.
- D1-D7 resolved; readiness section added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:31:12 -04:00
molluskandClaude Opus 4.8 cab6bafce5 docs: v3 audio-exclusion design — rewrite around Option C + AEC gate
v1/v2 described a move-based design that Option C superseded on 2026-07-20,
and the AEC playback-leg identity gate has since passed. Roughly two thirds
of v2 documented problems Option C does not have, so this is a rewrite rather
than a patch (v1/v2 remain at 88ad5a0 / 10203e1).

Folds in: the four AEC gate results, the five corrections that constrain them
(observed correlation not a contract; exact-equality only; index/link-group
reuse and node-id recycling; group prefix = hazard detection not ownership;
application.process.id == pipewire-pulse for module-created streams), the
verified implicit-drop ordering defect in ActiveSession, fail-closed
validation/revocation, the IPC shape, and the split-out prerequisites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:16:30 -04:00
molluskandClaude Opus 4.8 10203e1edb docs: adopt fan-out (Option C) after feasibility spike
Ran the direct-link spike on the live graph (PipeWire 1.6.8,
WirePlumber 0.5.15). Fan-out carries full-level audio for paplay, mpv
and VLC while the application keeps its existing speaker link;
WirePlumber does not reap foreign links across default-sink switch,
suspend/resume or 100s steady state; and non-lingering links are
destroyed automatically when their owning connection is SIGKILLed.

The decisive result is that destroying the capture sink mid-share left
the application playing to its speakers undisturbed, so capture-side
failure degrades to "not captured" rather than breaking the user's
audio. That is the property the move-based design had to work hard to
approximate.

Records what the spike does not prove: fidelity beyond signal presence,
daemon restart, quantum perturbation, and exclusive/passthrough streams.
The capture null sink is still pactl-owned, so Stop Share continues to
leak a module every time and the graceful-stop work is still owed.

Eligibility becomes a broad guarded selector rather than a narrow
allowlist, since copying no longer risks disturbing the source.

Option A and its attendant cleanup, restore and output-switch machinery
are retained for the record but are no longer the plan. A v3 rewrite is
owed once the AEC playback-leg identity is settled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:04:25 -04:00
molluskandClaude Opus 4.8 88ad5a0807 docs: screenshare audio exclusion design v2
Rewrite after Codex's adversarial review of v1 found four release
blockers, all independently verified against source.

v1's premise was wrong: whole-desktop capture bypasses Routing::start
entirely (pipeline.rs:121), so this needs a new capture mode rather than
an inverted predicate.

v2 replaces PID-based identity with ownership by inherited tag, and makes
the router an allowlist so unrecognized infrastructure is left alone
rather than optimistically moved. Graceful stop becomes a prerequisite:
Stop Share is currently SIGKILL, so cleanup never runs on the normal path.

Records live measurements taken 2026-07-20: PULSE_PROP tagging reaches
the graph for paplay, mpv and VLC, and application.process.id is the
client's own PID, not pipewire-pulse's — correcting a claim both the
review and v1 relied on.

Adds Option C (fan out a second owned link instead of moving streams),
which deletes most of the cleanup, latency and multi-host problems the
move-based design has to solve. Not yet implemented; gated on a
feasibility spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:54:53 -04:00
molluskandClaude Opus 4.8 0588d92537 release: 0.6.6
CI / check (push) Failing after 5m35s
The live-edge catch-up (8c4f4a0, b4a4c00) landed after the v0.6.5 tag, so
the 0.6.5 artifacts do not contain it — the same gap that left the fix out
of v0.6.4. Cut 0.6.6 so the published build actually carries it.

Local-only changes (no wire change; PROTO planes unchanged), so this is a
PATCH bump per VERSIONING.md.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:57:05 -04:00
molluskandClaude Opus 4.8 b4a4c00711 fix(screenshare): make live-edge catch-up actually recover
CI / check (push) Failing after 2m37s
The first cut used a fixed 1.05x drain, which measurement showed was too
gentle to matter: clearing a 6 s backlog would take two minutes, which a
viewer experiences as still broken.

Two changes, both measured on the netem satellite rig (loopback
impairment, gst -> ffmpeg HTTP relay -> mpv, matching the http:// URL
production actually serves):

1. Proportional drain. Speed now scales with buffer depth,
   1 + 0.05*(cache - 0.5), clamped to 1.15x, keeping the hysteresis band
   so it cannot oscillate. Deep backlogs recover in tens of seconds;
   small excursions still get an inaudible nudge.

2. Bound the byte cache in Low latency. The demuxer 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. Capping Low latency at 1 MiB halved the
   standing buffer, 6.0 s -> 2.8 s, on its own. Smooth keeps the user's
   value, since a deep buffer is that posture's whole point.

Measured effect with both: playback consumes 11.6% faster than realtime
while behind (ratio 1.1157 vs 0.9988 with catch-up off), i.e. ~9 s of
backlog cleared in 80 s where before it recovered nothing at all and the
viewer stayed behind for the rest of the call.

Rig caveat: its upstream queues hold an unbounded backlog, so the cache
never drops back through the low mark and the return-to-1x transition is
only covered by unit tests, not the rig.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:47:32 -04:00
molluskandClaude Opus 4.8 8c4f4a0b8b feat(screenshare): drain a lagging viewer back to the live edge
CI / check (push) Failing after 2m12s
On a lossy link the reliable PixelPass transport turns every loss burst
into buffered latency that nothing trims back, so the viewer settles
seconds behind the host and stays there. Measured on a tc netem satellite
simulation: a viewer parks at a ~6 s standing buffer indefinitely.

--untimed (0.6.5) does NOT fix this and measured marginally worse (+1.38 s
vs +1.24 s): it only unpaces presentation, while audio still drains at 1x
the DAC rate, so an accumulated backlog never shrinks. Drop it.

Instead give mpv a JSON IPC socket in the Low latency posture and drive
playback slightly fast while the buffer is deep, returning to 1x once it
drains. Pitch correction keeps it inaudible and A/V sync is preserved,
because audio and video speed up together.

The control law and IPC message handling are pure functions with unit
tests; the only I/O is livesync::drive, which ends by itself when the
player exits. Smooth is deliberately excluded — its ~2 s readahead is the
point of that posture, and catch-up would fight it every poll.

Known limitation: 1.05x needs ~120 s to clear a 6 s backlog, so recovery
is slower than ideal. Tuning (a proportional law, or a seek-to-live for
large backlogs) is the follow-up.

598 lib tests green (+11), clippy clean.

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