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:
parent
bd511fea33
commit
2cc1310752
4 changed files with 368 additions and 46 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
//
|
||||||
|
// On the DMA-BUF path `pipewiresrc` deliberately does *not* go through
|
||||||
|
// `videoconvert ! videoscale ! videorate ! video/x-raw,...` (plain
|
||||||
|
// system-memory caps) -- doing so forces PipeWire to hand the
|
||||||
|
// compositor's portal implementation a `wl_shm` (shared-memory) buffer
|
||||||
|
// request, and on this system (`xdg-desktop-portal-hyprland`) that path
|
||||||
|
// is real-world buggy: journalctl during a live freeze showed it
|
||||||
|
// 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 ! \
|
videoconvert ! videoscale ! videorate ! \
|
||||||
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
|
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
|
||||||
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \
|
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \
|
||||||
video/x-h264,profile=main ! \
|
video/x-h264,profile=main ! \
|
||||||
h264parse name=h264parse config-interval=-1 ! \
|
h264parse name=h264parse config-interval=-1 ! \
|
||||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||||
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true"
|
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true",
|
||||||
.replace("%VIDEO_NODE_ID%", &video_node_id.to_string());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue