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
+94 -17
View File
@@ -1,7 +1,10 @@
//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → gst-launch
//! pipewiresrc → MPEG-TS on gst stdout → in-process HTTP server bound on a
//! random localhost port. The host bridge TCP-connects to that server and
//! pumps bytes to QUIC.
//! random localhost port. One gst child feeds a tokio::sync::broadcast channel;
//! the HTTP listener accepts multiple connections and each one drains its own
//! fresh broadcast::Receiver — so a single capture pipeline fans out to N
//! concurrent viewers. Slow consumers see Lagged and skip ahead; the MPEG-TS
//! stream resyncs at the next keyframe.
use anyhow::{Context, Result, bail};
use ashpd::{
@@ -16,18 +19,30 @@ use nix::sys::signal::{Signal, kill};
use nix::unistd::{Pid, close};
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::{Child, ChildStdout, Command};
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tokio::time::{Instant, sleep, timeout};
use crate::cli::HostOpts;
/// Broadcast-channel capacity in chunks. Each chunk is up to 64 KiB from gst
/// stdout, so 16 chunks ≈ 1 MiB ≈ ~2 s of buffered jitter at the default
/// 4 Mbps bitrate. A viewer that falls behind by more than this gets Lagged
/// and skips ahead — MPEG-TS resyncs at the next keyframe.
const FANOUT_CAPACITY: usize = 16;
/// Size of each chunk read from gst stdout.
const READ_CHUNK: usize = 64 * 1024;
pub struct CaptureHandle {
port: u16,
gst: Option<Child>,
reader: Option<JoinHandle<()>>,
server: Option<JoinHandle<()>>,
}
@@ -37,8 +52,8 @@ impl CaptureHandle {
}
/// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL, then
/// abort the HTTP server task. Call this before dropping; Drop only fires
/// the kill backstop.
/// abort the reader + accept-loop tasks. Call this before dropping; Drop
/// only fires the kill backstop.
pub async fn shutdown(mut self) {
if let Some(child) = self.gst.as_mut()
&& let Some(pid) = child.id()
@@ -49,6 +64,9 @@ impl CaptureHandle {
let _ = timeout(Duration::from_millis(1000), child.wait()).await;
let _ = child.start_kill();
}
if let Some(task) = self.reader.take() {
task.abort();
}
if let Some(task) = self.server.take() {
task.abort();
}
@@ -60,6 +78,9 @@ impl Drop for CaptureHandle {
if let Some(child) = self.gst.as_mut() {
let _ = child.start_kill();
}
if let Some(task) = self.reader.as_ref() {
task.abort();
}
if let Some(task) = self.server.as_ref() {
task.abort();
}
@@ -199,28 +220,68 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
.take()
.context("gst-launch-1.0 stdout pipe unavailable")?;
// 4. Spawn the HTTP server task. It owns the listener + gst stdout: it
// accepts one client (the host's bridge socket via connect_to_capture),
// drains the HTTP request, writes a fixed MPEG-TS response, then
// copies gst stdout to the socket forever.
let server = tokio::spawn(serve_capture(listener, gst_stdout));
// 4. Set up the broadcast fanout. The reader task pumps gst stdout chunks
// into the channel; the accept-loop task spawns one sender task per
// accepted TCP connection, each draining a fresh broadcast::Receiver.
let (tx, _) = broadcast::channel::<Arc<Vec<u8>>>(FANOUT_CAPACITY);
let reader = tokio::spawn(pump_gst_to_broadcast(gst_stdout, tx.clone()));
let server = tokio::spawn(run_accept_loop(listener, tx));
Ok(CaptureHandle {
port,
gst: Some(gst),
reader: Some(reader),
server: Some(server),
})
}
async fn serve_capture(listener: TcpListener, mut gst_stdout: ChildStdout) {
let mut sock = match listener.accept().await {
Ok((s, _)) => s,
Err(e) => {
tracing::warn!("capture HTTP accept failed: {e}");
return;
/// Reads gst's stdout in chunks and broadcasts each to all current subscribers.
/// `broadcast::send` returns Err when there are no receivers; we ignore it and
/// keep reading so gst doesn't backpressure waiting for a viewer.
async fn pump_gst_to_broadcast(
mut gst_stdout: ChildStdout,
tx: broadcast::Sender<Arc<Vec<u8>>>,
) {
let mut buf = vec![0u8; READ_CHUNK];
loop {
match gst_stdout.read(&mut buf).await {
Ok(0) => {
tracing::info!("gst stdout EOF — fanout reader exiting");
return;
}
Ok(n) => {
let chunk = Arc::new(buf[..n].to_vec());
let _ = tx.send(chunk);
}
Err(e) => {
tracing::warn!("gst stdout read error: {e}");
return;
}
}
};
}
}
/// Accepts TCP connections on the local capture port forever. Each accepted
/// connection becomes its own viewer-serving task with a private receiver.
async fn run_accept_loop(listener: TcpListener, tx: broadcast::Sender<Arc<Vec<u8>>>) {
loop {
let sock = match listener.accept().await {
Ok((s, _)) => s,
Err(e) => {
tracing::warn!("capture HTTP accept failed: {e}");
return;
}
};
let rx = tx.subscribe();
tokio::spawn(serve_one_viewer(sock, rx));
}
}
/// Drains the HTTP request, writes a fixed 200 OK, then pumps broadcast
/// chunks to the socket until the channel closes or the socket errors out.
/// On Lagged (slow consumer), skip ahead — MPEG-TS recovers at next keyframe.
async fn serve_one_viewer(mut sock: TcpStream, mut rx: broadcast::Receiver<Arc<Vec<u8>>>) {
if !drain_http_request(&mut sock).await {
return;
}
@@ -234,7 +295,23 @@ async fn serve_capture(listener: TcpListener, mut gst_stdout: ChildStdout) {
return;
}
let _ = tokio::io::copy(&mut gst_stdout, &mut sock).await;
loop {
match rx.recv().await {
Ok(chunk) => {
if sock.write_all(&chunk).await.is_err() {
return;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"viewer fanout lagged — MPEG-TS will resync at next keyframe"
);
continue;
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
async fn drain_http_request(sock: &mut TcpStream) -> bool {