Skip to content

fix: Linux PipeWire engine two-level Frame enum + bounded frame channel (all platforms)#183

Open
Tristan-Stoltz-ERC wants to merge 9 commits into
CapSoftware:mainfrom
Luminous-Dynamics:fix/linux-engine-two-level-frame-enum
Open

fix: Linux PipeWire engine two-level Frame enum + bounded frame channel (all platforms)#183
Tristan-Stoltz-ERC wants to merge 9 commits into
CapSoftware:mainfrom
Luminous-Dynamics:fix/linux-engine-two-level-frame-enum

Conversation

@Tristan-Stoltz-ERC

@Tristan-Stoltz-ERC Tristan-Stoltz-ERC commented Apr 19, 2026

Copy link
Copy Markdown

Summary

The Frame enum was refactored into a two-level shape (Frame::Audio(AudioFrame) / Frame::Video(VideoFrame::*)) and the per-format structs' display_time field was changed from u64 to SystemTime, but the Linux PipeWire engine in src/capturer/engine/linux/mod.rs was not updated to match.

Result: 8 compile errors on Linux; 0.1.0-beta.1 on crates.io does not build there, and neither does the current main (verified on Ubuntu 24 with pipewire 1.0). fix-windows addressed the Windows path but left Linux untouched.

While fixing that, I found and fixed a real memory-growth bug in the shared frame channel — and then, in fixing that, broke the Windows/macOS build the same way the original Linux bug happened, so I fixed that too. Details below, split by commit since they're three separate concerns.

Commit 1 — fix(linux): use two-level Frame::Video(VideoFrame::...) enum + SystemTime display_time

  • Wraps the four emit sites (RGBx / RGB / xBGR / BGRx) in Frame::Video(VideoFrame::...(...)) to match frame::Frame's current two-level shape.
  • Replaces display_time: timestamp as u64 with display_time: SystemTime::now(), matching what the macOS (src/capturer/engine/mac/mod.rs) and Windows (src/capturer/engine/win/mod.rs) engines already do. The raw PipeWire pts from spa_meta_header is monotonic ns since an arbitrary reference, not wall-clock — it cannot be converted losslessly to SystemTime. Relative frame ordering survives via channel-send order.
  • Adds SystemTime to the std::time imports and VideoFrame to the crate::frame imports.

Commit 2 — fix(linux): bound the internal frame channel to stop unbounded memory growth

Capturer::build() wired an unbounded std::sync::mpsc::channel() between each platform's capture callback and the consumer. On Linux, the PipeWire callback thread copies every frame via to_vec() unconditionally, independent of whether get_next_frame() is being drained — so any time the consumer lags even briefly, frames pile up with zero backpressure.

Confirmed via heaptrack on a real KDE-Wayland capture session: ~1.44G of 1.47G leaked in a 25.76s run, traced entirely to this call site. Bounded the shared channel to mpsc::sync_channel(2) instead. Re-verified with the same repro run 5x longer (136.74s): total leaked dropped to ~77.6MB (unrelated debug-backtrace-formatting overhead), and the process_callback leak site is completely gone from the profile.

This changes Capturer::build() and the shared (non-cfg-gated) Engine::new() from mpsc::Sender<ChannelItem> to mpsc::SyncSender<ChannelItem>.

Commit 3 — fix: propagate SyncSender to win/mac + avoid blocking send on all engines

Commit 2's type change is not cfg-gatedEngine::new() unconditionally forwards the new SyncSender into win::create_capturer() and mac::create_capturer(), both of which still declared the old mpsc::Sender type. mpsc::Sender and mpsc::SyncSender are distinct, non-coercible std types, so commit 2 alone breaks the Windows and macOS build — exactly the same class of bug this PR opened by fixing on Linux, just introduced in the opposite direction. This repo's PR CI doesn't build those targets, so it wouldn't have surfaced before merge; I only caught it by reading win/mod.rs/mac/mod.rs directly against the new signature.

This commit:

  • Propagates mpsc::Sendermpsc::SyncSender through win/mod.rs (Capturer, FlagStruct, create_capturer, spawn_audio_stream) and mac/mod.rs (CapturerInner, create_capturer), matching Engine::new().
  • Replaces blocking .send() with .try_send() on every frame/sample emit site across all three engines, dropping the frame when the channel is full instead of blocking the capture callback thread. This matters most on Linux: process_callback runs on the same thread that pipewire_capturer's loop polls CAPTURER_STATE on, and LinuxCapturer::stop_capture() .join()s that thread. A blocking send() on a full bounded channel (e.g. the consumer paused draining get_next_frame()) would leave that thread permanently parked inside send(), so stop_capture() would hang forever. try_send() + drop-on-full keeps commit 2's bounded-memory fix without introducing that deadlock. Applied the same pattern to Windows' capture/audio callbacks and macOS' ScreenCaptureKit delivery callback, since an OS-driven callback thread blocking indefinitely isn't something worth newly introducing there either.

