breadcast/breadcastd/src/cast_mirror.rs
Breadway 2cc1310752 Detect the DMA-BUF crash bug by Hyprland version, add a capture-stall watchdog
The 1080p DMA-BUF pipeline crashed the user's entire Hyprland session
tonight. Root cause identified: hyprwm/Hyprland PR #15167 (fixed
2026-06-18, first shipped v0.56.0) -- CGLRenderbuffer's destructor
unconditionally dereferences m_framebuffer, which is left null when
createEGLImage() fails to import the DMA-BUF. This machine runs
v0.55.4, a week before the fix. The crash happens inside Hyprland's
own Screenshare::CScreenshareFrame::copyDmabuf(), confirmed against
the coredump; it is not a breadcast, GStreamer, or PipeWire bug, and
it is not resolution-dependent (upstream's own reports span unrelated
triggers -- touchpad gestures, tab switching, a Discord stream -- not
capture geometry), so 1080p vs 720p was never the actual variable.

choose_capture_backend() now reads the running compositor's version
over its own IPC socket and only uses the DMA-BUF path on Hyprland
>= 0.56.0 (or non-Hyprland sessions, unaffected by this bug). Below
that, or if the version can't be determined, it falls back to the
plain system-memory/wl_shm pipeline -- slower and still subject to
xdg-desktop-portal-hyprland's separate "Out of buffers" stall bug
(also confirmed via journalctl, and also not fixed in the installed
xdpw 1.3.12), but that failure mode is a stall, not a compositor-wide
abort.

