feat: M2 teardown — persistent capturers for clean reconnects

Disconnect/reconnect now works reliably. Previously each stream spawned its own
portal+PipeWire (and PipeWire audio) capture threads and never stopped them, so a
reconnect opened a SECOND screencast session that conflicted with the leaked
first one ("no PipeWire frame within 10s" → black screen on reconnect).

- The screen capturer and audio capturer are now persistent, held in AppState and
  reused across streams (created on the first stream). One screencast session for
  the host's lifetime → no conflict, and instant reconnect (no re-handshake).
  Verified live: 3 stream cycles, 1 create + 2 "reusing capturer", clean every time.
- Capturer::set_active gates the (5K, ~1.3 GB/s) de-pad copy to active streams, so
  the persistent video capturer is nearly free while idle between streams.
- AudioCapturer::drain discards buffered chunks on reuse so the client never hears
  stale audio captured while idle.
- stream.rs / gamestream/audio.rs split into a borrow-the-capturer wrapper + the
  encode/send body, so the capturer is always returned to its slot on exit.

This holds whether the client reconnects via /resume (Moonlight's "running →
play/continue") or a fresh /launch — both re-run RTSP PLAY → a new stream cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:35:10 +00:00
parent af4360c930
commit 6de09fd822
8 changed files with 148 additions and 22 deletions
+33 -4
View File
@@ -18,13 +18,19 @@
use super::{CapturedFrame, Capturer, PixelFormat};
use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Live monitor capturer backed by the portal + PipeWire threads.
/// Live monitor capturer backed by the portal + PipeWire threads. Kept alive (reused) across
/// streams — [`set_active`](Capturer::set_active) gates the per-frame de-pad copy so it costs
/// almost nothing between streams while the screencast session stays up (instant reconnect,
/// and no second session to conflict with).
pub struct PortalCapturer {
frames: Receiver<CapturedFrame>,
active: Arc<AtomicBool>,
}
impl PortalCapturer {
@@ -48,16 +54,21 @@ impl PortalCapturer {
// Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
let active = Arc::new(AtomicBool::new(false));
let active_cb = active.clone();
thread::Builder::new()
.name("lumen-pipewire".into())
.spawn(move || {
if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx) {
if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb) {
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
}
})
.context("spawn pipewire thread")?;
Ok(PortalCapturer { frames: frame_rx })
Ok(PortalCapturer {
frames: frame_rx,
active,
})
}
}
@@ -86,6 +97,10 @@ impl Capturer for PortalCapturer {
}
Ok(latest)
}
fn set_active(&self, active: bool) {
self.active.store(active, Ordering::Relaxed);
}
}
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
@@ -180,7 +195,9 @@ mod pipewire {
use pipewire as pw;
use pw::{properties::properties, spa};
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use spa::param::video::{VideoFormat, VideoInfoRaw};
@@ -205,9 +222,16 @@ mod pipewire {
/// Negotiated layout (`None` until param_changed, or if unsupported).
format: Option<PixelFormat>,
tx: SyncSender<CapturedFrame>,
/// When false (no active stream), skip the de-pad copy — the buffer is just released.
active: Arc<AtomicBool>,
}
pub fn pipewire_thread(fd: OwnedFd, node_id: u32, tx: SyncSender<CapturedFrame>) -> Result<()> {
pub fn pipewire_thread(
fd: OwnedFd,
node_id: u32,
tx: SyncSender<CapturedFrame>,
active: Arc<AtomicBool>,
) -> Result<()> {
crate::pwinit::ensure_init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
@@ -220,6 +244,7 @@ mod pipewire {
info: VideoInfoRaw::default(),
format: None,
tx,
active,
};
let stream = pw::stream::StreamBox::new(
@@ -278,6 +303,10 @@ mod pipewire {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.active.load(Ordering::Relaxed) {
return;
}
let datas = buffer.datas_mut();
if datas.is_empty() {
return;