Verification

  • cargo check on Linux (Ubuntu 24, pipewire 1.0, via nix-shell -p pipewire dbus pkg-config clang) passes clean after all three commits: same 16 pre-existing lifetime-elision warnings as before, zero new warnings or errors.
  • win/mod.rs and mac/mod.rs could not be compiled or type-checked in my environment (no Windows/macOS toolchain, and their #[cfg(target_os = ...)]-gated mod declarations mean rustc never even parses them on a Linux host). I verified commit 3's changes to those files by: (1) rustfmt --check, which requires syntactically valid Rust to run at all — both pass; (2) manually cross-referencing every mpsc type at each call site against its declaration. I'd appreciate a maintainer (or CI) actually building this branch on Windows and macOS before merging — that's the one thing I can't self-verify here.

Follow-ups not in this PR

  • The pts value is currently discarded (let _ = timestamp;) since we can't faithfully convert monotonic-ns to wall-clock SystemTime. A follow-up could add a monotonic_time: Option<Duration> sibling field if callers want sub-ms buffer timing on Linux. Left as a separate concern.
  • The lifetime-elision warnings in portal.rs and a few other files are out of scope here but would be an easy clippy pass.
  • A channel capacity of 2 is a somewhat arbitrary starting bound (chosen to eliminate the observed leak with margin) — if real-world usage shows it dropping frames too eagerly under normal jitter, it's an easy follow-up to raise it or make it configurable via Options.

Context

Found while integrating scap into xenia-capture, the screen-capture abstraction of the Xenia remote-session stack — we picked scap as our primary cross-platform backend specifically because of your PipeWire-native Wayland path. Thanks for the work on this crate.

…Time display_time

The Frame enum was refactored into a two-level shape (Frame::Audio /
Frame::Video(VideoFrame::*)) and the per-format structs' display_time
field was changed from u64 to SystemTime, but the Linux PipeWire engine
in src/capturer/engine/linux/mod.rs was not updated to match. Result:
8 compile errors on Linux, no crates.io release compiles there.

This commit:

- Wraps the four emit sites (RGBx / RGB / xBGR / BGRx) in
  Frame::Video(VideoFrame::...(...)) to match frame::Frame's current
  two-level shape.
- Replaces `display_time: timestamp as u64` with
  `display_time: SystemTime::now()`, matching what the macOS and
  Windows engines already do.  The raw PipeWire pts from
  spa_meta_header is monotonic ns since an arbitrary reference, not
  wall-clock, so it cannot be converted losslessly to SystemTime.
  Relative frame ordering survives via channel-send order.
- Adds SystemTime to the std::time imports and VideoFrame to the
  crate::frame imports.

`cargo check` on Linux (Ubuntu 24, pipewire 1.0, via nix develop)
now succeeds — 16 warnings remaining, all pre-existing lifetime-
elision nits unrelated to this fix.
Tristan-Stoltz-ERC added a commit to Luminous-Dynamics/xenia-peer that referenced this pull request Jun 26, 2026
* unify(u4): xenia-inject crate — input-injection abstraction

Ports Symthaea's rdp_input.rs as a standalone library crate under
the unified xenia stack. Apache-2.0 OR MIT per ADR-002.

- InputEvent enum (Pointer / Key / Touch, serde + bincode-compatible)
- InputInjector trait with default process_events dispatch
- NoopInjector + LoggingInjector (for tests and dry-run)
- Feature-gated scaffolds: wayland-virtual (wlroots) + uinput

X11 backend intentionally dropped per ADR-001 "Wayland-only" stance.
5 unit tests covering coordinate denormalization, clamping, event
dispatch, and bincode round-trip.

Real backend plumbing (wayland-client + uinput kernel calls) lands
with the matching xenia-capture backends — scaffold today so the
daemon can accept InputEvent envelopes from xenia-wire and route
them to a real injector when the compositor-specific code arrives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* unify(u3): xenia-handshake crate — PQC hybrid handshake (Ed25519 + ML-KEM-768)

Fresh implementation against RustCrypto's ml-kem (FIPS 203),
ed25519-dalek 2.x, and hkdf 0.12. Apache-2.0 OR MIT per ADR-002.

Not a carry-over of symthaea/src/swarm/pqc_handshake.rs — that
version is tightly coupled to mycelix_crypto's abstractions
(TaggedPublicKey, AlgorithmId, KeyEncapsulator trait) which would
pull the mycelix-identity monorepo into every xenia-peer deployment.
The API shape is aligned (kem_public_key_bytes, receive_kem_public_key,
encapsulate_for_peer, decapsulate_and_derive, session_key,
remove_session) so migrating Symthaea's RDP pipeline over is a
search-and-replace, but there is zero cross-repo coupling.

Protocol:
  1. Each node generates a long-lived Ed25519 identity + per-session
     ML-KEM-768 keypair.
  2. Initiator receives responder's encapsulation key (1184 bytes),
     calls encapsulate_for_peer() -> 1088-byte ciphertext.
  3. Responder calls decapsulate_and_derive() on the ciphertext.
  4. Both sides derive the same 32-byte session key via
     HKDF-SHA-256(ikm = classical_nonce || kem_shared_secret,
                  salt = b"xenia-handshake-v1",
                  info = b"xenia-session-key").

Key design decisions:
- FIPS 203 implicit rejection is honored as-is — wrong ciphertexts
  yield a pseudorandom shared secret rather than erroring. Session
  authenticity is established at the Ed25519/HKDF layer when both
  sides compare derived keys, not at the KEM layer.
- SessionKey zeroizes on drop (zeroize::ZeroizeOnDrop).
- KEM keypair regenerated per HandshakeManager instance — long-lived
  identity is Ed25519 only, consistent with hybrid-forward-secrecy
  guidance in Bindel et al. 2019.
- HKDF-SHA-256 (not BLAKE3 derive_key as Symthaea's version uses) —
  sticks to NIST-approved primitives end-to-end for auditability.

15 unit tests: key sizes, round-trip matching-key derivation, per-peer
and per-nonce key separation, length validation, FIPS 203 implicit
rejection, Ed25519 sign/verify round-trip, session lifecycle, HKDF
determinism, bincode wire-format round-trip.

Closes ROADMAP's B1 blocker ("PQC handshake replaces FIXTURE_KEY") at
the crate layer. Wiring into xenia-peer binaries is a separate step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* unify(u5): ROADMAP refresh — U1-U4 landed, B1 crate-complete

Records the unification arc (U1-U4) and flips status for B1 from
"not started" to "crate shipped, wiring pending". Resolves
open-question #3 (license drift) with a pointer to ADR-002. Adds
explicit Symthaea-side migration note as the next-session handoff.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-capture): scaffold scap backend + document upstream Linux breakage

Wires the `scap` crate (primary cross-platform capture choice per
mycelix-sovereign ADR 0001) into xenia-capture as a new `scap-backend`
feature. Exposes `ScapCapture`, `ScapOptions`, `ScapResolution` via the
pre-existing `ScreenCapture` trait — no new trait added; the prior-art
abstraction already provides the moat.

Design:
- Worker thread owns scap's `Capturer` (which is `!Send` on Windows per
  upstream issue #145; constructing it inside the closure sidesteps
  the Send bound)
