breadcast/breadcast-core/src/caststream.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

342 lines
16 KiB
Rust

//! Safe wrapper over `breadcast-caststream-sys`'s raw FFI to the vendored
//! openscreen Cast Streaming sender — the same low-latency mirroring
//! protocol Chrome's tab/desktop casting uses (unlike [`crate::cast_sender`]'s
//! HLS approach, which targets the Default Media Receiver instead). See
//! `breadcast-caststream-sys/vendor/openscreen/PATCHES.md` for how the
//! vendored C++ this wraps was built.
//!
//! This does *not* replicate [`CastSession`](crate::cast_sender::CastSession)'s
//! own single-io-thread actor pattern internally — the underlying C++ already
//! runs its own dedicated TaskRunner/networking threads (see `facade.h`'s
//! threading contract), so every method here just marshals across FFI rather
//! than through a Rust-owned loop. What *does* need a Rust-side thread is
//! draining [`CastStreamEvents`] and forwarding [`CastStreamEvent::OutboundMessage`]
//! over the existing CASTV2 connection — see `cast_stream_test.rs` for the
//! intended pattern (pump events on one thread, call `on_message`/
//! `enqueue_frame` from others).
use std::ffi::c_void;
use std::os::raw::c_char;
use std::sync::mpsc;
use anyhow::{Result, bail};
use breadcast_caststream_sys::{
self as sys, breadcast_caststream_sender_create, breadcast_caststream_sender_destroy,
breadcast_caststream_sender_enqueue_frame, breadcast_caststream_sender_estimated_bandwidth_bps,
breadcast_caststream_sender_needs_key_frame, breadcast_caststream_sender_negotiate,
breadcast_caststream_sender_on_message, breadcast_caststream_sender_take_stats,
};
/// The Cast Streaming ("Mirroring") receiver app id, pre-installed on every
/// Chromecast/Google TV — distinct from [`rust_cast::channels::receiver::CastDeviceApp::DefaultMediaReceiver`]'s
/// `CC1AD845`, which is what [`crate::cast_sender::CastSession`] launches for
/// the HLS path.
pub const MIRRORING_APP_ID: &str = "0F5096E8";
/// The CASTV2 namespace Cast Streaming's OFFER/ANSWER exchange runs on.
pub const WEBRTC_NAMESPACE: &str = "urn:x-cast:com.google.cast.webrtc";
#[derive(Debug, Clone, Copy)]
pub struct VideoParams {
pub width: i32,
pub height: i32,
pub max_bitrate_bps: i32,
pub max_frame_rate_numerator: i32,
pub max_frame_rate_denominator: i32,
}
impl Default for VideoParams {
fn default() -> Self {
Self {
// NOT the value that goes on the wire. `build_video_pipeline_for_streaming`
// returns the geometry it will really encode, and both
// `breadcastd::cast_mirror` and `cast_stream_test` pass *that*
// to `CastStreamSender::start` -- this default only supplies the
// fields that don't vary with the capture path (bitrate, frame
// rate denominator).
//
// It has to work that way because the pipeline picks its capture
// backend at runtime, and the two backends differ in resolution
// and frame rate. This OFFER's resolution is what the receiver
// allocates its decoder/output surface for, so advertising
// anything other than what's actually sent is a real protocol
// mismatch -- one that plausibly explains a receiver decoder
// corrupting/freezing rather than just looking soft. Keeping the
// two in sync by hand across two files is exactly how that got
// out of step before; returning it from the pipeline builder
// makes the mismatch unrepresentable. The values below are the
// DMA-BUF path's, kept only as a sane standalone default.
width: 1920,
height: 1080,
// Kept equal to `breadcastd::cast_mirror::MAX_BITRATE_KBPS *
// 1000` -- see that constant's doc comment for why 8 Mbps
// (this struct's previous value) isn't used here: real hardware
// testing showed the AIMD probe pinning to whatever this
// ceiling is for the entire session once the estimator reports
// (unreliably) that there's room, and 8 Mbps sustained was more
// than the previous network+receiver could actually hold,
// producing repeated multi-second freezes rather than just
// softer video.
max_bitrate_bps: 6_000_000,
// A *ceiling*, not a promise -- which is what makes it safe for
// the DMA-BUF path, where nothing caps the rate (`vapostproc` is
// a per-frame transform and can't do temporal conversion, see
// `build_video_pipeline_for_streaming`) and frames arrive at
// whatever the compositor delivers, up to this machine's 60Hz
// refresh. The wl_shm path does have a `videorate` capping it
// hard, and overrides this with the rate it actually enforces.
max_frame_rate_numerator: 60,
max_frame_rate_denominator: 1,
}
}
}
/// Events pushed from the underlying C++ TaskRunner thread — see this
/// module's doc comment on why a Rust-owned pump loop is still needed even
/// though the FFI layer runs its own threads.
#[derive(Debug)]
pub enum CastStreamEvent {
/// The C++ side needs this JSON `message` sent to `destination_id` on
/// [`WEBRTC_NAMESPACE`] over the existing CASTV2 connection (e.g. the
/// OFFER). The caller is expected to do that via
/// `rust_cast::CastDevice::send_message` (see the `send_message` patch
/// documented in `vendor/rust_cast-0.21.0/PATCHES.md`).
OutboundMessage { destination_id: String, message: String },
/// OFFER/ANSWER negotiation succeeded; `enqueue_frame` will now accept
/// frames.
Negotiated,
/// A negotiation or session error occurred.
Error(String),
/// The receiver reported picture loss and wants a key frame ASAP (also
/// obtainable via the pull-style [`CastStreamSender::needs_key_frame`]).
PictureLost,
}
struct CallbackContext {
events_tx: mpsc::Sender<CastStreamEvent>,
}
/// A live Cast Streaming sender session. See the module doc comment for the
/// threading model.
pub struct CastStreamSender {
raw: *mut sys::CastStreamSender,
// Kept alive for `raw`'s lifetime -- its address is the FFI `user_data`
// every callback trampoline below casts back. Never read directly
// through this field; the callbacks access it via the raw pointer, so
// this exists purely to own the allocation and free it (after
// `destroy()`, in `Drop`) rather than leak it.
_context: Box<CallbackContext>,
}
// Safety: the underlying C++ handle has no thread-affinity for the FFI
// entry points themselves (see facade.h's threading contract) -- every
// `breadcast_caststream_sender_*` call internally marshals onto the
// TaskRunner thread via `TaskRunner::PostTask`, which is documented
// thread-safe regardless of caller thread. All methods below take `&self`
// only (no interior mutation outside that marshaling), so concurrent calls
// from multiple threads sharing an `Arc<CastStreamSender>` are as safe as
// they are from a single thread -- hence `Sync` too, not just `Send`.
unsafe impl Send for CastStreamSender {}
unsafe impl Sync for CastStreamSender {}
impl CastStreamSender {
/// Starts a Cast Streaming session targeting `remote_ip` (the same IP
/// `rust_cast` already connected to for the CASTV2 control channel).
/// `local_source_id`/`receiver_id` are the CASTV2 source/destination IDs
/// to use on [`WEBRTC_NAMESPACE`] -- `receiver_id` should be the
/// launched Mirroring app's `transport_id` (the same id
/// `connection`/`media` channels already target), matching how every
/// other namespace conversation with a launched app is addressed.
///
/// Returns the sender plus a receiver for [`CastStreamEvent`]s -- drain
/// it on a dedicated thread; `OutboundMessage` events in particular need
/// prompt forwarding for negotiation to make progress.
pub fn start(
remote_ip: &str,
local_source_id: &str,
receiver_id: &str,
params: VideoParams,
) -> Result<(Self, mpsc::Receiver<CastStreamEvent>)> {
let (events_tx, events_rx) = mpsc::channel();
let context = Box::into_raw(Box::new(CallbackContext { events_tx }));
let raw = unsafe {
breadcast_caststream_sender_create(
remote_ip.as_ptr() as *const c_char,
remote_ip.len(),
local_source_id.as_ptr() as *const c_char,
local_source_id.len(),
receiver_id.as_ptr() as *const c_char,
receiver_id.len(),
params.width,
params.height,
params.max_bitrate_bps,
params.max_frame_rate_numerator,
params.max_frame_rate_denominator,
context as *mut c_void,
post_message_trampoline,
on_negotiated_trampoline,
on_error_trampoline,
on_picture_lost_trampoline,
)
};
if raw.is_null() {
// SAFETY: `context` was created by the `Box::into_raw` above and
// has not been handed to any live C++ object (create() failed
// before storing it anywhere), so reclaiming and dropping it
// here is the only way to avoid leaking it.
drop(unsafe { Box::from_raw(context) });
bail!("breadcast_caststream_sender_create failed (invalid remote_ip?)");
}
// SAFETY: `context` was created by `Box::into_raw` immediately
// above and its address was just handed to the C++ side as
// `user_data` -- reconstructing the `Box` here doesn't move or free
// the underlying allocation (only dropping it would), so the
// pointer C++ holds stays valid for as long as this `Box` lives,
// i.e. until `Drop` runs (after `destroy()`, see below).
let context = unsafe { Box::from_raw(context) };
Ok((Self { raw, _context: context }, events_rx))
}
/// Sends the OFFER and begins waiting for an ANSWER (delivered via
/// [`Self::on_message`]). Completion is reported as a
/// [`CastStreamEvent::Negotiated`] or [`CastStreamEvent::Error`] on the
/// event receiver returned by [`Self::start`].
pub fn negotiate(&self) {
unsafe { breadcast_caststream_sender_negotiate(self.raw) };
}
/// Delivers a message received on [`WEBRTC_NAMESPACE`] (e.g. the
/// receiver's ANSWER) into the session.
pub fn on_message(&self, source_id: &str, message_namespace: &str, message: &str) {
unsafe {
breadcast_caststream_sender_on_message(
self.raw,
source_id.as_ptr() as *const c_char,
source_id.len(),
message_namespace.as_ptr() as *const c_char,
message_namespace.len(),
message.as_ptr() as *const c_char,
message.len(),
);
}
}
/// Enqueues one encoded video access unit (Annex-B H.264) for sending.
/// `capture_time_us` only needs to be monotonically increasing and
/// proportional to real elapsed time between frames -- it does not need
/// to be wall-clock-accurate.
///
/// Returns an error if the session isn't negotiated yet or the frame
/// was rejected under backpressure; callers should treat the latter as
/// a dropped frame, not a fatal condition (see
/// [`Self::needs_key_frame`]/[`Self::estimated_bandwidth_bps`] for how
/// to react).
pub fn enqueue_frame(&self, data: &[u8], is_key_frame: bool, capture_time_us: i64) -> Result<()> {
let result = unsafe {
breadcast_caststream_sender_enqueue_frame(
self.raw,
data.as_ptr(),
data.len(),
is_key_frame as i32,
capture_time_us,
)
};
if result != 0 {
bail!("frame not enqueued (session not negotiated yet)");
}
Ok(())
}
/// A snapshot of how the *underlying* `Sender::EnqueueFrame` has been
/// answering, plus its in-flight window gauges. Reading resets the
/// counters, so polling once a second yields per-second rates.
///
/// [`Self::enqueue_frame`] deliberately cannot report any of this: it
/// returns as soon as the frame is posted to openscreen's TaskRunner,
/// before the real accept/reject happens. Any "frames enqueued per
/// second" figure derived from its return value is therefore a count of
/// *attempts*, and stays pinned at the capture rate even while every
/// frame is being rejected downstream and the picture is frozen.
pub fn enqueue_stats(&self) -> sys::EnqueueStats {
let mut stats = sys::EnqueueStats::default();
unsafe { breadcast_caststream_sender_take_stats(self.raw, &mut stats) };
stats
}
/// True if the receiver wants a key frame as soon as possible. Cheap to
/// poll frequently (e.g. once per captured frame, before encoding it).
pub fn needs_key_frame(&self) -> bool {
unsafe { breadcast_caststream_sender_needs_key_frame(self.raw) != 0 }
}
/// Best-effort current bandwidth estimate in bits per second, meant to
/// drive the video encoder's target bitrate -- this vendored subset of
/// openscreen only does flow control, not congestion control. Cheap to
/// poll frequently.
pub fn estimated_bandwidth_bps(&self) -> i32 {
unsafe { breadcast_caststream_sender_estimated_bandwidth_bps(self.raw) }
}
}
impl Drop for CastStreamSender {
fn drop(&mut self) {
// Blocks until the C++ side's threads stop -- after this returns,
// no more callbacks will fire, so it's safe for `_context` to be
// freed right after (implicitly, as this struct finishes dropping).
unsafe { breadcast_caststream_sender_destroy(self.raw) };
}
}
unsafe fn context_from_user_data<'a>(user_data: *mut c_void) -> &'a CallbackContext {
// SAFETY: every callback below is only ever invoked by the C++ facade
// with the exact `user_data` pointer passed into `sender_create`, which
// is `_context`'s address for the lifetime of the owning
// `CastStreamSender` (see its field doc comment) -- and per facade.h's
// threading contract, no callback fires after `sender_destroy` returns,
// which is also the last point `_context` could be dropped.
unsafe { &*(user_data as *const CallbackContext) }
}
unsafe fn str_from_raw_parts<'a>(ptr: *const c_char, len: usize) -> std::borrow::Cow<'a, str> {
// SAFETY: every callback below documents (matching facade.h) that these
// buffers are borrowed and valid only for the duration of the call --
// this is called synchronously within that window, and the result is
// copied (via `.into_owned()` at each call site) before returning.
let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) };
String::from_utf8_lossy(bytes)
}
extern "C" fn post_message_trampoline(
user_data: *mut c_void,
destination_id: *const c_char,
destination_id_len: usize,
_message_namespace: *const c_char,
_message_namespace_len: usize,
message: *const c_char,
message_len: usize,
) {
let ctx = unsafe { context_from_user_data(user_data) };
let destination_id = unsafe { str_from_raw_parts(destination_id, destination_id_len) }.into_owned();
let message = unsafe { str_from_raw_parts(message, message_len) }.into_owned();
let _ = ctx.events_tx.send(CastStreamEvent::OutboundMessage { destination_id, message });
}
extern "C" fn on_negotiated_trampoline(user_data: *mut c_void) {
let ctx = unsafe { context_from_user_data(user_data) };
let _ = ctx.events_tx.send(CastStreamEvent::Negotiated);
}
extern "C" fn on_error_trampoline(user_data: *mut c_void, message: *const c_char, message_len: usize) {
let ctx = unsafe { context_from_user_data(user_data) };
let message = unsafe { str_from_raw_parts(message, message_len) }.into_owned();
let _ = ctx.events_tx.send(CastStreamEvent::Error(message));
}
extern "C" fn on_picture_lost_trampoline(user_data: *mut c_void) {
let ctx = unsafe { context_from_user_data(user_data) };
let _ = ctx.events_tx.send(CastStreamEvent::PictureLost);
}