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:
Breadway 2026-08-16 14:15:56 +08:00
parent a80c49593d
commit 17abeed7ae
26 changed files with 861 additions and 222 deletions

View file

@ -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:

View file

@ -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> {

View file

@ -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()
}

View file

@ -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();