- Main thread polls via `mpsc::Receiver::try_recv`, mapping
  `TryRecvError::Empty` -> `Ok(None)` per the trait's non-blocking
  contract; `Disconnected` -> `CaptureError::Backend`
- BGRA -> RGBA conversion in-place via `chunks_exact_mut(4).swap(0, 2)`
- macOS TCC permission -> `CapturerBuildError::PermissionNotGranted`
  -> `CaptureError::ConsentDenied`
- Defensive: drops 0-byte frames (upstream #159)

Upstream blocker found during cargo check:
scap 0.1.0-beta.1 (and tip of main c03f15a4) does NOT compile on Linux.
Eight errors in scap/src/capturer/engine/linux/mod.rs: Frame::XBGR /
Frame::BGRx variants not found; timestamp as u64 vs expected SystemTime.
The Frame enum was simplified without updating the Linux engine.
Feature marked scaffolded-but-not-usable-on-Linux pending scap
0.1.0-beta.2. ADR 0001 documents three forward paths (watch-and-wait /
escalate to xcap / upstream PR).

Baseline cargo check -p xenia-capture (feature off) still passes.
The ScapCapture code will compile against a working upstream without
changes - the trait-moat architecture validated its first real test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-capture): repoint scap at our fork; handle all 6 VideoFrame variants

scap 0.1.0-beta.1 and main are both broken on Linux (the Frame enum was
refactored to the two-level Frame::Video(VideoFrame::*) shape but the
Linux PipeWire engine was not updated; display_time changed from u64 to
SystemTime at the same time and the Linux engine still casts to u64).
Authored the upstream fix and submitted it as PR:
  CapSoftware/scap#183

This commit:

1. Repoints the scap git dependency at our Luminous-Dynamics fork's
   fix/linux-engine-two-level-frame-enum branch. When the upstream PR
   merges and a release cuts, we flip back to the crates.io version.

2. Updates scap_backend.rs frame_to_rgba() to the two-level
   Frame::Video(VideoFrame::*) enum shape. Along the way, adds handlers
   for every VideoFrame variant scap can emit on Linux — not just BGRA.
   On Linux scap's PipeWire negotiation emits whichever of RGBx / RGB /
   XBGR / BGRx / BGR0 the compositor offers (upstream #151: FrameType
   request is ignored on Linux). If we only matched BGRA we'd silently
   drop every Linux frame.

   All conversions normalize to our trait's RGBA top-left contract.
   NV12 YUVFrame is discarded (420 subsampling + color-matrix choice
   are out of scope for this wrapper).

3. Adds a length_matches() defensive check for upstream #159 (scap
   occasionally emits 0-byte frames after which latency spikes 4-5x).

cargo check --features scap-backend inside xenia-peer's nix develop:
green on Linux.  7/7 tests pass (4 pre-existing + 3 scap_backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-ledger): scaffold AGPL verifiable-consent ledger crate

New crate in the xenia-peer workspace, licensed AGPL-3.0-or-later —
the cryptographic moat of the Mycelix Sovereign commercial suite.

Intentional exception to ADR-001 Decision 3 (library crates = permissive
Apache/MIT). Rationale in crates/xenia-ledger/README.md: this library is
functionally the commercial product, not shared-commons infrastructure.
A community-built compatible Xenia client can use xenia-wire / xenia-
peer-core / xenia-handshake / xenia-capture / xenia-inject freely — they
just need to bring their own ledger. Exception to be formally recorded
in a follow-up ADR.

Design:

- Append-only hash chain: blake3 over (seq, prev_hash, timestamp, event)
  for each entry; seq monotonic from 0; genesis prev_hash = [0; 32].
- Ed25519 signature over each entry_hash using the operator's key.
- Stateless Verifier checks sequence continuity, hash linkage,
  entry_hash recomputation, and signatures — can be run offline by any
  third-party auditor holding only the operator's public key.
- No persistence in this crate: callers decide their own storage
  (JSON, CBOR, SQLite, Holochain entries). `Chain::from_entries`
  rehydrates any persisted sequence.
- bincode v1 for canonical serialization, locked via workspace.

