Author SHA1 Message Date
molluskandClaude Opus 4.8 279903e56e host/taint: correct the buffered-echo scoping (in-threat-model); pin ambiguous-client (Codex round 6)
Codex refuted my round-5 disposition and was right: the buffered-echo gap
is NOT limited to keyless/unbounded readers. A normal PID-bearing app —
recorder, DAW, GStreamer — can read the call, buffer it in application
memory, fully tear down its PipeWire Node *and* Client, then (still the
same live process) open a fresh Client + output and replay. `seed_sticky`
drops the PID fingerprint once every old serial is gone, so the replayed
leg is Eligible. That is in-threat-model, so my "outside the threat model"
claim was false.

- Rewrote the module-doc gap note honestly: in-threat-model, reachable by
  non-adversarial software, sitting on the design's §6.1.3 "full teardown
  ⇒ starts clean" boundary. Framed the two options — (A) accept as a
  documented v1 limitation, (B) process-generation lifetime (PID + /proc
  start-time, phase 3 supplies liveness, §6.1.3 revised). This is a
  designer's decision (it revises the security surface); NOT resolved in
  code. `a_fingerprint_does_not_outlive_its_owner` currently encodes
  Option A and flips under B.
- P2 (fixed): pinned the ambiguous-client-id branch. A mutation
  remembering only the first of two clients claiming one global id
  survived the suite; added a test scoped to the ambiguous owner (the
  global count was masked by the peerspeak owner's client). Verified the
  `.next()` mutation now fails it.

57 tests. Phase 2 is NOT converged — the buffered-echo design decision is
owed to the user before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 04:29:40 -04:00
molluskandClaude Opus 4.8 65fde92628 host/taint: pin the role-receiver mutation; doc fixes; document the unbounded-buffer limit (Codex round 5)
Round 5 convergence check. Codex confirmed F1(broad rule)/F2(doc)/
link-group fingerprint complete, and raised three more:

- P2 (fixed): a mutation deleting the *role-based* receiver insertion
  survived all 55 tests — every tested bridge source also had an inbound
  link. A pixelpass capture sink is a taint root before anything links
  into it, and its re-emitting sibling must bridge from it on role alone.
  Added `a_local_root_receiver_bridges_without_an_inbound_link`; mutation
  now killed.
- P3 (fixed): doc drift. The backstop's preamble still described the old
  "targets must be unbounded / apps never swept" rule; rewritten to the
  two-tier trigger/sweep. The `session_device` factory guidance now says
  explicit allowlist, not "and the like".
- P1 (dispositioned as a documented v1 limitation, not fixed): a buffered
  echo across a *full* teardown of an *unbounded* reader. Grounds, in the
  module docs: (1) it needs a stream exposing no PID/module-id/link-group,
  which is malformed/identity-hiding and outside v3.4 §2's non-adversarial
  threat model; (2) it contradicts the design's explicit "reappears after
  full teardown ⇒ new owner, starts clean" (§6.1.3), so closing it is a
  design change; (3) the only closed-form fix is a whole-share hammer
  (one keyless stream ⇒ desktop unshareable for the share). Reachable
  cases — a reader live now — are already covered by the backstop.
  Owed to the design doc as a round-8 note.

56 tests. Taking the P1 disposition to Codex for ratification, then to
the user as a design decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:06:45 -04:00
molluskandClaude Opus 4.8 2183084ec8 host/taint: concede the unbounded-reader rule; pin link-group fingerprints (Codex round 4)
Round 4 adjudicated my three round-3 pushbacks. Codex ruled: F2 bool seam
sufficient (YES), F3 accepted as a phase-3 contract not a phase-2 blocker
(YES) — but my F1 narrowing was unsound (NO), with a clean counterexample.

F1 (conceded): I had narrowed "unbounded tainted reader ⇒ exclude every
output" to spare outputs carrying a real, non-daemon PID, arguing an
unbounded reader must be daemon-owned. Codex refuted it:
`application.process.id` is optional and client-controlled, so one real
process can present NO pid on its reading leg (unbounded) and a real pid
on its output leg — the narrowing spares that output and leaks the call.
App properties cannot carry a soundness argument; only `pipewire.*` has
protected identity. Reverted to the broad rule: an unbounded tainted
reader excludes the whole candidate universe. Added the exact
counterexample as a test (`a_real_app_with_no_pid_on_its_reader_leg...`)
and kept a bounded-reader test to show the round-1 blast-radius guarantee
still holds for the bounded tier.

F2 (doc corrected): removed the "a mis-classified filter is still braced"
claim — Codex showed a filter with no shared strong key, wrongly marked
`session_device`, cannot trip the backstop from its reading leg and leaks
through a differently-keyed output. A false positive is now documented as
leak-capable; the only defence is the correct positive classifier.

F3 (link-group fingerprint, pinned): a mutation dropping LinkGroup
fingerprints survived all 53 tests, because the strong-key fingerprint
test used pulse.module.id. Added a link-group new-connection test.

Mutation-verified 2/2. 55 tests.

Phase-2 open item is now only F3-as-phase-3-contract, which Codex accepted
is not a phase-2 blocker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:54:08 -04:00
molluskandClaude Opus 4.8 f35bab0379 host/taint: close the inverse asymmetric leak; strengthen contracts (Codex round 3)
Round 3 was the second verification round. One real leak, one accepted
narrowing of Codex's own suggested fix, two contract strengthenings, and
a test-gap fix.

F1 (P1, real leak, fixed): the inverse of round-1 finding 4. A tainted
reader that is itself *unbounded* (client.id only, daemon PID suppressed)
whose re-emitting leg carried an *unmatched* strong key left that leg
"bounded" and Eligible. An unbounded reader cannot be positively related
to any output, so a strong key that does not match it back proves nothing.

  Two-tier backstop. A bounded tainted reader excludes only unbounded
  outputs (a differently-keyed output is provably a different owner). An
  unbounded tainted reader also excludes daemon-owned outputs — but NOT
  ordinary apps.

  ⚠️ Deliberately narrower than Codex's suggested "exclude every output".
  An unbounded reader is necessarily daemon-owned (a real app has its own
  PID, which is a usable key, so it would be bounded), so its sibling is
  another daemon leg, never an app. Sweeping in real apps would lose the
  round-1 "blast radius stays small" guarantee for no safety gain. When
  the daemon PID is unknown the app/leg distinction collapses and the rule
  degrades to Codex's exclude-all. Both directions are pinned by tests,
  and the over-aggressive variant fails the spares-real-apps test.

F2 (contract, strengthened): `session_device` is documented as a positive
high-confidence phase-3 classification, not `device.id`+`device.api`
(measured insufficient — a card filter can carry both; node.physical is
null on the real ALSA nodes so it is not a discriminator). Fail closed:
unknown ⇒ false. Documented why a mis-classified filter still does not
leak in practice — its legs share a link-group (strong-key bridge) and an
unbounded reading leg trips the two-tier backstop.

F5 (P2, test gap): a mutation keeping only PID fingerprints survived all
49 tests. Added a strong-key (pulse.module.id) new-connection fixture.

Mutation-verified 3/3 including the over-aggressive counter-mutation.

Still OWED to round 3, carried to round 4 for adjudication: finding 3
(a not-ready epoch can persist provisional owner *fusion* as sticky
over-exclusion). It is over-exclusion, never an echo leak, and closing it
needs a readiness/provenance model decision rather than a local patch —
see the round-4 handoff. 53 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:40:24 -04:00
molluskandClaude Opus 4.8 31084edcfa host/taint: close the partial fixes found in Codex round 2
The verification round earned its place: five of the six round-1 fixes
were partial, and two of the gaps were worse than the bugs they replaced.

1. ⚠️ The round-1 sticky fix smuggled the suppressed key back in.
   `client_serials_of` recorded the shared `WirePlumber [export]` client as
   a member of a tainted hardware sink's owner, so the *second* recompute
   expanded that client to every sound card on the box, tainted the
   microphone, and excluded every app holding one — the §6.1.1 catastrophe
   arriving one epoch late instead of never. `client.id` may now only be
   recorded, or expanded, for nodes where it is a usable owner key.
   The regression test evaluates an unchanged snapshot three times: a
   correct engine's answer must not drift when nothing has.
2. Sticky followed a surviving *connection*, not a surviving *owner*. A
   process can leave one client idle and open a second — GStreamer opens
   one per stream as a matter of course — and the new leg escaped.
   `StickyOwner` now carries owner **fingerprints** (strong keys and a
   usable PID, never `client.id`), applied only while some serial member
   is still live, so a recyclable key cannot resurrect a dead owner.
3. An **ambiguous** link input endpoint tainted every claimant but made
   none of them a receiver, so their sibling output legs stayed eligible.
   Taint without receiver status cannot start an owner bridge.
4. `device.id` is a raw observation, not the classification the coarse-key
   exception needs — PipeWire defines it only as "the Device this node
   belongs to", so a forwarding node carrying one would have lost both its
   owner keys and its ability to trip the backstop. Replaced by
   `session_device`, a phase-3 obligation (`device.id` AND `device.api`)
   documented to fail closed when it cannot classify.
5. Readiness now gates sticky **retirement only**. Round 1 stopped a
   not-ready epoch erasing history; it also stopped it recording any, so a
   reader could consume and buffer the call during that epoch, vanish
   before readiness, and leave its output eligible.
6. Added the unresolved-output-plus-unknown-role fixture: deleting one
   `receivers.insert` survived all 42 previous tests.

Mutation-verified: 7/7 reverts killed by their intended test. Two attempts
did not land first time and both were my error, not the engine's — the
client-key guard is applied at two sites so removing one is not a revert
(removing the pair is, and that is killed), and the fingerprint-lifetime
test put the recycled node in a snapshot *after* the entry had already
been retired, so the guard was never consulted. Rewritten to place it in
the same snapshot that first sees the owner gone.

Cost comment corrected again, to O(D·(V+E+Σ|sources|·|targets|)).

49 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:12:21 -04:00
molluskandClaude Opus 4.8 a46c4cd20c host/taint: close five leaks found in Codex round 1
All five were reachable, all five now have a regression test, and each
test was verified by injecting the mutation that reverts its fix.

1. Sticky taint ignored surviving Client members. An app can close every
   stream while keeping its PipeWire connection open and then open a new
   one — Firefox does this constantly — and the new leg came back
   Eligible while the owner's buffers still held the call. Sticky seeding
   now resolves live Client serials to their current nodes.
2. "Receives audio" was inferred from `media.class` alone, so a node with
   an absent or unexpected class sitting on a real inbound link could not
   start an owner bridge and its sibling re-emitted the call. A node is
   now a receiver if it appears as a resolved `link.input.node` OR has a
   receiving role.
3. The device-node coarse-key exception was keyed on `media.class` being
   `Audio/Sink|Source|Duplex`, which also stripped the only correlation a
   *native virtual sink* has (own client, no link-group, no module id).
   Now keyed on `device.id`, measured on the live graph as the exact
   discriminator: the 5 ALSA nodes carry device.id 43/45/46 and share
   `client.id` 42 (`WirePlumber [export]`); the 3 `support.null-audio-sink`
   nodes carry no device.id and hold their own clients.
4. The unbounded-owner backstop required the tainted *reader* to be
   unbounded. Properties can be asymmetric — a reader with a link-group
   whose re-emitting leg has none is bounded while its sibling is not
   findable — so that condition is dropped; targets stay restricted to
   unbounded output legs, which keeps the blast radius small.
5. A not-ready snapshot could retire sticky owners, erasing taint history
   on the strength of a graph already declared untrustworthy. `evaluate`
   now returns the prior state unchanged while `!graph_ready`.

Test-quality findings, also fixed:
- a single pass of each rule survived all 32 tests (every fixture needed
  at most one owner hop) → two-chained-forwarder test with a clean
  control, plus a 60-layer chain to catch an accidental blow-up
- first-write-wins `raise()` survived → a node reached by bridge on one
  pass and by a direct link on the next must report the stronger reason
- `drop_clients` left the fixture's client caches stale, so "a fresh
  client after teardown" was really a dangling id; the recycling row now
  reuses node id, client id AND `pulse.module.id` verbatim

Also corrected the cost claim: this is O((V+E)·D) for owner-bridge depth
D, not O(V+E) as v3.4 §6.4 states. Owner keys are now computed once per
snapshot instead of per candidate pair.

42 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:47:38 -04:00
molluskandClaude Opus 4.8 6ead1fe9f8 host/taint: pure graph model + taint engine (phase 2)
Implements design v3.4 §6.1–§6.1.3 behind a fixture test surface. No
PipeWire types in any signature; nothing here links against libpipewire.
Not wired into anything yet — phase 3's registry observer is what will
feed it, so the module is `#![allow(dead_code)]` for now.

    evaluate(&GraphSnapshot, &ExclusionCtx, &StickyState)
        -> (Decisions, StickyState)

- snapshot.rs: owned Node/Port/Link/Client model keyed on `Serial`
  (object.serial, 64-bit, identity) with `GlobalId` retained strictly as
  a snapshot-local lookup key. Two live objects claiming one id resolve
  as `Ambiguous`, which fails closed.
- owner.rs: the owner bridge — the key union (link-group, pulse.module.id,
  client.id, application.process.id) with equality-not-first-present
  semantics, transitive union-find components, and both suppression rules.
- mod.rs: monotone fixpoint over link edges, the conditional owner bridge
  (gated on the tainted member being one that *receives* audio) and the
  unbounded-owner backstop, then sticky merge. Stable `Reason` codes with
  an explicit priority so the reported reason never depends on traversal
  order.

Three judgement calls that go beyond what v3.4 spells out, all flagged
in the source:

1. Coarse keys (client.id, application.process.id) may not bridge
   device-role nodes. Every ALSA device is created by one WirePlumber
   process, so they share a client and a PID; peerspeak's playback taints
   the default sink on every recompute, and without this rule that taint
   reaches the microphone source and then every app holding a mic loses
   its playback — the §6.1.1 catastrophe by another route.
2. "Owner is bounded" is not "has a usable key": client.id alone does not
   bound an owner (the measured GStreamer split-client refutation), so
   the fail-closed backstop keys on strong keys or a usable PID.
3. Sticky entries record a reason per node rather than one per owner, so
   a forwarder's output leg keeps `tainted-owner-bridge` instead of
   inheriting its input leg's `tainted-upstream`.

32 fixture tests, each asserting an exact partition of the full candidate
universe rather than spot-checking named nodes: v3.4 §12's matrix, the
impl plan's degenerate-snapshot boundary, and the eligible half of every
scenario so an exclude-everything build fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:24:15 -04:00
molluskandClaude Opus 4.8 d54e2b99fc Merge phase 0a: object.serial u32→u64
Impl-plan §2/0a. Exit gate (boundary parse tests) met; reviewed by Codex
(gpt-5.6-sol xhigh) round 1 — APPROVE-WITH-NITS, one P3 fixed and its
mutant verified. Unblocks phase 2 (pure taint engine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:49 -04:00
molluskandClaude Opus 4.8 87de5213fe audio: cover ordinary serial lengths in parse tests
Codex round 1 (P3): the valid cases were only 1, 10 and 20 digits long,
so `if (2..10).contains(&raw.len()) { return None }` survived all four
tests while rejecting every serial a freshly started daemon hands out.
Verified: that mutant passes the old suite and fails the new test.

Also corrects the doc comment — leading zeroes are accepted (harmless
and unambiguous), only whitespace padding is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:00:34 -04:00
molluskandClaude Opus 4.8 9b6c8bb5c3 audio: parse object.serial as u64 (phase 0a)
`object.serial` is a 64-bit PipeWire counter, not a u32 object id.
Parsing it with `parse::<u32>()` returns None past u32::MAX, which
silently leaves `RouterState::sink_serial` unset — `try_flush` then
routes nothing and app-filter mode is dead with no diagnostic.

- factor the parse into a pure `parse_object_serial(&str) -> Option<u64>`
  (strict decimal; rejects signs, padding, overflow) with unit tests at
  the u32 boundary, past it, and at u64::MAX
- widen `RouterState::sink_serial` to `Option<u64>`
- log a warning when the sink's serial is unusable instead of returning
  silently
- audit the other `parse::<u32>` in this file: `load_module` returns a
  PulseAudio module index (uint32_t), genuinely 32-bit — annotated, not
  changed

Prerequisite for the taint engine's lifetime-awareness, which is keyed
on object.serial (screenshare-audio-exclusion-impl-plan.md §1, §2/0a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:47:30 -04:00
molluskandClaude Opus 4.8 40604c716c debug: add PIXELPASS_TS_DUMP tap for A/V drift analysis
When PIXELPASS_TS_DUMP=<path> is set, tee the muxed MPEG-TS to a file in
addition to the normal fd=1 serve path, so the host-side stream can be
ffprobe'd for capture-side audio/video PTS drift. Each tee branch gets its
own queue so the disk sink cannot backpressure the live serve branch.

No effect when the variable is unset, mirroring PIXELPASS_GST_DEBUG.

Used to establish that the host produces an A/V-clean realtime stream
(+/-18 ms over 170 s), ruling out the capture side in the screen-share
drift investigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:49:42 -04:00
mollusk 3b92bcbe52 chore: update dependencies for RustSec advisories 2026-07-15 06:29:05 -04:00
molluskandClaude Opus 4.8 b6240c17c5 viewer: drop forced --hwdec=auto (froze video on frame 1)
The screen-share viewer ran mpv with --profile=low-latency (hwdec off by
default) and then forced --hwdec=auto back on. On some drivers the HW H.264
decoder stalls mid-stream: a viewer receiving a software-x264 share froze on
the first frame while audio kept playing (one MPEG-TS byte stream, so bytes
were still flowing — the video decoder gave up, the audio decoder didn't).
Screen-share H.264 at these bitrates decodes trivially in software, so leave
hwdec at the low-latency default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 c1b21b32c7 Add pixelpass --doctor environment diagnostic
Screen-share failures are usually environment gaps, not pixelpass bugs —
most often a GPU/driver with no working VA-API H.264 encoder, so the
default vah264enc pipeline produces no video and the viewer "can't
connect." doctor probes the whole chain and prints one actionable report
so a remote tester can read it over a call instead of us guessing from
logs, and it validates any X11/Wayland test environment we stand up.

Checks (each a ✓/!/✗ line with a distro-aware install hint):
- display server (Wayland/X11 + session env), and the X server vendor/
  version so an xlibre server is distinguishable from stock Xorg
- capture: gst tools + the backend's source element (pipewiresrc/ximagesrc)
- encode: hardware H.264 (vah264enc + DRM render node + a VA-API H.264
  *encode* entrypoint parsed from vainfo) and the software x264 fallback
- mux/audio tail + pactl
- viewer player (mpv/vlc)
- network: binds a real endpoint and checks relay reachability

Unlike deps::check_host_binaries (bails on first miss), doctor runs every
check and reports them together. Closes with a specific hosting verdict and
exits non-zero on any hard failure so scripts/CI can gate. Pure seams
(vainfo entrypoint parse, summary tally, hosting verdict) are unit-tested;
deps.rs gained pub(crate) which/gst_element_exists/install-hint/distro
helpers so doctor reuses the same package-name knowledge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:19:06 -04:00
molluskandClaude Opus 4.8 b0ff20fe3f host/x11: default to XDamage capture; drop --untimed from viewers
X11 full-desktop capture used `ximagesrc use-damage=false`, which copies
the whole root window every frame. On servers without working MIT-SHM
(and CPU-bound everywhere else) this collapses to ~1 fps — a field test
over an xlibre host played back at roughly one frame per minute. Default
to `use-damage=true` (XDamage re-grabs only changed regions); keep
`PIXELPASS_X11_NO_DAMAGE=1` as an escape hatch for driver artifacts.

Also drop `--untimed` from both mpv invocations (viewer banner + the
interactive launcher). `--untimed` displays each frame as it decodes and
ignores audio timestamps, which drifts a shared *video* progressively
out of sync with its audio. Pacing to the audio clock keeps A/V synced
at a negligible latency cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:44 -04:00
molluskandClaude Opus 4.8 b5c03e7705 fix(host): let the sharer hear the app they're sharing (local monitor)
In strict per-app mode the stream router *moves* the chosen app's output
off the sharer's speakers into the private capture null-sink, so the
viewer heard it but the sharer went silent — you couldn't watch a video
together because only the remote side had audio.

Add a "local monitor" loopback (null-sink.monitor → @DEFAULT_SINK@) that
mirrors the routed app back to the sharer's own speakers. It carries only
the chosen app (never the desktop/voice call), so it can't echo into the
capture, and it's loaded on the first routed stream — after the default
loopback is unloaded — so the two are never live at once (no feedback).
Unloaded when the app stops and torn down before the null-sink on cleanup.

Extend `--repair` to recognise this loopback by its `source=` arg (it
targets @DEFAULT_SINK@, not a pixelpass name) so a crashed host's local
monitor is swept too. New pure `loopback_capture_pid` + 3 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:03:17 -04:00
molluskandClaude Opus 4.8 31b33e9e5a docs(deb): document the Debian .deb build environment
Companion to peerspeak's packaging/debian/README. Captures the shared bookworm
distrobox build, the box-local CARGO_TARGET_DIR, and — most importantly — why
the GStreamer capture stack is hard-coded into Depends (invoked as subprocesses,
invisible to dpkg-shlibdeps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:03:49 -04:00
molluskandClaude Opus 4.8 e16b7190bb packaging: build from public gitbutter repo instead of local path
The PKGBUILD url + source pointed at file:///home/mollusk/git/butter/pixelpass,
a local-only path no one else could build from. The repo is public on
gitbutter, so point both at the anonymous HTTPS clone URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:59:11 -04:00
molluskandClaude Opus 4.8 c39ab081d9 packaging: pull GStreamer capture stack into .deb runtime deps
pixelpass invokes the GStreamer tools and pactl as subprocesses, not as
linked libraries, so dpkg-shlibdeps (`depends = "$auto"`) never sees them.
On a fresh Ubuntu host that means `deps::check_host_binaries` bails before
the host emits its ticket — peerspeak then reports the generic "pixelpass
host exited before emitting a ticket" (first 2-human field hit, 2026-06-26).

List the runtime stack explicitly so `apt install ./pixelpass.deb` pulls in
gstreamer1.0-{tools,plugins-base,plugins-good,plugins-bad,plugins-ugly,libav,
pipewire,pulseaudio}, pulseaudio-utils and x11-utils. Recommends mpv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:08 -04:00
molluskandClaude Opus 4.8 646f35d3eb host/audio: emit initial app_audio "lost" at strict capture start (A23 P2/F1)
In strict per-app mode the default-sink loopback is suppressed, so until the
chosen app's first stream routes the viewer hears silence. Previously no event
fired for an app that never routed (`lost` only fires on an N→0 transition
after a prior route), so peerspeak couldn't warn — the share looked normal but
was silent. Emit a `lost` at capture start (lazy, on first viewer) when, and
only when, `--app` + `--strict-audio` are both set; whole-desktop and
best-effort modes keep audio flowing via the loopback and emit nothing.

Factored the emit decision into the pure, unit-tested `initial_app_audio_state`;
derive Debug/PartialEq/Eq on AppAudioState so it can be asserted on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 ff7daee34e packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Add a [package.metadata.deb] block so the headless default build (no `gui`
feature) — the variant peerspeak spawns as a child — can be packaged with
`cargo deb` from inside a Debian/Ubuntu distrobox. Ships only the pixelpass
binary; runtime shared-lib deps resolved by dpkg-shlibdeps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 85fdebeb66 feat(audio): add --strict-audio + app_audio route-status events
With --app, pixelpass mirrors the default-sink monitor (whole desktop) until the
chosen app's streams route, and restores that loopback if the app's audio later
stops. That fallback captures everything playing — including a voice call the
sharer is in — so a caller watching the share can hear themselves echoed back
(peerspeak bug A23: the per-app pick alone is best-effort, not a guarantee).

- New --strict-audio flag (HostOpts.strict_audio): with --app, never load the
  default-sink loopback (not at startup, not on LastRoutedStreamGone). The viewer
  hears only the chosen app, and silence when it's quiet — never the rest of the
  desktop. No effect without --app; standalone best-effort behavior is unchanged.
- New app_audio JSON event ({"event":"app_audio","state":"routed"|"lost"}),
  emitted whenever --app is set, so a front-end (peerspeak) can tell when the
  chosen app's audio is actually live vs. dropped and warn accordingly.
- Banner capture summary shows "(strict)" when active.

Unknown-event-tolerant: pixelpass's own --gui child parser skips lines it can't
deserialize, so app_audio doesn't disturb it. 10 tests (+2: wire-shape + banner),
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:44:02 -04:00
molluskandClaude Opus 4.8 cfc480044f fix: three robustness bugs outside the friends list
Found in a wider bug audit of the streaming/process-management code.

- Viewer ctrl-c/SIGINT was ignored mid-stream: viewer::run raced the
  cancel token only against listener.accept(), not the bridge itself, so
  once the local player connected nothing checked it. CLI needed a second
  ctrl-c to quit and a GUI "Disconnect" only took effect via the child's 2s
  SIGKILL backstop (and the host saw the viewer ~2s longer). Now races the
  bridge against cancel, mirroring the host's handle_peer. (viewer/mod.rs)

- Wayland portal pipewire fd leaked on a capture-setup error: wayland::start
  into_raw_fd'd the fd and relied on pipeline::spawn's after_spawn hook to
  close it, but setup_audio/gst-spawn can ?-return before the hook runs,
  leaking the fd per failed attempt. Now the OwnedFd is moved into the hook,
  so it's closed whether the hook runs or (on early error) the unused closure
  is dropped. (host/wayland.rs)

- Detached players (mpv/vlc) zombied under the long-lived GUI: spawn_detached
  dropped the std Child, which has no orphan reaping, so each closed player
  left a <defunct> entry until the GUI exited. Now a detached thread wait()s
  it; the setsid'd player still survives a parent exit (init reaps it then).
  A double-fork was avoided deliberately — fork(2) + non-trivial work in this
  multithreaded process is unsound. (common/process.rs)

47 gui / 8 headless tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:27:07 -04:00
molluskandClaude Opus 4.8 6d0bf99076 fix(friends): five robustness bugs in the friends/control plane
Found in a bug audit of the just-merged friends-list feature. No crashes
or security holes, but five real state/correctness bugs:

- Host child dying on its own left the share campaign running, so it kept
  pushing a now-dead ticket to friends (retrying offline ones forever) and
  leaked share_status/met/share_code. The unexpected-exit path now captures
  the stderr error, then routes through the full stop_host() teardown
  (notably stop_share). (gui/mod.rs pump_host_events)

- on_friend_request downgraded an already-Accepted friend back to
  PendingIncoming when they re-sent a request (e.g. after losing their
  store). It now stays Accepted and re-confirms. (friends.rs)

- on_friend_accept advanced *any* known peer to Accepted, including a
  PendingIncoming one — a peer could mark itself accepted without the local
  user's consent. Now only a PendingOutgoing request we sent is honoured.
  (friends.rs)

- A ShareCode redelivered by an ACK-loss retry fired a duplicate desktop
  notification. push_notice now reports whether the code is new/changed and
  only then toasts. (gui/mod.rs)

- An inbound control message could be delayed up to IO_TIMEOUT on a degraded
  link because handle() awaited the sender's close before forwarding it.
  Forward to the UI first, then await close so the ACK still flushes.
  (control.rs)

Adds two friends-store transition tests (accept ignores a pending-incoming
peer; request doesn't downgrade an accepted friend). 47 gui / 8 headless
tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:10:22 -04:00
28 changed files with 5796 additions and 696 deletions
Generated
+584 -591
View File
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -6,12 +6,33 @@ description = "P2P screen sharing CLI over iroh"
license = "MIT OR Apache-2.0"
publish = false
# Debian/Ubuntu packaging (cargo-deb). Headless default build (no `gui` feature) —
# that is exactly what peerspeak spawns as a child. Runtime shared-lib deps
# (libpipewire, libc, …) are resolved by dpkg-shlibdeps via `depends = "$auto"`.
# Build inside a Debian/Ubuntu distrobox, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
section = "net"
priority = "optional"
# $auto covers linked shared libs (dpkg-shlibdeps). The GStreamer capture stack
# and pactl are invoked as *subprocesses* (gst-launch-1.0 / gst-inspect-1.0 /
# pactl), so shlibdeps can't see them — list them explicitly or a fresh Ubuntu
# host bails at `deps::check_host_binaries` before emitting its ticket. Covers
# both backends: pipewiresrc (Wayland), ximagesrc (X11, in plugins-good), the
# VAAPI + software H.264 encoders, the AAC/TS mux tail, and the PulseAudio src.
depends = "$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base, gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire, gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils"
recommends = "mpv"
extended-description = "Peer-to-peer screen sharing over iroh (QUIC). Companion to peerspeak: shares a window or screen directly to a peer with no central server, driven via the CLI and its JSON event stream."
assets = [
["target/release/pixelpass", "usr/bin/", "755"],
]
[[bin]]
name = "pixelpass"
path = "src/main.rs"
[dependencies]
iroh = "1.0.0-rc.0"
iroh = "1.0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
tokio-util = { version = "0.7", features = ["io"] }
clap = { version = "4", features = ["derive"] }
@@ -27,7 +48,7 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0-rc.0"
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] }
+31
View File
@@ -23,6 +23,8 @@ Working:
- Audio capture of the default sink's monitor, with optional per-app
routing (`--app <name>`)
- `--repair` cleanup of orphaned PipeWire state left by a crashed host
- `--doctor` environment diagnostic (capture/encode deps, VA-API H.264,
viewer player, relay reachability) — see [Diagnostics](#diagnostics)
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`)
@@ -135,6 +137,35 @@ sudo pacman -S vlc vlc-plugin-dvb vlc-plugin-ffmpeg
If the viewer is running on battery, set the CPU governor to performance
or balanced — power-saver can choke even hardware-decoded 1080p H.264.
## Diagnostics
`pixelpass --doctor` prints a one-shot report of everything the above
requirements cover and exits — run it on any machine before a real session:
```sh
pixelpass --doctor
```
It checks, and prints a `✓ / ! / ✗` line for each:
- **display server** — Wayland vs. X11 (autodetected), the raw session env
vars, and the X server's vendor/version (so an xlibre server is visible)
- **capture** — the GStreamer tools plus the source element for your backend
(`pipewiresrc` on Wayland, `ximagesrc` on X11)
- **encode** — whether hardware H.264 works (the `vah264enc` plugin, a DRM
render node, and a VA-API H.264 *encode* entrypoint via `vainfo`), and
whether the software `x264enc` fallback is available. This is the usual
culprit when a viewer "can't connect": a GPU with no H.264 encode entrypoint
produces no video under the default encoder — the report tells you to host
with `--no-hwencode`
- **mux / audio** — the TS mux + AAC + PulseAudio tail, and `pactl`
- **viewer** — whether `mpv` or `vlc` is installed
- **network** — binds a real endpoint and checks a relay is reachable
Each failing line includes a distro-aware install hint, and the closing summary
says whether the machine can host and how. The exit code is non-zero if any
hard requirement is missing, so it can gate a script or CI.
## Build
```sh
+3 -3
View File
@@ -1,6 +1,6 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Local versioned package, built from the local git repo on `main`.
# Versioned package, built from the public gitbutter repo on `main`.
# For a tagged release, switch the source fragment to `#tag=v0.1.0`.
pkgname=pixelpass
@@ -8,7 +8,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
arch=('x86_64')
url='file:///home/mollusk/git/butter/pixelpass'
url='https://gitbutter.xyz/mollusk/pixelpass'
license=('MIT' 'Apache-2.0' 'OFL-1.1')
depends=(
'gstreamer' # gst-launch-1.0 / gst-inspect-1.0
@@ -33,7 +33,7 @@ optdepends=(
makedepends=('cargo' 'git')
options=('!lto')
_branch='main'
source=("$pkgname::git+file:///home/mollusk/git/butter/pixelpass#branch=$_branch")
source=("$pkgname::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=$_branch")
sha256sums=('SKIP')
prepare() {
+63
View File
@@ -0,0 +1,63 @@
# Debian / Ubuntu `.deb` build
This documents how the `pixelpass_*.deb` is produced. The deb **recipe itself**
lives in-repo as the `[package.metadata.deb]` block in `Cargo.toml` (cargo-deb's
equivalent of a PKGBUILD); this file documents only the build environment.
pixelpass is the screen-share companion to peerspeak and is built the same way
in the same box. See peerspeak's `packaging/debian/README.md` for the full
rationale behind each step — this is the short version.
## TL;DR
```sh
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/pixelpass
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass # MANDATORY
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/pixelpass_<version>-1_amd64.deb
```
## Build environment
- **Base: the same Debian 12 (bookworm) distrobox `peerspeak-bookworm`**
(glibc 2.36) used for peerspeak. **Never build on the Arch host** (newer glibc
+ shared `$HOME`/`target/` would link Arch C objects into the binary).
- **Use a box-local, pixelpass-specific `CARGO_TARGET_DIR`** (distinct from
peerspeak's) so the two never share an artifact cache:
`export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/pixelpass`.
- Toolchain provisioning (rustup stable + `cargo-deb` + `build-essential`
`pkg-config`) is identical to peerspeak's README. pixelpass itself links few
C libraries — the heavy GStreamer stack it uses is invoked as subprocesses,
not linked (see below), so it adds no extra `*-dev` build-deps beyond the base.
## Why `Depends` lists the whole GStreamer stack explicitly
pixelpass does its screen capture by shelling out to the GStreamer CLI
(`gst-launch-1.0` / `gst-inspect-1.0`) and to `pactl`, **not** by linking the
GStreamer libraries. That means `dpkg-shlibdeps` (which only sees linked `.so`
files) cannot detect them, so `$auto` alone would ship a `.deb` whose `Depends`
omits the entire capture stack. A fresh Ubuntu host would then fail at
pixelpass's own `deps::check_host_binaries` startup probe — *before* it ever
prints a connection ticket, which is exactly the field bug that motivated this.
So the `Cargo.toml` `depends` hard-codes the runtime stack on top of `$auto`:
```
$auto, gstreamer1.0-tools, gstreamer1.0-plugins-base,
gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad,
gstreamer1.0-plugins-ugly, gstreamer1.0-libav, gstreamer1.0-pipewire,
gstreamer1.0-pulseaudio, pulseaudio-utils, x11-utils
```
This covers both capture backends (`pipewiresrc` on Wayland, `ximagesrc` on X11
from plugins-good), the VAAPI + software H.264 encoders, the AAC/TS mux tail,
the PulseAudio source, and the `pactl`/`xdpyinfo` helpers.
## glibc floor
Same as peerspeak: built against glibc 2.36 → runs on Debian 12+ / Ubuntu
24.04+. (pixelpass's own linked-library floor is lower, ~2.39-era, but it is
always shipped alongside peerspeak, whose 2.36 floor governs the pair.)
+24
View File
@@ -27,6 +27,17 @@ pub struct Cli {
#[arg(long, value_name = "NAME")]
pub app: Option<String>,
/// With `--app`, never fall back to whole-desktop audio. By default an
/// app-filtered host mirrors the default sink's monitor until (and again
/// after) the chosen app's streams route, so the viewer isn't left in
/// silence. That fallback also captures everything else playing — including
/// a voice call the sharer is in — so a caller can hear themselves echoed.
/// `--strict-audio` suppresses the fallback entirely: the viewer hears only
/// the chosen app, and silence when it isn't producing audio. Ignored
/// without `--app`.
#[arg(long)]
pub strict_audio: bool,
/// Override display server autodetection.
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
@@ -94,6 +105,14 @@ pub struct Cli {
#[arg(long)]
pub repair: bool,
/// Print an environment diagnostic report (display server, capture/encode
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
/// then exit. Use this to check a machine can host or view before a real
/// session — especially to confirm hardware H.264 encode works, since a GPU
/// without it silently produces no video under the default encoder.
#[arg(long)]
pub doctor: bool,
/// Re-run the bandwidth pre-flight test, save the result, then exit.
/// Use this if your connection has changed (new ISP, moved house, etc.)
/// or if the previously saved test result is stale.
@@ -135,6 +154,10 @@ pub enum Quality {
pub struct HostOpts {
pub window: bool,
pub app: Option<String>,
/// With `app` set, suppress the whole-desktop loopback fallback so the
/// viewer only ever hears the chosen app (silence when it's quiet). No
/// effect when `app` is None.
pub strict_audio: bool,
pub display_server: Option<DisplayServerArg>,
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
pub quality: Quality,
@@ -164,6 +187,7 @@ impl Cli {
HostOpts {
window: self.window,
app: self.app,
strict_audio: self.strict_audio,
display_server: self.display_server,
// No `--quality` and nothing picked interactively → the documented
// default, Auto.
+7 -4
View File
@@ -166,13 +166,16 @@ async fn handle(incoming: Incoming, tx: &mpsc::Sender<Inbound>) -> Result<()> {
.await
.context("timed out reading control message")??;
// Wait (briefly) for the sender's close so our ACK flushes before the
// connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
// Hand the message up first, so it reaches the UI promptly even when the
// sender is slow to close (a degraded link could otherwise delay a friend
// request / pushed code by up to IO_TIMEOUT).
tx.send(Inbound { from, msg })
.await
.map_err(|_| anyhow::anyhow!("control: receiver dropped"))?;
// Then wait (briefly) for the sender's close so our ACK has flushed before
// the connection is dropped at the end of this scope.
let _ = tokio::time::timeout(IO_TIMEOUT, conn.closed()).await;
Ok(())
}
+15 -10
View File
@@ -56,12 +56,7 @@ fn require(bin: &str) -> Result<PathBuf> {
}
fn require_gst_element(name: &str) -> Result<()> {
let ok = Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
if !gst_element_exists(name) {
bail!(
"GStreamer element `{name}` not available.\n{}",
install_hint_for_gst_element(name)
@@ -70,7 +65,17 @@ fn require_gst_element(name: &str) -> Result<()> {
Ok(())
}
fn which(bin: &str) -> Option<PathBuf> {
/// Whether a GStreamer element is registered, via `gst-inspect-1.0 --exists`.
/// Non-bailing counterpart to [`require_gst_element`] for the `doctor` report.
pub(crate) fn gst_element_exists(name: &str) -> bool {
Command::new("gst-inspect-1.0")
.args(["--exists", name])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn which(bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
@@ -81,7 +86,7 @@ fn which(bin: &str) -> Option<PathBuf> {
None
}
fn install_hint_for_bin(bin: &str) -> String {
pub(crate) fn install_hint_for_bin(bin: &str) -> String {
let distro = detect_distro();
let pkg = match bin {
"gst-launch-1.0" | "gst-inspect-1.0" => match distro.as_deref() {
@@ -113,7 +118,7 @@ fn install_hint_for_bin(bin: &str) -> String {
install_command(&distro, pkg)
}
fn install_hint_for_gst_element(name: &str) -> String {
pub(crate) fn install_hint_for_gst_element(name: &str) -> String {
let distro = detect_distro();
let pkg = match name {
"pipewiresrc" => match distro.as_deref() {
@@ -210,7 +215,7 @@ fn install_command(distro: &Option<String>, pkg: &str) -> String {
format!("Install hint: {cmd}")
}
fn detect_distro() -> Option<String> {
pub(crate) fn detect_distro() -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("ID=") {
+53 -18
View File
@@ -147,35 +147,45 @@ impl FriendStore {
self.friends.len() != before
}
/// Apply an inbound friend request. Returns `true` if it *completes a mutual
/// match* — we'd already sent them one, so they're now [`Accepted`] and the
/// caller should reply with a `FriendAccept`. Otherwise it's recorded as
/// Apply an inbound friend request. Returns `true` if the friendship is now
/// settled at [`Accepted`] and the caller should reply with a `FriendAccept`
/// — either because we'd already sent them a request (a mutual match) or
/// because they're an existing friend re-announcing (we never downgrade an
/// [`Accepted`] friend back to pending; a peer who lost their store and
/// re-adds us just gets re-confirmed). Otherwise it's recorded as
/// [`PendingIncoming`] for the user to act on and `false` is returned.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_request(&mut self, id: EndpointId, name: String) -> bool {
match self.find(&id).map(|f| f.state) {
Some(FriendState::PendingOutgoing | FriendState::Accepted) => {
self.upsert(id, name, FriendState::Accepted);
true
}
_ => {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` only if
/// it advanced one of *our* outgoing requests to [`Accepted`]. An accept for
/// any other state is ignored: a stranger's, or one for a peer still in
/// [`PendingIncoming`] (their request, awaiting our decision) — honouring the
/// latter would let a peer mark itself accepted without the local user's
/// consent.
///
/// [`Accepted`]: FriendState::Accepted
/// [`PendingIncoming`]: FriendState::PendingIncoming
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if matches!(
self.find(&id).map(|f| f.state),
Some(FriendState::PendingOutgoing)
) {
self.upsert(id, name, FriendState::Accepted);
true
} else {
self.upsert(id, name, FriendState::PendingIncoming);
false
}
}
/// Apply an inbound acceptance of a request we sent. Returns `true` if it
/// advanced a friendship to [`Accepted`] (i.e. we actually knew this peer);
/// an accept from a stranger is ignored.
///
/// [`Accepted`]: FriendState::Accepted
pub fn on_friend_accept(&mut self, id: EndpointId, name: String) -> bool {
if self.find(&id).is_some() {
self.upsert(id, name, FriendState::Accepted);
true
} else {
false
}
@@ -293,4 +303,29 @@ mod tests {
assert!(!store.on_friend_accept(stranger, "Nope".into()));
assert!(store.find(&stranger).is_none());
}
#[test]
fn accept_does_not_advance_a_pending_incoming_peer() {
// They asked us and we haven't decided yet; an unsolicited FriendAccept
// from them must not auto-accept on our behalf (consent bypass).
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Theirs".into(), FriendState::PendingIncoming);
assert!(!store.on_friend_accept(id, "Theirs".into()));
assert_eq!(store.find(&id).unwrap().state, FriendState::PendingIncoming);
}
#[test]
fn request_does_not_downgrade_an_accepted_friend() {
// A current friend re-sending a request (e.g. after losing their store)
// must stay accepted; the call signals a re-confirm rather than a
// downgrade to pending.
let mut store = FriendStore::default();
let id = sample_id();
store.upsert(id, "Pal".into(), FriendState::Accepted);
let settled = store.on_friend_request(id, "Pal (reinstalled)".into());
assert!(settled);
assert_eq!(store.find(&id).unwrap().state, FriendState::Accepted);
assert_eq!(store.find(&id).unwrap().name, "Pal (reinstalled)");
}
}
+34
View File
@@ -58,6 +58,11 @@ pub enum Event<'a> {
ViewerRefused { reason: &'a str },
/// Viewer-side: the local player URL is ready to open.
Connected { url: &'a str },
/// Per-app audio routing state (only emitted when `--app` is set). `routed`
/// = the chosen app's audio is now reaching viewers; `lost` = its last
/// stream went away. Under `--strict-audio`, `lost` means viewers currently
/// hear silence; without it, viewers fall back to whole-desktop audio.
AppAudio { state: AppAudioState },
}
#[derive(Serialize)]
@@ -67,6 +72,13 @@ pub enum CaptureState {
Stopped,
}
#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AppAudioState {
Routed,
Lost,
}
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
/// output was enabled with [`set_json`], so call sites can sprinkle these
/// unconditionally without branching.
@@ -85,3 +97,25 @@ pub fn emit(event: Event) {
Err(e) => tracing::warn!("failed to serialize event: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
// The app_audio event is the wire contract peerspeak parses to drive its
// echo warning; pin the exact shape so a rename here is caught here.
#[test]
fn app_audio_event_wire_shape() {
let routed = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Routed,
})
.unwrap();
assert_eq!(routed, r#"{"event":"app_audio","state":"routed"}"#);
let lost = serde_json::to_string(&Event::AppAudio {
state: AppAudioState::Lost,
})
.unwrap();
assert_eq!(lost, r#"{"event":"app_audio","state":"lost"}"#);
}
}
+18 -5
View File
@@ -6,10 +6,19 @@ use std::process::{Command, Stdio};
///
/// The child gets its own session via `setsid(2)` and null stdio, so it
/// survives the parent exiting and doesn't take a SIGKILL cascade when
/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop`
/// does not kill the process on Unix.
/// pixelpass dies.
///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
/// players itself and lives for the whole session, and `std::process::Child`
/// (unlike tokio's) has no orphan reaping, so simply dropping the handle would
/// leak a zombie per closed player. If the parent exits while the player is
/// still up, the reaper thread dies with it but the `setsid`'d player survives
/// and is reaped by init. (A double-fork would also avoid the zombie, but
/// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
unsafe {
let child = unsafe {
Command::new(prog)
.args(args)
.stdin(Stdio::null())
@@ -19,7 +28,11 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
nix::unistd::setsid().ok();
Ok(())
})
.spawn()?;
}
.spawn()?
};
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
+648
View File
@@ -0,0 +1,648 @@
//! `pixelpass doctor` — environment diagnostics.
//!
//! Screen-share failures are usually not pixelpass bugs but environment gaps:
//! a missing GStreamer plugin, an X vs. Wayland mismatch, or — the common one —
//! a GPU/driver with no working VA-API H.264 encoder, so the default
//! `vah264enc` pipeline never produces a byte and the viewer "can't connect."
//! `doctor` probes all of that up front and prints one actionable report, so a
//! remote tester can read it over a call instead of us guessing from logs. It
//! also validates any X11/Wayland test environment we stand up.
//!
//! Unlike [`crate::common::deps::check_host_binaries`], which bails on the first
//! missing dependency, doctor runs *every* check and reports them together — a
//! diagnostic wants the whole picture, not the first failure.
use anyhow::Result;
use std::time::Duration;
use crate::common::deps;
use crate::common::display::DisplayServer;
use crate::common::endpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// Working as needed.
Ok,
/// Degraded but not fatal (e.g. a fallback path is available).
Warn,
/// Screen-sharing will not work until this is fixed.
Fail,
/// Neutral fact, no judgement.
Info,
}
impl Status {
fn icon(self) -> char {
match self {
Self::Ok => '✓',
Self::Warn => '!',
Self::Fail => '✗',
Self::Info => '·',
}
}
}
/// One line in the report: a status, a short label, a detail, and an optional
/// remediation hint printed on its own indented line.
pub struct Check {
pub status: Status,
pub label: String,
pub detail: String,
pub hint: Option<String>,
}
impl Check {
fn new(status: Status, label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
status,
label: label.into(),
detail: detail.into(),
hint: None,
}
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Ok, label, detail)
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Warn, label, detail)
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Fail, label, detail)
}
fn info(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(Status::Info, label, detail)
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
/// Tally of the non-trivial statuses across every section.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Summary {
pub fails: usize,
pub warns: usize,
}
/// A named group of checks, printed under a header.
struct Section {
name: &'static str,
checks: Vec<Check>,
}
/// Run all diagnostics and print the report. Always prints; the process exit
/// code is non-zero only when a hard failure (a `Fail`) was found, so scripts
/// and CI can gate on it while a human still sees everything.
pub async fn run(relay: Option<String>) -> Result<()> {
let display = DisplayServer::detect();
let sections = vec![
system_section(display),
capture_section(display),
encode_section(),
mux_audio_section(),
viewer_section(),
network_section(relay.as_deref()).await,
];
print_report(&sections);
let summary = summarize(sections.iter().flat_map(|s| s.checks.iter()));
print_summary(summary, &sections);
if summary.fails > 0 {
std::process::exit(1);
}
Ok(())
}
// ── sections ──────────────────────────────────────────────────────────────
fn system_section(display: DisplayServer) -> Section {
let mut checks = vec![
Check::info(
"pixelpass",
format!("{} (gui: {})", env!("CARGO_PKG_VERSION"), gui_built()),
),
Check::info("distro", distro_detail()),
display_check(display),
];
// Probe the actual X server when one is reachable — this is where an xlibre
// vs. Xorg difference (the thing we most want to see on a tester's box)
// shows up. Skip it on a pure Wayland session with no X at all.
if display == DisplayServer::X11 || std::env::var_os("DISPLAY").is_some() {
checks.push(x_server_check());
}
Section {
name: "System",
checks,
}
}
fn capture_section(display: DisplayServer) -> Section {
let mut checks = vec![
bin_check("gst-launch-1.0", "gstreamer tools"),
bin_check("gst-inspect-1.0", "gstreamer tools"),
];
match display {
DisplayServer::Wayland => {
checks.push(gst_check("pipewiresrc", "Wayland capture"));
}
DisplayServer::X11 => {
checks.push(gst_check("ximagesrc", "X11 capture"));
checks.push(match deps::which("xwininfo") {
Some(p) => Check::ok("window picker", p.display().to_string())
.with_hint("needed only for `--window` (share a single window)"),
None => Check::info("window picker", "xwininfo not found")
.with_hint("optional — only `--window` needs it"),
});
}
DisplayServer::Unknown => {
checks.push(
Check::info("capture backend", "unknown — cannot probe a source element")
.with_hint("force one with `--display-server x11|wayland` when hosting"),
);
}
}
Section {
name: "Capture (host)",
checks,
}
}
fn encode_section() -> Section {
Section {
name: "Encode",
checks: vec![hardware_encode_check(), software_encode_check()],
}
}
/// The load-bearing check for the common "viewer can't connect" report: the
/// default host pipeline uses `vah264enc`, which needs both the GStreamer VA
/// plugin *and* a GPU/driver that actually exposes an H.264 encode entrypoint.
/// A box with the plugin but no encode entrypoint (or no render node) produces
/// no video — the exact silent failure `--no-hwencode` works around.
fn hardware_encode_check() -> Check {
if !deps::gst_element_exists("vah264enc") {
return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
"{} — or just host with `--no-hwencode` (software x264)",
deps::install_hint_for_gst_element("vah264enc")
));
}
if !has_render_node() {
return Check::warn(
"hardware H.264",
"vah264enc present, but no DRM render node (/dev/dri/renderD*)",
)
.with_hint("GPU encode is unavailable here — host with `--no-hwencode`");
}
match vainfo_output() {
Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
"hardware H.264",
"VA-API H.264 encode available (vah264enc)",
),
Some(_) => Check::warn(
"hardware H.264",
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
)
.with_hint("this GPU/driver can't hardware-encode H.264 — host with `--no-hwencode`"),
None => Check::info(
"hardware H.264",
"vah264enc + render node present; couldn't confirm the VA-API encode entrypoint",
)
.with_hint("install `vainfo` (libva-utils) to verify, or just test a real host session"),
}
}
fn software_encode_check() -> Check {
if deps::gst_element_exists("x264enc") {
Check::ok("software H.264", "x264enc available (`--no-hwencode`)")
} else {
Check::warn("software H.264", "x264enc not installed").with_hint(format!(
"{} — the fallback for GPUs without VA-API H.264 encode",
deps::install_hint_for_gst_element("x264enc")
))
}
}
fn mux_audio_section() -> Section {
// These live in plugins-bad/-good/-libav and plugins-base; all are required
// for either backend, so a miss here is a hard Fail.
let tail = [
"h264parse",
"mpegtsmux",
"aacparse",
"avenc_aac",
"pulsesrc",
"videoscale",
];
let missing: Vec<&str> = tail
.iter()
.copied()
.filter(|e| !deps::gst_element_exists(e))
.collect();
let tail_check = if missing.is_empty() {
Check::ok("mux + audio tail", tail.join(", "))
} else {
Check::fail(
"mux + audio tail",
format!("missing: {}", missing.join(", ")),
)
.with_hint(deps::install_hint_for_gst_element(missing[0]))
};
Section {
name: "Mux / audio",
checks: vec![tail_check, bin_check("pactl", "pactl")],
}
}
fn viewer_section() -> Section {
let mpv = deps::which("mpv");
let vlc = deps::which("vlc");
let check = match (mpv, vlc) {
(Some(p), _) => Check::ok("player", format!("mpv ({})", p.display())),
(None, Some(p)) => Check::ok("player", format!("vlc ({})", p.display()))
.with_hint("mpv is the recommended player; vlc needs the dvb + ffmpeg plugins"),
(None, None) => Check::warn("player", "neither mpv nor vlc found")
.with_hint("a viewer needs one of them; the GUI launches mpv by default"),
};
Section {
name: "Viewer",
checks: vec![check],
}
}
/// Bind a real video-plane endpoint and wait briefly for a relay, mirroring
/// what a host does. Directly relevant to "couldn't connect": if this machine
/// can't reach a relay, hole-punching to a peer is unlikely to work either.
async fn network_section(relay: Option<&str>) -> Section {
let check = match endpoint::bind(relay).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
// without calling close" error into the middle of the report.
ep.close().await;
if online && relay_count > 0 {
Check::ok("relay", format!("home relay reachable{where_}"))
} else if online {
Check::warn(
"relay",
format!("endpoint online but no relay address{where_}"),
)
.with_hint(
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
)
} else {
Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
"check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
)
}
}
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
};
Section {
name: "Network",
checks: vec![check],
}
}
// ── small check builders ────────────────────────────────────────────────────
fn bin_check(bin: &str, label: &str) -> Check {
match deps::which(bin) {
Some(p) => Check::ok(label, format!("{bin} ({})", p.display())),
None => Check::fail(label, format!("{bin} not found on PATH"))
.with_hint(deps::install_hint_for_bin(bin)),
}
}
fn gst_check(element: &str, label: &str) -> Check {
if deps::gst_element_exists(element) {
Check::ok(label, element.to_string())
} else {
Check::fail(
label,
format!("GStreamer element `{element}` not available"),
)
.with_hint(deps::install_hint_for_gst_element(element))
}
}
fn display_check(display: DisplayServer) -> Check {
let env = display_env_summary();
match display {
DisplayServer::Wayland => Check::ok("display server", format!("Wayland ({env})")),
DisplayServer::X11 => Check::ok("display server", format!("X11 ({env})")),
DisplayServer::Unknown => Check::fail("display server", format!("undetected ({env})"))
.with_hint(
"no WAYLAND_DISPLAY/DISPLAY/XDG_SESSION_TYPE — capture can't start; \
run inside a graphical session or pass `--display-server`",
),
}
}
/// Connect to the X server and report its vendor + version. This is how an
/// xlibre server distinguishes itself from stock Xorg (vendor string / release
/// number), which is exactly what we want to see on a tester's machine.
fn x_server_check() -> Check {
use x11rb::connection::Connection;
match x11rb::connect(None) {
Ok((conn, _screen)) => {
let setup = conn.setup();
let vendor = String::from_utf8_lossy(&setup.vendor);
let detail = format!(
"vendor \"{}\", protocol {}.{}, release {}",
vendor.trim(),
setup.protocol_major_version,
setup.protocol_minor_version,
setup.release_number,
);
let label = "X server";
if vendor.to_lowercase().contains("xlibre") {
Check::info(label, format!("XLibre — {detail}"))
} else {
Check::info(label, detail)
}
}
Err(_) => Check::info("X server", "DISPLAY set but the X server is unreachable"),
}
}
// ── environment helpers ─────────────────────────────────────────────────────
fn gui_built() -> &'static str {
if cfg!(feature = "gui") { "yes" } else { "no" }
}
fn distro_detail() -> String {
let id = deps::detect_distro();
let pretty = os_release_field("PRETTY_NAME");
match (id, pretty) {
(Some(id), Some(p)) => format!("{id} ({p})"),
(Some(id), None) => id,
(None, Some(p)) => p,
(None, None) => "unknown".to_string(),
}
}
fn os_release_field(key: &str) -> Option<String> {
let contents = std::fs::read_to_string("/etc/os-release").ok()?;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix(&format!("{key}=")) {
return Some(rest.trim_matches('"').to_string());
}
}
None
}
fn display_env_summary() -> String {
let mut parts = Vec::new();
for var in [
"WAYLAND_DISPLAY",
"DISPLAY",
"XDG_SESSION_TYPE",
"XDG_CURRENT_DESKTOP",
] {
if let Some(v) = std::env::var_os(var) {
parts.push(format!("{var}={}", v.to_string_lossy()));
}
}
if parts.is_empty() {
"no display env vars set".to_string()
} else {
parts.join(", ")
}
}
fn has_render_node() -> bool {
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
return false;
};
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
}
fn vainfo_output() -> Option<String> {
deps::which("vainfo")?;
let out = std::process::Command::new("vainfo").output().ok()?;
// vainfo prints its profile/entrypoint table to stdout; some builds also
// spill driver banners to stderr. Concatenate both so parsing is robust.
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
Some(s)
}
/// Pure: does a `vainfo` dump advertise an H.264 *encode* entrypoint? vainfo
/// lists one `VAProfile… : VAEntrypoint…` pair per line; hardware H.264 encode
/// is any `VAProfileH264*` profile paired with an `EncSlice`/`EncSliceLP`
/// entrypoint. VLD-only H.264 (decode) does not count.
fn vainfo_has_h264_encode(output: &str) -> bool {
output.lines().any(|line| {
line.contains("VAProfileH264")
&& (line.contains("VAEntrypointEncSlice") || line.contains("VAEntrypointEncSliceLP"))
})
}
// ── reporting ───────────────────────────────────────────────────────────────
fn print_report(sections: &[Section]) {
println!("pixelpass doctor\n");
for section in sections {
println!("{}", section.name);
for check in &section.checks {
println!(
" {} {:<16} {}",
check.status.icon(),
check.label,
check.detail
);
if let Some(hint) = &check.hint {
println!("{hint}");
}
}
println!();
}
}
fn summarize<'a>(checks: impl Iterator<Item = &'a Check>) -> Summary {
let mut summary = Summary::default();
for check in checks {
match check.status {
Status::Fail => summary.fails += 1,
Status::Warn => summary.warns += 1,
Status::Ok | Status::Info => {}
}
}
summary
}
fn print_summary(summary: Summary, sections: &[Section]) {
let hosting = hosting_verdict(sections);
let counts = match (summary.fails, summary.warns) {
(0, 0) => "all checks passed".to_string(),
(0, w) => format!("{w} warning{}", plural(w)),
(f, 0) => format!("{f} failure{}", plural(f)),
(f, w) => format!("{f} failure{}, {w} warning{}", plural(f), plural(w)),
};
println!("Summary: {counts}. {hosting}");
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// A one-line verdict on whether this box can host, and how. Reads the actual
/// encode + capture checks rather than the raw tally so the advice is specific.
fn hosting_verdict(sections: &[Section]) -> String {
let find = |section: &str, label: &str| -> Option<Status> {
sections
.iter()
.find(|s| s.name == section)?
.checks
.iter()
.find(|c| c.label == label)
.map(|c| c.status)
};
let hw = find("Encode", "hardware H.264");
let sw_ok = find("Encode", "software H.264") == Some(Status::Ok);
let capture_broken = sections
.iter()
.find(|s| s.name == "Capture (host)")
.map(|s| s.checks.iter().any(|c| c.status == Status::Fail))
.unwrap_or(false);
if capture_broken {
"Hosting will fail: the capture backend is incomplete (see Capture above).".to_string()
} else if hw == Some(Status::Ok) {
"Hosting will work (hardware H.264 encode).".to_string()
} else if hw == Some(Status::Info) && sw_ok {
// Plugin + render node present but VA-API unverified (no vainfo): the
// default encoder is likely fine; `--no-hwencode` is the safe fallback.
"Hosting should work (hardware H.264 likely; `--no-hwencode` is the fallback).".to_string()
} else if sw_ok {
"Hosting should work with `--no-hwencode` (software H.264 encode).".to_string()
} else {
"Hosting may fail: no working H.264 encoder found (see Encode above).".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vainfo_detects_h264_encode_entrypoint() {
// Realistic AMD/RADV-style dump: H.264 has both decode (VLD) and encode.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264Main : VAEntrypointEncSlice
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_low_power_encode_counts() {
let dump = "VAProfileH264ConstrainedBaseline: VAEntrypointEncSliceLP";
assert!(vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_decode_only_h264_is_not_encode() {
// Decode-only H.264 (VLD) plus HEVC encode must NOT be read as H.264
// encode — this is exactly the "default encoder fails" case.
let dump = "\
VAProfileH264Main : VAEntrypointVLD
VAProfileH264High : VAEntrypointVLD
VAProfileHEVCMain : VAEntrypointEncSlice";
assert!(!vainfo_has_h264_encode(dump));
}
#[test]
fn vainfo_empty_is_not_encode() {
assert!(!vainfo_has_h264_encode(""));
}
#[test]
fn summarize_counts_fails_and_warns_only() {
let checks = [
Check::ok("a", "x"),
Check::info("b", "x"),
Check::warn("c", "x"),
Check::warn("d", "x"),
Check::fail("e", "x"),
];
let summary = summarize(checks.iter());
assert_eq!(summary, Summary { fails: 1, warns: 2 });
}
#[test]
fn hosting_verdict_prefers_hardware_then_software() {
let hw = vec![Section {
name: "Encode",
checks: vec![
Check::ok("hardware H.264", "ok"),
Check::ok("software H.264", "ok"),
],
}];
assert!(hosting_verdict(&hw).contains("hardware"));
let sw = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::ok("software H.264", "ok"),
],
}];
assert!(sw_verdict_uses_no_hwencode(&hosting_verdict(&sw)));
let none = vec![Section {
name: "Encode",
checks: vec![
Check::warn("hardware H.264", "no"),
Check::warn("software H.264", "no"),
],
}];
assert!(hosting_verdict(&none).contains("may fail"));
}
fn sw_verdict_uses_no_hwencode(v: &str) -> bool {
v.contains("--no-hwencode")
}
#[test]
fn capture_failure_dominates_verdict() {
let sections = vec![
Section {
name: "Capture (host)",
checks: vec![Check::fail("X11 capture", "missing")],
},
Section {
name: "Encode",
checks: vec![Check::ok("hardware H.264", "ok")],
},
];
assert!(hosting_verdict(&sections).contains("capture"));
}
}
+37 -17
View File
@@ -1145,11 +1145,14 @@ impl PixelPassApp {
f.name = name.clone();
store_changed = true;
}
self.push_notice(from, name.clone(), ticket);
notify(
"PixelPass — a friend is sharing",
format!("{name} is sharing their screen. Open PixelPass to watch."),
);
// Only toast for a new/changed code — an ACK-loss retry
// redelivers the same code and shouldn't fire again.
if self.push_notice(from, name.clone(), ticket) {
notify(
"PixelPass — a friend is sharing",
format!("{name} is sharing their screen. Open PixelPass to watch."),
);
}
} else {
tracing::warn!(from = %from, "presence: ignoring ShareCode from a non-friend");
}
@@ -1197,13 +1200,20 @@ impl PixelPassApp {
}
/// Record a share code a friend pushed us, replacing any prior notice from
/// the same friend (their previous code is stale once they re-host).
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) {
/// the same friend (their previous code is stale once they re-host). Returns
/// `true` if this is a new notice or a *different* code than we already had
/// from them — i.e. worth a fresh desktop notification. A duplicate delivery
/// (an ACK-loss retry redelivering the same code) updates in place and
/// returns `false`, so it doesn't fire a second toast.
fn push_notice(&mut self, from: iroh::EndpointId, name: String, code: String) -> bool {
if let Some(n) = self.notices.iter_mut().find(|n| n.from == from) {
let changed = n.code != code;
n.name = name;
n.code = code;
changed
} else {
self.notices.push(ShareNotice { from, name, code });
true
}
}
@@ -2318,19 +2328,29 @@ impl PixelPassApp {
self.apply_host_event(ev);
}
if let Some(p) = &mut self.host.proc
&& !p.is_alive()
{
if self.host.ticket.is_none() {
let tail = p.stderr_tail();
self.host.error = Some(if tail.trim().is_empty() {
let dead = self.host.proc.as_mut().is_some_and(|p| !p.is_alive());
if dead {
// If it never reached a ticket, capture why (from the stderr tail)
// before tearing down. Then run the *full* Stop cleanup — most
// importantly stop_share, so a host that died on its own stops
// pushing its now-dead code to friends. Without this the campaign
// would keep retrying offline friends with a stale ticket for the
// life of the GUI, and share_status/met/share_code would leak.
let error = self.host.ticket.is_none().then(|| {
let tail = self
.host
.proc
.as_mut()
.map(|p| p.stderr_tail())
.unwrap_or_default();
if tail.trim().is_empty() {
"Host exited before it could start.".to_string()
} else {
format!("Host exited before it could start:\n{tail}")
});
}
self.host.proc = None;
self.host.capturing = false;
}
});
self.stop_host();
self.host.error = error;
}
}
+245 -21
View File
@@ -17,6 +17,15 @@
//! filtered audio twice (once via the routed stream, once via the
//! default-sink monitor loopback).
//!
//! - **Local monitor** (pactl shell-out, app mode only): rerouting *moves*
//! the chosen app off the sharer's speakers into the null-sink, so without
//! this the sharer would go deaf to the very content they're sharing. We
//! mirror the null-sink's monitor back to `@DEFAULT_SINK@` so the sharer
//! hears it too. Only the chosen app is in the null-sink — never the
//! desktop/call — so this can't echo back into the capture. It is loaded on
//! the first routed stream (after the default-sink loopback is gone, so the
//! two never coexist and feed back) and unloaded when the app stops.
//!
//! pactl is the right tool for the one-shot null-sink/loopback graph
//! mutations. libpipewire is dragged in only when per-stream filtering
//! is requested, because that needs registry-event subscription.
@@ -40,6 +49,11 @@ pub struct Routing {
/// first successful route. `Routing::shutdown` unloads whatever
/// remains.
loopback_module: Arc<Mutex<Option<u32>>>,
/// The `null-sink.monitor → @DEFAULT_SINK@` loopback that lets the sharer
/// hear the routed app. Shared with the event task, which loads it on the
/// first routed stream and unloads it when the app stops. `None` outside
/// app mode and whenever no app is currently routed.
local_monitor_module: Arc<Mutex<Option<u32>>>,
sink_name: String,
stream_router: Option<StreamRouter>,
event_task: Option<tokio::task::JoinHandle<()>>,
@@ -55,27 +69,43 @@ impl Routing {
let sink_module = load_module(&["module-null-sink", &format!("sink_name={sink_name}")])
.context("failed to load module-null-sink")?;
// In strict per-app mode we never mirror the default sink: the viewer
// must hear *only* the chosen app, never the whole desktop (which would
// leak e.g. a voice call the sharer is in back to viewers — the echo
// bug A23). Without strict mode (whole-desktop share, or best-effort
// app filtering) we load the monitor loopback so the viewer hears
// system audio immediately and during any gap before the app routes.
// 20ms loopback latency keeps the mirrored audio tight; pactl's
// default of 200ms is enough to be perceptible.
let loopback_module = load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink will be cleaned up on Drop)")?;
let strict_app = opts.app.is_some() && opts.strict_audio;
let loopback_module = if strict_app {
None
} else {
Some(
load_module(&[
"module-loopback",
"source=@DEFAULT_SINK@.monitor",
&format!("sink={sink_name}"),
"latency_msec=20",
])
.context("failed to load module-loopback (null-sink cleaned up on Drop)")?,
)
};
tracing::info!(
sink_module,
loopback_module,
?loopback_module,
strict_app,
%sink_name,
"audio routing: null-sink + loopback ready"
"audio routing: null-sink ready (loopback skipped in strict app mode)"
);
let loopback_arc = Arc::new(Mutex::new(Some(loopback_module)));
let loopback_arc = Arc::new(Mutex::new(loopback_module));
let local_monitor_arc = Arc::new(Mutex::new(None));
let mut routing = Self {
sink_module: Some(sink_module),
loopback_module: Arc::clone(&loopback_arc),
local_monitor_module: Arc::clone(&local_monitor_arc),
sink_name: sink_name.clone(),
stream_router: None,
event_task: None,
@@ -84,8 +114,11 @@ impl Routing {
if let Some(app) = &opts.app {
let (router, mut event_rx) = StreamRouter::spawn(app.clone(), sink_name.clone())?;
let loopback_for_task = Arc::clone(&loopback_arc);
let local_monitor_for_task = Arc::clone(&local_monitor_arc);
let sink_name_for_task = sink_name.clone();
let strict = opts.strict_audio;
let event_task = tokio::spawn(async move {
use crate::common::output::{self, AppAudioState};
while let Some(ev) = event_rx.recv().await {
match ev {
Event::FirstRoutedStream => {
@@ -96,11 +129,66 @@ impl Routing {
);
unload_module(id);
}
// Mirror the routed app back to the sharer's own
// speakers so they hear the content they're sharing.
// Loaded *after* the default-sink loopback is gone so
// the two never coexist (which would feed back), and
// sourced from the null-sink monitor — the chosen app
// only, never the desktop/call — so it can't echo into
// the capture.
if local_monitor_for_task.lock().unwrap().is_none() {
match load_module(&[
"module-loopback",
&format!("source={sink_name_for_task}.monitor"),
"sink=@DEFAULT_SINK@",
"latency_msec=20",
]) {
Ok(id) => {
tracing::info!(
module = id,
"audio routing: local monitor loaded (sharer hears the shared app)"
);
*local_monitor_for_task.lock().unwrap() = Some(id);
}
Err(e) => tracing::warn!(
"audio routing: failed to load local monitor loopback: {e:#}"
),
}
}
// Tell the front-end the chosen app's audio is live.
output::emit(output::Event::AppAudio {
state: AppAudioState::Routed,
});
}
Event::LastRoutedStreamGone => {
// Routed app exited mid-session. Restore the
// default-sink loopback so the viewer hears
// system audio again instead of silence.
// Routed app exited/paused mid-session. Notify the
// front-end either way; the recovery differs by mode.
output::emit(output::Event::AppAudio {
state: AppAudioState::Lost,
});
// The shared app is gone, so its null-sink is silent:
// stop mirroring it to the sharer's speakers. Re-loads
// on the next FirstRoutedStream if the app resumes.
if let Some(id) = local_monitor_for_task.lock().unwrap().take() {
tracing::info!(
module = id,
"audio routing: last routed stream gone → unloading local monitor"
);
unload_module(id);
}
if strict {
// Strict mode: do NOT restore the whole-desktop
// loopback. Viewers hear silence until the app
// produces audio again — never the rest of the
// desktop (call included).
tracing::info!(
"audio routing: strict mode — last routed stream gone, leaving viewers silent"
);
continue;
}
// Best-effort mode: restore the default-sink loopback
// so the viewer hears system audio again instead of
// silence.
if loopback_for_task.lock().unwrap().is_some() {
continue;
}
@@ -130,6 +218,16 @@ impl Routing {
routing.event_task = Some(event_task);
}
// Strict per-app mode suppresses the default-sink loopback, so until the
// chosen app's first stream routes the viewer hears *silence*. Emit an
// initial `lost` at capture start (capture is lazy — this runs on the
// first viewer) so the front-end can warn from the outset rather than
// only after an app that *was* routed later stops (audit A23 P2/F1):
// `LastRoutedStreamGone`→`lost` never fires for an app that never routed.
if let Some(state) = initial_app_audio_state(opts) {
crate::common::output::emit(crate::common::output::Event::AppAudio { state });
}
Ok(routing)
}
@@ -153,6 +251,11 @@ impl Routing {
if let Some(id) = self.loopback_module.lock().unwrap().take() {
unload_module(id);
}
// Unload the local monitor before the null-sink it reads from, so the
// sink has no active loopback reader when it's destroyed.
if let Some(id) = self.local_monitor_module.lock().unwrap().take() {
unload_module(id);
}
if let Some(id) = self.sink_module.take() {
unload_module(id);
}
@@ -171,6 +274,18 @@ impl Drop for Routing {
}
}
/// The app-audio state to announce at capture start, if any. Only strict per-app
/// mode warrants one: there the loopback is suppressed, so the viewer hears
/// silence until the chosen app's first stream routes — surface that as an
/// initial `lost`. In every other mode (whole-desktop, or best-effort app
/// filtering) the loopback keeps audio flowing from the outset, so there is no
/// initial gap to report. Pure: no I/O, so the emit decision is unit-testable.
pub(super) fn initial_app_audio_state(
opts: &HostOpts,
) -> Option<crate::common::output::AppAudioState> {
(opts.app.is_some() && opts.strict_audio).then_some(crate::common::output::AppAudioState::Lost)
}
// ──────────────────────────────────────────────────────────────────────
// App enumeration (interactive picker source)
// ──────────────────────────────────────────────────────────────────────
@@ -250,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
.context("pactl returned non-UTF-8")?
.trim()
.to_string();
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
// back verbatim. Do not widen it.
id_str
.parse::<u32>()
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
@@ -417,13 +535,21 @@ fn run_router(
return;
};
if props.get("node.name") == Some(sink_name_owned.as_str()) {
if let Some(serial) = props
.get("object.serial")
.and_then(|s| s.parse::<u32>().ok())
{
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
match props.get("object.serial").and_then(parse_object_serial) {
Some(serial) => {
state_for_reg.borrow_mut().sink_serial = Some(serial);
tracing::info!(serial, "audio routing: pixelpass sink registered");
try_flush(&state_for_reg, &event_tx_for_reg);
}
// Never silently: without a serial `try_flush` can
// never route anything, so the whole app-filter mode
// is dead and the only symptom is missing audio.
None => tracing::warn!(
node_id = obj.id,
serial = props.get("object.serial").unwrap_or("<absent>"),
"audio routing: pixelpass sink has no usable object.serial; \
stream rerouting disabled"
),
}
return;
}
@@ -476,8 +602,30 @@ fn run_router(
Ok(())
}
/// Parse a PipeWire `object.serial` property value.
///
/// `object.serial` is a **64-bit** monotonically-increasing counter
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
/// (those are `u32` and get recycled — the serial exists precisely so
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
/// sink is never registered and no stream is ever routed.
///
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
/// property we do not understand and must not guess at. Leading zeroes
/// are accepted — they are unambiguous and parse to the same value.
fn parse_object_serial(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
raw.parse::<u64>().ok()
}
struct RouterState {
sink_serial: Option<u32>,
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
/// the `u32` node ids in `routed_node_ids` / `pending`.
sink_serial: Option<u64>,
default_metadata: Option<pipewire::metadata::Metadata>,
routed_node_ids: Vec<u32>,
pending: Vec<u32>,
@@ -539,3 +687,79 @@ fn try_flush(
let _ = event_tx.send(Event::FirstRoutedStream);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_serial_parses_past_u32() {
// The regression this fix exists for: a serial one past `u32::MAX`
// used to parse as `None` and silently disable rerouting.
let beyond = u64::from(u32::MAX) + 1;
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
assert_eq!(
parse_object_serial(&u64::MAX.to_string()),
Some(u64::MAX),
"the full 64-bit range must round-trip"
);
}
#[test]
fn object_serial_accepts_ordinary_serials() {
// Without this the valid cases are only 1, 10 and 20 digits long, and
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
// survives the whole suite while rejecting every serial a freshly
// started daemon actually hands out. (Codex, round 1.)
for serial in 0_u64..=1024 {
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
}
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
assert_eq!(
parse_object_serial("007"),
Some(7),
"leading zeroes are fine"
);
}
#[test]
fn object_serial_boundary_values() {
assert_eq!(parse_object_serial("0"), Some(0));
assert_eq!(parse_object_serial("1"), Some(1));
let max32 = u64::from(u32::MAX);
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
assert_eq!(
parse_object_serial(&(max32 - 1).to_string()),
Some(max32 - 1)
);
}
#[test]
fn object_serial_round_trips_through_the_metadata_string() {
// `try_flush` writes the serial back out as a decimal string for
// `target.object`; widening must not introduce a formatting change.
for raw in ["0", "4294967296", "18446744073709551615"] {
let parsed = parse_object_serial(raw).expect("valid serial");
assert_eq!(parsed.to_string(), raw);
}
}
#[test]
fn object_serial_rejects_malformed() {
for raw in [
"",
" 12",
"12 ",
"+12",
"-1",
"1.0",
"0x10",
"12a",
"abc",
// u64::MAX + 1 — overflow must be rejected, not wrapped.
"18446744073709551616",
] {
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
}
}
}
+66 -1
View File
@@ -3,6 +3,7 @@ mod capture;
mod pipeline;
mod quality;
mod serve;
pub mod taint;
mod wayland;
mod x11;
@@ -488,9 +489,73 @@ fn copy_to_clipboard(text: &str) -> bool {
fn capture_summary(opts: &HostOpts) -> String {
let mut bits = vec![if opts.window { "window" } else { "fullscreen" }.to_string()];
if let Some(app) = &opts.app {
bits.push(format!("app-audio={app}"));
if opts.strict_audio {
bits.push(format!("app-audio={app} (strict)"));
} else {
bits.push(format!("app-audio={app}"));
}
} else {
bits.push("system-audio".to_string());
}
bits.join(" + ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Quality;
fn opts(app: Option<&str>, strict_audio: bool) -> HostOpts {
HostOpts {
window: false,
app: app.map(str::to_string),
strict_audio,
display_server: None,
quality: Quality::Auto,
bitrate: None,
framerate: None,
max_height: None,
no_hwencode: false,
max_viewers: None,
interactive: false,
relay: None,
}
}
#[test]
fn capture_summary_reflects_audio_mode() {
assert_eq!(
capture_summary(&opts(None, false)),
"fullscreen + system-audio"
);
assert_eq!(
capture_summary(&opts(Some("Firefox"), false)),
"fullscreen + app-audio=Firefox"
);
// strict only shows when an app is selected.
assert_eq!(
capture_summary(&opts(Some("Firefox"), true)),
"fullscreen + app-audio=Firefox (strict)"
);
assert_eq!(
capture_summary(&opts(None, true)),
"fullscreen + system-audio"
);
}
#[test]
fn initial_app_audio_is_lost_only_in_strict_app_mode() {
use crate::common::output::AppAudioState;
use crate::host::audio::initial_app_audio_state;
// Strict + app: announce silence up front (loopback suppressed).
assert_eq!(
initial_app_audio_state(&opts(Some("Firefox"), true)),
Some(AppAudioState::Lost)
);
// Best-effort app (no strict): loopback covers the gap → no initial event.
assert_eq!(initial_app_audio_state(&opts(Some("Firefox"), false)), None);
// Whole-desktop (strict is ignored without --app): no per-app events.
assert_eq!(initial_app_audio_state(&opts(None, true)), None);
assert_eq!(initial_app_audio_state(&opts(None, false)), None);
}
}
+25 -2
View File
@@ -189,9 +189,32 @@ fn build_args(
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
];
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
// Each tee branch has its own queue so the disk sink can't backpressure the
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
let path = dump.to_string_lossy().into_owned();
args.extend([
"tee".into(),
"name=dbgtee".into(),
"!".into(),
"queue".into(),
"!".into(),
"fdsink".into(),
"fd=1".into(),
"dbgtee.".into(),
"!".into(),
"queue".into(),
"!".into(),
"filesink".into(),
format!("location={path}"),
]);
} else {
args.extend(["fdsink".into(), "fd=1".into()]);
}
// Downscale step for the quality presets. `None` = encode at native size
// (the "Source" preset, or a source already at/below the target height — we
+1
View File
@@ -205,6 +205,7 @@ mod tests {
HostOpts {
window: false,
app: None,
strict_audio: false,
display_server: None::<DisplayServerArg>,
quality,
bitrate: None,
+338
View File
@@ -0,0 +1,338 @@
//! Synthetic graph builders for the taint-engine tests.
//!
//! Serials are handed out monotonically and never reused, exactly as
//! PipeWire does; global ids are handed out separately and **may be reused
//! on purpose**, which is what the recycling tests need.
use std::collections::BTreeMap;
use super::snapshot::{
ClientSnapshot, GlobalId, GraphSnapshot, LinkSnapshot, MediaRole, NodeProps, NodeSnapshot,
PortDirection, PortSnapshot, Serial,
};
/// pipewire-pulse's PID, as measured on the target machine.
pub const PULSE_PID: u32 = 2541;
/// WirePlumber's PID — one process owning every device node on the box.
pub const SESSION_PID: u32 = 900;
/// A node's identity in a fixture: what tests pass around.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct NodeRef {
pub serial: Serial,
pub id: GlobalId,
}
#[derive(Default)]
pub struct Graph {
next_serial: u64,
next_id: u32,
nodes: Vec<NodeSnapshot>,
ports: Vec<PortSnapshot>,
links: Vec<LinkSnapshot>,
clients: Vec<ClientSnapshot>,
/// One client connection per process / per module, which is what the
/// live graph looks like. Tests that need the *split*-client shape
/// (GStreamer opens one per stream) pass clients explicitly instead.
client_by_app: BTreeMap<u32, GlobalId>,
client_by_module: BTreeMap<u64, GlobalId>,
session_client: Option<GlobalId>,
}
impl Graph {
pub fn new() -> Self {
Self {
// Start past u32::MAX so every fixture also exercises the phase
// 0a widening: a serial that a u32 model would have truncated.
next_serial: u64::from(u32::MAX) + 1,
next_id: 1,
..Self::default()
}
}
fn serial(&mut self) -> Serial {
self.next_serial += 1;
Serial(self.next_serial)
}
fn id(&mut self) -> GlobalId {
self.next_id += 1;
GlobalId(self.next_id)
}
/// A client object. `sec_pid` is `pipewire.sec.pid` — pipewire-pulse's
/// PID for Pulse-emulated clients.
pub fn client(&mut self, sec_pid: Option<u32>) -> GlobalId {
let serial = self.serial();
let id = self.id();
self.clients.push(ClientSnapshot {
serial,
id,
sec_pid,
});
id
}
/// The client connection an ordinary process holds — one per PID,
/// created on demand.
pub fn client_of_app(&mut self, pid: u32) -> GlobalId {
if let Some(id) = self.client_by_app.get(&pid) {
return *id;
}
let id = self.client(Some(PULSE_PID));
self.client_by_app.insert(pid, id);
id
}
/// An ordinary application stream: its own client, its own PID.
pub fn app_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
/// The client a pactl module holds. Measured: each module gets its own
/// (`sink-sunshine-*` were clients 83/86/92), which is why one tainted
/// module does not fuse with the next.
pub fn client_of_module(&mut self, module: u64) -> GlobalId {
match self.client_by_module.get(&module) {
Some(id) => *id,
None => {
let id = self.client(Some(PULSE_PID));
self.client_by_module.insert(module, id);
id
}
}
}
/// A leg of a pactl-loaded module: one client per module, and the
/// node's `application.process.id` is **pipewire-pulse's own**, because
/// pipewire-pulse genuinely is the client.
pub fn module_node(&mut self, name: &str, role: MediaRole, module: u64) -> NodeRef {
let client = self.client_of_module(module);
self.node(name, role, pulse_module(client, module, PULSE_PID))
}
/// A leg joined to its siblings by `node.link-group` — loopback,
/// filter-chain, echo-cancel.
pub fn group_node(&mut self, name: &str, role: MediaRole, group: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, link_group(group, client, pid))
}
/// A device node as the session manager creates it: no strong key,
/// WirePlumber's client and PID — shared with every other device — and
/// a `device.id`, which is what marks it as session-manager-exported.
pub fn device_node(&mut self, name: &str, role: MediaRole) -> NodeRef {
let session = match self.session_client {
Some(id) => id,
None => {
let id = self.client(None);
self.session_client = Some(id);
id
}
};
self.node(name, role, device(session, SESSION_PID))
}
/// A node that *belongs to* a Device but is not a passive device node —
/// a filter associated with a card. Phase 3 must not classify this as a
/// session device, or it loses both its coarse owner keys and its
/// ability to trip the fail-closed backstop.
pub fn device_associated_filter(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
/// A **virtual** sink an application created natively: an `Audio/Sink`
/// with no `device.id` and no strong key, sharing one client with the
/// stream that re-emits what it receives. Coarse keys must still bridge
/// these two, or the whole call leaks through the re-emitting leg.
pub fn native_virtual_node(&mut self, name: &str, role: MediaRole, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, role, app(client, pid))
}
pub fn peerspeak_node(&mut self, name: &str, pid: u32) -> NodeRef {
let client = self.client_of_app(pid);
self.node(name, MediaRole::StreamOutput, peerspeak_owned(client, pid))
}
pub fn node(&mut self, name: &str, role: MediaRole, props: NodeProps) -> NodeRef {
let id = self.id();
self.node_with_id(name, role, id, props)
}
/// Force a global id — for reproducing id recycling after teardown.
pub fn node_with_id(
&mut self,
name: &str,
role: MediaRole,
id: GlobalId,
props: NodeProps,
) -> NodeRef {
let serial = self.serial();
self.nodes.push(NodeSnapshot {
serial,
id,
name: Some(name.to_string()),
role,
props,
});
NodeRef { serial, id }
}
pub fn port(&mut self, node: NodeRef, direction: PortDirection, exclusive: bool) {
let serial = self.serial();
let id = self.id();
self.ports.push(PortSnapshot {
serial,
id,
node: node.id,
direction,
exclusive,
monitor: false,
});
}
/// A signal edge: audio flows `from → to`.
pub fn link(&mut self, from: NodeRef, to: NodeRef) {
self.link_ids(from.id, to.id);
}
/// A link naming raw ids, so a test can dangle an endpoint.
pub fn link_ids(&mut self, from: GlobalId, to: GlobalId) {
let serial = self.serial();
let id = self.id();
self.links.push(LinkSnapshot {
serial,
id,
output_node: from,
input_node: to,
output_port: None,
input_port: None,
});
}
/// An id that belongs to nothing — for unresolved-endpoint tests.
pub fn dangling_id(&mut self) -> GlobalId {
self.id()
}
pub fn build(&self) -> GraphSnapshot {
self.build_without(&[])
}
/// A later snapshot in which some nodes have gone away, along with
/// their ports and every link touching them. Surviving objects keep
/// their serials, which is what makes sticky-taint sequences testable.
pub fn build_without(&self, dropped: &[NodeRef]) -> GraphSnapshot {
let gone_serials: Vec<Serial> = dropped.iter().map(|n| n.serial).collect();
let nodes: Vec<NodeSnapshot> = self
.nodes
.iter()
.filter(|n| !gone_serials.contains(&n.serial))
.cloned()
.collect();
// Filter by what was *dropped*, not by what is live: a link to an id
// that never had a node is a dangling endpoint, and dropping those
// here would quietly disarm every unresolved-ancestry test.
let gone_ids: Vec<GlobalId> = dropped.iter().map(|n| n.id).collect();
GraphSnapshot::new(
nodes,
self.ports
.iter()
.filter(|p| !gone_ids.contains(&p.node))
.cloned()
.collect(),
self.links
.iter()
.filter(|l| !gone_ids.contains(&l.output_node) && !gone_ids.contains(&l.input_node))
.cloned()
.collect(),
self.clients.clone(),
)
}
/// Drop clients too — full owner teardown.
///
/// Invalidates the per-app/per-module caches as well: leaving them
/// stale made a later `client_of_app` hand back the *removed* client's
/// id, so a test that meant "a brand-new client after teardown" was
/// really building a node pointing at a client object that no longer
/// existed (Codex round 1, finding 8).
pub fn drop_clients(&mut self, ids: &[GlobalId]) {
self.clients.retain(|c| !ids.contains(&c.id));
self.client_by_app.retain(|_, id| !ids.contains(id));
self.client_by_module.retain(|_, id| !ids.contains(id));
if self.session_client.is_some_and(|id| ids.contains(&id)) {
self.session_client = None;
}
}
/// A client that reuses a global id a dead client had — the recycling
/// case, with a fresh serial.
pub fn client_with_id(&mut self, id: GlobalId, sec_pid: Option<u32>) -> GlobalId {
let serial = self.serial();
self.clients.push(ClientSnapshot {
serial,
id,
sec_pid,
});
id
}
}
/// An ordinary application stream: real PID, one client connection.
pub fn app(client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
client_id: Some(client),
process_id: Some(pid),
..NodeProps::default()
}
}
/// A pactl-module-created stream: the daemon is the client, so the node's
/// `application.process.id` is pipewire-pulse's own.
pub fn pulse_module(client: GlobalId, module: u64, pulse_pid: u32) -> NodeProps {
NodeProps {
pulse_module_id: Some(module),
client_id: Some(client),
process_id: Some(pulse_pid),
..NodeProps::default()
}
}
/// A PipeWire-module leg joined to its siblings by `node.link-group`
/// (loopback, filter-chain, echo-cancel).
pub fn link_group(group: &str, client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
link_group: Some(group.to_string()),
client_id: Some(client),
process_id: Some(pid),
..NodeProps::default()
}
}
/// A device node as the session manager creates it: no strong key, and the
/// session manager's own client and PID — shared with every other device.
///
/// Measured 2026-07-21: real ALSA device nodes carry the shared
/// `client.id` but **no** `application.process.id` at all. Giving them one
/// here is deliberately *more* pessimistic than reality — it hands the
/// engine a second coarse key it could fuse devices on, so a test that
/// passes here also passes against the real props.
pub fn device(session_client: GlobalId, session_pid: u32) -> NodeProps {
NodeProps {
client_id: Some(session_client),
process_id: Some(session_pid),
session_device: true,
..NodeProps::default()
}
}
pub fn peerspeak_owned(client: GlobalId, pid: u32) -> NodeProps {
NodeProps {
peerspeak_owned: true,
..app(client, pid)
}
}
+956
View File
@@ -0,0 +1,956 @@
//! The taint engine — decides which `Stream/Output/Audio` nodes may be
//! fanned out into the screen-share capture without echoing peerspeak's own
//! audio back at the viewer.
//!
//! Implements design v3.4 §6.1–§6.1.3 (`peerspeak/docs/
//! screenshare-audio-exclusion-plan.md`), phase 2 of the implementation
//! plan. **Pure**: no PipeWire types appear in any signature, nothing here
//! touches the daemon, and every test builds its own graph.
//!
//! ## The one-sentence predicate
//!
//! > A node is eligible only if **no** signal path reaches it from a
//! > peerspeak-owned node, the live AEC identity, or any pixelpass-owned
//! > object. **Unresolvable ancestry is not eligible.**
//!
//! That last sentence is the invariant the whole design rests on: every
//! other failure mode in here degrades into over-exclusion (one app's audio
//! silently missing from the share) rather than into echo.
//!
//! ## Why a graph walk and not a property check
//!
//! Exclusion does not propagate downstream by itself. Any node that
//! re-emits audio it received is a fresh, *untagged* `Stream/Output/Audio`
//! carrying the mix — including the one peerspeak playback stream that was
//! correctly excluded one hop earlier. EasyEffects, `module-loopback`,
//! combine-sinks, tunnel/RTP sinks and virtual-sink forwarders all have this
//! shape, and at least one such topology has been observed live on the
//! target machine.
//!
//! Taint therefore flows over **three** edge types:
//!
//! 1. **Link edges** — `link.output.node → link.input.node`.
//! 2. **Sink → monitor** — free at node granularity: the monitor connection
//! *is* a real Link whose output node is the sink node itself (measured).
//! A port-granular walk would need a synthetic edge; a node-granular one
//! does not.
//! 3. **Owner bridges** — the intra-process hop the graph cannot see. See
//! [`owner`]; this is the hard one.
//!
//! ## Stickiness
//!
//! Taint is **sticky per owner** for the duration of the share, because a
//! topological recompute forgets *buffered* audio: an app can read a tainted
//! monitor into a 5-second ring buffer, then have its input leg vanish, and
//! a purely topological engine would relink its output while it is still
//! emitting peerspeak's audio out of that buffer. No graph event marks the
//! moment a buffer drains.
//!
//! Stickiness is keyed on [`Serial`] — never on a node id, `client.id`,
//! module index or `link-group` string, **all of which recycle on this
//! stack**. An entry is cleared only once every member object has
//! disappeared; a key that reappears after full teardown is a new owner and
//! starts clean.
//!
//! ## ⚠️ KNOWN OPEN GAP — buffered audio across a full PipeWire teardown of
//! ## a still-live process (Codex phase-2 rounds 56) — DESIGN DECISION OWED
//!
//! **This is an in-threat-model echo gap, not an outside-the-model one — an
//! earlier version of this note wrongly scoped it to keyless streams.**
//!
//! The scenario, entirely with a real PID-bearing app (a recorder, a DAW,
//! a GStreamer pipeline): it reads the call into an application buffer,
//! **fully** tears down its PipeWire Node *and* Client while keeping that
//! buffer, then — still the same live process — opens a fresh Client and a
//! `Stream/Output/Audio` and replays. Every old serial is gone, so
//! [`seed_sticky`] refuses to apply the remembered PID fingerprint (the
//! fingerprint is lifetime-scoped to a live serial member, because bare keys
//! recycle); no reader is live in the new epoch, so the backstop does not
//! fire; the replayed leg is eligible.
//!
//! It is real and reachable by non-adversarial software. It also sits
//! exactly on the design's stated boundary (v3.4 §6.1.3: "a key that
//! reappears after full teardown is a new owner and starts clean"), so
//! closing it is a **design change**, not a local bug fix:
//!
//! - **Option A — accept as a documented v1 limitation.** Contrived in
//! practice (most apps hold their PipeWire connection open for their
//! lifetime; the round-2 fix already covers the common
//! idle-a-client-and-open-another case), never a *silent* correctness
//! regression since it is written down, and phase 5's dry run would show
//! it. But it is a known echo path, which sits badly against the feature's
//! fail-closed ethos.
//! - **Option B — process-generation lifetime.** Key the fingerprint's
//! lifetime on the owning **process** being alive — PID + `/proc` start
//! time (or a pidfd) to defeat PID reuse — instead of on a live PipeWire
//! object. Phase 3 supplies process liveness; §6.1.3's node/client-only
//! lifetime definition is revised. Closes the PID-bearing case; the truly
//! keyless sub-case (no PID at all) genuinely *is* outside the threat
//! model and stays a documented limit.
//!
//! The choice is the designer's (it revises the security surface). Until it
//! is made, `a_fingerprint_does_not_outlive_its_owner` encodes Option A's
//! behaviour — flip it if B is chosen. Owed to the design doc as round 8.
// Phase 2 lands the engine behind its own test surface and nothing else:
// the registry observer that will feed it is phase 3, so in a non-test
// build every item here is legitimately unreachable for now.
#![allow(dead_code)]
pub mod owner;
pub mod snapshot;
#[cfg(test)]
mod fixture;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use owner::{OwnerComponents, OwnerKey};
use snapshot::{GraphSnapshot, IdLookup, MediaRole, NodeSnapshot, Serial};
/// The `node.name` prefix of a pixelpass capture sink. Any host's sink
/// counts, not just ours — fanning out a stream that is downstream of
/// *another* pixelpass host's capture sink builds a cycle (v3.4 §6.2).
pub const CAPTURE_SINK_PREFIX: &str = "pixelpass_capture_";
/// `node.link-group` prefix that marks *some* echo canceller. Hazard
/// detection only — it does **not** identify peerspeak's instance, which is
/// what `pulse.module.id` is for (v3.4 §5.2 correction 4).
pub const ECHO_CANCEL_GROUP_PREFIX: &str = "echo-cancel-";
/// Why a node is tainted or excluded. Stable machine-readable codes: this
/// value is the phase 5 audit output, the phase 6 status event, and the
/// eventual answer to "why isn't this app being shared?".
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Reason {
/// Carries the `peerspeak.owned` tag (v3.4 §5.1).
PeerspeakOwned,
/// `pulse.module.id` equals the live AEC module index — exact equality
/// only. "Has any `pulse.module.id`" is explicitly rejected as a rule:
/// tunnel/RTP/loopback modules may be the only carrier of audio the
/// user legitimately wants shared (v3.4 §5.2 correction 2).
AecIdentity,
/// A pixelpass-owned object, ours or another host's capture sink.
PixelpassOwned,
/// An `echo-cancel-*` group that is **not** our validated identity.
/// Decision D3: warn and exclude rather than fan out.
ForeignEchoCancel,
/// Reached by a signal path from a tainted node (link or monitor edge).
TaintedUpstream,
/// Reached across an owner bridge; the key that did it, when the
/// tainted member shares one directly rather than transitively.
TaintedOwnerBridge { key: Option<OwnerKey> },
/// A link endpoint, or a node's own id, could not be resolved in this
/// snapshot. Fail closed (v3.4 §6.1.4).
UnresolvedAncestry,
/// A tainted capture stream whose owner cannot be bounded by any usable
/// key, so its sibling output legs cannot be identified. Fail closed
/// (v3.4 §6.1.1, final paragraph).
UnresolvedOwner,
/// The observer has not reached a complete, coherent view of the graph
/// yet. No decision made from a partial graph is a decision.
GraphNotReady,
/// A `port.exclusive` port — fan-out will be refused (v3.4 §6.2). Local
/// to the node; does not propagate.
PortExclusive,
/// An encoded/passthrough stream — a second link would corrupt it.
/// Local to the node; does not propagate.
Passthrough,
}
impl Reason {
pub fn code(self) -> &'static str {
match self {
Self::PeerspeakOwned => "peerspeak-owned",
Self::AecIdentity => "aec-identity",
Self::PixelpassOwned => "pixelpass-owned",
Self::ForeignEchoCancel => "foreign-echo-cancel",
Self::TaintedUpstream => "tainted-upstream",
Self::TaintedOwnerBridge { .. } => "tainted-owner-bridge",
Self::UnresolvedAncestry => "unresolved-ancestry",
Self::UnresolvedOwner => "unresolved-owner",
Self::GraphNotReady => "graph-not-ready",
Self::PortExclusive => "port-exclusive",
Self::Passthrough => "passthrough",
}
}
/// Lower wins. A node can acquire taint several ways in one recompute
/// and the reported reason must not depend on traversal order, or the
/// audit output is unstable and the fixture tests are flaky. Explicit
/// priority, not BFS arrival order.
fn priority(self) -> u8 {
match self {
Self::PeerspeakOwned => 0,
Self::AecIdentity => 1,
Self::PixelpassOwned => 2,
Self::ForeignEchoCancel => 3,
Self::TaintedUpstream => 4,
Self::TaintedOwnerBridge { .. } => 5,
Self::UnresolvedAncestry => 6,
Self::UnresolvedOwner => 7,
// Non-propagating; never competes with the taint reasons above
// because it is only consulted for untainted candidates.
Self::GraphNotReady => 8,
Self::PortExclusive => 9,
Self::Passthrough => 10,
}
}
/// Does this reason spread to downstream nodes and owner siblings?
fn propagates(self) -> bool {
self.priority() <= Self::UnresolvedOwner.priority()
}
}
/// Everything the engine needs that is not in the graph itself.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExclusionCtx {
/// The **validated** live AEC module index, or `None` for `--aec=off`.
/// The validation state machine (phase 4) owns the transitions; if it
/// is still `Validating` or has `Failed`, its caller must not fan out at
/// all rather than passing `None` here, which would merely mean "there
/// is no AEC".
pub aec_module_id: Option<u64>,
/// pipewire-pulse's own PID, derived by the observer (phase 3) from a
/// consistent `pipewire.sec.pid` across Pulse clients validated against
/// `/proc/<pid>/comm`. `None` is safe but coarse — see [`owner`].
pub pipewire_pulse_pid: Option<u32>,
/// Serials of objects pixelpass itself created this run.
pub pixelpass_owned: BTreeSet<Serial>,
/// False until the readiness epoch has been reached (phase 3). Every
/// candidate is then ineligible: a decision from a partial graph is not
/// a decision.
pub graph_ready: bool,
}
/// Object identity for sticky bookkeeping. Always a [`Serial`] — never a
/// recyclable id (v3.4 §6.1.3).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum ObjectRef {
Node(Serial),
Client(Serial),
}
/// One owner that has been tainted, and every object observed to constitute
/// it. Cleared only when **all** of them are gone.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StickyOwner {
/// Every object seen to be part of this owner, ever. Membership
/// accumulates: that is what makes "clear only once all member objects
/// have disappeared" true across churn.
pub members: BTreeSet<ObjectRef>,
/// Owner keys remembered across connections — strong keys and a usable
/// process id, never `client.id`. Applied only while some serial member
/// above is still live, which is what keeps a recyclable key from
/// resurrecting a dead owner.
///
/// Needed because a live Client is not the same thing as a live owner:
/// a process can leave one connection idle and open a second, and
/// GStreamer opens one connection per stream as a matter of course, so
/// following connections alone lets the next leg escape (Codex round 2,
/// finding 2).
pub fingerprints: BTreeSet<owner::Fingerprint>,
/// The reason recorded for each node that was tainted in its own right.
/// Kept per node rather than collapsed to one owner-wide reason, or a
/// forwarder's output leg inherits its *input* leg's `tainted-upstream`
/// and the audit output stops naming the mechanism that actually
/// excluded it.
pub node_reasons: BTreeMap<Serial, Reason>,
}
impl StickyOwner {
/// The reason to apply to a member: its own recorded one, or — for a
/// leg that appeared later — the fact that it belongs to a tainted
/// owner, which is a bridge by definition.
fn reason_for(&self, serial: Serial) -> Reason {
self.node_reasons
.get(&serial)
.copied()
.unwrap_or(Reason::TaintedOwnerBridge { key: None })
}
}
/// Threaded explicitly through [`evaluate`] so stickiness is testable as a
/// sequence of snapshots rather than as hidden mutable state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StickyState {
pub owners: Vec<StickyOwner>,
}
impl StickyState {
pub fn is_empty(&self) -> bool {
self.owners.is_empty()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Eligibility {
Eligible,
NotEligible {
reason: Reason,
/// The taint was carried over from a previous snapshot rather than
/// derived from the current topology.
sticky: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeDecision {
pub serial: Serial,
pub name: Option<String>,
pub eligibility: Eligibility,
}
impl NodeDecision {
pub fn is_eligible(&self) -> bool {
matches!(self.eligibility, Eligibility::Eligible)
}
pub fn reason(&self) -> Option<Reason> {
match self.eligibility {
Eligibility::Eligible => None,
Eligibility::NotEligible { reason, .. } => Some(reason),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct TaintEntry {
pub reason: Reason,
pub sticky: bool,
}
/// The result of one recompute.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Decisions {
/// Every `Stream/Output/Audio` node in the snapshot — the complete
/// candidate universe, so callers can assert an exact partition rather
/// than spot-checking named nodes.
pub candidates: BTreeMap<Serial, NodeDecision>,
/// Taint over *all* node roles, for diagnostics and for the phase 5
/// audit output.
pub taint: BTreeMap<Serial, TaintEntry>,
}
impl Decisions {
/// Serials of eligible candidates, ascending.
pub fn eligible(&self) -> Vec<Serial> {
self.candidates
.values()
.filter(|d| d.is_eligible())
.map(|d| d.serial)
.collect()
}
/// `(serial, reason code)` for excluded candidates, ascending.
pub fn excluded(&self) -> Vec<(Serial, &'static str)> {
self.candidates
.values()
.filter_map(|d| d.reason().map(|r| (d.serial, r.code())))
.collect()
}
}
/// Recompute eligibility for the whole graph.
///
/// Full recompute per graph event is the v1 design; there is deliberately
/// no incremental dirty-set.
///
/// ⚠️ **Cost is not O(V+E), despite what v3.4 §6.4 says.** Each fixpoint
/// pass re-runs a full link BFS *and* a full owner scan, and the bridge
/// scans every tainted source in a component for each target, so the bound
/// is `O(D · (V + E + Σ_C |sources_C|·|targets_C|))` — worst case
/// `O(D · (V² + E))` — for an owner-bridge depth D. D is 1 for every
/// topology observed so far and 2 for a forwarder feeding a forwarder, and
/// components on a real desktop are two or three nodes; the quadratic term
/// needs one owner with many legs. A 60-layer chain test guards the depth
/// dimension only. Phase 5 records the real recompute-duration
/// distribution and maximum, which is what "full recompute is fine for v1"
/// should rest on — measured headroom, not a node count.
pub fn evaluate(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
prior: &StickyState,
) -> (Decisions, StickyState) {
let components = OwnerComponents::build(snapshot, ctx.pipewire_pulse_pid);
let keys = owner::OwnerKeyIndex::build(snapshot, ctx.pipewire_pulse_pid);
let mut taint: BTreeMap<Serial, Reason> = BTreeMap::new();
let mut sticky_serials: BTreeSet<Serial> = BTreeSet::new();
seed_local_roots(snapshot, ctx, &mut taint);
seed_sticky(
snapshot,
&keys,
prior,
&components,
&mut taint,
&mut sticky_serials,
);
// Monotone fixpoint: every step only adds taint, or lowers a node's
// reason priority, both of which are bounded. Link propagation and the
// owner bridge feed each other — a bridged output leg has downstream
// links, and a downstream monitor reader bridges to its own siblings —
// so neither can be run once.
let edges = downstream_edges(snapshot, &mut taint);
loop {
let mut changed = false;
changed |= propagate_links(&edges.edges, &mut taint);
changed |= propagate_owner_bridge(&keys, &components, &edges, &mut taint);
changed |= propagate_unresolved_owner(snapshot, &keys, &edges, &mut taint);
if !changed {
break;
}
}
let decisions = build_decisions(snapshot, ctx, &taint, &sticky_serials);
// ⚠️ Readiness gates **retirement only**, never addition (Codex rounds
// 1 and 2, which caught the two halves of this in turn). An object
// missing from an untrustworthy snapshot has not been observed to
// disappear, so retiring on that basis erases history and the next
// ready recompute hands back a clean bill of health. But taint
// *observed* during a not-ready epoch is real — a reader can consume
// and buffer the call and then vanish before readiness — so discarding
// additions was the same defect pointing the other way.
let next_sticky = build_sticky(snapshot, &keys, &components, &taint, prior, ctx.graph_ready);
(decisions, next_sticky)
}
/// Roots that are visible on the node itself.
fn seed_local_roots(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
taint: &mut BTreeMap<Serial, Reason>,
) {
for node in snapshot.nodes() {
if let Some(reason) = local_root_reason(node, ctx) {
raise(taint, node.serial, reason);
}
// A node whose own global id is ambiguous cannot be the reliable
// endpoint of any link, so its ancestry is unresolvable.
if snapshot.node_by_id(node.id) == Some(IdLookup::Ambiguous) {
raise(taint, node.serial, Reason::UnresolvedAncestry);
}
}
}
fn local_root_reason(node: &NodeSnapshot, ctx: &ExclusionCtx) -> Option<Reason> {
if node.props.peerspeak_owned {
return Some(Reason::PeerspeakOwned);
}
if let (Some(module), Some(aec)) = (node.props.pulse_module_id, ctx.aec_module_id)
&& module == aec
{
return Some(Reason::AecIdentity);
}
if ctx.pixelpass_owned.contains(&node.serial)
|| node
.name
.as_deref()
.is_some_and(|name| name.starts_with(CAPTURE_SINK_PREFIX))
{
return Some(Reason::PixelpassOwned);
}
if node
.props
.link_group
.as_deref()
.is_some_and(|group| group.starts_with(ECHO_CANCEL_GROUP_PREFIX))
{
return Some(Reason::ForeignEchoCancel);
}
None
}
/// Carry taint forward from previous snapshots (v3.4 §6.1.3).
///
/// An owner is re-seeded from three kinds of evidence, all lifetime-scoped
/// to a still-live member: its own surviving nodes, nodes on a surviving
/// **Client**, and nodes presenting a remembered owner **fingerprint**.
fn seed_sticky(
snapshot: &GraphSnapshot,
keys: &owner::OwnerKeyIndex,
prior: &StickyState,
components: &OwnerComponents,
taint: &mut BTreeMap<Serial, Reason>,
sticky_serials: &mut BTreeSet<Serial>,
) {
for entry in &prior.owners {
let mut live_nodes: Vec<Serial> = Vec::new();
for member in &entry.members {
match member {
ObjectRef::Node(serial) => {
if snapshot.node(*serial).is_some() {
live_nodes.push(*serial);
}
}
// A surviving **Client** re-seeds too. An app can close
// every stream it had while keeping its PipeWire connection
// open, then open a fresh one — Firefox does exactly this.
ObjectRef::Client(serial) => {
live_nodes.extend(nodes_of_client(snapshot, keys, *serial));
}
}
}
if live_nodes.is_empty() && !entry.members.iter().any(|m| is_live(snapshot, *m)) {
// Nothing of this owner remains; its fingerprints are just
// recyclable strings now and must not be applied to anyone.
continue;
}
// Fingerprints reach a *new connection* of the same still-live
// process, which neither of the two paths above can see.
for fingerprint in &entry.fingerprints {
live_nodes.extend(
snapshot
.nodes()
.filter(|node| keys.has_fingerprint(node.serial, fingerprint))
.map(|node| node.serial),
);
}
// The owner is sticky, not the individual node: a leg that appears
// later in the same still-live owner inherits the taint.
for serial in live_nodes {
for member in components.members_with(serial) {
let reason = entry.reason_for(*member);
if raise(taint, *member, reason) || taint.get(member) == Some(&reason) {
sticky_serials.insert(*member);
}
}
}
}
}
/// Nodes currently attached to a client, by the client's **serial**. The
/// client's snapshot-local id is resolved fresh each time, so a recycled id
/// can never resurrect a dead owner.
///
/// Nodes for which `client.id` is not a usable owner key — session-manager
/// device nodes — are excluded, or the shared `WirePlumber [export]` Client
/// would drag every sound card on the box into one sticky owner.
///
/// The same gate is applied when *recording* clients into a sticky entry
/// (`owner::client_serials_of`). Either one alone closes the leak; both are
/// kept because they answer different questions ("may this client be
/// remembered?" and "may this client speak for that node?"), and the
/// regression test kills the removal of the pair.
fn nodes_of_client(
snapshot: &GraphSnapshot,
keys: &owner::OwnerKeyIndex,
client: Serial,
) -> Vec<Serial> {
let Some(id) = snapshot
.clients()
.find(|c| c.serial == client)
.map(|c| c.id)
else {
return Vec::new();
};
snapshot
.nodes()
.filter(|node| node.props.client_id == Some(id))
.filter(|node| keys.uses_client_key(node.serial))
.map(|node| node.serial)
.collect()
}
/// `output node → input nodes`, resolving snapshot-local ids. An endpoint
/// that does not resolve taints the *other* end as unresolved ancestry when
/// that other end is the input side — we cannot know what is feeding it.
fn downstream_edges(snapshot: &GraphSnapshot, taint: &mut BTreeMap<Serial, Reason>) -> Edges {
let mut edges: BTreeMap<Serial, Vec<Serial>> = BTreeMap::new();
let mut receivers: BTreeSet<Serial> = BTreeSet::new();
for link in snapshot.links() {
let from = snapshot.node_by_id(link.output_node);
let to = snapshot.node_by_id(link.input_node);
match (from, to) {
(Some(IdLookup::Unique(from)), Some(IdLookup::Unique(to))) => {
edges.entry(from).or_default().push(to);
receivers.insert(to);
}
(_, Some(IdLookup::Unique(to))) => {
// Something feeds this node and we cannot say what.
raise(taint, to, Reason::UnresolvedAncestry);
receivers.insert(to);
}
(_, Some(IdLookup::Ambiguous)) => {
// Several nodes claim the input id and we cannot say which
// one this link feeds, so every claimant is a receiver.
// They are already tainted as unresolved by their own
// ambiguous id — but taint without receiver status cannot
// start an owner bridge, so their sibling output legs stayed
// Eligible (Codex round 2, finding 3).
receivers.extend(
snapshot
.nodes_with_id(link.input_node)
.map(|node| node.serial),
);
}
_ => {}
}
}
for targets in edges.values_mut() {
targets.sort_unstable();
targets.dedup();
}
// A node that receives audio by *role* counts even with no inbound link
// yet: a pixelpass capture sink is a taint root the moment it exists,
// and its owner's re-emitting leg must be bridged from it immediately.
receivers.extend(
snapshot
.nodes()
.filter(|node| node.role.receives_audio())
.map(|node| node.serial),
);
Edges { edges, receivers }
}
/// Resolved signal edges plus the set of nodes that can receive audio.
struct Edges {
edges: BTreeMap<Serial, Vec<Serial>>,
/// ⚠️ Membership is "appears as a resolved `link.input.node`" **or**
/// "has a receiving role" — deliberately not role alone. Codex round 1:
/// a node whose `media.class` is absent or unexpected (`Other`), or an
/// `Audio/Source` that is really a filter output, can sit on an inbound
/// link carrying tainted audio; inferring "receives audio" from the role
/// alone left such a node unable to start an owner bridge, and its
/// sibling output leg stayed Eligible while re-emitting the call.
receivers: BTreeSet<Serial>,
}
fn propagate_links(
downstream: &BTreeMap<Serial, Vec<Serial>>,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
let mut queue: VecDeque<Serial> = taint
.iter()
.filter(|(_, reason)| reason.propagates())
.map(|(serial, _)| *serial)
.collect();
while let Some(serial) = queue.pop_front() {
let Some(targets) = downstream.get(&serial) else {
continue;
};
for target in targets {
if raise(taint, *target, Reason::TaintedUpstream) {
changed = true;
queue.push_back(*target);
}
}
}
changed
}
/// The conditional owner bridge (v3.4 §6.1.1): taint crosses to an owner's
/// other legs **only** when the tainted member is one that actually
/// receives audio. The naive "this owner has both an input and an output
/// leg ⇒ exclude the output" rule would exclude every app using a
/// microphone, Firefox in a video call included.
fn propagate_owner_bridge(
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
edges: &Edges,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut changed = false;
for members in components.components() {
let sources: BTreeSet<Serial> = members
.iter()
.copied()
.filter(|serial| {
taint.get(serial).is_some_and(|r| r.propagates())
&& edges.receivers.contains(serial)
})
.collect();
if sources.is_empty() {
continue;
}
for target in members {
if sources.contains(target) {
continue;
}
// Name the strongest key shared directly with any tainted
// member; `None` means the two are only transitively related.
let key = sources
.iter()
.filter_map(|source| keys.strongest_shared(*source, *target))
.min();
changed |= raise(taint, *target, Reason::TaintedOwnerBridge { key });
}
}
changed
}
/// Fail-closed backstop for an owner we cannot bound (v3.4 §6.1.1, final
/// paragraph): something read tainted audio and nothing about the output
/// legs on this box lets us enumerate which of them are its siblings, so we
/// cannot know which one is re-emitting what it read. Exclude the output
/// legs that are equally unbounded.
///
/// The trigger and the sweep, precisely (both edges hard-won across four
/// Codex rounds):
///
/// - **Trigger — any tainted receiver that is not a real device node.** A
/// tainted hardware sink is the normal case, not an anomaly (peerspeak's
/// playback taints the default sink every recompute), so device nodes do
/// not trip it. The source does **not** have to be unbounded: a reader
/// with a `node.link-group` whose re-emitting leg carries none is bounded
/// while its sibling is unfindable (round 1).
/// - **Sweep — depends on whether any tainted reader is itself unbounded.**
/// A *bounded* reader's siblings are exactly the outputs sharing its key,
/// so only the unbounded outputs (which could share its unknowable-only-
/// in-part identity) are swept; a differently-keyed output is provably a
/// different owner. An *unbounded* reader could be **any** owner — a real
/// process may present no PID on its reading leg (round 4) — so every
/// output candidate is swept, real apps included.
///
/// **Two tiers, because a tainted reader we cannot bound is a bigger
/// unknown than one we can** (Codex round 3 — the mirror image of the
/// round-1 case):
///
/// - A *bounded* tainted reader has a strong key or a usable PID, so its
/// siblings are exactly the output legs sharing that key. Any output leg
/// that is *itself* bounded by a **different** key is provably a different
/// owner and stays eligible; only unbounded output legs are its possible
/// siblings. → exclude unbounded outputs.
/// - An *unbounded* tainted reader has nothing that identifies its owner, so
/// its re-emitting leg could be **any** output on the box, and no property
/// on an output leg can prove it is unrelated. → exclude every output
/// candidate.
///
/// ⚠️ I tried to narrow this to "daemon-owned outputs only", on the
/// theory that an unbounded reader must be daemon-owned (a real app has a
/// PID, which would bound it) so a real-PID output is provably a different
/// owner. **Codex refuted it (round 4):** `application.process.id` is
/// optional and client-controlled, so a real process can present *no* PID
/// on its reading leg (unbounded) and a real PID on its output leg — one
/// owner, spared by the narrowing, leaking the call. Only `pipewire.*`
/// properties have protected identity; app properties cannot carry a
/// soundness argument. So: exclude everything. The trigger is genuinely
/// anomalous — a keyless reader actively consuming the call; EasyEffects
/// and loopbacks carry a `node.link-group` and are *bounded*, so they do
/// not trip this tier — and phase 5's dry run surfaces it before it can
/// gate anything real.
fn propagate_unresolved_owner(
snapshot: &GraphSnapshot,
keys: &owner::OwnerKeyIndex,
edges: &Edges,
taint: &mut BTreeMap<Serial, Reason>,
) -> bool {
let mut has_tainted_reader = false;
let mut has_unbounded_tainted_reader = false;
for node in snapshot.nodes() {
let is_tainted_reader = !node.props.session_device
&& edges.receivers.contains(&node.serial)
&& taint.get(&node.serial).is_some_and(|r| r.propagates());
if is_tainted_reader {
has_tainted_reader = true;
has_unbounded_tainted_reader |= !keys.is_bounded(node.serial);
}
}
if !has_tainted_reader {
return false;
}
let mut changed = false;
for node in snapshot.nodes() {
if node.role == MediaRole::StreamOutput
&& (has_unbounded_tainted_reader || !keys.is_bounded(node.serial))
{
changed |= raise(taint, node.serial, Reason::UnresolvedOwner);
}
}
changed
}
fn build_decisions(
snapshot: &GraphSnapshot,
ctx: &ExclusionCtx,
taint: &BTreeMap<Serial, Reason>,
sticky_serials: &BTreeSet<Serial>,
) -> Decisions {
let mut candidates = BTreeMap::new();
for node in snapshot.nodes().filter(|n| n.role.is_candidate()) {
let sticky = sticky_serials.contains(&node.serial);
let eligibility = if !ctx.graph_ready {
Eligibility::NotEligible {
reason: Reason::GraphNotReady,
sticky: false,
}
} else if let Some(reason) = taint.get(&node.serial) {
Eligibility::NotEligible {
reason: *reason,
sticky,
}
} else if let Some(reason) = local_exclusion(snapshot, node) {
Eligibility::NotEligible {
reason,
sticky: false,
}
} else {
Eligibility::Eligible
};
candidates.insert(
node.serial,
NodeDecision {
serial: node.serial,
name: node.name.clone(),
eligibility,
},
);
}
Decisions {
candidates,
taint: taint
.iter()
.map(|(serial, reason)| {
(
*serial,
TaintEntry {
reason: *reason,
sticky: sticky_serials.contains(serial),
},
)
})
.collect(),
}
}
/// Node-local reasons a link cannot be created even though the node is
/// clean. These do not propagate — an exclusive-port stream is unlinkable,
/// not hazardous.
fn local_exclusion(snapshot: &GraphSnapshot, node: &NodeSnapshot) -> Option<Reason> {
if node.props.passthrough {
return Some(Reason::Passthrough);
}
if snapshot.ports_of(node.id).any(|port| port.exclusive) {
return Some(Reason::PortExclusive);
}
None
}
/// Sticky bookkeeping for the next recompute: every tainted owner, with
/// every object observed to constitute it, merged with any prior entry that
/// still overlaps. Members accumulate — that is what makes "clear only once
/// all member objects have disappeared" true across churn.
fn build_sticky(
snapshot: &GraphSnapshot,
keys: &owner::OwnerKeyIndex,
components: &OwnerComponents,
taint: &BTreeMap<Serial, Reason>,
prior: &StickyState,
retire_absent: bool,
) -> StickyState {
let mut entries: Vec<StickyOwner> = Vec::new();
// Carry forward prior entries that still have at least one live member.
// An entry with none is gone for good: serials never recycle, so a
// vanished member can never come back — but only a *trustworthy*
// snapshot is allowed to conclude that a member is absent.
for entry in &prior.owners {
if !retire_absent
|| entry
.members
.iter()
.any(|member| is_live(snapshot, *member))
{
entries.push(entry.clone());
}
}
for members in components.components() {
let node_reasons: BTreeMap<Serial, Reason> = members
.iter()
.filter_map(|serial| {
taint
.get(serial)
.filter(|reason| reason.propagates())
.map(|reason| (*serial, *reason))
})
.collect();
if node_reasons.is_empty() {
continue;
}
let mut refs: BTreeSet<ObjectRef> = members.iter().map(|s| ObjectRef::Node(*s)).collect();
refs.extend(
owner::client_serials_of(snapshot, keys, members)
.into_iter()
.map(ObjectRef::Client),
);
let fingerprints = members
.iter()
.flat_map(|serial| keys.fingerprints(*serial))
.collect();
entries.push(StickyOwner {
members: refs,
fingerprints,
node_reasons,
});
}
StickyState {
owners: merge_overlapping(entries),
}
}
fn is_live(snapshot: &GraphSnapshot, member: ObjectRef) -> bool {
match member {
ObjectRef::Node(serial) => snapshot.node(serial).is_some(),
ObjectRef::Client(serial) => snapshot.clients().any(|c| c.serial == serial),
}
}
/// Merge entries that share any member, keeping the strongest reason.
/// Owners fuse over time (a component that gains a leg belonging to a
/// previously separate sticky owner is one owner now); splitting them back
/// apart would drop taint, which is the unsafe direction.
fn merge_overlapping(mut entries: Vec<StickyOwner>) -> Vec<StickyOwner> {
let mut merged: Vec<StickyOwner> = Vec::new();
while let Some(mut entry) = entries.pop() {
let mut absorbed = true;
while absorbed {
absorbed = false;
let mut rest = Vec::with_capacity(entries.len());
for other in entries.drain(..) {
if entry.members.is_disjoint(&other.members) {
rest.push(other);
} else {
for (serial, reason) in other.node_reasons {
entry
.node_reasons
.entry(serial)
.and_modify(|existing| {
if reason.priority() < existing.priority() {
*existing = reason;
}
})
.or_insert(reason);
}
entry.members.extend(other.members);
entry.fingerprints.extend(other.fingerprints);
absorbed = true;
}
}
entries = rest;
}
merged.push(entry);
}
merged.sort_by(|a, b| a.members.iter().next().cmp(&b.members.iter().next()));
merged
}
/// Record `reason` for `serial` if it is new or strictly stronger than what
/// is already recorded. Returns whether anything changed — the fixpoint's
/// termination argument rests on this being monotone.
fn raise(taint: &mut BTreeMap<Serial, Reason>, serial: Serial, reason: Reason) -> bool {
match taint.get(&serial) {
Some(existing) if existing.priority() <= reason.priority() => false,
_ => {
taint.insert(serial, reason);
true
}
}
}
+390
View File
@@ -0,0 +1,390 @@
//! The owner bridge — grouping nodes that belong to the same *owner* even
//! though the graph shows no Link between them.
//!
//! This is the subtlest part of the design (v3.4 §6.1.2). Measured fact it
//! exists to handle: a `module-loopback` forwarder's input leg and output
//! leg have **no Link between them**, so walking Links alone from the
//! leaking output leg finds no inbound links at all — a dead end that reads
//! as "clean". The legs are related only by shared properties.
//!
//! ## The rule
//!
//! A union of keys, strongest first:
//!
//! | # | key | scope |
//! | --- | --- | --- |
//! | 1 | `node.link-group` | per module/filter instance |
//! | 2 | `pulse.module.id` | per pactl module |
//! | 3 | `client.id` | per **connection** |
//! | 4 | `application.process.id` | per process |
//!
//! ⚠️ **"Resolves" means the two legs carry the key AND the values are
//! EQUAL — not "the first key present".** A first-present implementation
//! reproduces the exact measured leak: for `gst-launch pulsesrc ! pulsesink`
//! both legs carry `client.id` (209 and 210) but the values *differ*, so
//! first-present stops at key 3, sees a mismatch, and concludes "different
//! owners". The legs are in fact one process (`application.process.id`
//! 20172 on both). So: try each key in order, and a key resolves only if
//! both legs carry it and the values are equal; otherwise fall through.
//!
//! ## Two exceptions, both guarding against mass over-exclusion
//!
//! 1. **Never bridge on key 4 when the value is pipewire-pulse's own PID**
//! (v3.4 §6.1.2). Module-created streams all carry the daemon's PID, so
//! bridging on it fuses every Pulse module into one owner and a single
//! tainted module input would exclude every module-created stream on the
//! box. Keys 1 and 2 already cover those cases precisely.
//!
//! 2. **Coarse keys (3 and 4) may not bridge nodes exported from a real
//! `Device`** — i.e. nodes carrying `device.id`. ⚠️ This rule is *not*
//! in design v3.4; it was found while implementing, and it is the exact
//! analogue of exception 1 for the session manager.
//! ✅ **MEASURED on the live graph 2026-07-21:**
//!
//! | node | `client.id` | `device.id` | `factory.name` |
//! | --- | --- | --- | --- |
//! | 5 × `alsa_{output,input}.*` | **42** (`WirePlumber [export]`) | 43/45/46 | `api.alsa.pcm.{sink,source}` |
//! | 3 × `sink-sunshine-*` | 83 / 86 / 92 (each its own) | **absent** | `support.null-audio-sink` |
//!
//! So one shared coarse key genuinely does relate every hardware device
//! on the box, and `device.id` cleanly separates that set from virtual
//! sinks. Without the rule, the hardware sink carrying peerspeak's
//! playback (tainted by design, every single recompute) would bridge to
//! *every other device node including the microphone source*, whose
//! readers would then taint their owners' playback legs — reproducing
//! precisely the §6.1.1 catastrophe ("excludes any app using a
//! microphone") through a different door.
//!
//! ⚠️ **Keyed on `device.id`, NOT on `media.class` being `Audio/Sink`.**
//! The first cut suppressed coarse keys for every device-*role* node,
//! and Codex refuted it: a **native virtual sink** — an app that creates
//! an `Audio/Sink` plus a re-emitting stream on one client, with no
//! `link-group` and no `pulse.module.id` — would then have had its only
//! correlation stripped, and it would have leaked the whole call. Such a
//! sink has no `device.id`, so it now bridges on `client.id` as it
//! should.
//!
//! Grouping is **transitive** (union-find). That is the fail-closed
//! direction: bigger owner components mean more taint, never less.
use std::collections::BTreeMap;
use super::snapshot::{GlobalId, GraphSnapshot, NodeSnapshot, Serial};
/// Which key bridged two legs. Ordered strongest first; the `Ord` derive is
/// load-bearing for "report the strongest shared key".
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum OwnerKey {
LinkGroup,
PulseModuleId,
ClientId,
ProcessId,
}
impl OwnerKey {
/// Stable, machine-readable — this ends up in the phase 5 audit output
/// and the phase 6 status event.
pub fn code(self) -> &'static str {
match self {
Self::LinkGroup => "node.link-group",
Self::PulseModuleId => "pulse.module.id",
Self::ClientId => "client.id",
Self::ProcessId => "application.process.id",
}
}
}
/// The value a node presents for a given key, if it presents one at all.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum KeyValue {
Text(String),
Num(u64),
}
/// Owner keys usable on this node, strongest first.
///
/// A key that is present but unusable (the pipewire-pulse PID; a coarse key
/// on a device node) is **absent** here — that is the whole mechanism of the
/// two exceptions.
fn keys_of(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> Vec<(OwnerKey, KeyValue)> {
let mut out = Vec::new();
if let Some(group) = &node.props.link_group {
out.push((OwnerKey::LinkGroup, KeyValue::Text(group.clone())));
}
if let Some(module) = node.props.pulse_module_id {
out.push((OwnerKey::PulseModuleId, KeyValue::Num(module)));
}
// Exception 2: coarse keys never bridge passive session-manager device
// nodes — they all share the session manager's client.
if node.props.session_device {
return out;
}
if let Some(client) = node.props.client_id {
out.push((OwnerKey::ClientId, KeyValue::Num(u64::from(client.0))));
}
if let Some(pid) = node.props.process_id {
// Exception 1. Note the fail-closed asymmetry when the daemon PID is
// unknown (`None`): the exception does *not* fire, key 4 applies to
// everything, and Pulse modules fuse into one owner. That is broad
// over-exclusion — annoying and safe — which is the direction v3.4
// §6.1.2's failure-mode paragraph asks for.
if Some(pid) != pipewire_pulse_pid {
out.push((OwnerKey::ProcessId, KeyValue::Num(u64::from(pid))));
}
}
out
}
/// Can this node's owner be positively bounded — i.e. can we enumerate its
/// sibling legs and be right?
///
/// ⚠️ Not the same as "has any usable key", and the difference is a leak.
/// `client.id` alone does **not** bound an owner: that is the measured
/// GStreamer refutation, where one process presented two different
/// `client.id`s for its two legs. So an owner is bounded only by a strong
/// key (link-group / pulse.module.id) or by a *usable* process id — usable
/// meaning key 4 was not suppressed as pipewire-pulse's own PID.
///
/// The case this exists for is v3.4 §12's "module forwarder with neither
/// `link-group` nor `pulse.module.id`": its process id is the daemon's and
/// therefore suppressed, its two legs may carry different `client.id`s, and
/// nothing else relates them. Its sibling output leg cannot be found, so
/// the engine must fail closed rather than declare it clean
/// (v3.4 §6.1.1, final paragraph).
pub fn owner_is_bounded(node: &NodeSnapshot, pipewire_pulse_pid: Option<u32>) -> bool {
keys_of(node, pipewire_pulse_pid)
.iter()
.any(|(key, _)| *key != OwnerKey::ClientId)
}
/// Owner keys computed once per snapshot.
///
/// `keys_of` allocates a `Vec` and clones the `link-group` string, and the
/// bridge asks for keys once per (tainted member × component member) pair —
/// so recomputing was the hot spot in an otherwise linear pass.
#[derive(Debug, Default)]
pub struct OwnerKeyIndex {
keys: BTreeMap<Serial, Vec<(OwnerKey, KeyValue)>>,
}
impl OwnerKeyIndex {
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
Self {
keys: snapshot
.nodes()
.map(|node| (node.serial, keys_of(node, pipewire_pulse_pid)))
.collect(),
}
}
/// The strongest key these two nodes share directly, if any.
pub fn strongest_shared(&self, a: Serial, b: Serial) -> Option<OwnerKey> {
let (Some(a_keys), Some(b_keys)) = (self.keys.get(&a), self.keys.get(&b)) else {
return None;
};
// Stored strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*key)
})
}
/// Is `client.id` a usable owner key for this node?
///
/// ⚠️ Load-bearing for sticky state. A device node's `client.id` is
/// suppressed by exception 2, so recording the session manager's Client
/// as a *member* of a tainted device's sticky owner would smuggle the
/// suppressed key back in: the next recompute would expand that Client
/// to every hardware node on the box — the microphone included — and
/// the §6.1.1 catastrophe would arrive one epoch late instead of never.
/// (Codex round 2, finding 1.)
pub fn uses_client_key(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key == OwnerKey::ClientId))
}
/// The owner keys that are safe to remember *across* connections, for
/// sticky taint: the strong keys plus a usable process id.
///
/// `client.id` is deliberately excluded — it identifies a *connection*,
/// and the whole point of a fingerprint is to survive one process
/// closing a connection and opening another. A live Client member is
/// what covers the same-connection case, precisely.
///
/// These are recyclable strings and numbers, so they are only ever
/// applied while some **serial** member of the owner is still live
/// (v3.4 §6.1.3): while the process is alive, its PID cannot have been
/// handed to anyone else.
pub fn fingerprints(&self, serial: Serial) -> Vec<Fingerprint> {
self.keys
.get(&serial)
.map(|keys| {
keys.iter()
.filter(|(key, _)| *key != OwnerKey::ClientId)
.map(|(key, value)| Fingerprint(*key, value.clone()))
.collect()
})
.unwrap_or_default()
}
/// Does this node currently present `fingerprint`?
pub fn has_fingerprint(&self, serial: Serial, fingerprint: &Fingerprint) -> bool {
self.keys.get(&serial).is_some_and(|keys| {
keys.iter()
.any(|(key, value)| *key == fingerprint.0 && *value == fingerprint.1)
})
}
/// See [`owner_is_bounded`].
pub fn is_bounded(&self, serial: Serial) -> bool {
self.keys
.get(&serial)
.is_some_and(|keys| keys.iter().any(|(key, _)| *key != OwnerKey::ClientId))
}
}
/// The strongest key two nodes share, or `None` if they share none. Used to
/// *name* the key in a bridge decision; membership itself is transitive and
/// comes from [`OwnerComponents`].
pub fn strongest_shared_key(
a: &NodeSnapshot,
b: &NodeSnapshot,
pipewire_pulse_pid: Option<u32>,
) -> Option<OwnerKey> {
let a_keys = keys_of(a, pipewire_pulse_pid);
let b_keys = keys_of(b, pipewire_pulse_pid);
// `keys_of` yields strongest-first, so the first match is the strongest.
a_keys.iter().find_map(|(key, value)| {
b_keys
.iter()
.any(|(other_key, other_value)| other_key == key && other_value == value)
.then_some(*key)
})
}
/// A remembered owner key — see [`OwnerKeyIndex::fingerprints`].
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Fingerprint(OwnerKey, KeyValue);
/// Nodes partitioned into owner components.
#[derive(Clone, Debug, Default)]
pub struct OwnerComponents {
/// node serial → component index.
of_node: BTreeMap<Serial, usize>,
/// component index → member node serials, ascending.
members: Vec<Vec<Serial>>,
}
impl OwnerComponents {
pub fn build(snapshot: &GraphSnapshot, pipewire_pulse_pid: Option<u32>) -> Self {
let serials: Vec<Serial> = snapshot.nodes().map(|n| n.serial).collect();
let index: BTreeMap<Serial, usize> =
serials.iter().enumerate().map(|(i, s)| (*s, i)).collect();
let mut uf = UnionFind::new(serials.len());
// Group by (key, value) and union within each group. Equivalent to
// the pairwise "some key resolves" rule, and O(n log n).
let mut buckets: BTreeMap<(OwnerKey, KeyValue), Vec<usize>> = BTreeMap::new();
for node in snapshot.nodes() {
let slot = index[&node.serial];
for (key, value) in keys_of(node, pipewire_pulse_pid) {
buckets.entry((key, value)).or_default().push(slot);
}
}
for group in buckets.values() {
for pair in group.windows(2) {
uf.union(pair[0], pair[1]);
}
}
// Compact roots into dense component indices, deterministically.
let mut root_to_component: BTreeMap<usize, usize> = BTreeMap::new();
let mut members: Vec<Vec<Serial>> = Vec::new();
let mut of_node = BTreeMap::new();
for (slot, serial) in serials.iter().enumerate() {
let root = uf.find(slot);
let component = *root_to_component.entry(root).or_insert_with(|| {
members.push(Vec::new());
members.len() - 1
});
members[component].push(*serial);
of_node.insert(*serial, component);
}
Self { of_node, members }
}
pub fn component_of(&self, serial: Serial) -> Option<usize> {
self.of_node.get(&serial).copied()
}
/// Member serials of the component containing `serial`, including it.
/// Empty if the node is not in this snapshot.
pub fn members_with(&self, serial: Serial) -> &[Serial] {
match self.component_of(serial) {
Some(component) => &self.members[component],
None => &[],
}
}
pub fn components(&self) -> impl Iterator<Item = &[Serial]> {
self.members.iter().map(Vec::as_slice)
}
}
struct UnionFind {
parent: Vec<usize>,
}
impl UnionFind {
fn new(len: usize) -> Self {
Self {
parent: (0..len).collect(),
}
}
fn find(&mut self, mut node: usize) -> usize {
while self.parent[node] != node {
self.parent[node] = self.parent[self.parent[node]];
node = self.parent[node];
}
node
}
fn union(&mut self, a: usize, b: usize) {
let (a, b) = (self.find(a), self.find(b));
if a != b {
// Lowest root wins, so components are deterministic.
let (low, high) = if a < b { (a, b) } else { (b, a) };
self.parent[high] = low;
}
}
}
/// Client objects belonging to an owner component, so sticky taint can be
/// keyed on every object that constitutes the owner (v3.4 §6.1.3: clear the
/// entry only once **all** member objects are gone).
pub fn client_serials_of(
snapshot: &GraphSnapshot,
keys: &OwnerKeyIndex,
nodes: &[Serial],
) -> Vec<Serial> {
let mut out: Vec<Serial> = nodes
.iter()
// Only nodes for which `client.id` is a *usable* owner key. See
// `uses_client_key`: recording a device node's shared session-manager
// Client here would defeat exception 2 on the next recompute.
.filter(|serial| keys.uses_client_key(**serial))
.filter_map(|serial| snapshot.node(*serial))
.filter_map(|node| node.props.client_id)
// An ambiguous client id means two Clients claim it and we cannot
// say which one is ours, so remember both: an entry that recorded
// neither could be retired while its owner was still live.
.flat_map(|id: GlobalId| snapshot.clients_with_id(id).map(|client| client.serial))
.collect();
out.sort_unstable();
out.dedup();
out
}
+332
View File
@@ -0,0 +1,332 @@
//! The plain, owned graph model the taint engine reasons over.
//!
//! **No PipeWire types appear in this file, by design** (impl plan §4,
//! phase 2). The registry observer (phase 3) translates live globals into
//! these structs; every test builds them by hand. Nothing here ever links
//! against libpipewire.
//!
//! Two id-ish things live in this model and confusing them is the bug the
//! whole file is shaped to prevent:
//!
//! - [`Serial`] — `object.serial`, 64-bit, monotonic, **never reused**.
//! This is *identity*. Sticky taint is keyed on it.
//! - [`GlobalId`] — the PipeWire global id, 32-bit and **recycled**. It is
//! a *lookup key within one snapshot* and nothing else: links name their
//! endpoints with it, nodes name their client with it. It must never
//! outlive the snapshot it was read from (design v3.4 §6.1.3).
use std::collections::BTreeMap;
/// `object.serial` — 64-bit, monotonic, never recycled. Identity.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub struct Serial(pub u64);
/// A PipeWire global id — 32-bit and **recycled**. Snapshot-local lookup
/// key only; see the module docs.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub struct GlobalId(pub u32);
/// What a node does with audio, parsed from `media.class`.
///
/// Taint is computed at **node** granularity (v3.4 §6.1 edge type 2: the
/// monitor connection is already a real Link whose output node is the sink
/// itself, so a node-level walk crosses `app → sink → monitor-reader` for
/// free). Ports exist in the model for link creation in phase 6 and for the
/// `port.exclusive` predicate, not for taint.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum MediaRole {
/// `Stream/Output/Audio` — an application playing audio. The only
/// fan-out candidate.
StreamOutput,
/// `Stream/Input/Audio` — an application capturing audio.
StreamInput,
/// `Audio/Sink` — a real or virtual sink.
Sink,
/// `Audio/Source` — a real or virtual source.
Source,
/// `Audio/Duplex`. ⚠️ Node granularity smears taint across both roles
/// of these; accepted for v1 as fail-closed over-exclusion
/// (v3.4 §6.1, edge type 2 caveat).
Duplex,
/// Anything else, including video and unparseable/absent `media.class`.
Other,
}
impl MediaRole {
pub fn parse(media_class: Option<&str>) -> Self {
match media_class {
Some("Stream/Output/Audio") => Self::StreamOutput,
Some("Stream/Input/Audio") => Self::StreamInput,
Some("Audio/Sink") => Self::Sink,
Some("Audio/Source") => Self::Source,
Some("Audio/Duplex") => Self::Duplex,
_ => Self::Other,
}
}
/// Can this node *receive* audio? This is the gate on the owner bridge:
/// taint crosses the intra-process hop only when the owner is actually
/// reading tainted audio (v3.4 §6.1.1 — "this client has both an input
/// and an output leg ⇒ exclude the output" is the catastrophic rule
/// that excludes every app with a microphone).
///
/// `Sink` counts: EasyEffects' `ee_sink` is an `Audio/Sink` that
/// receives the tainted mix, and its re-emitting leg is joined to it by
/// `node.link-group` with no Link between them.
pub fn receives_audio(self) -> bool {
matches!(self, Self::StreamInput | Self::Sink | Self::Duplex)
}
/// Device-ish nodes — everything that is not a `Stream/*`. Coarse owner
/// keys are not allowed to bridge these; see [`super::owner`].
pub fn is_device_role(self) -> bool {
matches!(self, Self::Sink | Self::Source | Self::Duplex)
}
/// Only `Stream/Output/Audio` nodes are fan-out candidates (v3.4 §6.2).
pub fn is_candidate(self) -> bool {
matches!(self, Self::StreamOutput)
}
}
/// The subset of node properties the engine actually reasons about.
///
/// Deliberately a struct of parsed fields rather than a property bag: the
/// parsing (and its failure modes) belongs at the observer boundary, and a
/// bag invites `props.get("...")` typos that silently read `None` — which
/// on this feature means "not tainted".
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NodeProps {
/// `peerspeak.owned` is present and truthy (v3.4 §5.1). A correctness
/// mechanism, explicitly *not* a security boundary.
pub peerspeak_owned: bool,
/// `pulse.module.id`, parsed as `u64` — never `u32`, per v3.4 §5.2's
/// parse-defensively note and the phase 0a truncation bug.
pub pulse_module_id: Option<u64>,
/// `node.link-group` — owner key 1, and the `echo-cancel-` hazard
/// prefix (v3.4 §5.4 / D3).
pub link_group: Option<String>,
/// `client.id` — owner key 3. A **connection**, not an owner: GStreamer
/// opens one per stream (v3.4 §6.1.2, measured refutation).
pub client_id: Option<GlobalId>,
/// `application.process.id` **on the node** — owner key 4. For
/// module-created streams this is pipewire-pulse's own PID, which is
/// why [`super::ExclusionCtx::pipewire_pulse_pid`] exists.
pub process_id: Option<u32>,
/// The stream negotiated an encoded/passthrough format; a second link
/// would refuse or corrupt it (v3.4 §6.2).
pub passthrough: bool,
/// This node is a **passive device node exported by the session
/// manager** — a real sound card's sink or source, not something that
/// forwards audio.
///
/// ⚠️ **A positive high-confidence classification the observer owes, not
/// a raw property** (Codex rounds 23). PipeWire defines `device.id`
/// only as "the Device this node belongs to" and `device.api` as that
/// Device's access API; **neither promises the node passively terminates
/// audio**, so a card-associated filter can satisfy both. Setting this
/// flag *removes* two protections at once — the node's coarse owner keys
/// (`owner` exception 2) and its ability to trip the fail-closed
/// backstop — so a false positive is a leak, not over-exclusion.
///
/// **Phase-3 contract:**
/// - Set `true` only on positively-identified passive hardware
/// terminals: a resolved `device.id` on a real backend
/// (`device.api` present) whose `factory.name` is on an **explicit
/// hardware-PCM allowlist** — `api.alsa.pcm.sink`, `api.alsa.pcm.source`,
/// and the equivalent for other real backends (bluez5, v4l2 for the
/// media case) as phase 3 enumerates them — never a filter, loopback,
/// or `support.null-audio-sink` factory. An allowlist, not a
/// substring or a denylist: an unknown factory is not a device.
/// Measured discriminator on the
/// target box: the five ALSA nodes carry `device.api=alsa` +
/// `factory.name=api.alsa.pcm.*` and share `client.id=42`
/// (`WirePlumber [export]`); the three `support.null-audio-sink` nodes
/// carry neither. (`node.physical` was measured **null** on the ALSA
/// nodes here, so it is *not* a usable discriminator — do not rely on
/// it.)
/// - **Fail closed: unknown ⇒ `false`.** A node that cannot be
/// positively classified keeps its owner keys and can trip the
/// backstop; both are the safe direction.
/// - A node MUST NOT enter a snapshot with this field provisional. If
/// the Device backing a node has not yet been bound, withhold the node
/// and keep the epoch not-ready — otherwise a provisional `false`
/// during not-ready fuses sink and mic on the shared session client
/// and that fusion can persist as sticky over-exclusion (round-3
/// finding 3).
///
/// ⚠️ **A false positive is leak-capable — do not treat it as braced.**
/// I claimed a mis-classified filter could not leak because its legs
/// share a `node.link-group` (strong-key bridge) or trip the unbounded
/// backstop. Codex refuted it (round 4): a filter *without* a shared
/// strong key, marked `session_device=true`, cannot activate the
/// backstop from its reading leg, so a differently-keyed re-emitting leg
/// leaks. Those braces catch *some* shapes, not all. The only real
/// defence is a correct classifier — hence "positive high-confidence"
/// and "fail closed to false" above, without exception.
///
/// What it is for: every real device node shares the session manager's
/// `client.id`, so coarse owner keys must not bridge them — else
/// peerspeak's playback (which taints the default sink every recompute)
/// would reach the microphone. See [`super::owner`] exception 2.
pub session_device: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `node.name`, for diagnostics and for `pixelpass_capture_*` ancestry
/// detection (v3.4 §6.2, cycle prevention).
pub name: Option<String>,
pub role: MediaRole,
pub props: NodeProps,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PortDirection {
In,
Out,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// Owning node, by snapshot-local id.
pub node: GlobalId,
pub direction: PortDirection,
/// `port.exclusive` — fan-out will be refused (v3.4 §6.2).
pub exclusive: bool,
/// `port.monitor`. Recorded for phase 6 link creation; taint does not
/// need it at node granularity.
pub monitor: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `link.output.node` — the node audio flows **from**.
pub output_node: GlobalId,
/// `link.input.node` — the node audio flows **to**.
pub input_node: GlobalId,
pub output_port: Option<GlobalId>,
pub input_port: Option<GlobalId>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientSnapshot {
pub serial: Serial,
pub id: GlobalId,
/// `pipewire.sec.pid` — for Pulse-emulated clients this is
/// **pipewire-pulse's** PID, identical across every unrelated app
/// (v3.4 §5.2 correction 5). Phase 3 derives the daemon PID from the
/// consistency of this value; the engine only consumes the result.
pub sec_pid: Option<u32>,
}
/// How a snapshot-local id resolves.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum IdLookup {
Unique(Serial),
/// Two live objects in one snapshot claim the same global id — the
/// observer missed a removal, so the recycled id is ambiguous. Every
/// edge touching it is treated as unresolved, i.e. fail closed.
Ambiguous,
}
/// One coherent observation of the graph.
///
/// Built through [`GraphSnapshot::new`] so the id indexes and the ambiguity
/// detection cannot be skipped.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GraphSnapshot {
nodes: BTreeMap<Serial, NodeSnapshot>,
ports: BTreeMap<Serial, PortSnapshot>,
links: BTreeMap<Serial, LinkSnapshot>,
clients: BTreeMap<Serial, ClientSnapshot>,
node_ids: BTreeMap<GlobalId, IdLookup>,
client_ids: BTreeMap<GlobalId, IdLookup>,
}
impl GraphSnapshot {
pub fn new(
nodes: Vec<NodeSnapshot>,
ports: Vec<PortSnapshot>,
links: Vec<LinkSnapshot>,
clients: Vec<ClientSnapshot>,
) -> Self {
let node_ids = index_ids(nodes.iter().map(|n| (n.id, n.serial)));
let client_ids = index_ids(clients.iter().map(|c| (c.id, c.serial)));
Self {
nodes: nodes.into_iter().map(|n| (n.serial, n)).collect(),
ports: ports.into_iter().map(|p| (p.serial, p)).collect(),
links: links.into_iter().map(|l| (l.serial, l)).collect(),
clients: clients.into_iter().map(|c| (c.serial, c)).collect(),
node_ids,
client_ids,
}
}
pub fn nodes(&self) -> impl Iterator<Item = &NodeSnapshot> {
self.nodes.values()
}
pub fn node(&self, serial: Serial) -> Option<&NodeSnapshot> {
self.nodes.get(&serial)
}
pub fn links(&self) -> impl Iterator<Item = &LinkSnapshot> {
self.links.values()
}
pub fn ports(&self) -> impl Iterator<Item = &PortSnapshot> {
self.ports.values()
}
pub fn clients(&self) -> impl Iterator<Item = &ClientSnapshot> {
self.clients.values()
}
/// Resolve a snapshot-local node id. `None` means "no such node in this
/// snapshot", which for a link endpoint means unresolved ancestry.
pub fn node_by_id(&self, id: GlobalId) -> Option<IdLookup> {
self.node_ids.get(&id).copied()
}
pub fn client_by_id(&self, id: GlobalId) -> Option<IdLookup> {
self.client_ids.get(&id).copied()
}
/// Every node claiming a global id. More than one means the id is
/// [`IdLookup::Ambiguous`] and each claimant must be treated as a
/// possible endpoint of any link naming it.
pub fn nodes_with_id(&self, id: GlobalId) -> impl Iterator<Item = &NodeSnapshot> {
self.nodes.values().filter(move |node| node.id == id)
}
/// Every client claiming a global id — same fail-closed reasoning.
pub fn clients_with_id(&self, id: GlobalId) -> impl Iterator<Item = &ClientSnapshot> {
self.clients.values().filter(move |client| client.id == id)
}
/// Ports belonging to a node, by the node's snapshot-local id.
pub fn ports_of(&self, node: GlobalId) -> impl Iterator<Item = &PortSnapshot> {
self.ports.values().filter(move |p| p.node == node)
}
}
fn index_ids(entries: impl Iterator<Item = (GlobalId, Serial)>) -> BTreeMap<GlobalId, IdLookup> {
let mut out: BTreeMap<GlobalId, IdLookup> = BTreeMap::new();
for (id, serial) in entries {
out.entry(id)
.and_modify(|slot| {
if *slot != IdLookup::Unique(serial) {
*slot = IdLookup::Ambiguous;
}
})
.or_insert(IdLookup::Unique(serial));
}
out
}
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -12,8 +12,7 @@ use ashpd::{
},
};
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use nix::unistd::close;
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
@@ -61,11 +60,14 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
tracing::info!(node_id, width = w, height = h, "portal handshake complete");
// The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec. We then leak it via into_raw_fd so its lifetime spans the spawn,
// and close the parent's copy once gst is running (the pipeline's
// after_spawn hook below).
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst
// inherits its own copy at exec) by moving it into the after_spawn hook,
// which drops — and so closes — the parent's copy once gst is running. If
// pipeline::spawn errors *before* calling the hook (e.g. audio setup or the
// gst spawn fails), the unused closure is dropped, dropping the fd just the
// same — so the portal fd never leaks on the error path.
clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.into_raw_fd();
let raw_fd: RawFd = pw_fd.as_raw_fd();
let source_args = vec![
"pipewiresrc".to_string(),
@@ -81,7 +83,7 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
source_args,
move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
let _ = close(raw_fd);
drop(pw_fd);
},
)
.await
+14 -4
View File
@@ -37,12 +37,22 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<Captur
}
};
// XDamage capture (`use-damage=true`) only re-grabs changed screen
// regions instead of copying the whole root window every frame. On a busy
// desktop that is the difference between a usable framerate and ~1 fps —
// `use-damage=false` does a full XGetImage per frame, which collapses on
// servers without working MIT-SHM (and pins the CPU everywhere else).
// Kept as the default; `PIXELPASS_X11_NO_DAMAGE=1` restores full-frame
// capture if a driver produces partial-update artifacts with damage on.
let use_damage = if std::env::var_os("PIXELPASS_X11_NO_DAMAGE").is_some() {
"use-damage=false"
} else {
"use-damage=true"
};
let mut source_args = vec![
"ximagesrc".to_string(),
// Full frames (no damage regions) to avoid partial-update artifacts;
// use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
// show-pointer matches Wayland's CursorMode::Embedded.
use_damage.to_string(),
"show-pointer=true".to_string(),
];
if let Some(xid) = xid {
+5 -2
View File
@@ -279,9 +279,12 @@ impl Player {
Player::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
// No `--untimed`: it ignores audio timestamps and drifts a
// shared video out of sync. Pacing to audio keeps A/V synced.
// Also leave hwdec at the `low-latency` default (software
// decode): forcing `--hwdec=auto` froze some viewers on
// frame 1 while audio kept playing.
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
+8
View File
@@ -1,5 +1,6 @@
mod cli;
mod common;
mod doctor;
#[cfg(feature = "gui")]
mod gui;
mod host;
@@ -36,6 +37,13 @@ async fn main() -> Result<()> {
}
}
// Diagnostics run before pipewire::init() (they don't need it) and work
// regardless of the `gui` feature, so a headless tester can probe their box.
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
// libpipewire requires global init before any pw_* call. Idempotent;
// safe to call even when the per-app audio thread never spawns.
pipewire::init();
+46 -7
View File
@@ -50,13 +50,11 @@ pub async fn run() -> Result<()> {
if m.name != "module-loopback" {
continue;
}
let Some(sink) = extract_kv(&m.args, "sink") else {
continue;
};
let Some(pid_str) = sink.strip_prefix(SINK_NAME_PREFIX) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
// A pixelpass loopback references a capture sink either as its
// destination (`sink=pixelpass_capture_<pid>` — the default→null
// mirror) or as its source (`source=pixelpass_capture_<pid>.monitor`
// — the local monitor that lets the sharer hear the app). Match both.
let Some(pid) = loopback_capture_pid(&m.args) else {
continue;
};
if dead_pids.contains(&pid) {
@@ -166,6 +164,19 @@ fn list_modules() -> Result<Vec<Module>> {
Ok(modules)
}
/// The `pixelpass_capture_<pid>` PID a loopback references, whether the capture
/// sink is its destination (`sink=pixelpass_capture_<pid>`) or its source
/// (`source=pixelpass_capture_<pid>.monitor`). `None` for unrelated loopbacks.
fn loopback_capture_pid(args: &str) -> Option<u32> {
let from_sink = extract_kv(args, "sink").and_then(|v| v.strip_prefix(SINK_NAME_PREFIX));
let from_source = extract_kv(args, "source")
.and_then(|v| v.strip_prefix(SINK_NAME_PREFIX))
.and_then(|rest| rest.strip_suffix(".monitor"));
from_sink
.or(from_source)
.and_then(|pid| pid.parse::<u32>().ok())
}
fn extract_kv<'a>(args: &'a str, key: &str) -> Option<&'a str> {
for token in args.split_whitespace() {
if let Some(rest) = token.strip_prefix(key)
@@ -195,3 +206,31 @@ fn unload_module(id: u32) -> Result<()> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loopback_pid_matches_default_null_mirror_by_sink() {
// The default→null loopback: capture sink is the destination.
let args = "source=@DEFAULT_SINK@.monitor sink=pixelpass_capture_4242 latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_matches_local_monitor_by_source() {
// The local monitor: capture sink's monitor is the source, and the
// destination is the real default sink (not a pixelpass name).
let args = "source=pixelpass_capture_4242.monitor sink=@DEFAULT_SINK@ latency_msec=20";
assert_eq!(loopback_capture_pid(args), Some(4242));
}
#[test]
fn loopback_pid_ignores_unrelated_loopback() {
assert_eq!(
loopback_capture_pid("source=alsa_output.pci.monitor sink=some_other_sink"),
None
);
}
}
+13 -2
View File
@@ -71,7 +71,18 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
accepted = listener.accept() => {
let (tcp, peer) = accepted?;
tracing::info!(%peer, "local viewer connected");
crate::common::tunnel::bridge(quic_send, quic_recv, tcp).await
// Race the bridge against ctrl-c so a disconnect lands promptly
// mid-stream (mirrors the host's handle_peer). Without this, the
// cancel token is set but nothing checks it once the player has
// connected — ctrl-c is ignored until a second press, and a GUI
// "Disconnect" only takes effect via the child's SIGKILL backstop.
tokio::select! {
res = crate::common::tunnel::bridge(quic_send, quic_recv, tcp) => res,
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received during stream — disconnecting");
Ok(())
}
}
}
_ = cancel.cancelled() => {
tracing::info!("ctrl-c received before local viewer connected");
@@ -91,7 +102,7 @@ fn print_viewer_banner(url: &str) {
eprintln!("│ Connected to host. Open the stream in your player:");
eprintln!("");
eprintln!(
"│ mpv --profile=low-latency --untimed --hwdec=auto --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
"│ mpv --profile=low-latency --audio-buffer=0.2 --demuxer-max-bytes=2M --demuxer-readahead-secs=0.5 {url}"
);
eprintln!("│ vlc --network-caching=200 --live-caching=200 {url}");
eprintln!("");