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.
This commit is contained in:
Breadway 2026-08-06 14:54:05 +08:00
parent bd511fea33
commit 2cc1310752
4 changed files with 368 additions and 46 deletions

View file

@ -48,18 +48,26 @@ pub struct VideoParams {
impl Default for VideoParams { impl Default for VideoParams {
fn default() -> Self { fn default() -> Self {
Self { Self {
// Must match what `build_video_pipeline_for_streaming` actually // NOT the value that goes on the wire. `build_video_pipeline_for_streaming`
// encodes (`breadcast-core/src/pipeline/mod.rs`), not just what // returns the geometry it will really encode, and both
// we'd like to send -- this OFFER's resolution is what the // `breadcastd::cast_mirror` and `cast_stream_test` pass *that*
// receiver allocates its decoder/output surface for. Advertising // to `CastStreamSender::start` -- this default only supplies the
// a resolution other than what's actually sent is a real // fields that don't vary with the capture path (bitrate, frame
// protocol mismatch that plausibly explains a receiver decoder // rate denominator).
// corrupting/freezing rather than just looking soft. //
// Reverted from a brief 1920x1080 experiment -- see // It has to work that way because the pipeline picks its capture
// `build_video_pipeline_for_streaming`'s doc comment for why // backend at runtime, and the two backends differ in resolution
// (the freeze wasn't a resolution/bandwidth problem at all). // and frame rate. This OFFER's resolution is what the receiver
width: 1280, // allocates its decoder/output surface for, so advertising
height: 720, // 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 * // Kept equal to `breadcastd::cast_mirror::MAX_BITRATE_KBPS *
// 1000` -- see that constant's doc comment for why 8 Mbps // 1000` -- see that constant's doc comment for why 8 Mbps
// (this struct's previous value) isn't used here: real hardware // (this struct's previous value) isn't used here: real hardware
@ -70,7 +78,14 @@ impl Default for VideoParams {
// producing repeated multi-second freezes rather than just // producing repeated multi-second freezes rather than just
// softer video. // softer video.
max_bitrate_bps: 6_000_000, max_bitrate_bps: 6_000_000,
max_frame_rate_numerator: 30, // 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, max_frame_rate_denominator: 1,
} }
} }

View file

@ -10,7 +10,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use breadcast_core::caststream::{CastStreamEvent, VideoParams, WEBRTC_NAMESPACE}; use breadcast_core::caststream::{CastStreamEvent, WEBRTC_NAMESPACE};
use breadcast_core::net::local_lan_ip; use breadcast_core::net::local_lan_ip;
use breadcast_core::pipeline::{ use breadcast_core::pipeline::{
build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps, build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps,
@ -58,7 +58,7 @@ async fn main() -> anyhow::Result<()> {
let capture = breadcast_core::CaptureSession::start().await?; let capture = breadcast_core::CaptureSession::start().await?;
println!("Got PipeWire video node id: {}", capture.video_node_id()); println!("Got PipeWire video node id: {}", capture.video_node_id());
let (pipeline, appsink, encoder) = build_video_pipeline_for_streaming(capture.video_node_id())?; let (pipeline, appsink, encoder, video_params) = build_video_pipeline_for_streaming(capture.video_node_id())?;
// Watch the encode pipeline's own bus in the background -- see // Watch the encode pipeline's own bus in the background -- see
// mirror_test.rs's identical block for why this matters. // mirror_test.rs's identical block for why this matters.
@ -81,7 +81,7 @@ async fn main() -> anyhow::Result<()> {
&device.host, &device.host,
"sender-0", "sender-0",
session.transport_id(), session.transport_id(),
VideoParams::default(), video_params,
)?; )?;
let sender = Arc::new(sender); let sender = Arc::new(sender);

View file

@ -1,4 +1,6 @@
use std::io::{Read as _, Write as _};
use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::DirBuilderExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@ -8,6 +10,8 @@ use gstreamer::prelude::*;
use gstreamer_app as gst_app; use gstreamer_app as gst_app;
use gstreamer_video as gst_video; use gstreamer_video as gst_video;
use crate::caststream::VideoParams;
/// Builds (but doesn't start) the capture → encode → mux → HLS pipeline for /// Builds (but doesn't start) the capture → encode → mux → HLS pipeline for
/// a single video source. `output_dir` is created if it doesn't exist; /// a single video source. `output_dir` is created if it doesn't exist;
/// `hlssink3` writes `segment%05d.ts` files and `playlist.m3u8` there. /// `hlssink3` writes `segment%05d.ts` files and `playlist.m3u8` there.
@ -145,31 +149,243 @@ pub fn build_video_pipeline(video_node_id: u32, output_dir: &Path) -> Result<gst
/// `CastStreamSender::estimated_bandwidth_bps()` — see /// `CastStreamSender::estimated_bandwidth_bps()` — see
/// [`request_key_frame`]/`set_video_bitrate_kbps` for the two knobs a /// [`request_key_frame`]/`set_video_bitrate_kbps` for the two knobs a
/// congestion-control loop needs). /// congestion-control loop needs).
/// Which capture-side memory path [`build_video_pipeline_for_streaming`] asks
/// PipeWire (and through it, the compositor's portal implementation) to hand
/// this pipeline. The two are not interchangeable: they have *different*
/// known failure modes on different compositor versions, which is why the
/// choice is made at runtime rather than baked into one pipeline string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CaptureBackend {
/// Zero-copy: `pipewiresrc` hands DMA-BUFs straight to `vapostproc`,
/// which hands VA-memory straight to `vah264enc`. Nothing ever touches
/// system memory, and the portal never falls back to `wl_shm`.
Dmabuf,
/// `videoconvert ! videoscale ! videorate` on plain system memory, which
/// makes PipeWire request `wl_shm` buffers from the portal. CPU-costly
/// and stall-prone (see [`choose_capture_backend`]), but it does not go
/// anywhere near a compositor's DMA-BUF render-into-client-buffer path.
Shm,
}
/// First Hyprland release containing `renderer/rbo: avoid nullptr deref`
/// (upstream commit `ae1690c2`, PR #15167, 2026-06-18), tagged in v0.56.0 on
/// 2026-07-20.
///
/// Below this, asking Hyprland for a DMA-BUF screencast can **kill the
/// user's entire compositor session**, and there is nothing this pipeline
/// can do about it from the client side. The mechanism, confirmed against a
/// real coredump on v0.55.4 plus upstream's own source:
/// `Screenshare::CScreenshareFrame::copyDmabuf()` renders the monitor into
/// the client-supplied DMA-BUF via `IHyprRenderer::beginRender` ->
/// `getOrCreateRenderbuffer`. If `CGLRenderbuffer`'s constructor fails to
/// import the buffer (`createEGLImage` returns `EGL_NO_IMAGE_KHR`) it
/// early-returns leaving `m_framebuffer` null -- and v0.55.4's destructor
/// then unconditionally does `unbind(); m_framebuffer->release();` on that
/// null pointer while the failed renderbuffer is being torn down. The
/// resulting abort takes down Hyprland, every window, and (separately,
/// same instant) `xdg-desktop-portal-hyprland`. Upstream's fix is a one-line
/// `if (m_framebuffer)` guard; it converts the abort into a dropped frame.
///
/// Reported upstream at least three times (hyprwm/Hyprland #13487, #13543,
/// #13653, all v0.54.x, all on AMD) and auto-closed unread by the
/// issues-are-disabled bot rather than triaged, so the "is it fixed?"
/// question can only be answered from the commit log, not the tracker. The
/// reported triggers (window-group tab switching, touchpad gestures, an
/// emulator in a Discord stream) have nothing in common with each other or
/// with resolution -- treat the import failure as intermittent, not as
/// something a particular capture geometry or DRM modifier provokes.
///
/// Note in particular that this is *not* avoidable by requesting a
/// "simpler" buffer layout. `vapostproc` advertises exactly one AMD DRM
/// modifier on its `video/x-raw(memory:DMABuf)` pads -- `0x0200000008401b04`
/// = GFX11, 64K_R_X tiling, `DCC=0` (verified with `gst-inspect-1.0
/// vapostproc` and `drm_fourcc.h`'s field shifts). It offers no LINEAR
/// alternative, and the one modifier it does offer is already uncompressed,
/// so there is no tiling/compression hazard left to negotiate away.
const HYPRLAND_MIN_SAFE_DMABUF: (u32, u32, u32) = (0, 56, 0);
/// Reads the running Hyprland's version over its own IPC socket (the same
/// `j/version` request `hyprctl version -j` makes) without spawning
/// `hyprctl`, which needn't be installed. `None` if this isn't a Hyprland
/// session at all, or if the version can't be determined.
fn hyprland_version() -> Option<(u32, u32, u32)> {
let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?;
let mut socket = UnixStream::connect(format!("{runtime_dir}/hypr/{signature}/.socket.sock")).ok()?;
// Bounded on both halves: this runs on the way into starting a mirror
// session, and a wedged compositor must not be able to hang that.
socket.set_write_timeout(Some(Duration::from_secs(1))).ok()?;
socket.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
socket.write_all(b"j/version").ok()?;
let mut response = String::new();
socket.read_to_string(&mut response).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&response).ok()?;
// `version` is the plain "0.55.4"; `tag` is "v0.55.4" and is what older
// Hyprlands report, so accept either.
let raw = parsed.get("version").or_else(|| parsed.get("tag"))?.as_str()?;
parse_hyprland_version(raw)
}
/// Splits a Hyprland version string into comparable components. Strips a
/// leading `v` (`tag` carries one, `version` doesn't) and anything from the
/// first `-` (a git build's tag looks like `v0.55.4-123-gdeadbee`).
fn parse_hyprland_version(raw: &str) -> Option<(u32, u32, u32)> {
let mut parts = raw.trim().trim_start_matches('v').split('-').next()?.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
// A two-component "0.56" is treated as 0.56.0 rather than rejected --
// erring toward *parsing* here is safe, since the comparison against
// `HYPRLAND_MIN_SAFE_DMABUF` is what decides anything.
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
/// Picks the capture path, trading two *different* real bugs off against
/// each other rather than pretending either one is hypothetical.
///
/// [`CaptureBackend::Dmabuf`] is the better path and the default: it is
/// genuinely zero-copy, and it sidesteps `xdg-desktop-portal-hyprland`'s
/// `wl_shm` stall entirely. That stall is not a hiccup -- it is terminal.
/// In xdpw's `src/portals/Screencopy.cpp`, when the PipeWire consumer is
/// holding every buffer, the portal logs "Out of buffers" and re-queues a
/// frame only while `copyRetries++ < MAX_RETRIES` (10); `copyRetries` is
/// reset to 0 *only* on a successful copy. So ten consecutive misses and
/// the portal stops requesting frames forever, without sending an error to
/// PipeWire -- which is exactly why a 45-second freeze showed up in
/// `journalctl` and nowhere on this pipeline's own GStreamer bus. (Worth
/// keeping in mind that "the consumer is holding every buffer" means the
/// stall can *originate* downstream: a brief encoder or RTP-send stall stops
/// buffers being recycled, and the portal's give-up logic then makes it
/// permanent. [`pull_encoded_frame`]'s watchdog is the backstop for both.)
///
/// But on Hyprland older than [`HYPRLAND_MIN_SAFE_DMABUF`] the DMA-BUF path
/// can abort the compositor outright, which is a categorically worse outcome
/// than a stalled cast -- so there, fall back to `wl_shm` and let the
/// watchdog bound the damage. Non-Hyprland sessions are unaffected by that
/// bug and keep DMA-BUF.
fn choose_capture_backend() -> CaptureBackend {
let Some(version) = hyprland_version() else {
// Either not Hyprland (so the Hyprland-specific crash can't apply),
// or Hyprland with an unreadable version. The latter is the
// ambiguous case; prefer the path that cannot take the desktop down.
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
tracing::warn!(
"running under Hyprland but could not read its version; using the slower wl_shm \
capture path, since DMA-BUF screencast aborts the compositor before v{}.{}.{}",
HYPRLAND_MIN_SAFE_DMABUF.0,
HYPRLAND_MIN_SAFE_DMABUF.1,
HYPRLAND_MIN_SAFE_DMABUF.2,
);
return CaptureBackend::Shm;
}
return CaptureBackend::Dmabuf;
};
if version < HYPRLAND_MIN_SAFE_DMABUF {
tracing::warn!(
hyprland = format!("{}.{}.{}", version.0, version.1, version.2),
"this Hyprland predates the fix for the DMA-BUF screencast compositor crash \
(upstream PR #15167, released in v0.56.0) -- falling back to the slower, \
stall-prone wl_shm capture path. Updating Hyprland restores zero-copy capture."
);
return CaptureBackend::Shm;
}
CaptureBackend::Dmabuf
}
pub fn build_video_pipeline_for_streaming( pub fn build_video_pipeline_for_streaming(
video_node_id: u32, video_node_id: u32,
) -> Result<(gst::Pipeline, gst_app::AppSink, gst::Element)> { ) -> Result<(gst::Pipeline, gst_app::AppSink, gst::Element, VideoParams)> {
gst::init().context("failed to initialize GStreamer")?; gst::init().context("failed to initialize GStreamer")?;
// 1280x720@30 Main profile. Briefly raised to 1080p, then reverted here: // 1920x1080@native-rate Main profile. Went 1080p -> 720p -> 1080p again
// a near-instant freeze *on a faster network* turned out to have nothing // tonight: the first 1080p attempt froze near-instantly, but that had
// to do with resolution or bandwidth at all -- see `frame_chain_broken` // nothing to do with resolution -- it was the `xdg-desktop-portal-hyprland`
// in `breadcast-caststream-sys/src/facade.cc` for the actual bug (the // wl_shm buffer-exhaustion bug below (real, structural, whatever the
// FFI silently drops frames under openscreen's in-flight budget and lets // resolution) compounded by openscreen's in-flight RTP budget being too
// the encoder's reference chain corrupt as a result). 1080p roughly // tight for this receiver's actual RTT (see `frame_chain_broken` in
// tripled the per-frame packet count, which made that bug's real trigger // `breadcast-caststream-sys/src/facade.cc`, and the playout-delay tuning
// -- exceeding the in-flight window -- worse, not the resolution itself. // in `breadcast-caststream-sys/src/session.cc`). With both of those
// Reverted alongside fixing that bug rather than keeping both variables // fixed -- confirmed via a real, freeze-free 720p session -- the
// in motion at once; revisit once `frame_chain_broken` on its own is // packet-count increase 1080p brings back is no longer landing on an
// confirmed to have fixed the freeze at 720p. // already-struggling budget, so it's worth trying again on its own
let pipeline_str = "pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \ // merits.
videoconvert ! videoscale ! videorate ! \ //
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \ // On the DMA-BUF path `pipewiresrc` deliberately does *not* go through
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \ // `videoconvert ! videoscale ! videorate ! video/x-raw,...` (plain
video/x-h264,profile=main ! \ // system-memory caps) -- doing so forces PipeWire to hand the
h264parse name=h264parse config-interval=-1 ! \ // compositor's portal implementation a `wl_shm` (shared-memory) buffer
video/x-h264,stream-format=byte-stream,alignment=au ! \ // request, and on this system (`xdg-desktop-portal-hyprland`) that path
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true" // is real-world buggy: journalctl during a live freeze showed it
.replace("%VIDEO_NODE_ID%", &video_node_id.to_string()); // repeatedly logging "Asked for a wl_shm buffer which is legacy" / "Out
// of buffers" / "Retrying screencopy" in a tight loop that never
// actually delivered a frame -- multi-second (once 45+ second) stalls
// with *zero* signal on breadcast's own GStreamer bus, since nothing
// here was erroring, it was just starved waiting on a buffer the
// portal's legacy path never produced. See `choose_capture_backend` for
// why that stall is permanent rather than transient, and for the one
// case where it's still the lesser evil.
//
// `vapostproc` (VA-API postprocessor -- confirmed present via
// `gst-inspect-1.0 vapostproc`, ships in `gst-plugins-bad`'s `va`
// plugin) accepts `video/x-raw(memory:DMABuf)` directly from
// `pipewiresrc` and outputs `video/x-raw(memory:VAMemory)`, which
// `vah264enc` also accepts natively -- a fully zero-copy DMA-BUF path
// from portal to hardware encoder that never touches the legacy wl_shm
// fallback at all. No `videorate` in this path: `vapostproc` is a
// per-frame transform (scale/convert), not a temporal one, so it can't
// do frame-rate reduction the way `videorate` does on raw memory --
// frames flow at whatever rate PipeWire actually delivers rather than a
// forced 30fps. This is fine for RTP: `facade.cc` derives RTP timestamps
// from each frame's real capture time regardless of the nominal rate,
// and openscreen's own frame pacing doesn't assume a fixed source rate
// either. The returned `VideoParams` advertises 60 as a *ceiling*
// (`max_frame_rate_*`), which stays truthful whether the compositor
// actually delivers 60 or fewer; the wl_shm path's `videorate` does cap
// hard, so it advertises the rate it really enforces.
//
// Both branches return the geometry they actually encode, and the caller
// hands that straight to the Cast OFFER. That coupling is deliberate:
// advertising a resolution other than what's really sent is a genuine
// protocol mismatch this project has already been bitten by once, and
// keeping two constants manually in sync across two files is how that
// happened. Returning it makes the mismatch unrepresentable.
let (pipeline_str, params) = match choose_capture_backend() {
CaptureBackend::Dmabuf => (
"pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \
video/x-raw(memory:DMABuf),format=DMA_DRM ! \
vapostproc ! \
video/x-raw(memory:VAMemory),format=NV12,width=1920,height=1080 ! \
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \
video/x-h264,profile=main ! \
h264parse name=h264parse config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true",
VideoParams { width: 1920, height: 1080, max_frame_rate_numerator: 60, ..VideoParams::default() },
),
// 720p30 rather than 1080p60 on this path on purpose: every frame is
// a CPU convert + scale here, and CPU cost is precisely what makes
// the portal's "Out of buffers" give-up more likely, since the
// portal runs out exactly when the consumer is slow to recycle
// buffers. The lighter shape is also what was last known to work on
// real hardware before the DMA-BUF switch.
CaptureBackend::Shm => (
"pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \
videoconvert ! videoscale ! videorate ! \
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \
video/x-h264,profile=main ! \
h264parse name=h264parse config-interval=-1 ! \
video/x-h264,stream-format=byte-stream,alignment=au ! \
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true",
VideoParams { width: 1280, height: 720, max_frame_rate_numerator: 30, ..VideoParams::default() },
),
};
let pipeline_str = pipeline_str.replace("%VIDEO_NODE_ID%", &video_node_id.to_string());
let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?; let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?;
let Ok(pipeline) = element.downcast::<gst::Pipeline>() else { let Ok(pipeline) = element.downcast::<gst::Pipeline>() else {
@ -184,12 +400,41 @@ pub fn build_video_pipeline_for_streaming(
let encoder = pipeline.by_name("venc").context("parsed pipeline has no element named 'venc'")?; let encoder = pipeline.by_name("venc").context("parsed pipeline has no element named 'venc'")?;
Ok((pipeline, appsink, encoder)) Ok((pipeline, appsink, encoder, params))
} }
/// How long [`pull_encoded_frame`] waits per `try_pull_sample` call. Short
/// enough that a teardown from another thread is noticed promptly, long
/// enough not to spin.
const CAPTURE_STALL_POLL: Duration = Duration::from_millis(250);
/// How long [`pull_encoded_frame`] tolerates a *playing* pipeline producing
/// no frames at all before declaring the capture dead.
///
/// This exists because the failure it catches is otherwise completely
/// silent. `xdg-desktop-portal-hyprland` stops requesting frames after ten
/// consecutive "Out of buffers" misses and never sends an error to PipeWire
/// (see [`choose_capture_backend`]); a compositor that fails to import a
/// capture buffer likewise just drops the frame. In both cases GStreamer has
/// nothing to report -- no bus error, no EOS, no flow-return failure -- so
/// without a timeout here the frame pump blocks in `pull_sample` forever and
/// the mirror session appears frozen with nothing anywhere saying why. That
/// is precisely the 45-second freeze that took a `journalctl` dig to
/// explain.
///
/// Bailing propagates out of `breadcastd`'s frame-pump thread, which already
/// reports `DaemonCommand::SessionEnded` on exit, so the session tears down
/// and the failure surfaces as a real event instead of a hang. Generous
/// enough (10s) that a merely slow moment -- a heavy compositor frame, a
/// bitrate renegotiation -- doesn't trip it; anything longer than this is
/// not a hiccup, since neither of the known failure modes recovers.
const CAPTURE_STALL_TIMEOUT: Duration = Duration::from_secs(10);
/// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking /// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking
/// until one is available. Returns `None` once the pipeline reaches EOS or /// until one is available. Returns `None` once the pipeline reaches EOS or
/// the sink otherwise stops (e.g. pipeline torn down from another thread). /// the sink otherwise stops (e.g. pipeline torn down from another thread),
/// and errors if the pipeline is still playing but has gone
/// [`CAPTURE_STALL_TIMEOUT`] without producing a frame.
/// ///
/// A buffer with no PTS is skipped (this pulls the next one instead) rather /// A buffer with no PTS is skipped (this pulls the next one instead) rather
/// than reported with a substituted timestamp, as an earlier version did /// than reported with a substituted timestamp, as an earlier version did
@ -203,12 +448,38 @@ pub fn build_video_pipeline_for_streaming(
/// redundant, since `enqueue_frame` is a public FFI entry point that has to /// redundant, since `enqueue_frame` is a public FFI entry point that has to
/// hold up against any caller. /// hold up against any caller.
pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>, bool, i64)>> { pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>, bool, i64)>> {
let poll = gst::ClockTime::from_mseconds(CAPTURE_STALL_POLL.as_millis() as u64);
let mut stalled_for = Duration::ZERO;
loop { loop {
let sample = match appsink.pull_sample() { let Some(sample) = appsink.try_pull_sample(Some(poll)) else {
Ok(sample) => sample, // EOS is the ordinary end: the user hit "Stop sharing" in the
Err(_) if appsink.is_eos() => return Ok(None), // portal, or the source went away.
Err(e) => bail!("appsink pull_sample failed: {e}"), if appsink.is_eos() {
return Ok(None);
}
// Teardown from another thread (`CastMirrorSession::stop` sets
// the pipeline to Null) makes the sink flush, and a flushing
// sink returns `None` *immediately* rather than after the
// timeout. Treat that as a clean end too -- otherwise this would
// busy-spin for the whole stall budget and then report a
// spurious "capture stalled" on every normal stop.
if !matches!(appsink.current_state(), gst::State::Playing | gst::State::Paused) {
return Ok(None);
}
stalled_for += CAPTURE_STALL_POLL;
if stalled_for < CAPTURE_STALL_TIMEOUT {
continue;
}
bail!(
"capture stalled: no encoded frame for {}s while the pipeline was still \
playing (no GStreamer error, no EOS). This is the shape of a portal-side \
give-up -- see `choose_capture_backend` -- rather than a pipeline fault, \
and it will not recover on its own",
CAPTURE_STALL_TIMEOUT.as_secs()
);
}; };
stalled_for = Duration::ZERO;
let buffer = sample.buffer().context("pulled sample had no buffer")?; let buffer = sample.buffer().context("pulled sample had no buffer")?;
let Some(capture_time_us) = buffer.pts().map(|t| t.useconds() as i64) else { let Some(capture_time_us) = buffer.pts().map(|t| t.useconds() as i64) else {
tracing::debug!("skipped an encoded frame with no PTS"); tracing::debug!("skipped an encoded frame with no PTS");
@ -352,3 +623,33 @@ pub async fn wait_for_playlist_segments(playlist_path: &Path, min_segments: usiz
tokio::time::sleep(Duration::from_millis(250)).await; tokio::time::sleep(Duration::from_millis(250)).await;
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_both_shapes_hyprland_reports() {
// `version` (plain) and `tag` (v-prefixed) from the same running
// compositor, plus the `-N-gSHA` suffix a git build's tag carries.
assert_eq!(parse_hyprland_version("0.55.4"), Some((0, 55, 4)));
assert_eq!(parse_hyprland_version("v0.55.4"), Some((0, 55, 4)));
assert_eq!(parse_hyprland_version("v0.56.0-123-gdeadbee"), Some((0, 56, 0)));
assert_eq!(parse_hyprland_version("0.56"), Some((0, 56, 0)));
assert_eq!(parse_hyprland_version(" v0.56.2\n"), Some((0, 56, 2)));
assert_eq!(parse_hyprland_version(""), None);
assert_eq!(parse_hyprland_version("unknown"), None);
}
#[test]
fn straddles_the_dmabuf_crash_fix_correctly() {
// The whole point of the constant: v0.55.4 aborts the compositor on
// a DMA-BUF screencast, v0.56.0 is the first release with the fix.
assert!(parse_hyprland_version("0.55.4").unwrap() < HYPRLAND_MIN_SAFE_DMABUF);
assert!(parse_hyprland_version("0.55.99").unwrap() < HYPRLAND_MIN_SAFE_DMABUF);
assert!(parse_hyprland_version("0.56.0").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF);
assert!(parse_hyprland_version("0.56.1").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF);
assert!(parse_hyprland_version("1.0.0").unwrap() >= HYPRLAND_MIN_SAFE_DMABUF);
}
}

View file

@ -13,7 +13,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use breadcast_core::caststream::{CastStreamEvent, VideoParams, WEBRTC_NAMESPACE}; use breadcast_core::caststream::{CastStreamEvent, WEBRTC_NAMESPACE};
use breadcast_core::pipeline::{ use breadcast_core::pipeline::{
build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps, build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps,
}; };
@ -87,7 +87,13 @@ impl CastMirrorSession {
let capture = CaptureSession::start().await.context("failed to start portal screen capture")?; let capture = CaptureSession::start().await.context("failed to start portal screen capture")?;
let video_node_id = capture.video_node_id(); let video_node_id = capture.video_node_id();
let (pipeline, appsink, encoder) = // `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")?; build_video_pipeline_for_streaming(video_node_id).context("failed to build the encode pipeline")?;
{ {
@ -116,7 +122,7 @@ impl CastMirrorSession {
.context("failed to connect and launch the Mirroring receiver")?; .context("failed to connect and launch the Mirroring receiver")?;
let (sender, stream_events) = let (sender, stream_events) =
CastStreamSender::start(&device.host, "sender-0", session.transport_id(), VideoParams::default()) CastStreamSender::start(&device.host, "sender-0", session.transport_id(), video_params)
.context("failed to start the Cast Streaming session")?; .context("failed to start the Cast Streaming session")?;
let sender = Arc::new(sender); let sender = Arc::new(sender);