Dependencies:
- blake3 1.5, ed25519-dalek 2 (default-features off + std/rand_core/serde)
- serde-big-array 0.5 for [u8; 64] signature ser/de
- uuid 1 for session/request IDs (v4 + serde features)

Tests: 9 passing, covering every attack vector (tampering with event
data, tampering with entry_hash, reordering, wrong public key, forged
genesis with nonzero prev_hash) plus construction/rehydration happy
paths.

Out of scope here (tracked separately):
- External xenia-ledger-verify binary (AGPL)
- PQC signature option (Dilithium / ML-DSA)
- Chain-to-chain merkle anchoring for inter-operator attestation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-capture): capture_bench example — W0 scap validation harness

Timed capture-session harness producing the numbers the ADR 0001
"Pending validation" subsection needs: effective fps, first-frame
latency, bytes/frame + MB/s, try_recv Empty-poll count, error count,
VERDICT: PASS/FAIL against the 15-fps-at-native-resolution floor.

Env overrides: FRAMES=N, DURATION_SECS=N, FPS=N, DUMP_FRAME=path
(dumps first RGBA frame to disk for alpha-byte correctness checks).

Registered in Cargo.toml via [[example]] with required-features =
["scap-backend"] so default cargo build/test runs don't pull the scap
dep. Compiles cleanly against our Luminous-Dynamics/scap fork inside
xenia-peer's nix develop shell.

Per-OS invocation is documented in the companion runbook:
mycelix-sovereign/docs/capture-validation-runbook.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-admin): scaffold Leptos CSR admin console (AGPL)

W1 Stream A starter move — the operator surface of the Mycelix
Sovereign suite.  Leptos 0.8 CSR (matching the Mycelix ecosystem
convention).  AGPL-3.0-or-later per the application-layer license
policy.  Serves at port 8134 (dev: trunk serve; prod: Cloudflare
Tunnel -> admin.sovereign.mycelix.net).

Layout:
- src/main.rs                  mount entry
- src/app.rs                   Router + top nav + AuthStatus
- src/auth.rs                  AuthState (signal + localStorage
                               rehydration)
- src/pages/{login,devices,    four routes: /, /login, /devices,
            sessions,policy}   /sessions, /policy
- index.html                   Trunk entry
- Trunk.toml                   port 8134
- styles/main.css              minimal dark-theme scaffold

Scaffold scope:
- LoginPage validates DID shape ('did:' prefix + minimum length), no
  cryptographic verification yet.  Real resolve_did / MFA challenge
  integration is W1 follow-up.
- DevicesPage and SessionsPage render mock rows so the console has
  shape before xenia-peer-core session registry + xenia-ledger
  integration lands.  Both include TODO headers pointing at the
  relevant plan sections.
- PolicyPage is a stub that lists the planned controls (tier
  thresholds, session TTL, MFA enforcement, per-device capability
  grants, emergency revocation).

Workspace wiring:
- xenia-admin registered in Cargo.toml [workspace.members]
- default-members added to exclude xenia-admin from host `cargo
  check` / `cargo build` default runs (it's wasm32-unknown-unknown
  only).  Other crates' host builds unchanged.

Verification: cargo check -p xenia-admin --target wasm32-unknown-
unknown green in 1m 29s cold (Leptos 0.8 full tree).

Not wired yet (W1 tail-end):
- DID resolution against mycelix-identity via mycelix-bridge-common
- xenia-ledger Verifier invocation from the Sessions page
- Cross-cluster auth context threading
- NIS2 Art. 21 mapping as an in-app compliance helper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-admin): live xenia-ledger demo on Sessions page + README

The NIS2 Art. 21(f) "admin cannot rewrite the audit log" property
rendered as 15 seconds of clickable UX. On mount the Sessions page:

- Generates a fresh Ed25519 key pair via OsRng (browser Web Crypto
  through getrandom/js feature)
- Builds a 5-entry synthetic consent chain (Request -> Approval ->
  Request broader scope -> Approval -> Revocation) using xenia-ledger's
  Chain::append
- Runs xenia_ledger::Verifier::verify_chain against the full entries
  slice + the freshly-generated public key
- Renders a green "Chain verified" badge, the operator's full public
  key in hex, and a per-entry table showing seq / kind / scope /
  truncated entry_hash

Every row has a "Tamper" button that flips that entry's ConsentKind
(e.g. Approval -> Denial) and re-runs the Verifier. Because the
entry_hash and signature cover the original event, verification flips
to the matching VerifyError variant (EntryHashMismatch / BadSignature)
and the badge goes red. Clicking the same button again restores the
kind and the badge goes green. Zero network traffic — every byte is
computed by ed25519-dalek + blake3 in the browser.

Changes:

- crates/xenia-admin/Cargo.toml
    - xenia-ledger = { path = "../xenia-ledger" } (AGPL, fine — admin
      console is AGPL too)
    - ed25519-dalek 2, rand_core 0.6 (with getrandom feature), uuid
      with js feature, getrandom 0.2 with js feature (forces WebCrypto
      backend in the browser)
- crates/xenia-admin/src/pages/sessions.rs
    - Full rewrite from mocked rows to live LedgerDemo component
- crates/xenia-admin/styles/main.css
    - .badge, .verify-row, .ledger-table, .kind-*, .pub-key, .footnote
- crates/xenia-admin/README.md (new)
    - How to trunk serve; 3-min E2E walkthrough; what's worth showing
      a CISO vs what's still scaffold; W1 follow-up checklist