That stall used to be silent forever: xdpw stops requesting frames
after 10 failed retries and never signals PipeWire, so GStreamer's own
bus reports nothing -- no error, no EOS -- and the frame pump just
blocks. pull_encoded_frame now bails after 10s of a Playing pipeline
producing nothing, converting an indefinite silent freeze into a real,
reported session failure (still correctly distinguishing a genuine
stall from ordinary EOS/teardown, so a normal stop() doesn't trip it).

VideoParams is no longer a hand-synced constant: build_video_pipeline_for_streaming
now returns the geometry/frame-rate it actually chose alongside the
pipeline, and both breadcastd::cast_mirror and cast_stream_test thread
that straight into the OFFER instead of a separately-maintained
default. Keeping two copies in sync by hand is exactly how the
resolution mismatch bug happened earlier tonight; returning the real
value makes that class of bug unrepresentable rather than just fixed
once.

Verified without touching the real compositor: cargo build --workspace
--examples, clippy, and both new unit tests (version parsing, the
0.55.4/0.56.0 backend-selection boundary) are clean. The watchdog's
firing path and the DMA-BUF path post-Hyprland-update are not yet
validated against real hardware -- deliberately, given what the last
live test cost. Recommended order: update Hyprland
(pacman -Syu hyprland xdg-desktop-portal-hyprland gets 0.56.1 + xdpw
1.4.1, which also picks up upstream fixes for the exact copy-fence and
SHM-handling bugs hit tonight) and confirm `hyprctl version` reports
>= 0.56.0 before testing DMA-BUF again. Without updating, this commit
still helps: the wl_shm path is selected automatically and the stall
is now bounded instead of indefinite.
2026-08-15 22:34:21 +08:00

447 lines
22 KiB
Rust

//! Owns one active Cast Streaming mirroring session end-to-end: portal
//! capture, the GStreamer encode pipeline, the CASTV2 connection to the
//! Mirroring receiver, and the three pump threads that shuttle
//! OFFER/ANSWER messages and encoded frames between them. This is
//! `cast_stream_test.rs`'s orchestration, restructured into something the
//! daemon can start and stop on demand instead of running for a fixed
//! duration from a CLI `main`.
//!
//! The Cast Streaming (low-latency, RTP-based) path — see `dlna_mirror.rs`
//! for the DLNA/UPnP counterpart (HLS-over-HTTP, polled instead of pushed).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result};
use breadcast_core::caststream::{CastStreamEvent, WEBRTC_NAMESPACE};
use breadcast_core::pipeline::{
build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps,
};
use breadcast_core::{CastDevice, CaptureSession, CastSession, CastStreamSender};
use gstreamer as gst;
use gstreamer::prelude::*;
use rust_cast::channels::receiver::CastDeviceApp;
use crate::daemon::DaemonCommand;
/// The encoder's starting target bitrate, in kbps -- must match the
/// `bitrate=` property `build_video_pipeline_for_streaming` builds the
/// `vah264enc` with, since [`bitrate_control_step`] treats it as the value
/// already in effect at t=0.
const INITIAL_BITRATE_KBPS: u32 = 4000;
/// Never encode below this. 720p30 below roughly 1.5 Mbps is a wall of
/// blocking artifacts -- if the link genuinely can't carry that, dropping
/// frames is a better failure mode than shipping unwatchable video.
const MIN_BITRATE_KBPS: u32 = 1500;
/// Never encode above this, regardless of how much headroom the estimator
/// reports. Matches `VideoParams::default().max_bitrate_bps`, i.e. what the
/// OFFER told the receiver to expect -- see that constant's doc comment for
/// why this is 6 Mbps and not higher: real hardware testing showed the AIMD
/// probe below pinning to whatever this ceiling is for the *entire* session
/// (the estimator it trusts read a suspiciously flat ~20 Mbps almost the
/// whole time), and 8 Mbps sustained was more than the previous
/// network+receiver could actually hold without repeated multi-second
/// freezes -- though the deeper cause of those freezes turned out to be
/// `frame_chain_broken` in `facade.cc`, not bitrate on its own. 6 Mbps is
/// still a very generous ceiling for 720p30; revisit only with real
/// evidence this specific link+receiver can sustain more, not just because
/// the estimator claims there's headroom.
const MAX_BITRATE_KBPS: u32 = 6000;
pub struct CastMirrorSession {
pipeline: gst::Pipeline,
session: CastSession,
capture: Option<CaptureSession>,
/// The FFI Cast Streaming session. Held here (rather than only inside
/// the pump-thread closures, as an earlier version did) so its
/// `Drop` -- which calls `breadcast_caststream_sender_destroy` and
/// blocks until openscreen's threads stop -- happens at an explicit,
/// deterministic point in [`Self::stop`], instead of "whichever
/// detached pump thread happened to drop the last `Arc`."
sender: Option<Arc<CastStreamSender>>,
/// Forwards inbound CASTV2 `urn:x-cast:com.google.cast.webrtc` messages
/// (the ANSWER) into the FFI session. Ends when [`CastSession::stop`]
/// closes the raw-message channel. Holds an `Arc<CastStreamSender>`.
message_pump: Option<std::thread::JoinHandle<()>>,
/// Forwards outbound FFI events (the OFFER) onto the CASTV2 connection.
/// Ends only once the `CastStreamSender` itself is dropped (that is what
/// closes the event channel), so it must be joined *after* `sender` is
/// dropped, not before -- joining it first would deadlock.
event_pump: Option<std::thread::JoinHandle<()>>,
/// Pulls encoded frames from the appsink into the FFI session. Ends on
/// pipeline EOS/flush. Holds an `Arc<CastStreamSender>`.
frame_pump: Option<std::thread::JoinHandle<()>>,
}
impl CastMirrorSession {
/// Starts mirroring to `device`. Blocks (briefly) on the portal picker,
/// the CASTV2 handshake, and OFFER/ANSWER negotiation before returning
/// -- by the time this resolves, frames are already flowing.
///
/// `daemon_tx` is used to report unprompted session death (a GStreamer
/// error, the user clicking "stop sharing" in the portal picker, the
/// 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> {
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
let video_node_id = capture.video_node_id();
// `video_params` describes what this pipeline will *actually* encode
// -- it isn't a constant, because the pipeline picks its capture path
// at runtime (see `build_video_pipeline_for_streaming`) and the two
// paths differ in resolution and frame rate. It's threaded into the
// 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")?;
{
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, "encode pipeline bus watcher ended"),
Err(e) => tracing::error!(error = ?e, "encode pipeline error"),
}
});
}
// The blocking CASTV2 TCP+TLS handshake + app launch is quick
// (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 || {
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")?;
let (sender, stream_events) =
CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params)
.context("failed to start the Cast Streaming session")?;
let sender = Arc::new(sender);
let message_pump = {
let sender = sender.clone();
std::thread::spawn(move || {
while let Some(msg) = raw_messages.recv() {
if msg.namespace == WEBRTC_NAMESPACE {
sender.on_message(&msg.source_id, &msg.namespace, &msg.message);
}
}
})
};
let negotiated = Arc::new(AtomicBool::new(false));
let event_pump = {
let session = session.clone();
let negotiated = negotiated.clone();
std::thread::spawn(move || {
while let Ok(event) = stream_events.recv() {
match event {
CastStreamEvent::OutboundMessage { message, .. } => {
if let Err(e) = session.send_raw_message(WEBRTC_NAMESPACE, &message) {
tracing::warn!(error = ?e, "failed to send Cast Streaming message");
}
}
CastStreamEvent::Negotiated => negotiated.store(true, Ordering::Release),
CastStreamEvent::Error(message) => tracing::warn!(%message, "Cast Streaming error"),
CastStreamEvent::PictureLost => tracing::debug!("receiver reported picture loss"),
}
}
})
};
tracing::info!(device = %device.name, "sending Cast Streaming OFFER");
sender.negotiate();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
while !negotiated.load(Ordering::Acquire) && tokio::time::Instant::now() < deadline {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
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;
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")?;
tracing::info!(device = %device.name, "mirroring started");
let frame_pump = {
let device_name = device.name.clone();
let sender = sender.clone();
std::thread::spawn(move || {
let result = frame_pump_loop(&appsink, &encoder, &sender);
if let Err(e) = result {
tracing::warn!(device = %device_name, error = ?e, "frame pump ended with an error");
}
// Best-effort: if this is running, the daemon actor is (or
// 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);
})
};
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),
})
}
/// Tears down the session. Order matters and is not interchangeable:
///
/// 1. Pipeline to `Null` -- unblocks the frame pump's blocking
/// `appsink.pull_sample()`, so it can exit and release its
/// `Arc<CastStreamSender>`.
/// 2. Stop the CASTV2 session -- ends its io thread, closing the
/// raw-message channel the message pump blocks on, so it too can exit
/// and release its `Arc`.
/// 3. Join those two. After this, no thread is calling into the FFI
/// session and `self.sender` holds the only remaining `Arc`.
/// 4. Drop `self.sender` -- runs `breadcast_caststream_sender_destroy`
/// (blocking until openscreen's threads stop) at a point where
/// nothing else can be mid-call into it, and closes the FFI event
/// channel.
/// 5. Only *then* join the event pump, which blocks on that channel and
/// would deadlock if joined before step 4.
pub async fn stop(mut self) {
if let Err(e) = self.pipeline.set_state(gst::State::Null) {
tracing::warn!(error = ?e, "failed to stop the encode pipeline cleanly");
}
stop_session_bounded(&self.session).await;
if let Some(capture) = self.capture.take() {
close_capture_bounded(capture).await;
}
join_pump(self.frame_pump.take(), "frame").await;
join_pump(self.message_pump.take(), "message").await;
// Step 4: the blocking FFI teardown, kept off the async runtime's
// worker threads for the same reason the joins are.
if let Some(sender) = self.sender.take() {
let _ = tokio::task::spawn_blocking(move || drop(sender)).await;
}
join_pump(self.event_pump.take(), "event").await;
}
}
/// `CastSession::stop` blocks on a round trip the receiver has to answer,
/// over a `rust_cast` connection that offers no read timeout -- if the
/// receiver has gone unresponsive (wedged decoder, dropped off the
/// network, etc.) that round trip never returns. Both callers run inside
/// the single-threaded daemon actor, so an unbounded wait here doesn't
/// just fail one stop -- it permanently freezes the entire daemon (every
/// future IPC request hangs too), recoverable only by killing the
/// process. Bound it: if the receiver hasn't answered in 5s, give up on a
/// graceful stop and let teardown continue anyway. The io thread may leak
/// (still blocked in that same call), but a single leaked thread beats an
/// unrecoverable daemon.
async fn stop_session_bounded(session: &CastSession) {
let session = session.clone();
match tokio::time::timeout(std::time::Duration::from_secs(5), tokio::task::spawn_blocking(move || session.stop())).await {
Ok(Ok(Err(e))) => tracing::warn!(error = ?e, "failed to cleanly stop the cast session"),
Ok(Err(panic)) => tracing::warn!(error = ?panic, "cast session stop task panicked"),
Err(_) => tracing::warn!("cast session did not acknowledge stop within 5s (receiver unresponsive?) -- tearing down anyway"),
Ok(Ok(Ok(()))) => {}
}
}
/// Same reasoning as [`stop_session_bounded`]: `capture.close()` is a
/// `Session::close()` D-Bus call to the xdg-desktop-portal backend, which
/// this project has observed to be flaky (the "Failed to populate
/// properties cache... UnknownMethod" warnings logged on every portal
/// session) -- an unbounded `.await` here is just as capable of freezing
/// the whole daemon actor if that call never gets a reply.
async fn close_capture_bounded(capture: CaptureSession) {
match tokio::time::timeout(std::time::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(())) => {}
}
}
/// Bounded to 5s for the same reason [`CastMirrorSession::stop`]'s own wait
/// on `CastSession::stop` is: `message_pump` blocks on a channel only the
/// (possibly wedged, per that comment) cast session io thread ever closes,
/// so an unbounded join here is exactly as capable of freezing the whole
/// daemon actor forever. A timed-out thread is abandoned rather than
/// joined -- it may still be running, but nothing here waits on it again.
async fn join_pump(handle: Option<std::thread::JoinHandle<()>>, what: &str) {
let Some(handle) = handle else { return };
match tokio::time::timeout(std::time::Duration::from_secs(5), tokio::task::spawn_blocking(move || handle.join())).await {
Ok(Err(panic)) => tracing::warn!(pump = what, error = ?panic, "mirror session pump thread join task panicked"),
Err(_) => tracing::warn!(pump = what, "mirror session pump thread did not exit within 5s -- abandoning it"),
Ok(Ok(_)) => {}
}
}
/// One step of the encoder-bitrate congestion-control loop: given the
/// currently-applied target and openscreen's latest bandwidth estimate,
/// returns the new target in kbps.
///
/// openscreen's `BandwidthEstimator` deliberately *under*-estimates capacity
/// whenever the transmit rate is below it (see its class comment in
/// `vendor/openscreen/cast/streaming/impl/bandwidth_estimator.h`), and
/// prescribes a TCP-like response: cut hard when the estimate is below the
/// current target, ramp back up *gradually* when it's above. An earlier
/// version of this loop instead did `target = 0.85 * estimate` every second
/// unconditionally, which multiplies the target by <= 0.85 once a second
/// with no way back up -- 4000 kbps collapses past 1500 within ~6 seconds
/// and pins at the floor, which is exactly the "low quality / compression
/// artifacts" symptom, on a perfectly healthy LAN.
///
/// An estimate of 0 means "not enough recent data to say" (documented
/// return value), and must leave the target alone rather than be treated as
/// a zero-bandwidth link.
fn bitrate_control_step(current_kbps: u32, estimate_bps: i32) -> u32 {
if estimate_bps <= 0 {
return current_kbps;
}
let estimate_kbps = (estimate_bps / 1000) as u32;
let next = if estimate_kbps < current_kbps {
// Below target: back off immediately to just under the estimate.
((estimate_kbps as f64) * 0.85) as u32
} else {
// Headroom: probe upward by 10% per second, not straight to the
// estimate -- the estimate is a lower bound, and jumping to it
// oscillates.
current_kbps + current_kbps / 10
};
next.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS)
}
fn frame_pump_loop(
appsink: &gstreamer_app::AppSink,
encoder: &gst::Element,
sender: &CastStreamSender,
) -> Result<()> {
let mut last_bitrate_update = std::time::Instant::now();
let mut current_kbps = INITIAL_BITRATE_KBPS;
// `needs_key_frame()` is a snapshot of an atomic the C++ side only
// refreshes every 100ms, so it stays true for several frames after a
// request has already been sent upstream. Firing a force-key-unit event
// per frame in that window makes the encoder emit a burst of IDRs, which
// under CBR eats the whole bitrate budget and produces a visible quality
// dip on every picture-loss report. One request per refresh window is
// enough.
let mut last_key_frame_request: Option<std::time::Instant> = None;
// Pulled-frame and successful-enqueue counters, logged once/sec
// alongside the bitrate step below -- otherwise a stalled pipeline
// (upstream not producing samples) and a stalled sender (producing
// samples nobody can get rid of) are both silent: this is a per-frame
// hot loop, so anything more than a periodic summary would flood the
// log rather than help debug either case.
let mut pulled_since_log: u32 = 0;
let mut enqueued_since_log: u32 = 0;
loop {
let Some((data, is_key_frame, capture_time_us)) = pull_encoded_frame(appsink)? else {
return Ok(()); // EOS -- pipeline was set to Null, or the portal source ended
};
pulled_since_log += 1;
if sender.needs_key_frame()
&& !is_key_frame
&& last_key_frame_request.is_none_or(|t| t.elapsed() >= std::time::Duration::from_millis(250))
{
request_key_frame(appsink);
last_key_frame_request = Some(std::time::Instant::now());
}
if is_key_frame {
last_key_frame_request = None;
}
match sender.enqueue_frame(&data, is_key_frame, capture_time_us) {
Ok(()) => enqueued_since_log += 1,
Err(e) => tracing::debug!(error = ?e, "dropped a frame (not negotiated yet or backpressure)"),
}
if last_bitrate_update.elapsed() >= std::time::Duration::from_secs(1) {
let next_kbps = bitrate_control_step(current_kbps, sender.estimated_bandwidth_bps());
if next_kbps != current_kbps {
current_kbps = next_kbps;
set_video_bitrate_kbps(encoder, current_kbps);
}
// `enqueued_fps` counts *posted* frames, not accepted ones (see
// `CastStreamSender::enqueue_stats`) -- it is the `accepted_fps`
// and `rejected_*` fields below that say whether video is
// actually reaching the receiver. A run where `enqueued_fps`
// holds at 30 while `accepted_fps` drops to 0 is a frozen
// picture, and nothing else logged here would show it.
let stats = sender.enqueue_stats();
tracing::debug!(
pulled_fps = pulled_since_log,
enqueued_fps = enqueued_since_log,
accepted_fps = stats.enqueue_ok,
rejected_in_flight = stats.enqueue_max_duration_in_flight,
rejected_id_span = stats.enqueue_id_span_limit,
rejected_too_large = stats.enqueue_payload_too_large,
dropped_non_monotonic = stats.dropped_non_monotonic,
in_flight_frames = stats.in_flight_frames,
in_flight_ms = stats.in_flight_ms,
max_in_flight_ms = stats.max_in_flight_ms,
rtt_ms = stats.round_trip_time_ms,
bitrate_kbps = current_kbps,
estimated_bandwidth_bps = sender.estimated_bandwidth_bps(),
"frame pump rate"
);
pulled_since_log = 0;
enqueued_since_log = 0;
last_bitrate_update = std::time::Instant::now();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zero_estimate_leaves_the_target_alone() {
assert_eq!(bitrate_control_step(4000, 0), 4000);
assert_eq!(bitrate_control_step(4000, -1), 4000);
}
#[test]
fn headroom_ramps_up_gradually_and_is_capped() {
assert_eq!(bitrate_control_step(4000, 20_000_000), 4400);
assert_eq!(bitrate_control_step(MAX_BITRATE_KBPS, 20_000_000), MAX_BITRATE_KBPS);
}
#[test]
fn a_low_estimate_backs_off_but_not_below_the_floor() {
assert_eq!(bitrate_control_step(4000, 2_000_000), 1700);
assert_eq!(bitrate_control_step(4000, 100_000), MIN_BITRATE_KBPS);
}
/// The regression this loop exists to prevent: a *steady* estimate at
/// roughly the current encode rate must hold the target there (AIMD
/// oscillates a little around it, which is fine), not ratchet it down
/// once per second the way `target = 0.85 * estimate` did -- that
/// reached the floor in about a dozen iterations.
#[test]
fn a_steady_estimate_does_not_spiral_downward() {
let mut kbps = 4000;
for _ in 0..60 {
kbps = bitrate_control_step(kbps, 4_000_000);
assert!(kbps >= 3000, "target spiralled down to {kbps} kbps on a steady 4 Mbps estimate");
}
}
}