Compare commits

..
Author SHA1 Message Date
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
10 changed files with 2709 additions and 353 deletions
Generated
+1
View File
@@ -4883,6 +4883,7 @@ dependencies = [
"image",
"iroh",
"iroh-gossip",
"libc",
"opus",
"pipewire",
"rand 0.10.1",
+7
View File
@@ -109,3 +109,10 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
] }
# Unix-only. Used for exactly one thing: sending SIGINT to our own
# pixelpass child so it can run its cleanup before we resort to SIGKILL
# (src/core/teardown.rs). Already in the tree via alsa/cpal/tokio, so
# declaring it directly adds no new code to the build.
[target.'cfg(unix)'.dependencies]
libc = "0.2.186"
+359 -11
View File
@@ -76,7 +76,7 @@ if it differs, failing closed.
| # | Phase | Repo | Mutates graph? | Exit gate |
| --- | --- | --- | --- | --- |
| 0a | `object.serial` u64 fix | pixelpass | no | boundary parse tests |
| 0b | Explicit teardown + drop ordering | peerspeak | no | **five independent mutations** (plan §2) |
| 0b | Explicit teardown + drop ordering | peerspeak | no | **four independent mutations** (plan §2; revised from five, §10 r14) ✅ built |
| 0c | Graceful stop + connection-owned capture sink | both | sink ownership | two-host SIGKILL live gate **+ SIGINT-first gate** |
| 0d | **Typed capture plan + internal mode input** | pixelpass | no | mode matrix; neither unsafe source nor unsafe sink input constructible |
| 1 | peerspeak ownership tagging | peerspeak | no | tag on live nodes; literal pinned in plan §3 |
@@ -151,7 +151,19 @@ v3.4 §7.2, decision D4. All **three** of v3.4's fixes:
AEC guard unloads. This is the *only* protection on the panic/unwind path, and unwind is
reachable — the core has numerous `unwrap()` sites and no `panic=abort` profile.
⚠️ **Mutation testing: five mutations, each independently breaking a named test.** v1 demanded
> ✅ **0b IMPLEMENTED 2026-07-26** (peerspeak branch `phase-0b-teardown`). The ordering
> defect was live: `echo_cancel` was declared *ahead* of `screenshare_host`, so any unwind
> unloaded the AEC while the host was still fanning out. Fields moved into
> `src/core/teardown.rs` with `echo_cancel` declared last, and `ReapOnDrop` added because
> `kill_on_drop(true)` only *signals* — it hands the child to the runtime's orphan queue,
> which an unwinding runtime may never drain. Matrix revised to four mutations; see §10
> round 14, and round 15 for the two blocking review findings that followed.
>
> **Owed to phase 9:** an explicit lifecycle row — *drop the controller / close the command
> channel while sharing* — which is the live proof for the hoisted teardown call site.
⚠️ **Mutation testing: five mutations, each independently breaking a named test.**
⚠️ **SUPERSEDED by §10 round 14 — mutation 2 is vacuous and the matrix is now four.** v1 demanded
a mutation that targeted the wrong defense; v2 fixed that but bundled two defenses into one
combined mutant, which proves neither. Final form:
@@ -167,6 +179,45 @@ Both channel-close arms get their own test; a single "closes the command channel
exercise one arm and leave the other unsafe.
### 0c. Graceful stop + connection-owned capture sink — both
> ✅ **MECHANISM PROBE PASSED on this host, 2026-07-26** (PipeWire 1.6.8). Run *before* any
> structural work, on the reviewer's insistence, because a single unverified assumption could
> have invalidated the entire approach: whether a hand-created adapter is visible to
> pipewire-pulse under the name pixelpass's capture path depends on. It is.
>
> ```
> pw-cli> create-node adapter factory.name=support.null-audio-sink \
> node.name=pixelpass_probe_<pid> media.class=Audio/Sink \
> audio.channels=2 audio.position=[FL,FR] node.virtual=true \
> monitor.channel-volumes=true object.linger=false
> ```
>
> Five gates, all green:
> 1. `pactl list short sinks` shows the sink under the **exact** `node.name`.
> 2. `pactl list short sources` shows **`<node.name>.monitor`** — the derived monitor name is
> a pipewire-pulse contract, not a property of Pulse-created sinks. This was the one that
> could have sunk the approach.
> 3. `gst-launch-1.0 pulsesrc device=<node.name>.monitor num-buffers=40 ! fakesink` pulled its
> buffers and exited clean, and a real recording stream attached — so the pixelpass capture
> path works against it unchanged.
> 4. **No null-sink module was loaded** (`pactl list short modules | grep -c null-sink` stayed
> at its baseline of 3). It is genuinely not a Pulse module.
> 5. **SIGKILL of the owning connection removed both Pulse-visible names**, with zero residue
> anywhere in `pw-dump`. That is the entire point of 0c, demonstrated on the real graph.
>
> The default sink never moved, so this is also safe to run on a live desktop.
> **Every O1 stop condition listed for 0c is retired.** `object.linger=false` is load-bearing:
> the bundled pipewire-rs example sets `linger=1` for the opposite behaviour.
>
> ⚠️ **`--repair`'s job does not shrink — it BREAKS.** Discovery derives dead host PIDs
> **only** from `module-null-sink` entries (`pixelpass/src/repair.rs`), and only then matches
> loopbacks against that PID set. The native sink is scoped to **every mode that owns a
> capture sink**, not just `DesktopExcluding`, so legacy Pulse loopbacks will coexist with a
> connection-owned sink; when that host dies the sink vanishes automatically and its loopbacks
> become **undiscoverable orphans**. Candidate PIDs must be derived independently from all
> three module shapes (`null-sink sink_name=`, `loopback sink=`, `loopback source=…monitor`),
> with a liveness recheck immediately before each destructive unload. This makes the repair
> rework **load-bearing, not defensive**.
v3.4 §7.4. The **largest hidden cost in Phase 0**: moving the null sink off `pactl load-module`
(`pixelpass/src/host/audio.rs:69`, cleaned up only in `Routing::cleanup` at `:259-260`, which
SIGKILL skips) onto a connection-owned PipeWire object.
@@ -458,17 +509,32 @@ observable in Phase 5 before they gate anything real.
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results:
> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no
> rework — **it found the defect on its first live run**, which is the phase working exactly as
> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The
> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable
> under the defect, so none of it carries over. O5's numbers do not carry over either.
> **🚦 STATUS 2026-07-26: GATE PASSED on run 2.** Results:
> `docs/screenshare-audio-exclusion-phase5-results.md`. All 13 rows completed, the eligible
> half of every row is non-empty, and O5 is re-measured on the fixed graph (worst recompute
> 67 µs; readiness 12 ms with 18 binds). Three rows carry recorded substitutions (8, 9, 13)
> and three findings are recorded as non-blocking.
>
> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off
> pixelpass --audit-audio`. **Every partition row must run with `AEC=off`** — a
> **Run 2 found and fixed a third defect of the F2 class, F13-1:** pipewire-pulse's PID was
> unresolvable on this host *permanently*, because stage 1 of the derivation required exactly
> one repeated `sec_pid` and **WirePlumber repeats one too** (two Clients, one PID). Key 4's
> suppression therefore never fired and every Pulse-emulated node fused into one owner. Fixed
> in pixelpass `91c4ded`: probe every distinct `sec_pid` and let `/proc/<pid>/comm` decide.
> **The eligible half of row 1 is the only thing that exposed it** — the verdict was
> fail-closed and silent.
>
> ⚠️ **Phase 6 is NOT unblocked by this file alone.** F11-1 was the other gate and is now
> **closed** (2026-07-26, pixelpass `c78eb2d`: key 4 bounds an owner only when the node's
> Client resolves; measured cost on the live graph, zero — see the results file). Phases
> 0b/0c/0d and the "Stereo Mix" design call still precede phase 6.
>
> Two things to keep when re-running: **every partition row must run with `AEC=off`** (a
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
> row, which reads as a failure that is really a harness error.
> row, which reads as a failure that is really a harness error), and **start the audit BEFORE
> building the fixture**. Fixture-first makes the whole graph arrive as one enumeration burst,
> so every node is first tainted while `graph_ready` is false; that partial-graph taint enters
> sticky state and the keyless sticky reason then wins over the evidence-derived one, so a row
> cannot assert its own key. Read keys at *derivation* (first non-sticky appearance).
**Adds no capability. Its entire purpose is to be wrong loudly and safely.**
@@ -743,6 +809,288 @@ mid-share load stays a **synthetic** test until a second `enable` site or hot re
## 10. Adjudication record
**Round 14 (2026-07-26) — 0b's five-mutation matrix is revised to four, and one pinned
mutation is retired as vacuous.** Reached independently by both reviewers, then agreed.
- **Mutation 2 cannot be killed by any test, because its site cannot execute.** The
best-effort wake arm (`core/mod.rs`, the `besteffort_wake_rx` close arm) is unreachable
**by construction, twice over**: (i) `run_core_loop` owns a clone of `besteffort_wake_tx`
— created at `CoreController::new` and used for the `has_more` re-arm inside the loop —
and a tokio `Receiver::recv()` yields `None` only once *every* sender is dropped; (ii)
even without that clone, both `CoreController` and `CoreCommandSender` hold `reliable_tx`
alongside the wake sender, and the `select!` is `biased` with the reliable arm first, so
the reliable arm always wins the race to exit. Writing teardown there would be code that
provably never runs, dressed as a tested path.
- **Mutation 1's site is reachable but not unit-testable.** It sits inside `run_core_loop`,
which builds a real iroh endpoint and loads identity; no unit test can drive it.
- **Decision: (b) + (c).** Teardown is **hoisted to one unconditional site after the loop**,
so every `break` is covered structurally, including any added later — strictly better than
duplicating teardown across one live arm and one dead one. The **seam-level mutation gates
are the real ordering proof**, and the call site's live proof is owed to **phase 9**, which
gains an explicit row: *drop the controller / close the command channel while sharing*.
"UI crash" is not precise enough to serve as that row.
- **Rejected: an integration test built to preserve the number five.** It would pay for a
full iroh core plus test-only observability and prove only that a method was called — not
the ordering invariant, which is the thing that actually breaks.
- The 0b gate is therefore **four mutations**, enumerated exactly (round 16 P3-5 — the earlier
wording said "three plus the 0c pair", which reads as five and blurred what 0b owns):
| # | mutation | killed by | status |
|---|----------|-----------|--------|
| 1 (old gate 3) | remove the wait after the host kill | `explicit_shutdown_reaps_the_host_before_the_aec_can_unload` | killed now |
| 2 (old gate 4) | reverse `ScreenshareTeardown`'s field declaration order | `the_aec_unloads_after_the_children_on_the_drop_path` | killed now |
| 3 (old gate 5) | remove the reap loop from `ReapOnDrop::drop` | `dropping_a_guard_kills_and_then_reaps_the_child` | killed now |
| 4 (old 1) | remove the teardown at the hoisted post-loop call site | — | **deferred to the phase-9 row** *drop the controller / close the command channel while sharing* |
4-vs-5 separation verified: reversing the field order leaves the reap test green, and
removing the reap loop leaves the ordering test green. **0c's own pair (no SIGINT · no
SIGKILL fallback) is counted under 0c, not here**, along with the round-15/16 additions
(disarm the wrapper at entry · disarm it between the waits · treat a wait error as a reap ·
report an unconfirmed stop as clean · zero the grace).
**Round 15 (2026-07-26) — review of the 0b/0c-peerspeak implementation returned two blocking
findings, both accepted.** Recorded because both are the same shape: a defence that existed
but was disarmed exactly when it was needed.
- **The drop fallback was disarmed across its own wait.** `shutdown` took the child out of
the wrapper before the first `.await`; a cancellation or unwind during the wait left the
raw child to drop with `kill_on_drop` (which signals without reaping) while `Drop` found
`None`. The child now stays owned until the reap is **confirmed**.
- **A failed wait was reported as a reap, and the hard-kill wait was unbounded.** The
`io::Result` was discarded, so a wait error returned "reaped"; and a process in
uninterruptible sleep after SIGKILL could wedge the core loop forever. Both waits are now
bounded and the conflict case has a written policy: availability wins, the child stays
owned so the bounded `Drop` retry stays armed, and the residual risk is logged.
- **A gate of mine was vacuous and the review's fourth test-double point caught it.** The
elapsed-time assertion compared against `STOP_GRACE` itself, so zeroing the constant left
it trivially true. `the_grace_is_a_real_interval` now pins the constant to a band.
**Round 16 (2026-07-26) — the re-review of the 0b/0c-peerspeak fixes returned *approve with
follow-ups*: no blocking findings, five P3s, all five applied before the merge.** The two that
carry design content:
- **An unconfirmed stop was reported to the user as a clean one.** `stop_host` returned a bare
"was sharing" bool, so the one case where availability-first gives up (SIGKILL queued, reap
never confirmed) still emitted `ScreenShareStopped` with no warning — the UI would say
sharing ended while pixelpass might still be fanning out. `ReapOnDrop::shutdown` now returns
`StopOutcome`, `stop_host` returns `Option<StopOutcome>`, and an `Unconfirmed` user-initiated
stop raises a UI error naming the stray process. Session/viewer teardown discards the outcome
on purpose: no user is waiting on an answer there and the risk is already logged.
- **Cancellation coverage only reached the graceful wait.** The mid-wait test could not kill a
mutant that disarmed the wrapper *between* the two waits. Verified: the naive form of that
mutant does not compile (the child is borrowed from `self`), but the restructured form —
`self.child.take()` once cooperation has failed — compiles, and the pre-existing test passes
it. `cancelling_shutdown_after_the_kill_leaves_the_fallback_armed` kills it.
**Deferred item — aggregate teardown latency (round 16 P3-4).** Bounds are per child, not per
teardown. Sequential drain gives `2 × STOP_GRACE` per unconfirmed child inline (≈4 s), plus
`REAP_BUDGET` (250 ms) per child on the `Drop` path: three wedged children ≈6 s of command-loop
stall, ≈12.75 s worst case including drop retries. Accepted as-is for 0b — one host plus one or
two viewers is the real shape, and concurrency here would mean detaching children from the
session that owns the AEC's lifetime. **Trigger to revisit: a fourth tracked child becomes
routine, or a measured teardown exceeds 5 s.** The fix, when triggered, is to drain viewers
concurrently while still owned by `shutdown_children` — not to detach them.
**Round 17 (2026-07-26 night) — two reviews: the repair planner (changes-requested, all applied)
and the 0c actor design (four blocking issues, all accepted).** 0c is now sliced, because the
fault-handling surface — not the design — is what grew.
*The repair planner: no P1s, four reachable P2s and a P3, all applied in `9145b2a`.*
- **Only the canonical forms are ours.** `classify` recognised any loopback with one
pixelpass-looking endpoint, so a third party's `module-loopback source=some_mic
sink=pixelpass_capture_4242` was ours to unload once that pid died; and a `sink=` token nested
inside a quoted `sink_input_properties` value could be read as a top-level argument. The whole
recorded argument string must now equal what pixelpass itself writes.
- **The matcher's templates are generated from the loader's own renderers.** Hard-coding
`latency_msec=20` beside a matcher means a loader change silently blinds repair to every module
the new build loads — the fail-closed-and-silent class this project has now been bitten by
three times (F2, F13-1, the sticky-uncertainty inversion). `host/audio.rs` loads through the
same renderers, so drift is a compile-time question. Blindness is also *reported*:
`unrecognised_pixelpass_modules` names anything matching `pixelpass_capture_*` that no
canonical form recognises, so a newer pixelpass's shapes cannot make an older `--repair`
quietly clean up nothing.
- **Ordering is not a licence either.** Planning loopbacks before the sink is necessary and
insufficient: an unload can fail or be skipped, and a loopback can appear after planning. The
sink unload is now gated on `sink_still_referenced` against the fresh snapshot — any other
module naming that sink blocks it, ours or not, because the question is what would break rather
than who owns it.
- **Undecidable is not dead.** `Path::exists()` maps a permission error, a missing `/proc` and a
foreign pid namespace all to `false`, which read here as "dead, unload it". Liveness is now
`Alive | Dead | Unknown` via `try_exists()` behind a `/proc/self/stat` preflight, `Unknown`
behaves exactly like `Alive`, and it is reported separately so holding back is visible.
- **⚠️ One prescribed fix was not implementable as written, and measuring first is what caught
it.** The reviewer's fix for fingerprint fidelity was "use `pactl -f json list modules` and
deserialize the complete `argument`". **On pactl 17.0 those records carry no module index at
all** (`"index": null`), and `unload-module` accepts only an index — JSON alone cannot drive
repair. Replacement: **two listings, correlated positionally and checked** (ids and names from
the short listing, exact arguments from JSON; equal counts and equal names at every position or
the run refuses, with retries for a concurrent load). Verified on this host: both listings
return the same 17 modules in an identical name sequence, from 41 physical lines. The check
also turns the reviewer's fabricated-row attack from exploitable into harmless — a crafted
short-listing line has no JSON counterpart, so the sequences misalign and repair stops instead
of unloading an index inferred from text. *This is the reachability rule applied to a
prescription rather than a finding: the chain was valid, the API it assumed did not exist.*
- **Measured before relying on it (pactl 17.0, live server):** recorded arguments come back
byte-for-byte as passed, joined with single spaces, in order, and **`@DEFAULT_SINK@` is not
resolved** to the concrete device. Both facts are load-bearing for exact matching — had either
been false, the P2 fix would itself have been a silent blinding — so both carry a test.
- Normalisation deleted (P3): within one invocation every snapshot comes from one server, so
re-rendering does not happen and normalising only made different arguments compare equal. The
residual ABA window (planned module vanishes, a byte-identical one takes its index) cannot be
closed through an index-only unload API, and is now stated as a limitation in `Fingerprint`'s
own doc comment instead of implied away.
- Five vacuity gaps closed: a raw-pactl-text-to-plan test (the whole planner suite survived a
parser that dropped every argument), per-pid liveness counters over two pids, non-canonical and
nested-quote cases, and a reference-gate test. **One gap deliberately left open and declared:**
a comparator using only `id + args` cannot be killed by a non-vacuous test, because the module
*name* determines which argument grammar can match at all — that field is enforced structurally
by `classify`, and a test appearing to cover it would be the self-satisfying kind.
- **Field-verified twice on the live graph:** the A/B orphan test still removes exactly the two
orphans with the module table otherwise byte-identical, and a new fixture — a dead pid's legacy
sink plus a *non-canonical* loopback naming it — unloads nothing, reports the unrecognised
module, and reports the sink as still referenced.
*The 0c actor design: four blocking issues, all accepted; the epoch requirement conceded.*
- **A bounded join must not move the OS handle into `spawn_blocking`.** My ladder would have
taken the thread handle out of the guard to poll it; if the close future is then cancelled or
unwinds, `Drop` finds no handle and can neither poison nor fail-stop, while the blocking task
stays wedged forever and can pin runtime shutdown. This is the **same defect shape as round
15's** — a defence disarmed exactly when needed. The handle stays owned across every await;
`is_finished()` is polled and `join()` called only once it reports finished. Same rule for the
event task's handle (`await` through `&mut JoinHandle`).
- **`Commit::UnloadNow(id)` cannot forget the id.** An immediate unload can time out or be
cancelled, and a ledger that never recorded the module cannot retry or reconcile it. Slots
become a state machine — `Vacant | Loading { token, expected } | Loaded { fp } | Unloading
{ fp }` — with **affine** permits carrying a unique token, so two permitted loads for one slot
cannot both commit.
- **`kill_on_drop` does not roll back a server-side mutation.** A bounded `pactl load-module`
killed after the server created the module but before its id was read leaves a module with no
id anywhere. So an ambiguous load requires **bounded reconciliation by fingerprint** — reusing
repair's classification idea inside the live session, never its dead-pid policy — before any
further capture may start. Related: cancellation must never be `select!`ed against
`Command::output()`, or a completed load's id is dropped on the floor.
- **`_exit` is right, but the pre-exit sequence must not be able to block.** Event emission,
stdio flushing and tracing all take locks a wedged thread may hold, so the watchdog able to
`_exit` past a stalled diagnostic has to be **armed before** the wedge is detected, not created
in response to it. And `_exit` skips `CaptureHandle::Drop`, so `gst-launch-1.0` and any
in-flight `pactl` need parent-death/process-group containment or they outlive the host that
reported its own death — with gst still holding screen-capture resources.
- **Epoch conceded, and my vacuity instinct was right.** `object.serial` is unique and never
reused while global ids are, so "the object at this id still has the serial I recorded" is
complete proof of identity; there is no same-core interleaving that serial equality misses.
Epoch is carried for diagnostics and explicitly **not** a gate. It would only become
load-bearing across a daemon incarnation or an actor reconnect, and the design makes core
failure terminal with no reconnect — if that changes, the right answer is a core-incarnation
nonce, not a "something churned" counter that invalidates observations on unrelated traffic.
- **"Unjoinability, not slowness" is not literally implementable** and the wording is corrected:
no bounded observation distinguishes "returns one millisecond later" from "never returns", so
the death condition is *failure to terminate within the post-cancellation policy deadline*.
Two budgets, not one — a running MainLoop quitting is a different question from an
initialisation call returning after cancellation, and the second is normally longer.
- **`GraphCmd::Route(Vec<u32>)` is deleted rather than fixed.** Matching and routing stay inside
the actor's registry callback, where removals are already ordered against routes in-thread, so
the privacy race is not introduced at all. For phase 6 the rule is structural: the only
addressable type is an `ObservedNode { global_id, serial, epoch }` constructible solely from
the actor's own observation, kept private and non-`Copy`, revalidated on serial immediately
before any mutation. A bare id is not addressable.
- **An unacked `ClearRoutes` is not a wedge** (agreed), with one qualification taken: a stream
setting `node.dont-reconnect`/`node.dont-fallback` may be left silent rather than moved back to
the default, so the outcome is surfaced as `ClearRoutesUnconfirmed` rather than treated as
benign. Separately, blindly clearing `target.object` can erase a target the user set manually —
the prior value must be recorded and restored only while it is still pixelpass-owned.
- **One terminal fault needs a coordinator, not an emitter.** If the actor emits `CoreError`
immediately and the subsequent teardown then fails to join, peerspeak never learns the process
is fail-stopping. Actor faults are internal *candidates*; the tokio-side coordinator emits
exactly one final fault, and `Wedged` overrides any earlier candidate. Because a callback panic
can cross `extern "C"` and abort before any event is produced, **peerspeak must treat
unexpected stdout EOF as a synthetic terminal fault** rather than trusting that a JSON line
arrives.
*Measured for the actor argument (3 of 3 trials, live graph):* pipewire-pulse accepts **two sinks
with an identical `node.name`** — no rename, no suffix, no refusal, both visible as `<name>` and
`<name>.monitor` — and `pulsesrc device=<name>.monitor` attached to the **older** one every time.
So a surviving wedged owner does not merely risk a collision: it **silently steals the next
session's capture** while the loopbacks feed the new sink. That retires "detach and carry on" as
an option, and it is the evidence behind rejecting session-unique sink names (which would trade a
fail-stop ownership fault for silent accumulation, and re-open the discovery grammar 0c step 1
just closed and field-proved).
**0c step 2 is therefore sliced, and the slices land and are reviewed independently.** Nothing
here reopens D6 — the connection-owned-sink design is unchanged; what grew is the process-
lifecycle and fault surface, and a material part of it is pre-existing debt 0c forced into the
light (the `abort()` orphan race, the unbounded join, peerspeak advertising a dead share):
| slice | scope | why it can land alone |
|-------|-------|-----------------------|
| S1 | repair planner (`919d5bd` + `9145b2a`) | done; awaiting re-review, then merge |
| S2 | peerspeak host-fault path: always-on notice channel, EOF synthesis, session-scoped fault, clear `is_sharing` + presence ticket, `ScreenShareStopped` then error | fixes a defect **today** — a dead share stays advertised — and is independent of the actor |
| S3 | pixelpass ledger transactions + ambiguous-load reconciliation + child containment + pre-armed watchdog + poison state machine + supervisor health arm | fixes the `abort()` orphan race **today**; no libpipewire work |
| S4 | the `AudioGraphOwner` actor itself, the readiness handshake, and both measured budgets | the only slice that needs new PipeWire mechanism |
| S5 | the two live exit gates: two-host ownership, and the never-yet-run Stop Share SIGINT gate | needs S4 on the graph |
**Round 18 (2026-07-26 night) — two more repair review rounds. `--repair` now reads and unloads
through libpulse, and one of the review's own prescriptions had to be replaced after measuring.**
*Round 17c — the re-review of my round-17a fixes found two more blocking P2s. Two of the four
fixes I had applied were themselves defective; this is the third time the "audit your own fixes"
rule has paid.*
- **My two-listing correlation was unsound.** Pairing short-listing indices with JSON arguments by
position breaks whenever module names repeat: another client loading one module and unloading
another *between the two calls* leaves counts and names aligned while every argument has shifted
by one, so a foreign module inherits a canonical fingerprint. The name check cannot see it and
the retry never fires, because correlation "succeeded".
- **My liveness fix still converted invisible-but-alive into dead.** A `/proc/self` preflight
proves nothing: inside a pid namespace — a container, a distrobox — `self` stays visible while
every process in the parent namespace is invisible, and `hidepid` has the same shape.
*Round 18 (round 4) — the fix for both, and a third defect neither of us had reached.*
- **Record boundaries in `pactl list short modules` are unprovable, and this needs no adversary.**
A genuine module whose argument contains a newline renders a first line that is byte-exactly one
of our canonical forms, with the rest dropped as an unparseable continuation — no forged index,
so no duplicate-index check can see it. **Field-confirmed on the live server** with
`…latency_msec=20\nremix=false`, `remix` being a real loopback option. A tab in the same position
is worse: it hides a sink reference from the gate that protects a still-referenced sink.
- **Locality was a guess.** `PULSE_SERVER` is a fallback *list*, so `unix:/missing tcp:remote:4713`
passes any "starts with unix:" test and then connects to another machine, where local pids mean
nothing and a live remote host's modules look dead.
- **Resolution: `src/repair/introspect.rs`, one verified-local connection.** `pa_module_info`
carries index, name and exact argument in a single record; `pa_context_is_local()` answers
locality about the connection actually established; and unloading goes back through that same
connection, so listing and destruction cannot disagree about which server they mean. Bounded
throughout (3 s connect, 3 s per request, non-blocking iteration plus a 2 ms sleep). The layer
holds no policy but "refuse the wrong server" — every decision stays in the pure planner.
- **The dependency was the user's call, taken with sign-off after vetting.** libpulse-binding
2.30.1: MIT/Apache-2.0, 5.5M downloads, 3 new crates total, a build script that only probes
pkg-config, no network or subprocess use in any source, and all three historical RustSec
advisories (2018-0020/0021, 2019-0038) fixed by 2.6.0. Reasoning recorded beside the dep.
- ⚠️ **REUSABLE — a prescription can fail reachability, not just a finding.** The reviewer's
fidelity fix was "use `pactl -f json list modules`". On pactl 17 those records carry **no module
index at all** (`"index": null`) while `unload-module` accepts only an index, so it can never
stand alone. Measuring first is what caught it.
- ⚠️ **REUSABLE — the field test found a bug no unit test could reach, and it was 0b's bug again.**
The first introspection version did its work correctly and then aborted on the way out:
`Assertion '!e->dead' failed at mainloop.c:207, function mainloop_io_free()` — SIGABRT, core
dumped, **exit 134, so a fully successful repair reported failure to its caller**. Rust drops
fields in declaration order and the context's teardown frees IO events living in the mainloop,
which I had declared first. Fixed, then hardened past the fix: `Drop` explicitly takes and
destroys the context before the mainloop, so the ordering no longer depends on where the fields
are written. **Field-order drop hazards are not a peerspeak-specific lesson; they recur wherever
one object's teardown reaches into another's.**
- **Still open, deliberately, and recorded rather than guessed:** closing the namespace hole needs
modules to carry an **owner token** (machine/boot identity plus pid-namespace identity), with
token-less modules treated as `Unknown`. That changes what pixelpass writes into the graph *and
how far back `--repair` can clean up* — orphans from any older build would become uncleanable,
which is a regression in the tool's entire purpose. `NSpid > 1` remains a sound negative signal;
`NSpid == 1` is explicitly **not** proof, since its leftmost value is relative to the procfs that
was mounted.
- **Deferred, now cheap to reconsider:** `host/audio.rs` still loads modules via `pactl` and parses
the index off stdout, which is part of why S3's ambiguous-load problem exists. With libpulse in
the tree, `pa_context_load_module` returns the index through an observable operation.
**Round 1 — 13 items, 12 accepted.** Phase reorder (AEC machine before dry-run); typed capture
plan (accepted, moved *earlier* than proposed); Phase 3 five-part gate; tag-consumption gating;
Phase 6 matrix mandatory; 0b unwind backstop restored **and my mutation test corrected — it
+442 -252
View File
@@ -1,35 +1,420 @@
# Phase 5 — dry-run audit gate: results
**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them
fatal to the whole mechanism. Both go to the design doc as **round 8** per impl
plan §5.3.
**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-25
- **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 branch `phase5-dry-run-audit`, release profile
- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id`
88, pid 14651), Arctis 1 Wireless as an active sink
The audit itself worked exactly as designed: it observed the live graph, ran
phases 24 on every registry event, created no links, and reported a complete
eligible/excluded partition with stable reason codes. **It found the defects on
the first live run.** That is the phase doing its job — §5's argument was that a
fixture proves the code matches my model of PipeWire while only a live run proves
my model matches PipeWire, and my model was wrong.
- **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
---
## F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
## What changed since run 1
**The phase-3 adapter reads eight node properties that the PipeWire registry
never announces.** They are parsed off `obj.props` in the registry `global`
callback (`pixelpass/src/host/observer/adapter.rs`), where they are silently
absent, so every one of them is permanently `None`/`false`.
Run 1 failed on two defects, both fixed before this run:
### Measured
- **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.
The complete set of keys the registry announces for a `Node` global on this host
(union over every node, via `pw-cli ls Node`):
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
@@ -37,246 +422,51 @@ node.description node.name node.nick object.path object.serial
priority.driver priority.session
```
Against what the adapter tries to read:
| property | announced? | what dies without it |
| property | announced? | what died without it |
| --- | --- | --- |
| `object.serial` | ✅ | — |
| `node.name` | | |
| `media.class` | ✅ | — |
| `client.id` | ✅ | — |
| `device.id` | ✅ | — |
| **`peerspeak.owned`** | ❌ | **the primary taint root (v3.4 §5.1, all of phase 1)** |
| `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 (echo-cancel, EasyEffects, loopback siblings) |
| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) |
| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) |
| **`device.api`** | ❌ | `session_device` classification |
| **`factory.name`** | ❌ | `session_device` classification — the discriminator itself |
| **`alsa.driver_name`** | ❌ | `session_device` classification (the `snd_aloop` denylist) |
| **`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 and Links are also affected, one materially:
Ports lost `port.exclusive`; Links and Clients were fine — notably
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
| object | announced | missing |
| --- | --- | --- |
| Port | `node.id`, `object.serial`, `port.direction`, `port.monitor`, `port.physical`, `port.terminal`, `port.group`, `port.alias`, `port.name`, `port.id`, `audio.channel`, `format.dsp` | **`port.exclusive`** — the `port-exclusive` local exclusion never fires |
| Link | `object.serial`, `link.output.node`, `link.input.node`, `link.output.port`, `link.input.port`, `client.id`, `factory.id` | nothing the engine needs |
| Client | `object.serial`, **`pipewire.sec.pid`**, `application.name`, `module.id`, `pipewire.access`, `pipewire.protocol`, `pipewire.sec.{uid,gid,socket}` | nothing the engine needs |
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.
**Links and Clients are fine.** Notably the pulse-PID derivation (v3.4 §6.1.2)
works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are
*always* present, which confirms the phase-3 exit-gate worry that the
bind-`LinkInfoRef` fallback is dead code in practice — it is correctness
insurance, never exercised on this host.
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.
### Demonstrated end to end
A null sink carrying `peerspeak.owned=true`, its monitor read by a
`module-loopback` whose playback leg is a fan-out candidate — the exact shape the
tag exists to exclude:
```
pactl load-module module-null-sink sink_name=ppgate_src \
sink_properties="peerspeak.owned=true"
pactl load-module module-loopback source=ppgate_src.monitor sink=ppgate_dest \
source_output_properties=node.name=ppgate_cap \
sink_input_properties=node.name=ppgate_play
```
Audit verdict:
```json
{"kind":"audit","graph_ready":true,"epoch":"complete","aec_state":"not-configured",
"fan_out_permitted":true,
"candidates":[{"serial":280,"name":"FINAL FANTASY XIV","eligible":true,"sticky":false},
{"serial":309,"name":"ppgate_play","eligible":true,"sticky":false}],
"eligible_count":2,"excluded_count":0,"taint":[]}
```
`ppgate_play` **eligible**, and the `taint` set **empty** — the tagged sink was
not even recognised as a root. In phase 6 this is an echo: peerspeak's own call
playback carries `peerspeak.owned` and would be fanned straight into the share.
The AEC path fails in the other direction. With
`PIXELPASS_AUDIO_AUDIT_AEC=pulse-module:536870918` (a real live module index):
```
aec_state = failed fan_out_permitted = false gate_reason = aec-failed
```
Correct behaviour given its inputs — `pulse.module.id` never arrives, so the
identity can never be observed and the validator times out fail-closed — but it
means **§5.1 row 12 cannot be run as written**, and that with a real AEC
configured phase 6 would refuse to share any audio at all.
### The fix (for round 8)
The full property set *is* reachable: **bind each Node global and read the props
off its `info` event**, which is exactly how `pw-dump` obtains them. Verified on
the same objects that were missing them from the registry:
```
alsa_output.usb-SteelSeries… factory.name = 'api.alsa.pcm.sink'
device.api = 'alsa'
alsa.driver_name = 'snd_usb_audio'
ppgate_src peerspeak.owned = True
pulse.module.id = 536870917
ppgate_play pulse.module.id = 536870918
node.link-group = 'loopback-2528-13'
FINAL FANTASY XIV application.process.id = 14651
```
Two notes for whoever designs that change:
- **The pattern already exists.** Phase 3 built exactly this for Links (bind →
`LinkInfoRef``LinkEndpointsResolved`, "the optimisation is the props, the
bind is the correctness path"). Nodes need the same, but as the *only* path
rather than a fallback, and the readiness epoch must hold an obligation per
unbound node — which the model already supports (`withheld` / `pending_links`).
- **`factory.id` is not a shortcut.** The Factory global for `factory.id=19`
(which every ALSA node claims) resolves to `factory.name = "adapter"`, not
`api.alsa.pcm.sink`. The node's own `factory.name` is a different property and
binding is the only way to it.
Also relevant: **`device.api` is announced on the *Device* global** even though it
is absent from the Node. That is the phase-3 review's owed fix ("read the ALSA
driver from the backing Device global, authoritative") — now not merely better
but load-bearing, though `factory.name` and `alsa.driver_name` are absent from
the Device global too, so node binding is still required.
---
## F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
announced) is the only taint root that still fires. Running §5.1 row 7 —
a capture sink plus a controlled forwarder reading its monitor:
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.
```
candidates:
FINAL FANTASY XIV | eligible: false | reason: unresolved-owner
ppgate7_play | eligible: false | reason: tainted-owner-bridge
taint:
Midi-Bridge | tainted-owner-bridge
bluez_midi.server | tainted-owner-bridge
alsa_output.pci-0000_03_00.1.hdmi-stereo-… | tainted-owner-bridge
alsa_output.usb-SteelSeries_…-analog-stereo | tainted-upstream
alsa_input.usb-SteelSeries_…-mono-fallback | tainted-owner-bridge
alsa_output.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
alsa_input.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
FINAL FANTASY XIV | unresolved-owner
ppgate_dest | tainted-upstream
pixelpass_capture_ppgate7 | pixelpass-owned
ppgate7_play | tainted-owner-bridge
ppgate7_cap | tainted-upstream
```
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.
Row 7's own assertion held — `ppgate7_play` is excluded via the owner bridge, so
the cycle-prevention mechanism works. But the row **fails the §5.1 exact-partition
requirement**, because the eligible half is empty: FFXIV should have been
eligible and was not.
### What run 1's machinery got right
The mechanism: with `node.link-group`, `application.process.id` and
`pulse.module.id` all absent, no node has a *strong* owner key — `client.id` is
explicitly not one (v3.4 §6.1.3). So every tainted capture stream is an
**unbounded tainted reader**, which trips phase 2's documented fail-closed
backstop (`taint/mod.rs`, `an_unbounded_tainted_reader_excludes_every_output`)
and excludes every `Stream/Output/Audio` on the machine. Every device node
separately keeps its coarse keys (`session_device` is universally false, also from
F1) and they all share WirePlumber's `client.id = 42`, which fuses them into a
single owner and spreads the taint across the whole device layer.
So the engine's *net* live behaviour today is: exclude everything, always, as soon
as pixelpass's own capture sink exists. Fail-closed, so silence rather than echo —
but the feature is entirely non-functional, and it is non-functional in a way that
would have looked like "working safely" to any test that only asserted exclusions.
**This is the §5.1 argument vindicated in the most direct possible way.** The
current build *is* the degenerate exclude-everything implementation the plan
warned about, and it is the eligible half of the partition — asserted, per §5.1 —
that caught it. An exclusion-only checklist would have passed this build.
---
## §5.2 — O5 measurements
Recorded under deliberate churn: five load/unload cycles of
`module-null-sink` + `module-loopback`, 6.5 s wall.
```json
{"kind":"metrics","graph_events":308,"tick_events":26,"emitted_records":308,
"span_us":6499634,"graph_events_per_sec":47.39,
"recompute_max_us":15,"recompute_mean_us":4,
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
"recompute_distribution":[["<50us",334]],
"emit_max_us":12,"emit_mean_us":2,"emit_distribution":[["<50us",308]],
"busy_us":2331,"busy_fraction":0.0004,
"queued_events":198,"queue_threshold_us":100}
```
**O5 is closed: full recompute per graph event has roughly four orders of
magnitude of headroom.** Every one of 334 recomputes finished in under 50 µs, the
worst at 15 µs, against a 47 Hz event rate under churn far heavier than a desktop
produces at rest. The observer thread spent 0.04 % of wall time working.
`queued_events: 198` looks alarming and is not: PipeWire delivers enumeration and
teardown as back-to-back bursts, so most events do begin within 100 µs of the
previous one completing. With a 15 µs worst-case recompute the backlog drains
faster than it forms. `busy_fraction` is the number to trust here — it needs no
inference, and it is 0.0004.
**Caveat, and it is a real one.** These numbers were measured on the *degraded*
graph F1 produces. The recompute cost is over the same node and link count so the
taint-engine figure is representative, but the F1 fix adds a bind and an `info`
round-trip **per node**, which is new I/O this run did not measure at all. O5
should be re-measured after round 8 rather than inherited from here.
---
## Matrix status (§5.1)
| # | scenario | status |
| --- | --- | --- |
| 1 | null-sink + loopback forwarder, owner bridge | ⛔ blocked by F1 — needs a taint root (`peerspeak.owned`) |
| 1b | Sunshine's topology (opportunistic, non-gating) | not attempted |
| 2 | gst split clients, tainted input | ⛔ blocked by F1 — needs `application.process.id` |
| 3 | two Pulse modules, one tainted | ⛔ blocked by F1 |
| 46 | peerspeak playback / mpv / notification | ⛔ blocked by F1 — all three are `peerspeak.owned` tags |
| 7 | second host's capture sink + forwarder | 🟠 mechanism verified, **partition fails** (F2) |
| 8 | EasyEffects | ⛔ blocked by F1 — needs `node.link-group` |
| 9 | Firefox three cases | ⛔ blocked by F2 (everything excluded) |
| 10 | sticky taint across teardown | ⛔ blocked by F1 |
| 11 | recycled serial / index / link-group | ⛔ blocked by F1 |
| 12 | AEC loaded → unloaded → Revoked | ⛔ blocked by F1 — `pulse.module.id` never arrives; validator goes `failed` |
| 13 | `Audio/Duplex` device | not attempted (none present on this host) |
**No row can be completed until F1 is fixed.** The matrix is not re-runnable in a
meaningful sense before then — every row's eligible half is empty for the same
reason.
---
## What the audit machinery got right
Worth recording, because none of it needs revisiting in round 8:
None of this needed revisiting:
- Running the recompute **inline on the observer thread**, once per applied
registry event, upholds phase 4's no-coalescing contract and put the cost
exactly where O5 could measure it.
- The **complete-partition record** is what caught F2. A record of only the
interesting nodes would have shown row 7 passing.
- **Reason codes survived the trip** and were immediately diagnostic:
`unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed
straight at the missing strong keys.
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right** — phase 2's
engine does the correct thing when handed correct properties. The defect is
entirely at the observation boundary, which is where phase 5 was designed to
look.
## Next
1. **Design round 8** on F1: node binding in the observer, readiness obligations
per unbound node, and where `session_device` reads its inputs from.
2. Re-run this matrix in full afterwards. Rows 46 additionally need peerspeak
running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively.
3. Re-measure O5 with node binding in place.
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.
+95 -10
View File
@@ -1,11 +1,14 @@
# Design v3: whole-desktop screen-share audio without self-echo
**Status:** 🟠 **v3.6 — round 9, opened by a second MEASURED finding, this time from a live
audit run of the *fixed* observer.** v3.4's architecture is still unchanged and converged.
Round 8 revised the **observation boundary** (§6.7); round 9 revises what stickiness is
allowed to remember (new §6.8). Both were found by running code, not by reading it.
**Date:** 2026-07-25 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
v3.4 r7 · v3.5 r8 · v3.6 r9)
**Status:** 🟢 **v3.7 — round 10: the §5.1 matrix PASSED in full and the architecture is
unchanged for the third consecutive measured round.** Round 8 revised the **observation
boundary** (§6.7), round 9 revised what stickiness may remember (§6.8), and round 10 deletes
the pipewire-pulse PID **derivation heuristic** (§6.1.2) after measuring that WirePlumber
repeats a `sec_pid` too — which had switched key 4's suppression off permanently. All three
were found by running code, not by reading it, and all three were at the *observation*
boundary rather than in the design.
**Date:** 2026-07-26 (v1: 07-19 · v2: 07-20 · Option C 07-20 · v3.1 r4 · v3.2 r5 · v3.3 r6 ·
v3.4 r7 · v3.5 r8 · v3.6 r9 · v3.7 r10)
**Origin:** Joe's suggestion — "whitelist all audio except audio coming from peerspeak."
**Scope:** a new capture mode in pixelpass (`src/host/pipeline.rs`, `src/host/audio.rs`),
playback tagging + AEC-identity export + teardown-ordering invariants in peerspeak.
@@ -502,11 +505,47 @@ derive this itself; it cannot assume a value, and peerspeak can supply only a *h
- Read `pipewire.sec.pid` from the **Client** objects of Pulse-emulated streams. Measured:
it is `2541` for Firefox, Steam, KDE Connect, sunshine and libcanberra alike, while each
node's own `application.process.id` differs (Firefox `11114`, sunshine `4119`).
- Require a **single consistent** value across those clients, and validate it by reading
- ~~Require a **single consistent** value across those clients~~ and validate it by reading
`/proc/<pid>/comm` (or cmdline) and confirming it is `pipewire-pulse`.
- "This PID owns implausibly many unrelated streams" is a **diagnostic**, never
correctness logic.
> #### 🔴 Round 10 (MEASURED, phase-5 run 2): "a repeated `sec_pid`" does not identify pulse
>
> The struck rule above was implemented as *the single `sec_pid` shared by two or more
> Clients*, on the reasoning that native clients each carry their own distinct PID so only the
> Pulse shim repeats a value. **Measured on this host: WirePlumber repeats one too** — it holds
> two Clients, `WirePlumber` and `WirePlumber [export]`, both `sec_pid` 1747. Two values
> repeated, "single consistent" was unsatisfiable, and the derivation returned `None`
> **permanently, on a stock desktop**.
>
> The consequence was not a missing optimisation. With the daemon PID unknown the key-4
> exception never fires, every Pulse-emulated node fuses into one owner, and the result is the
> machine-wide over-exclusion cascade of phase 5's F2 — reached again from a new cause, and
> caught again only by the §5.1 requirement to assert the **eligible** half of a row.
>
> The rule failed in both directions, so the repetition test is **deleted** rather than
> tightened:
>
> - **False ambiguity** — any second process holding two Clients defeats it, and WirePlumber
> always does.
> - **False absence** — a session in which pipewire-pulse holds exactly one Client (one Pulse
> app running) repeats nothing at all, so the candidate is never even considered.
>
> `comm` was always the authoritative check; repetition was a heuristic standing in front of it,
> and what it actually encoded was an assumption about *other* processes' Client counts.
> **The rule is now: every distinct `pipewire.sec.pid` is a candidate; the daemon is the unique
> one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`.** Zero matches ⇒ `None` (nothing we
> can prove to suppress). **Several** matches ⇒ also `None`: two live pipewire-pulse daemons (a
> nested or sandboxed session) cannot both be suppressed by a single `Option<u32>`, and failing
> closed there lands on the over-exclusion side, consistent with the failure-mode paragraph
> below. Suppressing a *set* of daemon PIDs is the real answer if a multi-daemon host ever turns
> up; it is out of v1 and recorded rather than silently approximated.
>
> The lesson generalises past this key: **a property of the objects we are trying to identify is
> evidence; a property of everyone else's object count is a guess.** The `/proc` read was already
> there and already authoritative — the heuristic in front of it only added a way to be wrong.
Failure modes: if pixelpass fails to identify the real pipewire-pulse PID, the result is
broad **over-exclusion** (annoying, safe). If it wrongly suppresses a genuine app PID, the
result is over-exclusion **for that app** — safe *only* because unresolved ancestry is
@@ -542,6 +581,30 @@ Where an owner has a tainted input leg and an output leg with **no** resolvable
(§6.1.2), fail closed and exclude the output leg. This only ever engages for owners
actually reading a tainted monitor, so the blast radius is small.
> **⚠️ Round 13 (F11-1) — "bounded" is not "has a key". A self-claimed pid is not
> provenance.** Which legs this backstop sweeps depends on whether the tainted reader and
> the candidate outputs are *bounded* — i.e. whether we could enumerate their sibling legs
> and be right. Key 4 is a union of the node's `application.process.id` (client-controlled,
> optional) and its Client's `pipewire.sec.pid` (protected), so a node could bound itself
> with a value it invented and escape the sweep while its real sibling was unfindable.
>
> **Rule:** a strong key (`node.link-group`, `pulse.module.id`) bounds an owner on its own;
> 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. The
> ordering is load-bearing in both directions: read after suppression and every
> Pulse-emulated app on the box goes unbounded (§6.1.1 catastrophe, new door); accept "a
> unique Client object exists" instead of a `sec_pid` and a pid-less Client leaves the hole
> open.
>
> **Bridging is unchanged** — it still uses the full union, because a self-claimed pid is
> perfectly good *evidence that two legs are related*, which is the taint-increasing
> direction. Only the permission to declare a differently-keyed output "provably someone
> else" now demands a `pipewire.*` answer to "who is this".
>
> Measured cost on this host: **zero** — before/after binaries audited the same live graph
> simultaneously, same 14 decision states, no `unresolved-owner` on either side, eligible
> half non-empty. Implemented in pixelpass `c78eb2d`; five-case Client matrix in the tests.
### 6.1.3 ⚠️ Taint must be STICKY — current topology is not enough
**Conceded to Codex in round 6; my "conditional bridge" was correct about topology and
@@ -1143,9 +1206,28 @@ Both reviewers agree on all seven. Recorded as decided; reopen only with new evi
- **D7 — no materially simpler design exists** that still meets Joe's ask. The available
simplification is to *narrow v1 scope*, not to change architecture. ✅
## 14. Readiness — 🟠 v3.6 (round 9): architecture converged; observation boundary and sticky provenance REVISED.
## 14. Readiness — 🟢 v3.7 (round 10): the §5.1 matrix PASSED; architecture unchanged.
**Round 9 (2026-07-25, same day).** Phase 3r shipped §6.7 and the audit was re-run
**Round 10 (2026-07-26).** The phase-5 matrix ran in full and **passed all 13 rows with a
non-empty eligible half in every one** — results in
`screenshare-audio-exclusion-phase5-results.md`. It also found a third measured defect on
first contact, and once again the failure was fail-closed and *silent*, exposed only by the
requirement to assert what must remain **eligible**: §6.1.2's pulse-PID derivation returned
`None` permanently on this host, so key 4 fused every Pulse-emulated node into one owner.
| | verdict |
| --- | --- |
| Architecture — Option C, taint as a graph property, owner-key union, sticky taint, AEC identity state machine | **unchanged, three times vindicated** |
| §6.1.2 | **revised** — the derivation heuristic is deleted; `comm` alone decides |
| §5.1 matrix | **PASSED** — 13/13, incl. the full sticky lifecycle (row 10) and provable identifier recycling (row 11) |
| O5 | **closed on the real graph** — worst recompute 67 µs, churn mean 10 µs, busy fraction 0.0006 |
| Phase 5 | **machinery unchanged and correct** — three real defects caught on first contact with the live graph, none of them in the engine |
| Phase 6 | **still blocked** — by F11-1, phases 0b/0c/0d, and the "Stereo Mix" design call, *not* by this matrix. (F11-1 was **closed later the same day** with this matrix's data — §6.1.2's round-13 box; the rest stand.) |
Three rows passed with recorded substitutions (8 EasyEffects, 9 Firefox's own mic/monitor
paths, 13 a real `Audio/Duplex` device) and the third-party samples stay owed.
**Round 9 (2026-07-25).** Phase 3r shipped §6.7 and the audit was re-run
immediately; it found a *second* measured defect within minutes — a permanent sticky taint
on a hardware sink (§6.8). Both rounds share a shape worth naming: **the architecture was
right and the instrumentation was wrong**, and only running the code against a live daemon
@@ -1205,7 +1287,10 @@ How the blockers closed:
| **9** | `device_props` tested for one live *Device* rather than one live *global* on the id (Codex, certain) | **fixed** in phase 3r — stale `session_device` on a contested id is an echo path |
| **9** | `device.api` corroborated by presence, so `v4l2` under an ALSA factory passed (Codex) | **fixed** in phase 3r — the API must equal the allowlist's own |
| **9** | hardware playback-to-capture ("Stereo Mix") defeats the `session_device` classifier (Codex, P1 worth checking) | **OPEN — design decision owed**, §6.8; pre-existing, needs ALSA control inspection |
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; ~3 ms observed on this host |
| **9** | the 2 s readiness budget has no calibration argument (Codex) | **OPEN — measurement owed**, §6.8; 12 ms observed on this host with 18 binds (phase-5 run 2) |
| **10** | **the pulse-PID derivation required a *single* repeated `sec_pid`; WirePlumber repeats one too, so it returned `None` permanently and key 4's suppression never fired (measured, phase-5 run 2)** | **fixed** — §6.1.2 round-10 box: probe every distinct `sec_pid`, let `/proc/<pid>/comm` decide |
| **10** | the audit's `sticky` flag means "is in the remembered set", so it is true for nearly every tainted node and does not answer "excluded only because remembered" | **OPEN — reporting only**; the evidence-only pass §6.8 already computes what is needed |
| **10** | a bridge's named key is lost when a leg reappears under a new serial (sticky `reason_for` falls back to keyless, and `raise` will not replace a same-rank reason) | **OPEN — reporting only**; verdict unaffected |
### v1 scope — agreed
+12
View File
@@ -137,6 +137,16 @@ pub enum CoreCommand {
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
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
/// open it in a local player.
ViewShare {
@@ -271,6 +281,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ScreenShareHostFault { generation: _ }
| CoreCommand::ViewShare {
ticket: _,
settings: _,
@@ -363,6 +374,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ScreenShareHostFault { generation: _ }
| CoreCommand::ViewShare {
ticket: _,
settings: _,
+224 -72
View File
@@ -4,6 +4,7 @@ pub mod fetchbudget;
pub mod jitter;
pub mod messages;
mod recovery;
mod teardown;
use crate::audio::eq::{Eq, EqSettings};
use crate::audio::{AudioBackend, PlatformAudioBackend};
@@ -677,31 +678,33 @@ struct ActiveSession {
recovery_terminal_task: tokio::task::JoinHandle<()>,
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop).
screenshare_host: Option<tokio::process::Child>,
/// pixelpass viewer children we spawned to watch peers' shares, each paired
/// with the share ticket it's viewing so a re-watch of the same share can
/// replace (not stack) its player. Killed on session teardown (each also
/// self-exits when its player window closes).
screenshare_viewers: Vec<(String, tokio::process::Child)>,
/// The screen-share children and the echo-cancel module, held together
/// because their **destruction order** is load-bearing: the AEC module must
/// not unload while a pixelpass host is alive and fanning out (design v3.4
/// §7.1). `teardown` owns that ordering; see `core::teardown`.
teardown: SessionTeardown,
}
/// The session's teardown set, with the echo-cancel guard the platform actually
/// has. On non-Linux there is no AEC module, and `Infallible` makes that
/// structural — the `Option` cannot be `Some`.
#[cfg(target_os = "linux")]
type SessionTeardown = teardown::ScreenshareTeardown<
tokio::process::Child,
crate::audio::echo_cancel::EchoCancelGuard,
>;
#[cfg(not(target_os = "linux"))]
type SessionTeardown =
teardown::ScreenshareTeardown<tokio::process::Child, std::convert::Infallible>;
impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
// see the stream end without waiting on drop ordering).
if let Some(mut host) = self.screenshare_host.take() {
let _ = host.kill().await;
}
for (_, mut viewer) in self.screenshare_viewers.drain(..) {
let _ = viewer.kill().await;
}
// promptly, and so they are dead *and reaped* well before the AEC guard
// unloads at the end of this function (design v3.4 §7.1). Drop ordering
// is the backstop for the unwind path; this is the path we control.
self.teardown.shutdown_children().await;
self.datagram_task.abort();
self.mixer_task.abort();
self.event_task.abort();
@@ -726,8 +729,9 @@ impl ActiveSession {
// Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel);
// The screen-share children were killed *and reaped* at the top of this
// function, so nothing pixelpass-side is alive to see the module vanish.
drop(self.teardown);
crate::log_msg("Leaving room...");
let _ = self.room_state.leave().await;
@@ -1393,10 +1397,25 @@ async fn run_core_loop(
// later opt-in can immediately publish whatever is currently running.
let mut current_game: Option<crate::game::DetectedGame> = None;
let mut network_mode = NetworkMode::default();
// Pixelpass binary override (config), and the ticket of our own active screen
// share (rides our presence so the room — incl. late joiners — can watch).
// Pixelpass binary override (config), and our own active screen share: the
// 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 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;
// Standalone capture-only mic meter, live only when no session exists.
@@ -1511,6 +1530,8 @@ async fn run_core_loop(
biased;
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
Some(cmd) => cmd,
// Every `CoreController`/`CoreCommandSender` is gone — the UI has
// dropped the core. Teardown happens once, after the loop.
None => break,
},
maybe_wake = besteffort_wake_rx.recv() => match maybe_wake {
@@ -1529,8 +1550,27 @@ async fn run_core_loop(
None => continue,
}
}
// ⚠️ UNREACHABLE BY CONSTRUCTION, twice over — do not mistake this
// for a live teardown path (phase 0b finding, 2026-07-26):
// 1. this function owns `besteffort_wake_tx` (cloned at the
// `CoreController::new` spawn site, used just above for the
// `has_more` re-arm), so the channel can never close while
// this loop is running;
// 2. even without that, every holder of a wake sender —
// `CoreController` and `CoreCommandSender` — holds
// `reliable_tx` too, and the `biased` select polls that one
// first, so the reliable arm always wins the race to exit.
// Teardown is hoisted after the loop, so if this arm is ever made
// reachable it is already covered — nothing to add here.
None => break,
},
// 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) => {
// The detector worker published a new debounced game (or `None`).
let Some(detected) = game_change else {
@@ -1549,7 +1589,7 @@ async fn run_core_loop(
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
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;
}
@@ -1693,6 +1733,13 @@ async fn run_core_loop(
net.file_router.clear();
*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
// active, rebuild the persistent stack now — after the old session is
@@ -1788,8 +1835,8 @@ async fn run_core_loop(
secret_key.clone(),
));
// Fresh join starts not sharing; clear any stale share ticket.
current_sharing = None;
// (The share was already retired beside the session teardown
// above; a fresh join starts not sharing.)
let self_state =
presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
@@ -2726,9 +2773,9 @@ async fn run_core_loop(
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard,
screenshare_host: None,
screenshare_viewers: Vec::<(String, tokio::process::Child)>::new(),
teardown: SessionTeardown::new(echo_cancel_guard),
#[cfg(not(target_os = "linux"))]
teardown: SessionTeardown::new(None),
};
let self_id = endpoint.id().to_string();
@@ -2810,8 +2857,11 @@ async fn run_core_loop(
is_muted.store(new_state, Ordering::Relaxed);
if let Some(session) = &active_session {
let self_state =
presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone());
let self_state = presence.to_state(
new_state,
net.endpoint.addr(),
current_sharing.as_ref().map(|s| s.ticket.clone()),
);
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(
is_muted.load(Ordering::Relaxed),
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;
}
@@ -3137,7 +3187,7 @@ async fn run_core_loop(
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
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;
}
@@ -3343,7 +3393,7 @@ async fn run_core_loop(
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
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;
}
@@ -3403,7 +3453,7 @@ async fn run_core_loop(
.await;
continue;
};
if session.screenshare_host.is_some() {
if session.teardown.is_sharing() {
continue; // already sharing
}
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
@@ -3417,46 +3467,62 @@ async fn run_core_loop(
continue;
}
};
// Forward pixelpass `app_audio` events (only emitted when an app
// is selected) to the UI so it can warn when the chosen app's
// audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<
crate::screenshare::PixelpassEvent,
>();
let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let active = match ev {
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
crate::screenshare::PixelpassEvent::AppAudioLost => false,
_ => continue,
};
if ui_tx_notices
.send(UiEvent::ShareAudioActive(active))
.await
.is_err()
{
// Every share gets a notice forwarder — not just app-audio ones.
// pixelpass `app_audio` events (only emitted when an app is
// selected) become UI warnings, and the drain's terminal `Eof`
// becomes a host fault scoped to this spawn's generation, so a
// host that dies is torn down instead of staying advertised in
// presence forever. On a failed spawn the sender is dropped
// 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 fault_tx = host_fault_tx.clone();
tokio::spawn(async move {
while let Some(notice) = notices_rx.recv().await {
match notice {
crate::screenshare::HostNotice::Event(ev) => {
let active = match ev {
crate::screenshare::PixelpassEvent::AppAudioRouted => true,
crate::screenshare::PixelpassEvent::AppAudioLost => false,
_ => continue,
};
if ui_tx_notices
.send(UiEvent::ShareAudioActive(active))
.await
.is_err()
{
break;
}
}
// Terminal by contract: nothing follows on the
// channel, so the task ends here.
crate::screenshare::HostNotice::Eof => {
let _ = fault_tx.send(generation);
break;
}
}
});
tx
}
});
match crate::screenshare::spawn_host(
&bin,
audio_app.as_deref(),
&settings,
quality,
notices,
notices_tx,
)
.await
{
Ok((child, ticket)) => {
crate::log_msg("Screen share host started");
session.screenshare_host = Some(child);
current_sharing = Some(ticket.clone());
session.teardown.set_host(child);
current_sharing = Some(ActiveShare {
generation,
ticket: ticket.clone(),
});
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
@@ -3476,9 +3542,24 @@ async fn run_core_loop(
CoreCommand::StopScreenShare => {
current_sharing = None;
if let Some(session) = &mut active_session {
if let Some(mut child) = session.screenshare_host.take() {
let _ = child.kill().await;
crate::log_msg("Screen share host stopped");
match session.teardown.stop_host().await {
None => {}
Some(teardown::StopOutcome::Reaped) => {
crate::log_msg("Screen share host stopped");
}
// We gave up waiting rather than freeze the client, so
// pixelpass may still be alive and serving. Saying
// "stopped" and nothing else would be a lie the user
// cannot see through (round-16 review, P3-2).
Some(teardown::StopOutcome::Unconfirmed) => {
let _ = ui_tx
.send(UiEvent::Error(
"Couldn't confirm the screen-share process exited — \
it may still be sharing. Check for a stray pixelpass."
.into(),
))
.await;
}
}
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
@@ -3490,6 +3571,66 @@ async fn run_core_loop(
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 } => {
let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) {
Some(b) => b,
@@ -3505,16 +3646,12 @@ async fn run_core_loop(
if let Some(session) = &mut active_session {
// Drop viewers whose player window has already closed so the
// list only tracks live players.
session
.screenshare_viewers
.retain_mut(|(_, child)| !matches!(child.try_wait(), Ok(Some(_))));
session.teardown.sweep_exited_viewers();
// One player per share: a second Watch click on a share we're
// already viewing is a retry (usually because the first window
// froze), so replace the existing player rather than stacking a
// second mpv — two players would double the shared audio.
if let Some(pos) = replace_viewer_index(&session.screenshare_viewers, &ticket) {
let (_, mut old) = session.screenshare_viewers.remove(pos);
let _ = old.kill().await;
if session.teardown.replace_viewer(&ticket).await {
crate::log_msg("Screen share viewer replaced (re-watch)");
}
}
@@ -3522,7 +3659,7 @@ async fn run_core_loop(
Ok(child) => {
crate::log_msg("Screen share viewer started");
if let Some(session) = &mut active_session {
session.screenshare_viewers.push((ticket, child));
session.teardown.push_viewer(ticket, child);
}
}
Err(e) => {
@@ -3535,6 +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(())
}
+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);
}
}
+91 -8
View File
@@ -90,6 +90,21 @@ pub enum PixelpassEvent {
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.
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
let line = line.trim();
@@ -370,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
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// 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
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host(
@@ -378,7 +396,7 @@ pub async fn spawn_host(
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
) -> std::io::Result<(Child, String)> {
let args = host_args(audio_app, settings, quality);
// Log the exact argv we hand pixelpass so a field log can confirm which
@@ -433,7 +451,7 @@ pub async fn spawn_host(
if let Some(stderr) = stderr {
drain_stderr_in_background(stderr);
}
drain_in_background(lines, "host", notices);
drain_in_background(lines, "host", Some(notices));
Ok((child, ticket))
}
@@ -572,13 +590,15 @@ where
/// 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
/// parsed event is also forwarded to the caller (the core, which translates the
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
/// stops forwarding, draining continues. The task ends on EOF (child exited).
/// parsed event is also forwarded to the caller (the core), and when the stream
/// ends — EOF or read error, i.e. the child exited or its event stream broke —
/// 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>(
mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
notices: Option<tokio::sync::mpsc::UnboundedSender<HostNotice>>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
@@ -587,10 +607,14 @@ fn drain_in_background<R>(
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
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);
}
});
}
@@ -1376,4 +1400,63 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
#[cfg(not(windows))]
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");
}
}
+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));
}