- crates/xenia-admin/.gitignore (new)
    - Exclude trunk's /dist/ build output
- crates/xenia-ledger/src/lib.rs
    - #[derive(Clone)] on VerifyError so it can live in a Leptos
      RwSignal alongside the entries vector. No behavioral change.

Verification:

- cargo check -p xenia-admin --target wasm32-unknown-unknown: green
- trunk build --release: green, 592 KB wasm-opt'd (up from 476 KB
  scaffold; +116 KB is the actual cryptography — ed25519-dalek +
  blake3 + bincode + xenia-ledger + uuid)

Not wired yet (deliberate; see README W1 follow-up checklist):

- Real mycelix-identity::resolve_did on login (currently string-shape
  validation only)
- Session-registry integration (currently synthetic chain only)
- WebAuthn/TOTP MFA step
- Import-persisted-chain flow (paste a JSON, verify it, render badges)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-admin): export + import round-trip for ledger chains

Closes the W1 follow-up "replace SessionsPage synthetic chain with an
import+verify flow."  The live in-browser chain can now be serialized
to portable JSON and re-verified from paste — same code path a third-
party auditor would use offline.

Export:
- New <ExportSection> under the live ledger table.
- Memo derives pretty-printed JSON from the current entries + operator
  public-key hex on every tamper.
- Toggle button reveals a readonly textarea with the self-contained
  attestation (public key + entries in bincode-identical serde-json
  form).

Import:
- New <ChainImporter> section at the bottom of Sessions.
- Textarea + Verify button.
- parse_and_verify() distinguishes three failure modes:
  1. JSON parse error (e.g. user pastes garbage)
  2. public_key_hex not 64 hex chars / invalid key bytes
  3. VerifyError from xenia_ledger::Verifier
- Pass path renders "✓ Verified — N entries; chain integrity confirmed
  against embedded public key PUB_KEY_PREFIX…".
- Hex decoder is inline (10 lines) — no `hex` crate dep.

Round-trip demo on /sessions:
  1. Copy JSON from Show JSON export
  2. Modify a byte in an editor ("Approval" -> "Denial" in any entry)
  3. Paste into Verify a chain from JSON
  4. See the specific VerifyError variant surfaced
  Zero network traffic throughout.

ExportedChain shape (stable):
  { "public_key_hex": "<64 hex chars>", "entries": [LedgerEntry, ...] }

Deps added:
- serde_json 1 (pretty + parse)

Impl notes:
- ImportedSummary derives Clone (RwSignal<Option<Result<_, _>>> needs it).
- ChainImporter uses prop:value/on:input pair for textarea so Clear
  resets the bound signal correctly.

Verification:
- cargo check -p xenia-admin --target wasm32-unknown-unknown: green
- trunk build --release: green, 752 KB wasm-opt'd (up from 592 KB;
  +160 KB is serde_json + the new components)

README updated:
- New step 6 in the E2E walkthrough covers the round-trip demo.
- W1 follow-up checklist marks the import+verify box as shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-admin): wire LoginPage to real resolve_did via mycelix-leptos-client

Closes the W1 Stream A "DID resolution is string-shape-only scaffold"
placeholder. LoginPage now submits the DID to the Mycelix identity
hApp via a real Holochain conductor WebSocket call.

Flow on submit:
  1. Shape-check the DID (starts with "did:", length >= 8)
  2. spawn_local async:
     - BrowserWsTransport connects to ws://localhost:8888 (default;
       XENIA_ADMIN_CONDUCTOR_URL overridable at build time)
     - HolochainClient::call_zome("did_registry", "resolve_did", &did)
     - Deserializes the Option<Record> response into
       Option<serde_json::Value> (opaque — we only need existence,
       avoids pulling holochain_types into wasm)
  3. Branches on three outcomes:
     - Some(_) -> auth.sign_in(did) + navigate to /devices
     - None    -> "DID not found in the identity registry"
     - Err(_)  -> "Zome call failed: {e}" (connect, protocol, or
                  deserialization failure surfaces distinctly)

New LoginStatus enum drives the UI (Idle / InvalidShape / Resolving /
NotFound / Error) — button disables during Resolving, status banner
surfaces the specific failure mode.

Build-time overrides (option_env!, const match):
- XENIA_ADMIN_CONDUCTOR_URL       (default ws://localhost:8888)
- XENIA_ADMIN_APP_ID              (default mycelix-unified)
- XENIA_ADMIN_IDENTITY_ROLE       (default identity)

Same values are surfaced in the footer as readable codespans so
operators can diagnose conductor-mismatch failures without opening
DevTools.

Dep added: mycelix-leptos-client = { version = "0.1",
default-features = false, features = ["browser"] } — our own
freshly-published crates.io crate, pure-Rust WASM Holochain client
using web-sys::WebSocket + rmp-serde.

Verification:
- cargo check -p xenia-admin --target wasm32-unknown-unknown: green
- trunk build --release: green, 1016 KB wasm-opt'd (up from 752 KB;
  +264 KB is the real Holochain client + rmp-serde + additional
  WebSocket/Blob web-sys features)

Runtime verification pending: requires a live conductor at :8888 with
the identity hApp installed (per CLAUDE.md "Installed apps"). Next
session's human check: `cargo run` via trunk serve, paste a
registered DID, confirm the call reaches the conductor and the
response flows back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(xenia-admin): runtime window.__HC_* overrides (match Praxis convention)

