Fix Cast teardown leaks, keyframe latch, and DLNA session lifecycle
Dropped frames never requested a keyframe, SessionEnded skipped ordered stop (portal/TV/FFI leak, next start could abort), and a late end could kill the following cast. Failed starts left PlatformClientPosix alive. DLNA leaked its HTTP server and ignored portal EOS. Also: start no longer blocks the daemon actor, IPC accept/request loops stay up, HLS Range is clamped, LAN IP follows the renderer subnet, and the picker closes before the portal dialog and handles Escape.
This commit is contained in:
parent
a80c49593d
commit
17abeed7ae
26 changed files with 861 additions and 222 deletions
|
|
@ -83,7 +83,11 @@ impl CastMirrorSession {
|
|||
/// receiver dropping the connection) back to the daemon actor, so it
|
||||
/// can transition back to `Idle` and notify GUI clients even if nobody
|
||||
/// called `stop()`.
|
||||
pub async fn start(device: CastDevice, daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>) -> Result<Self> {
|
||||
pub async fn start(
|
||||
device: CastDevice,
|
||||
daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>,
|
||||
generation: u64,
|
||||
) -> Result<Self> {
|
||||
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
||||
let video_node_id = capture.video_node_id();
|
||||
|
||||
|
|
@ -94,13 +98,18 @@ impl CastMirrorSession {
|
|||
// OFFER below rather than re-derived there, so the advertised stream
|
||||
// and the encoded stream cannot drift apart.
|
||||
let (pipeline, appsink, encoder, video_params) =
|
||||
build_video_pipeline_for_streaming(video_node_id).context("failed to build the encode pipeline")?;
|
||||
match build_video_pipeline_for_streaming(video_node_id) {
|
||||
Ok(built) => built,
|
||||
Err(e) => {
|
||||
close_capture_bounded(capture).await;
|
||||
return Err(e).context("failed to build the encode pipeline");
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
std::thread::spawn(move || {
|
||||
match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(3600))
|
||||
{
|
||||
match breadcast_core::pipeline::run_until_eos_or_error(&pipeline_watch) {
|
||||
Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"),
|
||||
Err(e) => tracing::error!(error = ?e, "encode pipeline error"),
|
||||
}
|
||||
|
|
@ -111,19 +120,37 @@ impl CastMirrorSession {
|
|||
// (milliseconds on a LAN) but still blocking I/O -- run it off the
|
||||
// async worker thread pool rather than stalling it, even briefly.
|
||||
let device_for_connect = device.clone();
|
||||
let (session, _media_events, raw_messages) = tokio::task::spawn_blocking(move || {
|
||||
let connect = tokio::task::spawn_blocking(move || {
|
||||
CastSession::connect_app(
|
||||
&device_for_connect,
|
||||
CastDeviceApp::Custom(breadcast_core::caststream::MIRRORING_APP_ID.to_string()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.context("connect_app task panicked")?
|
||||
.context("failed to connect and launch the Mirroring receiver")?;
|
||||
.await;
|
||||
let (session, _media_events, raw_messages) = match connect {
|
||||
Ok(Ok(connected)) => connected,
|
||||
Ok(Err(e)) => {
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
close_capture_bounded(capture).await;
|
||||
return Err(e).context("failed to connect and launch the Mirroring receiver");
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
close_capture_bounded(capture).await;
|
||||
return Err(e).context("connect_app task panicked");
|
||||
}
|
||||
};
|
||||
|
||||
let (sender, stream_events) =
|
||||
CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params)
|
||||
.context("failed to start the Cast Streaming session")?;
|
||||
match CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params) {
|
||||
Ok(started) => started,
|
||||
Err(e) => {
|
||||
stop_session_bounded(&session).await;
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
close_capture_bounded(capture).await;
|
||||
return Err(e).context("failed to start the Cast Streaming session");
|
||||
}
|
||||
};
|
||||
let sender = Arc::new(sender);
|
||||
|
||||
let message_pump = {
|
||||
|
|
@ -138,9 +165,12 @@ impl CastMirrorSession {
|
|||
};
|
||||
|
||||
let negotiated = Arc::new(AtomicBool::new(false));
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let event_pump = {
|
||||
let session = session.clone();
|
||||
let negotiated = negotiated.clone();
|
||||
let failed = failed.clone();
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(event) = stream_events.recv() {
|
||||
match event {
|
||||
|
|
@ -150,37 +180,68 @@ impl CastMirrorSession {
|
|||
}
|
||||
}
|
||||
CastStreamEvent::Negotiated => negotiated.store(true, Ordering::Release),
|
||||
CastStreamEvent::Error(message) => tracing::warn!(%message, "Cast Streaming error"),
|
||||
CastStreamEvent::Error(message) => {
|
||||
tracing::warn!(%message, "Cast Streaming error");
|
||||
failed.store(true, Ordering::Release);
|
||||
// After negotiation the frame pump is running
|
||||
// and this is an unprompted death; before
|
||||
// negotiation, start() itself observes `failed`
|
||||
// and tears down.
|
||||
if negotiated.load(Ordering::Acquire) {
|
||||
let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation });
|
||||
}
|
||||
}
|
||||
CastStreamEvent::PictureLost => tracing::debug!("receiver reported picture loss"),
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// From here every error path must use the same join/drop order as
|
||||
// `stop()`. Building the session now and calling `stop()` on it is
|
||||
// what keeps a leaked `CastStreamSender` from leaving
|
||||
// PlatformClientPosix alive -- the next start would then hit
|
||||
// OSP_CHECK(!instance_) and abort the daemon.
|
||||
let mut started = Self {
|
||||
pipeline,
|
||||
session,
|
||||
capture: Some(capture),
|
||||
sender: Some(sender),
|
||||
message_pump: Some(message_pump),
|
||||
event_pump: Some(event_pump),
|
||||
frame_pump: None,
|
||||
};
|
||||
|
||||
tracing::info!(device = %device.name, "sending Cast Streaming OFFER");
|
||||
sender.negotiate();
|
||||
if let Some(sender) = started.sender.as_ref() {
|
||||
sender.negotiate();
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
|
||||
while !negotiated.load(Ordering::Acquire) && tokio::time::Instant::now() < deadline {
|
||||
while !negotiated.load(Ordering::Acquire)
|
||||
&& !failed.load(Ordering::Acquire)
|
||||
&& tokio::time::Instant::now() < deadline
|
||||
{
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
if failed.load(Ordering::Acquire) {
|
||||
started.stop().await;
|
||||
anyhow::bail!("Cast Streaming session error from {} during negotiation", device.name);
|
||||
}
|
||||
if !negotiated.load(Ordering::Acquire) {
|
||||
// Bounded for the same reason `Self::stop`'s calls are -- an
|
||||
// unresponsive receiver (which is exactly what "negotiation
|
||||
// timed out" implies) can wedge either of these forever
|
||||
// otherwise, taking the whole single-threaded daemon actor
|
||||
// down with it before this even gets to return an error.
|
||||
stop_session_bounded(&session).await;
|
||||
close_capture_bounded(capture).await;
|
||||
started.stop().await;
|
||||
anyhow::bail!("never received an ANSWER from {} (negotiation timed out)", device.name);
|
||||
}
|
||||
|
||||
pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?;
|
||||
if let Err(e) = started.pipeline.set_state(gst::State::Playing) {
|
||||
started.stop().await;
|
||||
return Err(e).context("failed to start the encode pipeline");
|
||||
}
|
||||
tracing::info!(device = %device.name, "mirroring started");
|
||||
|
||||
let frame_pump = {
|
||||
let device_name = device.name.clone();
|
||||
let sender = sender.clone();
|
||||
let sender = started.sender.as_ref().expect("sender installed above").clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = frame_pump_loop(&appsink, &encoder, &sender);
|
||||
if let Err(e) = result {
|
||||
|
|
@ -190,19 +251,12 @@ impl CastMirrorSession {
|
|||
// was, very recently) still alive. If the channel is full or
|
||||
// closed, there's nothing more useful to do from this
|
||||
// thread than drop the notification.
|
||||
let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded);
|
||||
let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation });
|
||||
})
|
||||
};
|
||||
started.frame_pump = Some(frame_pump);
|
||||
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
session,
|
||||
capture: Some(capture),
|
||||
sender: Some(sender),
|
||||
message_pump: Some(message_pump),
|
||||
event_pump: Some(event_pump),
|
||||
frame_pump: Some(frame_pump),
|
||||
})
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
/// Tears down the session. Order matters and is not interchangeable:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,29 @@ pub enum DaemonCommand {
|
|||
/// dropping the connection or stopping playback) -- as opposed to
|
||||
/// `StopCast` being called. Either way the daemon needs to forget the
|
||||
/// (now-dead) session and go back to `Idle`.
|
||||
SessionEnded,
|
||||
SessionEnded { generation: u64 },
|
||||
/// Result of a `StartCast` that ran off this actor (the portal picker
|
||||
/// and OFFER/ANSWER wait must not stall `list_devices` / `stop_cast`).
|
||||
/// Ignored when `generation` no longer matches -- that means `StopCast`
|
||||
/// cancelled the in-flight start.
|
||||
StartFinished {
|
||||
generation: u64,
|
||||
outcome: Result<StartedSession, StartFailed>,
|
||||
reply: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A session that finished starting, ready to be installed as `active_session`.
|
||||
pub(crate) struct StartedSession {
|
||||
pub session: ActiveSession,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
|
||||
pub(crate) struct StartFailed {
|
||||
pub device_id: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadClient) -> mpsc::Sender<DaemonCommand> {
|
||||
|
|
@ -45,6 +67,8 @@ pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadCli
|
|||
dlna_devices: HashMap::new(),
|
||||
state: StateInfo::Idle,
|
||||
active_session: None,
|
||||
starting: false,
|
||||
start_generation: 0,
|
||||
events_tx,
|
||||
bread_client,
|
||||
self_tx,
|
||||
|
|
@ -59,7 +83,7 @@ pub fn spawn(events_tx: broadcast::Sender<ServerMessage>, bread_client: BreadCli
|
|||
/// The currently active mirroring session, if any -- exactly one of the two
|
||||
/// protocol-specific session types, chosen by which device map `StartCast`
|
||||
/// found the requested device id in.
|
||||
enum ActiveSession {
|
||||
pub(crate) enum ActiveSession {
|
||||
Cast(CastMirrorSession),
|
||||
Dlna(Box<DlnaMirrorSession>),
|
||||
}
|
||||
|
|
@ -86,6 +110,13 @@ struct Daemon {
|
|||
dlna_devices: HashMap<String, DlnaDevice>,
|
||||
state: StateInfo,
|
||||
active_session: Option<ActiveSession>,
|
||||
/// True while a `StartCast` is running off this actor (portal picker /
|
||||
/// negotiation). Distinct from `active_session` so a second start is
|
||||
/// rejected before the first one has a session to install.
|
||||
starting: bool,
|
||||
/// Bumped by `StopCast` so a `StartFinished` from a cancelled start
|
||||
/// tears its session down instead of installing it.
|
||||
start_generation: u64,
|
||||
events_tx: broadcast::Sender<ServerMessage>,
|
||||
/// Used to publish `bread.cast.mirroring_started`/`.stopped`/`.failed`
|
||||
/// on state transitions -- see `bread_events.rs`. A no-op if breadd
|
||||
|
|
@ -111,6 +142,12 @@ impl Daemon {
|
|||
self.start_cast(device_id, reply).await;
|
||||
}
|
||||
DaemonCommand::StopCast { reply } => {
|
||||
if self.starting {
|
||||
// Invalidate the in-flight start so its StartFinished
|
||||
// tears the session down instead of installing it.
|
||||
self.starting = false;
|
||||
self.start_generation = self.start_generation.wrapping_add(1);
|
||||
}
|
||||
if let Some(session) = self.active_session.take() {
|
||||
session.stop().await;
|
||||
bread_events::emit_mirroring_stopped(&self.bread_client);
|
||||
|
|
@ -119,20 +156,32 @@ impl Daemon {
|
|||
self.broadcast_state();
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
DaemonCommand::SessionEnded => {
|
||||
// The session already tore itself down (that's what
|
||||
// triggered this) -- just drop our handle to it and update
|
||||
// state. Ignored if this arrives after an explicit
|
||||
// `StopCast` already cleared `active_session` (the
|
||||
// background pump/poll it came from may briefly outlive
|
||||
// that call).
|
||||
if self.active_session.take().is_some() {
|
||||
DaemonCommand::SessionEnded { generation } => {
|
||||
// Ignore a late notification from a session that already
|
||||
// stopped (or whose start was cancelled). An abandoned
|
||||
// pump after a 5s join timeout used to take() the *next*
|
||||
// session and flip the UI to Idle while it was still live.
|
||||
if generation != self.start_generation {
|
||||
return;
|
||||
}
|
||||
// The session's pump/poll noticed death, but the session
|
||||
// handle itself has *not* been torn down -- there is no
|
||||
// Drop impl. Dropping it here used to skip CastSession::stop
|
||||
// (TV left on the last frame), skip portal close (PipeWire
|
||||
// leak), and destroy the FFI sender while pump threads were
|
||||
// still calling into it. Always run the ordered stop.
|
||||
if let Some(session) = self.active_session.take() {
|
||||
tracing::info!("mirror session ended on its own, returning to idle");
|
||||
session.stop().await;
|
||||
self.start_generation = self.start_generation.wrapping_add(1);
|
||||
self.state = StateInfo::Idle;
|
||||
self.broadcast_state();
|
||||
bread_events::emit_mirroring_stopped(&self.bread_client);
|
||||
}
|
||||
}
|
||||
DaemonCommand::StartFinished { generation, outcome, reply } => {
|
||||
self.on_start_finished(generation, outcome, reply).await;
|
||||
}
|
||||
DaemonCommand::CastDeviceFound(device) => {
|
||||
// mDNS resolves one physical device on every local address
|
||||
// it has -- typically a private IPv4 and a link-local IPv6
|
||||
|
|
@ -147,8 +196,8 @@ impl Daemon {
|
|||
);
|
||||
if should_replace {
|
||||
self.cast_devices.insert(device.id.clone(), device);
|
||||
self.broadcast_devices();
|
||||
}
|
||||
self.broadcast_devices();
|
||||
}
|
||||
DaemonCommand::CastDeviceLost(id) => {
|
||||
self.cast_devices.remove(&id);
|
||||
|
|
@ -166,54 +215,100 @@ impl Daemon {
|
|||
}
|
||||
|
||||
async fn start_cast(&mut self, device_id: String, reply: oneshot::Sender<Result<(), String>>) {
|
||||
if self.active_session.is_some() {
|
||||
if self.active_session.is_some() || self.starting {
|
||||
let _ = reply.send(Err("already casting -- stop the current session first".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(device) = self.cast_devices.get(&device_id).cloned() {
|
||||
match CastMirrorSession::start(device.clone(), self.self_tx.clone()).await {
|
||||
Ok(session) => {
|
||||
self.active_session = Some(ActiveSession::Cast(session));
|
||||
self.state =
|
||||
StateInfo::Casting { device_id: device.id.clone(), device_name: device.name.clone(), protocol: Protocol::Cast };
|
||||
self.broadcast_state();
|
||||
bread_events::emit_mirroring_started(&self.bread_client, &device.id, &device.name, "cast");
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.starting = true;
|
||||
let generation = self.start_generation;
|
||||
let daemon_tx = self.self_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = match CastMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await {
|
||||
Ok(session) => Ok(StartedSession {
|
||||
session: ActiveSession::Cast(session),
|
||||
device_id: device.id,
|
||||
device_name: device.name,
|
||||
protocol: Protocol::Cast,
|
||||
}),
|
||||
// `{e:#}` (not `{e}`/`to_string()`) so the full anyhow
|
||||
// context chain reaches the caller/GUI instead of just
|
||||
// the outermost ".context()" message.
|
||||
bread_events::emit_mirroring_failed(&self.bread_client, &device.id, &format!("{e:#}"));
|
||||
let _ = reply.send(Err(format!("{e:#}")));
|
||||
}
|
||||
}
|
||||
Err(e) => Err(StartFailed { device_id: device.id, error: format!("{e:#}") }),
|
||||
};
|
||||
let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(device) = self.dlna_devices.get(&device_id).cloned() {
|
||||
match DlnaMirrorSession::start(device.clone(), self.self_tx.clone()).await {
|
||||
Ok(session) => {
|
||||
self.active_session = Some(ActiveSession::Dlna(Box::new(session)));
|
||||
self.state = StateInfo::Casting {
|
||||
device_id: device.url.clone(),
|
||||
device_name: device.friendly_name.clone(),
|
||||
self.starting = true;
|
||||
let generation = self.start_generation;
|
||||
let daemon_tx = self.self_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = match DlnaMirrorSession::start(device.clone(), daemon_tx.clone(), generation).await {
|
||||
Ok(session) => Ok(StartedSession {
|
||||
session: ActiveSession::Dlna(Box::new(session)),
|
||||
device_id: device.url,
|
||||
device_name: device.friendly_name,
|
||||
protocol: Protocol::Dlna,
|
||||
};
|
||||
self.broadcast_state();
|
||||
bread_events::emit_mirroring_started(&self.bread_client, &device.url, &device.friendly_name, "dlna");
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
bread_events::emit_mirroring_failed(&self.bread_client, &device.url, &format!("{e:#}"));
|
||||
let _ = reply.send(Err(format!("{e:#}")));
|
||||
}
|
||||
}
|
||||
}),
|
||||
Err(e) => Err(StartFailed { device_id: device.url, error: format!("{e:#}") }),
|
||||
};
|
||||
let _ = daemon_tx.send(DaemonCommand::StartFinished { generation, outcome, reply }).await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = reply.send(Err(format!("unknown device id \"{device_id}\"")));
|
||||
let error = format!("unknown device id \"{device_id}\"");
|
||||
bread_events::emit_mirroring_failed(&self.bread_client, &device_id, &error);
|
||||
let _ = reply.send(Err(error));
|
||||
}
|
||||
|
||||
async fn on_start_finished(
|
||||
&mut self,
|
||||
generation: u64,
|
||||
outcome: Result<StartedSession, StartFailed>,
|
||||
reply: oneshot::Sender<Result<(), String>>,
|
||||
) {
|
||||
if generation != self.start_generation || !self.starting {
|
||||
// StopCast cancelled this start while the portal/negotiation
|
||||
// was still running. Tear the session down if it succeeded
|
||||
// anyway, so we don't leak a sender or leave the TV casting.
|
||||
if let Ok(started) = outcome {
|
||||
started.session.stop().await;
|
||||
}
|
||||
let _ = reply.send(Err("start cancelled".to_string()));
|
||||
return;
|
||||
}
|
||||
self.starting = false;
|
||||
match outcome {
|
||||
Ok(started) => {
|
||||
self.active_session = Some(started.session);
|
||||
self.state = StateInfo::Casting {
|
||||
device_id: started.device_id.clone(),
|
||||
device_name: started.device_name.clone(),
|
||||
protocol: started.protocol,
|
||||
};
|
||||
self.broadcast_state();
|
||||
let protocol = match started.protocol {
|
||||
Protocol::Cast => "cast",
|
||||
Protocol::Dlna => "dlna",
|
||||
};
|
||||
bread_events::emit_mirroring_started(
|
||||
&self.bread_client,
|
||||
&started.device_id,
|
||||
&started.device_name,
|
||||
protocol,
|
||||
);
|
||||
let _ = reply.send(Ok(()));
|
||||
}
|
||||
Err(failed) => {
|
||||
bread_events::emit_mirroring_failed(&self.bread_client, &failed.device_id, &failed.error);
|
||||
let _ = reply.send(Err(failed.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn device_list(&self) -> Vec<DeviceInfo> {
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use breadcast_core::http_server::HttpServer;
|
||||
use breadcast_core::net::local_lan_ip;
|
||||
use breadcast_core::pipeline::{build_video_pipeline, hls_output_dir, wait_for_playlist_segments};
|
||||
use breadcast_core::pipeline::{
|
||||
build_video_pipeline, hls_output_dir, run_until_eos_or_error, wait_for_playlist_segments,
|
||||
};
|
||||
use breadcast_core::{CaptureSession, DlnaDevice, DlnaSession};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
|
|
@ -27,11 +30,20 @@ use crate::daemon::DaemonCommand;
|
|||
/// this is a poll, not a push.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the playlist may sit unchanged before we treat the encode
|
||||
/// side as dead. The Cast path has `pull_encoded_frame`'s stall watchdog;
|
||||
/// this is the HLS equivalent -- portal/encoder stalls produce no bus
|
||||
/// error, just a playlist that stops growing.
|
||||
const PLAYLIST_STALL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct DlnaMirrorSession {
|
||||
pipeline: gst::Pipeline,
|
||||
session: DlnaSession,
|
||||
capture: Option<CaptureSession>,
|
||||
http: HttpServer,
|
||||
output_dir: std::path::PathBuf,
|
||||
poll_task: tokio::task::JoinHandle<()>,
|
||||
stall_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl DlnaMirrorSession {
|
||||
|
|
@ -43,42 +55,57 @@ impl DlnaMirrorSession {
|
|||
/// renderer stopping playback on its own, a GStreamer error, or the
|
||||
/// renderer becoming unreachable) back to the daemon actor — mirrors
|
||||
/// `CastMirrorSession::start`'s same use of it.
|
||||
pub async fn start(device: DlnaDevice, daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>) -> Result<Self> {
|
||||
pub async fn start(
|
||||
device: DlnaDevice,
|
||||
daemon_tx: tokio::sync::mpsc::Sender<DaemonCommand>,
|
||||
generation: u64,
|
||||
) -> Result<Self> {
|
||||
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
|
||||
let video_node_id = capture.video_node_id();
|
||||
|
||||
let output_dir = hls_output_dir("dlna-mirror")?;
|
||||
let pipeline =
|
||||
build_video_pipeline(video_node_id, &output_dir).context("failed to build the encode pipeline")?;
|
||||
let output_dir = match hls_output_dir("dlna-mirror") {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
let _ = capture.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let pipeline = match build_video_pipeline(video_node_id, &output_dir) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = capture.close().await;
|
||||
let _ = std::fs::remove_dir_all(&output_dir);
|
||||
return Err(e).context("failed to build the encode pipeline");
|
||||
}
|
||||
};
|
||||
|
||||
// Fire-and-forget, same as `CastMirrorSession::start`'s identical
|
||||
// block: nothing joins this thread, it just self-terminates on
|
||||
// pipeline error, EOS, or its own 1-hour timeout, whichever is
|
||||
// first — see that function's doc comment for why that's fine.
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
std::thread::spawn(move || {
|
||||
match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(3600))
|
||||
{
|
||||
Ok(outcome) => tracing::debug!(?outcome, "DLNA encode pipeline bus watcher ended"),
|
||||
Err(e) => tracing::error!(error = ?e, "DLNA encode pipeline error"),
|
||||
}
|
||||
});
|
||||
if let Err(e) = pipeline.set_state(gst::State::Playing) {
|
||||
let _ = capture.close().await;
|
||||
let _ = std::fs::remove_dir_all(&output_dir);
|
||||
return Err(e).context("failed to start the encode pipeline");
|
||||
}
|
||||
|
||||
pipeline.set_state(gst::State::Playing).context("failed to start the encode pipeline")?;
|
||||
|
||||
let lan_ip = local_lan_ip().context("failed to determine this machine's LAN-reachable IP")?;
|
||||
let peer = host_ip_from_url(&device.url);
|
||||
let lan_ip = match peer.map(breadcast_core::net::local_lan_ip_for).unwrap_or_else(local_lan_ip) {
|
||||
Ok(ip) => ip,
|
||||
Err(e) => {
|
||||
abort_partial(&pipeline, capture, None, &output_dir).await;
|
||||
return Err(e).context("failed to determine this machine's LAN-reachable IP");
|
||||
}
|
||||
};
|
||||
// Bind an ephemeral port (`:0`) rather than a fixed one like the
|
||||
// `dlna_mirror_test` example uses -- the daemon may need to run
|
||||
// alongside that example, or a future concurrent-session mode,
|
||||
// without a bind conflict. `HttpServer::start`'s worker threads
|
||||
// outlive this session once it stops (documented pre-existing
|
||||
// limitation, see `http_server.rs` -- not something introduced
|
||||
// here); one leaked idle listener per DLNA cast is an accepted
|
||||
// cost until that gets a real shutdown path.
|
||||
let http = breadcast_core::http_server::HttpServer::start("0.0.0.0:0", output_dir.clone())
|
||||
.context("failed to start the HLS HTTP server")?;
|
||||
// without a bind conflict. The server is held on the session and
|
||||
// shut down in `stop()` so the last screen-recording segments are
|
||||
// not left reachable on the LAN.
|
||||
let http = match HttpServer::start("0.0.0.0:0", output_dir.clone()) {
|
||||
Ok(http) => http,
|
||||
Err(e) => {
|
||||
abort_partial(&pipeline, capture, None, &output_dir).await;
|
||||
return Err(e).context("failed to start the HLS HTTP server");
|
||||
}
|
||||
};
|
||||
let stream_url = http.url(lan_ip, "playlist.m3u8");
|
||||
|
||||
// Two segments, not three: this is a "don't hand the renderer a 404
|
||||
|
|
@ -87,30 +114,42 @@ impl DlnaMirrorSession {
|
|||
// `build_video_pipeline`'s note on HLS latency). Two is the minimum
|
||||
// that still proves the encoder is genuinely producing output rather
|
||||
// than having emitted one segment and stalled.
|
||||
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20))
|
||||
.await
|
||||
.context("encode pipeline never produced playable HLS segments")?;
|
||||
if let Err(e) = wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 2, Duration::from_secs(20)).await
|
||||
{
|
||||
abort_partial(&pipeline, capture, Some(http), &output_dir).await;
|
||||
return Err(e).context("encode pipeline never produced playable HLS segments");
|
||||
}
|
||||
|
||||
let session = DlnaSession::connect(&device).await.context("failed to connect to the DLNA renderer")?;
|
||||
session.load(&stream_url).await.context("renderer rejected the stream load")?;
|
||||
let session = match DlnaSession::connect(&device).await {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
abort_partial(&pipeline, capture, Some(http), &output_dir).await;
|
||||
return Err(e).context("failed to connect to the DLNA renderer");
|
||||
}
|
||||
};
|
||||
if let Err(e) = session.load(&stream_url).await {
|
||||
abort_partial(&pipeline, capture, Some(http), &output_dir).await;
|
||||
return Err(e).context("renderer rejected the stream load");
|
||||
}
|
||||
tracing::info!(device = %device.friendly_name, %stream_url, "DLNA mirroring started");
|
||||
|
||||
let poll_task = {
|
||||
let session = session.clone();
|
||||
let device_name = device.friendly_name.clone();
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
match session.transport_state().await {
|
||||
Ok(state) if state == "STOPPED" || state == "NO_MEDIA_PRESENT" => {
|
||||
tracing::info!(device = %device_name, %state, "DLNA renderer ended playback on its own");
|
||||
let _ = daemon_tx.send(DaemonCommand::SessionEnded).await;
|
||||
let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await;
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(device = %device_name, error = ?e, "DLNA transport state poll failed, treating renderer as gone");
|
||||
let _ = daemon_tx.send(DaemonCommand::SessionEnded).await;
|
||||
let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -118,17 +157,69 @@ impl DlnaMirrorSession {
|
|||
})
|
||||
};
|
||||
|
||||
Ok(Self { pipeline, session, capture: Some(capture), poll_task })
|
||||
let stall_task = {
|
||||
let playlist = output_dir.join("playlist.m3u8");
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut last_mtime = None;
|
||||
let mut stalled_for = Duration::ZERO;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let mtime = std::fs::metadata(&playlist).and_then(|m| m.modified()).ok();
|
||||
if mtime != last_mtime {
|
||||
last_mtime = mtime;
|
||||
stalled_for = Duration::ZERO;
|
||||
continue;
|
||||
}
|
||||
stalled_for += Duration::from_secs(1);
|
||||
if stalled_for >= PLAYLIST_STALL_TIMEOUT {
|
||||
tracing::warn!(
|
||||
"DLNA encode stalled: playlist unchanged for {}s",
|
||||
PLAYLIST_STALL_TIMEOUT.as_secs()
|
||||
);
|
||||
let _ = daemon_tx.send(DaemonCommand::SessionEnded { generation }).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Portal "stop sharing" / a real GStreamer error used to only log
|
||||
// -- the bus watcher never told the daemon, so the UI stayed on
|
||||
// Casting and the renderer kept looping stale segments. Notify.
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
match run_until_eos_or_error(&pipeline_watch) {
|
||||
Ok(outcome) => tracing::debug!(?outcome, "DLNA encode pipeline bus watcher ended"),
|
||||
Err(e) => tracing::error!(error = ?e, "DLNA encode pipeline error"),
|
||||
}
|
||||
let _ = daemon_tx.blocking_send(DaemonCommand::SessionEnded { generation });
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
pipeline,
|
||||
session,
|
||||
capture: Some(capture),
|
||||
http,
|
||||
output_dir,
|
||||
poll_task,
|
||||
stall_task,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tears down the session: stops polling, tells the renderer to stop,
|
||||
/// stops the encode pipeline, and closes the portal capture session.
|
||||
/// stops the encode pipeline, shuts the HLS server, closes the portal
|
||||
/// capture session, and deletes the recording directory.
|
||||
pub async fn stop(mut self) {
|
||||
// A request, not a wait -- if the poll task is mid-poll and sends
|
||||
// one more `SessionEnded` right as this races it, that's harmless:
|
||||
// `daemon.rs`'s handler already no-ops when `active_session` was
|
||||
// already cleared by this explicit stop.
|
||||
self.poll_task.abort();
|
||||
self.stall_task.abort();
|
||||
|
||||
if let Err(e) = self.session.stop().await {
|
||||
tracing::warn!(error = ?e, "failed to cleanly stop the DLNA session");
|
||||
|
|
@ -136,10 +227,45 @@ impl DlnaMirrorSession {
|
|||
if let Err(e) = self.pipeline.set_state(gst::State::Null) {
|
||||
tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly");
|
||||
}
|
||||
self.http.shutdown();
|
||||
if let Some(capture) = self.capture.take() {
|
||||
if let Err(e) = capture.close().await {
|
||||
tracing::warn!(error = ?e, "failed to cleanly close the portal capture session");
|
||||
match tokio::time::timeout(Duration::from_secs(5), capture.close()).await {
|
||||
Ok(Err(e)) => tracing::warn!(error = ?e, "failed to cleanly close the portal capture session"),
|
||||
Err(_) => tracing::warn!("portal capture session did not close within 5s -- abandoning it"),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::remove_dir_all(&self.output_dir) {
|
||||
tracing::debug!(error = %e, dir = %self.output_dir.display(), "failed to remove HLS output dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_partial(
|
||||
pipeline: &gst::Pipeline,
|
||||
capture: CaptureSession,
|
||||
http: Option<HttpServer>,
|
||||
output_dir: &std::path::Path,
|
||||
) {
|
||||
let _ = pipeline.set_state(gst::State::Null);
|
||||
if let Some(mut http) = http {
|
||||
http.shutdown();
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(5), capture.close()).await {
|
||||
Ok(Err(e)) => tracing::warn!(error = ?e, "failed to close portal capture after a failed DLNA start"),
|
||||
Err(_) => tracing::warn!("portal capture did not close within 5s after a failed DLNA start"),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(output_dir);
|
||||
}
|
||||
|
||||
fn host_ip_from_url(url: &str) -> Option<std::net::IpAddr> {
|
||||
let rest = url.split("://").nth(1)?;
|
||||
let hostport = rest.split('/').next()?;
|
||||
let host = if let Some(inside) = hostport.strip_prefix('[') {
|
||||
inside.split(']').next()?
|
||||
} else {
|
||||
hostport.rsplit_once(':').map(|(h, _)| h).unwrap_or(hostport)
|
||||
};
|
||||
host.parse().ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
//! a time, but nothing here assumes that) can connect concurrently; each
|
||||
//! gets its own copy of every broadcast event.
|
||||
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
|
||||
use serde_json::Value;
|
||||
|
|
@ -31,7 +33,13 @@ use crate::daemon::DaemonCommand;
|
|||
pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast::Sender<ServerMessage>) -> Result<()> {
|
||||
let socket_path = socket_path()?;
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
// 0700 even if umask is loose -- this directory holds the control
|
||||
// socket, and XDG_RUNTIME_DIR itself is 0700 but a recreate after
|
||||
// a wiped runtime dir should not inherit a world-readable mode.
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(parent)
|
||||
.with_context(|| format!("failed to create socket dir {}", parent.display()))?;
|
||||
}
|
||||
// A stale socket file from an unclean previous exit makes bind() fail
|
||||
|
|
@ -45,7 +53,18 @@ pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast:
|
|||
tracing::info!(path = %socket_path.display(), "IPC socket listening");
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await.context("failed to accept IPC connection")?;
|
||||
let (stream, _addr) = match listener.accept().await {
|
||||
Ok(accepted) => accepted,
|
||||
Err(e) => {
|
||||
// EMFILE / a single bad accept must not take the control
|
||||
// socket down for the rest of the daemon's life -- discovery
|
||||
// would keep running while every `breadcast` launch reports
|
||||
// "isn't running".
|
||||
tracing::warn!(error = %e, "IPC accept failed, retrying");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
let events_rx = events_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -106,10 +125,14 @@ async fn handle_connection(
|
|||
continue;
|
||||
}
|
||||
};
|
||||
let response = handle_request(request, &daemon_tx).await;
|
||||
if writer_tx.send(response).is_err() {
|
||||
break;
|
||||
}
|
||||
// Handle off the read loop so a long `start_cast` (portal picker)
|
||||
// does not block `stop_cast` sitting in the same socket buffer.
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
let writer_tx = writer_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let response = handle_request(request, &daemon_tx).await;
|
||||
let _ = writer_tx.send(response);
|
||||
});
|
||||
}
|
||||
|
||||
forward_task.abort();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue