multi-viewer: broadcast fanout + supervisor lifecycle

One gst capture pipeline now fans out to N concurrent viewers via a
tokio::sync::broadcast<Arc<Vec<u8>>>. The HTTP listener accepts forever;
each accepted connection spawns a sender task draining its own
broadcast::Receiver. Slow consumers see Lagged and skip ahead — MPEG-TS
resyncs at the next keyframe.

Host runtime is now lazy + sticky: a supervisor task owns the capture
handle and viewer count. First viewer triggers capture::spawn; last
viewer triggers shutdown. Subsequent reconnects re-trigger the portal
dialog as expected. --max-viewers (default 2) caps concurrent viewers;
additional connections get a "host is full" refusal and are dropped.

Banner updated to reflect the new lifecycle and viewer cap.

NOT YET RUNTIME-VERIFIED. cargo build is clean and the pipeline-level
smoke test still passes, but the multi-viewer behavior (cap enforcement,
lazy-sticky restart, concurrent fanout) requires manual end-to-end
testing with the portal dialog + multiple mpv instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:11:43 -04:00
parent 74b4101d4f
commit ffe5a90686
3 changed files with 262 additions and 54 deletions
+161 -37
View File
@@ -5,10 +5,25 @@ use anyhow::{Result, bail};
use iroh::Endpoint;
use iroh::endpoint::{Connection, presets};
use iroh_tickets::endpoint::EndpointTicket;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::cli::HostOpts;
use crate::common::{alpn::ALPN, deps, display::DisplayServer, signal};
use crate::common::{alpn::ALPN, deps, display::DisplayServer, signal, tunnel};
use self::capture::CaptureHandle;
/// Messages from per-viewer tasks to the capture supervisor.
enum SupervisorMsg {
/// A new viewer wants in. Supervisor replies with the local capture
/// HTTP port to connect to, or an error string if the host is full or
/// capture spawn failed.
AddViewer(oneshot::Sender<Result<u16, String>>),
/// A viewer's session ended. Supervisor decrements the count and tears
/// down capture if it just hit zero.
RemoveViewer,
}
pub async fn run(opts: HostOpts) -> Result<()> {
let display = DisplayServer::resolve(opts.display_server);
@@ -21,6 +36,10 @@ pub async fn run(opts: HostOpts) -> Result<()> {
);
}
if opts.max_viewers == 0 {
bail!("--max-viewers must be at least 1");
}
let cancel = signal::install_ctrl_c();
let endpoint = Endpoint::builder(presets::N0)
@@ -33,65 +52,168 @@ pub async fn run(opts: HostOpts) -> Result<()> {
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
print_host_banner(&ticket, display, &opts, clipboard_ok);
let result = accept_loop(&endpoint, display, &opts, cancel.clone()).await;
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
let supervisor = tokio::spawn(supervise(opts.clone(), display, sup_rx));
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
drop(sup_tx);
let _ = supervisor.await;
endpoint.close().await;
result
Ok(())
}
async fn accept_loop(
endpoint: &Endpoint,
display: DisplayServer,
opts: &HostOpts,
sup_tx: mpsc::Sender<SupervisorMsg>,
cancel: CancellationToken,
) -> Result<()> {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("cancellation requested before any peer connected");
Ok(())
}
accepted = endpoint.accept() => {
let Some(incoming) = accepted else {
bail!("endpoint stopped accepting connections");
};
let conn = incoming.await?;
let remote = conn.remote_id();
tracing::info!(%remote, "peer connected");
eprintln!("\n[pixelpass] peer connected: {remote}\n");
handle_peer(conn, display, opts, cancel).await
) {
loop {
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("cancellation requested — closing accept loop");
return;
}
accepted = endpoint.accept() => {
let Some(incoming) = accepted else {
tracing::info!("endpoint stopped accepting connections");
return;
};
let conn = match incoming.await {
Ok(c) => c,
Err(e) => {
tracing::warn!("incoming connection failed: {e:#}");
continue;
}
};
let sup_tx = sup_tx.clone();
let cancel = cancel.clone();
tokio::spawn(handle_peer(conn, sup_tx, cancel));
}
}
}
}
async fn handle_peer(
conn: Connection,
display: DisplayServer,
opts: &HostOpts,
sup_tx: mpsc::Sender<SupervisorMsg>,
cancel: CancellationToken,
) -> Result<()> {
let (quic_send, quic_recv) = conn.accept_bi().await?;
) {
let remote = conn.remote_id();
let capture_handle = capture::spawn(display, opts).await?;
let port = capture_handle.local_port();
let tcp = wayland::connect_to_capture(port, std::time::Duration::from_secs(5)).await?;
let (reply_tx, reply_rx) = oneshot::channel();
if sup_tx.send(SupervisorMsg::AddViewer(reply_tx)).await.is_err() {
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
return;
}
let port = match reply_rx.await {
Ok(Ok(p)) => p,
Ok(Err(reason)) => {
tracing::warn!(%remote, %reason, "refusing viewer");
eprintln!("[pixelpass] refusing viewer {remote}: {reason}");
return;
}
Err(_) => {
tracing::warn!(%remote, "supervisor reply dropped; dropping peer");
return;
}
};
let bridge = crate::common::tunnel::bridge(quic_send, quic_recv, tcp);
let (quic_send, quic_recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(%remote, "accept_bi failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
return;
}
};
eprintln!("[pixelpass] viewer connected: {remote}");
let tcp = match wayland::connect_to_capture(port, Duration::from_secs(5)).await {
Ok(t) => t,
Err(e) => {
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
return;
}
};
let bridge = tunnel::bridge(quic_send, quic_recv, tcp);
tokio::select! {
res = bridge => {
if let Err(e) = res {
tracing::warn!("bridge ended with error: {e:#}");
tracing::warn!(%remote, "bridge ended with error: {e:#}");
} else {
tracing::info!("bridge closed cleanly");
tracing::info!(%remote, "bridge closed cleanly");
}
}
_ = cancel.cancelled() => {
tracing::info!("cancellation requested during stream");
tracing::info!(%remote, "cancellation during stream");
}
}
capture_handle.shutdown().await;
Ok(())
eprintln!("[pixelpass] viewer disconnected: {remote}");
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
}
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
/// capture lazily on the first AddViewer; tears it down when the count drops
/// back to zero. Enforces the --max-viewers cap by refusing AddViewer when
/// the count is already at the cap.
async fn supervise(
opts: HostOpts,
display: DisplayServer,
mut rx: mpsc::Receiver<SupervisorMsg>,
) {
let mut handle: Option<CaptureHandle> = None;
let mut count: u32 = 0;
while let Some(msg) = rx.recv().await {
match msg {
SupervisorMsg::AddViewer(reply) => {
if count >= opts.max_viewers {
let _ = reply.send(Err(format!(
"host is full ({} of {} viewers connected)",
count, opts.max_viewers
)));
continue;
}
if handle.is_none() {
tracing::info!("first viewer arriving — spawning capture");
match capture::spawn(display, &opts).await {
Ok(h) => handle = Some(h),
Err(e) => {
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
continue;
}
}
}
let port = handle.as_ref().expect("handle was just set").local_port();
count += 1;
let _ = reply.send(Ok(port));
tracing::info!(active = count, cap = opts.max_viewers, "viewer joined");
}
SupervisorMsg::RemoveViewer => {
count = count.saturating_sub(1);
tracing::info!(active = count, cap = opts.max_viewers, "viewer left");
if count == 0
&& let Some(h) = handle.take()
{
tracing::info!("last viewer left — tearing down capture");
h.shutdown().await;
}
}
}
}
if let Some(h) = handle.take() {
tracing::info!("host shutdown — tearing down capture");
h.shutdown().await;
}
}
fn print_host_banner(
@@ -106,19 +228,21 @@ fn print_host_banner(
eprintln!("│ capture : {}", capture_summary(opts));
eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" });
eprintln!("│ max viewers : {}", opts.max_viewers);
eprintln!("");
if clipboard_ok {
eprintln!("│ Your share code has been copied to your clipboard.");
eprintln!("│ Send it to your viewer. (If clipboard didn't work, the");
eprintln!("│ Send it to your viewer(s). (If clipboard didn't work, the");
eprintln!("│ code is also shown below for manual copy.)");
} else {
eprintln!("│ Share this ticket with your viewer:");
eprintln!("│ Share this ticket with your viewer(s):");
}
eprintln!("");
eprintln!("│ pixelpass {ticket}");
eprintln!("");
eprintln!("│ Capture will not start until the viewer connects.");
eprintln!("Press Ctrl+C to stop.");
eprintln!("│ Capture starts when the first viewer connects, runs while");
eprintln!("any viewer is connected, and tears down when the last one");
eprintln!("│ leaves. Press Ctrl+C to stop the host entirely.");
eprintln!("└────────────────────────────────────────────────────────────");
eprintln!();
}