Replaces build-time option_env! gates with runtime window-global reads
— matches the pattern used by mycelix-leptos-core::holochain_provider
in the production Praxis frontend.  Lets us ship one WASM bundle and
deploy it against different conductors without a rebuild.

Override surface (all optional, set in index.html <script> or at
runtime before wasm init):

    window.__HC_CONDUCTOR_URL   (default ws://localhost:8888)
    window.__HC_APP_ID          (default mycelix-unified)
    window.__HC_IDENTITY_ROLE   (default identity)
    window.__HC_AUTH_TOKEN      (default none — fails on authenticated conductors)

Compile-time XENIA_ADMIN_* env vars retained as fallback defaults in
case a deployment-specific bundle makes sense, but the window globals
always win.

The footnote under the sign-in form now surfaces which values are in
effect (including "auth: none (will fail against authenticated
conductor)" when the token is missing) so operators can diagnose a
bad deployment without opening DevTools.

index.html gains a commented-out <script> block documenting the four
overrides, matching the shape other Mycelix ecosystem apps use.

Runtime verification this session (native E2E probe, /tmp/e2e-probe):
- Admin WebSocket connect at :33800 OK
- issue_app_auth_token for mycelix-unified OK (64-byte token)
- App WebSocket connect with token OK
- authorize_signing_credentials for identity cell OK
- resolve_did("did:mycelix:<real agent pubkey>") -> Ok(None) [1 byte,
  MessagePack None = 0xc0]
- resolve_did("did:mycelix:bogus") -> zome-side format rejection

The zome call path is architecturally verified end-to-end against
the live shared conductor. The browser flow via mycelix-leptos-client
uses unsigned zome calls (zeroed signature, raw agent_pub_key as
provenance) — works against conductors with Unrestricted capability
grants, which this conductor has.

cargo check -p xenia-admin --target wasm32-unknown-unknown: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Normalize Xenia peer workspace layout and validation gates

* Record Xenia normalization follow-up gates

* Resolve initial Xenia RC1 follow-up gates (#2)

* Resolve initial Xenia RC1 follow-up gates

* Replace runtime unwraps with explicit fallbacks (#3)

* Replace runtime unwraps with explicit fallbacks

* Fix xenia-peer clippy and handshake rustdoc warnings

* Fix viewer clippy and capture rustdoc warnings

* Replace semantic runtime expects with explicit handling (#4)

* Replace fallible runtime expects with explicit errors

* Handle missing admin app context without panics

* Document HKDF expansion invariant without expect

* Remove H264 decoder unsafe scaler cache (#5)

* Record reviewed secure-default warning exceptions (#6)

* Mark normalization layout state in release manifests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… growth

Capturer::build() wired an unbounded std::sync::mpsc::channel() between
the PipeWire callback thread (engine::linux::process_callback, which
copies every frame via to_vec() unconditionally, independent of whether
get_next_frame() is being drained) and the consumer. Any time the
consumer lags PipeWire's delivery rate even briefly, frames pile up here
with zero backpressure.

Confirmed via heaptrack on a real KDE-Wayland capture session: ~1.44G of
1.47G leaked in a 25.76s run traced entirely to this call site. Bounding
the channel to sync_channel(2) applies real backpressure to the PipeWire
iterate thread instead. Re-verified with the same repro run 5x longer
(136.74s): total leaked dropped to ~77.6MB (all attributable to
unrelated debug-backtrace-formatting overhead from a retry loop
elsewhere), and the process_callback leak site is completely gone from
the profile.

Propagates the channel's Sender -> SyncSender type change through
Engine::new and the Linux engine (ListenerUserData, pipewire_capturer,
LinuxCapturer::new, create_capturer). mac/windows engine modules are
cfg-gated out on non-matching targets so this doesn't affect them at
compile time on this branch.
Comment thread src/capturer/engine/linux/mod.rs Outdated
if let Err(e) = match user_data.format.format() {
VideoFormat::RGBx => user_data.tx.send(Frame::RGBx(RGBxFrame {
display_time: timestamp as u64,
VideoFormat::RGBx => user_data.tx.send(Frame::Video(VideoFrame::RGBx(RGBxFrame {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the producer/consumer path is sync_channel(2), send() here can block the PipeWire process callback when the consumer is behind. In a realtime-ish callback, that usually hurts more than dropping a frame.

Consider switching to try_send() and treating Full as a silent drop (and only logging on Disconnected).

Suggested change
VideoFormat::RGBx => user_data.tx.send(Frame::Video(VideoFrame::RGBx(RGBxFrame {
let send_result = match user_data.format.format() {
VideoFormat::RGBx => user_data
.tx
.try_send(Frame::Video(VideoFrame::RGBx(RGBxFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
}))),
VideoFormat::RGB => user_data
.tx
.try_send(Frame::Video(VideoFrame::RGB(RGBFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
}))),
VideoFormat::xBGR => user_data
.tx
.try_send(Frame::Video(VideoFrame::XBGR(XBGRFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
}))),
VideoFormat::BGRx => user_data
.tx
.try_send(Frame::Video(VideoFrame::BGRx(BGRxFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
}))),
_ => panic!("Unsupported frame format received"),
};
match send_result {
Ok(()) => {}
Err(mpsc::TrySendError::Full(_)) => {}
Err(mpsc::TrySendError::Disconnected(_)) => {
eprintln!("frame channel disconnected");
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already fixed in 9ed0211 before I saw this comment — switched to try_send() with Full as a silent drop and only logging on Disconnected (and per your later comment on this same line, that Disconnected log is now also deduped via STREAM_STATE_CHANGED_TO_ERROR.swap() in 2ae046c). Matches your suggestion almost exactly — good instinct.

Comment thread src/capturer/mod.rs Outdated
// independent of how fast `get_next_frame()` is drained downstream.
// An unbounded channel here means any transient slowdown in the
// consumer (encode/network backpressure) grows this queue without
// limit -- confirmed via heaptrack: ~1.44G leaked over a 25s run,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor wording nit: this is unbounded growth / retention rather than a true leak, so the comment reads a bit scary as-written.

Suggested change
// limit -- confirmed via heaptrack: ~1.44G leaked over a 25s run,
// consumer (encode/network backpressure) grows this queue without
// limit -- confirmed via heaptrack: ~1.44GiB retained over a 25s run,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9b30e91 — changed to "~1.44GiB retained", matching your wording exactly.

…ines

The previous commit (bounding the frame channel with sync_channel(2) to
fix an unbounded-memory leak on Linux) changed Capturer::build()'s and
Engine::new()'s shared, non-cfg-gated tx type from mpsc::Sender to
mpsc::SyncSender, but only updated the Linux engine to match. Since
Engine::new() unconditionally forwards that tx into
win::create_capturer(tx: mpsc::Sender<Frame>) and
mac::create_capturer(tx: mpsc::Sender<ChannelItem>) -- both of which
still declared the old Sender type -- this was a confirmed compile
break on Windows and macOS (verified by reading those files directly;
mpsc::Sender and mpsc::SyncSender are distinct, non-coercible types).
This repo's PR CI doesn't build those targets, so it wouldn't have
been caught before merge.

This commit:

- Propagates mpsc::Sender<Frame>/<ChannelItem> -> mpsc::SyncSender in
  win/mod.rs (Capturer, FlagStruct, create_capturer, spawn_audio_stream)
  and mac/mod.rs (CapturerInner, create_capturer), matching the shared
  Engine::new() signature.

- Replaces blocking `.send()` with `.try_send()` on every frame/sample
  emit site across all three engines (Linux, Windows, macOS), dropping
  the frame when the channel is full instead of blocking the capture
  callback thread. This matters most on Linux: process_callback runs on
  the same thread that pipewire_capturer's loop polls CAPTURER_STATE on,
  and LinuxCapturer::stop_capture() joins that thread. A blocking send()
  on a full bounded channel -- e.g. because the consumer paused draining
  get_next_frame() -- would leave that thread permanently parked inside
  send(), so stop_capture() would hang forever waiting for a join that
  can never happen. try_send() + drop-on-full preserves the bounded-
  memory fix's intent without introducing that deadlock. Applied the
  same pattern to Windows' OS capture callback and audio thread, and
  macOS' ScreenCaptureKit delivery callback, for the same reason (an OS-
  driven callback thread blocking indefinitely is not something we want
  to newly introduce on those platforms either).

Verification:
- `cargo check` on Linux (via nix-shell -p pipewire dbus pkg-config
  clang) passes clean: same 16 pre-existing lifetime-elision warnings
  as before this commit, zero new warnings or errors.
- win/mod.rs and mac/mod.rs cannot be compiled or type-checked on this
  machine (no Windows/macOS toolchain, and their cfg-gated `mod`
  declarations mean rustc never even parses them on a Linux host).
  Verified instead by: (1) `rustfmt --check` on both files, which
  requires syntactically valid Rust to run at all -- both pass; (2)
  manually cross-referencing every mpsc type at each call site listed
  above against its declaration. Recommend a maintainer or CI actually
  builds this branch on Windows/macOS before merging.
@Tristan-Stoltz-ERC Tristan-Stoltz-ERC changed the title fix(linux): update PipeWire engine to the two-level Frame enum + SystemTime display_time fix: Linux PipeWire engine two-level Frame enum + bounded frame channel (all platforms) Jul 15, 2026
eprintln!("{e}");
};

if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the receiver is disconnected, this will eprintln! once per frame until the capture thread stops. Since you already have STREAM_STATE_CHANGED_TO_ERROR, you can set it here (to exit the loop) and use swap to log only once.

Suggested change
if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {
if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {
if !STREAM_STATE_CHANGED_TO_ERROR.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!("Frame receiver disconnected");
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — applied exactly as suggested in 2ae046c. Reused STREAM_STATE_CHANGED_TO_ERROR so a disconnected receiver now ends the capture loop instead of spinning forever, and swapped to log the disconnect once.

Addresses a review comment from @tembo on PR CapSoftware#183: previously, if the
frame receiver (Capturer/rx) was dropped without calling
stop_capture() -- e.g. the caller just drops the Capturer -- the
PipeWire callback thread kept running indefinitely, since nothing
observed the disconnect. Every subsequent frame's try_send() would
hit TrySendError::Disconnected and eprintln!, spamming a log line per
frame forever with no way to stop it short of process exit.

Reuses the existing STREAM_STATE_CHANGED_TO_ERROR flag (already
checked by pipewire_capturer's main loop condition) to make a
disconnected receiver actually end the capture loop, and swaps
instead of stores so the disconnect is logged exactly once.
The heaptrack-measured memory was queued/undrained Frame allocations,
not a true leak (it would eventually free once drained) -- "leaked"
overstates it. Matches the reviewer's suggested wording.
Comment thread src/capturer/mod.rs Outdated
// growing memory unboundedly or blocking the platform capture
// thread (which on Linux would otherwise deadlock `stop_capture()`,
// since it joins that same thread).
let (tx, rx) = mpsc::sync_channel(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit: 2 feels a bit magic here. Maybe make it a named const so it's easier to tune later without hunting the callsite.

Suggested change
let (tx, rx) = mpsc::sync_channel(2);
const FRAME_CHANNEL_CAPACITY: usize = 2;
let (tx, rx) = mpsc::sync_channel(FRAME_CHANNEL_CAPACITY);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 95e1e9e — extracted to FRAME_CHANNEL_CAPACITY.

Comment thread src/capturer/engine/linux/mod.rs Outdated
// exit instead of spinning pw_loop.iterate() forever on a
// stream nobody will ever read from, and log it once (swap
// instead of store) rather than once per incoming frame.
if !STREAM_STATE_CHANGED_TO_ERROR.swap(true, std::sync::atomic::Ordering::Relaxed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small readability thing: STREAM_STATE_CHANGED_TO_ERROR is now also used for "receiver disconnected" (not just PipeWire state error). Might be worth renaming to something like STREAM_SHOULD_EXIT (or splitting into two flags) so the intent stays obvious as this grows.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to STREAM_SHOULD_EXIT in 95e1e9e — that's more accurate now anyway since it's a shared 'stop the loop' signal for two distinct causes (stream error, disconnected receiver), not just the error case. Also fixed the now-stale inline comment on the loop condition that only mentioned the error case.

Addresses two more nits from @tembo's review round on 9b30e91:

- src/capturer/mod.rs: extract the magic `2` into
  `FRAME_CHANNEL_CAPACITY`, so tuning it later doesn't mean hunting the
  call site.
- src/capturer/engine/linux/mod.rs: rename `STREAM_STATE_CHANGED_TO_ERROR`
  to `STREAM_SHOULD_EXIT`. It's set both on a genuine PipeWire stream
  error and (since 2ae046c) on a disconnected frame receiver -- the old
  name only described the first case. Also updates the stale inline
  comment on the loop condition that still said "only exits on stream
  error".
Comment thread src/capturer/engine/win/mod.rs Outdated
Comment thread src/capturer/engine/mac/mod.rs Outdated
// try_send, not send: don't block ScreenCaptureKit's delivery queue
// when the consumer is behind — drop the sample buffer instead
// (same rationale as the Linux/Windows engines).
let _ = self.inner_mut().tx.try_send((sample_buf.retained(), kind));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try_send makes sense here. Minor follow-up: if this starts returning TrySendError::Disconnected, you may want to treat that as a signal to stop the sc::Stream (or flip some shared flag) so ScreenCaptureKit isn’t continuously delivering buffers with no consumer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72c78cd -- went with your 'flip some shared flag' alternative since impl_stream_did_output_sample_buf has no return channel to signal a stop the way Windows' on_frame_arrived does (it's an extern C callback returning unit). Added a disconnected: Arc on CapturerInner, set once on the first Disconnected (swap, logged once) and checked at the top of the callback so later frames skip the retain+send work entirely. Didn't attempt to call into sc::Stream's stop path from inside the delivery callback itself -- no way for me to verify that's reentrant-safe without a macOS toolchain, so I kept this to the safer flag-only mitigation you offered as the alternative.

Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
Comment thread src/capturer/engine/win/mod.rs Outdated
Tristan-Stoltz-ERC and others added 2 commits July 16, 2026 11:20
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
…nects

Mirrors the fix @tembo suggested and you already applied on both
Windows call sites (d118c15, 32569f1): once tx.try_send() reports
TrySendError::Disconnected, don't keep repeating the (retain + send)
work on every subsequent ScreenCaptureKit callback with nowhere for
the result to go.

Windows' on_frame_arrived can signal this by returning an Err, which
windows_capture's GraphicsCaptureApiHandler treats as "stop capture".
impl_stream_did_output_sample_buf has no such return channel -- it's
an extern "C" callback returning (). So this uses the flag-based
alternative tembo's mac comment explicitly offered ("stop the
sc::Stream (or flip some shared flag)"): a new `disconnected:
Arc<AtomicBool>` on CapturerInner, set once (swap, logged once) on
first Disconnected, checked at the top of the callback to skip work
on every later frame. Doesn't attempt to call into sc::Stream's own
stop path from inside the delivery callback, since I have no way to
verify that's reentrant-safe on this machine (no macOS toolchain).
Comment on lines 349 to 353
// User has called Capturer::start() and we start the main loop
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* If the stream state got changed to `Error`, we exit. TODO: tell user that we exited */
!STREAM_STATE_CHANGED_TO_ERROR.load(std::sync::atomic::Ordering::Relaxed)
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case with STREAM_SHOULD_EXIT being a static: if the receiver is dropped without stop_capture(), the flag gets set to true and never reset, so a later capture session in the same process can exit immediately.

Suggested change
// User has called Capturer::start() and we start the main loop
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* If the stream state got changed to `Error`, we exit. TODO: tell user that we exited */
!STREAM_STATE_CHANGED_TO_ERROR.load(std::sync::atomic::Ordering::Relaxed)
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{
// User has called Capturer::start() and we start the main loop
STREAM_SHOULD_EXIT.store(false, std::sync::atomic::Ordering::Relaxed);
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant