From fc343d30f384e1a43dd6256ce2c21e6cf4a48b2b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 7 Nov 2026 11:52:15 +0100 Subject: [PATCH 1/4] fix(linux): retry on shared memory when every DMA-BUF frame arrives corrupted niri on the NVIDIA driver negotiates DMA-BUF, then marks every chunk SPA_CHUNK_FLAG_CORRUPTED with size 0, so no frame ever reaches the encoder and the app gives up after 10 s. After three such frames before any usable one, reconnect through the retained portal remote offering shm alone, the path the allocation-failure retry already takes. The GPU-import branch now reports corrupted frames too, so it triggers the same retry. Refs #1503 --- .../native/pipewire-capture/csrc/pw_shim.c | 10 +- electron/native/pipewire-capture/src/main.rs | 138 ++++++++++++++---- 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/electron/native/pipewire-capture/csrc/pw_shim.c b/electron/native/pipewire-capture/csrc/pw_shim.c index c85fb5da5..ddeeb7967 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.c +++ b/electron/native/pipewire-capture/csrc/pw_shim.c @@ -1463,10 +1463,16 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe stride = data->chunk->stride; if (is_dmabuf_import) { + /* Reported like the CPU path does, so a stream that only ever delivers + * corrupted frames can be renegotiated on shared memory (issue #1503). */ + if (SPA_FLAG_IS_SET(chunk_flags, SPA_CHUNK_FLAG_CORRUPTED)) { + osc_report_frame_drop(session, data, mapped_len, available, chunk_offset, + chunk_size, chunk_flags, stride, OSC_FRAME_BOUNDS_CORRUPTED); + return 0; + } /* A zero-sized chunk is how a compositor ships a cursor update with no new * frame attached. Not an error, just not a frame. */ - if (chunk_size == 0 || height <= 0 || - SPA_FLAG_IS_SET(chunk_flags, SPA_CHUNK_FLAG_CORRUPTED)) { + if (chunk_size == 0 || height <= 0) { return 0; } /* diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 2313c8868..1e451fd82 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -656,6 +656,61 @@ fn should_retry_shared_memory( && error.is_some_and(|message| message.contains("alloc buffers")) } +/// niri on the NVIDIA driver negotiates DMA-BUF, then marks every chunk +/// corrupted with size 0 (issue #1503), so no frame ever reaches the encoder. +/// Under the shim's five drop reports per stream, so the count is reached. +const CORRUPTED_FRAMES_BEFORE_RETRY: u32 = 3; + +fn is_corrupted_dmabuf_frame(code: &str, detail: &str) -> bool { + code == "frame-dropped" + && detail.contains("(producer-marked-frame-corrupted)") + && detail.contains("dataType=DmaBuf") +} + +/// Drops the failed stream and reconnects with the portal's independent +/// remote, offering the shm format alone; that attempt arms no further retry. +#[allow(clippy::too_many_arguments)] +fn retry_on_shared_memory( + emitter: &mut Emitter, + sender: &Sender, + frames: &Option>, + session: &mut Option, + portal_stream: &mut Option, + granted_kind: &mut Option, + fallback: SharedMemoryFallback, + shared_memory_fallback: &mut Option, + reason: &str, +) -> Result<(), ()> { + // Joined before reconnecting; queued messages from the old stream precede + // events from the new PipeWire thread. + drop(session.take()); + *portal_stream = None; + let _ = emitter.emit(&Event::Warning { + code: "capture-retry-shared-memory".to_owned(), + message: reason.to_owned(), + }); + let retry = portal::PortalStream { + fd: fallback.fd, + fallback_fd: None, + node_id: fallback.node_id, + position: fallback.position, + size: fallback.size, + source_kind: fallback.source_kind, + }; + begin_stream( + emitter, + sender, + frames, + session, + portal_stream, + granted_kind, + retry, + false, + true, + shared_memory_fallback, + ) +} + /// A stream error ends the helper only before capture has started, or the app /// waits on a recording that never starts. "Started" is the first staged frame /// in video mode, and `streaming` in cursor-only mode, which has no `Capture`. @@ -695,6 +750,8 @@ fn run( // Retain the portal grant while the preferred DMA-BUF format is starting, // so a buffer-allocation failure can retry shared-memory-first once. let mut shared_memory_fallback: Option = None; + // DMA-BUF frames the producer marked corrupted before any usable one. + let mut corrupted_frames = 0; // `record` has been received. Latched, because it can arrive before the // picker has been answered. let mut armed = false; @@ -864,6 +921,8 @@ fn run( None => held_frame.take(), }; if let Some(frame) = next { + // A usable frame: the corrupted-frame retry is moot. + shared_memory_fallback = None; // Opening the encoder is deferred to here because only a // frame carries the crop, and the crop — not the negotiated // format — is the size of a window. @@ -1253,10 +1312,31 @@ fn run( // Without this the only honest symptom is a file with no frames in // it and nothing anywhere saying why. Ok(Message::Stream(StreamEvent::CaptureIssue { code, detail })) => { + let corrupted = is_corrupted_dmabuf_frame(&code, &detail); let _ = emitter.emit(&Event::Warning { code, message: detail, }); + if corrupted && shared_memory_fallback.is_some() { + corrupted_frames += 1; + if corrupted_frames >= CORRUPTED_FRAMES_BEFORE_RETRY { + let fallback = shared_memory_fallback.take().expect("checked above"); + if let Err(()) = retry_on_shared_memory( + emitter, + &sender, + &frames, + &mut session, + &mut portal_stream, + &mut granted_kind, + fallback, + &mut shared_memory_fallback, + "The compositor marks every DMA-BUF frame corrupted; retrying screen capture with shared-memory buffers.", + ) { + exit_code = 1; + break; + } + } + } } Ok(Message::Stream(StreamEvent::State { state, error })) => { @@ -1281,9 +1361,12 @@ fn run( if streaming_since.is_none() { streaming_since = Some(timestamp_ms()); } - // No retry is appropriate after the compositor accepted - // the stream; release the extra portal fd immediately. - shared_memory_fallback = None; + // No allocation retry is appropriate after the compositor + // accepted the stream. A video session keeps the grant until + // its first frame, for the corrupted-frame retry (#1503). + if frames.is_none() { + shared_memory_fallback = None; + } } else if state == "unconnected" { streaming_since = None; } @@ -1296,42 +1379,20 @@ fn run( shared_memory_fallback.is_some(), ) { let fallback = shared_memory_fallback.take().expect("checked above"); - // Drop and join the failed stream before reconnecting with - // the portal's independent remote. The second attempt - // offers the shm format alone; it will not arm another retry. - drop(session.take()); - portal_stream = None; - let _ = emitter.emit(&Event::Warning { - code: "capture-retry-shared-memory".to_owned(), - message: "PipeWire buffer allocation failed; retrying screen capture with shared-memory buffers." - .to_owned(), - }); - let retry = portal::PortalStream { - fd: fallback.fd, - fallback_fd: None, - node_id: fallback.node_id, - position: fallback.position, - size: fallback.size, - source_kind: fallback.source_kind, - }; - if let Err(()) = begin_stream( + if let Err(()) = retry_on_shared_memory( emitter, &sender, &frames, &mut session, &mut portal_stream, &mut granted_kind, - retry, - false, - true, + fallback, &mut shared_memory_fallback, + "PipeWire buffer allocation failed; retrying screen capture with shared-memory buffers.", ) { exit_code = 1; break; } - // `begin_stream` already replaced the session and info; - // any queued messages from the old stream precede events - // from this new PipeWire thread. continue; } if let Some(error) = error { @@ -1905,7 +1966,8 @@ mod microphone_resolution_tests { #[cfg(test)] mod capture_fallback_tests { use super::{ - holds_for_crop, should_retry_shared_memory, stream_error_is_fatal, MAX_CROP_WAIT, + holds_for_crop, is_corrupted_dmabuf_frame, should_retry_shared_memory, + stream_error_is_fatal, MAX_CROP_WAIT, }; use std::time::Duration; @@ -1960,6 +2022,24 @@ mod capture_fallback_tests { )); } + #[test] + fn a_corrupted_dmabuf_frame_counts_toward_the_shared_memory_retry() { + // The shim's report for niri on NVIDIA (issue #1503). + let corrupted = |data_type: &str| { + format!( + "the compositor's buffer failed validation (producer-marked-frame-corrupted), so this frame was dropped: dataType={data_type} stride=10240 width=2560 height=1080 chunkOffset=0 chunkSize=0 available=11059200" + ) + }; + assert!(is_corrupted_dmabuf_frame("frame-dropped", &corrupted("DmaBuf"))); + // Shared memory already is the fallback. + assert!(!is_corrupted_dmabuf_frame("frame-dropped", &corrupted("MemFd"))); + assert!(!is_corrupted_dmabuf_frame( + "frame-dropped", + "the compositor's buffer failed validation (stride-shorter-than-row), so this frame was dropped: dataType=DmaBuf", + )); + assert!(!is_corrupted_dmabuf_frame("dmabuf-import-failed", &corrupted("DmaBuf"))); + } + #[test] fn does_not_retry_unrelated_pipewire_errors_or_non_error_states() { assert!(!should_retry_shared_memory( From 0113bd254b47d87090f438537fbceea1c697d62a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 11 Nov 2026 21:07:00 +0100 Subject: [PATCH 2/4] fix(linux): keep the shared-memory retry reachable until a frame stages Three review findings on the corrupted-frame retry: - the portal grant was released when a frame left the mailbox, before its GPU import could fail; it is now released once a frame stages; - corrupted-frame reports shared the shim's five-report budget with other drops, so other failures could keep the count under three; they now have their own budget; - events the old stream queued before it was joined are dropped at the retry, so a late error from it cannot end the new stream. Refs #1503 --- .../native/pipewire-capture/csrc/pw_shim.c | 9 ++++++-- electron/native/pipewire-capture/src/main.rs | 21 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/electron/native/pipewire-capture/csrc/pw_shim.c b/electron/native/pipewire-capture/csrc/pw_shim.c index ddeeb7967..96fe10870 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.c +++ b/electron/native/pipewire-capture/csrc/pw_shim.c @@ -233,6 +233,8 @@ struct osc_pw_session { int import_dmabuf; struct osc_dmabuf_map dmabuf_maps[OSC_MAX_DMABUF_MAPS]; int frame_drop_reports; + /* Own budget, so other drops cannot starve the corrupted-frame retry. */ + int corrupted_frame_reports; int capture_issue_reports; /* fd whose DMA_BUF_SYNC_START has not been closed by its END yet, or -1. * The bracket has to span the on_frame callback, not just osc_read_frame, @@ -740,6 +742,7 @@ static void osc_on_param_changed(void *userdata, uint32_t id, const struct spa_p * set actually in use rather than a set that no longer exists. */ session->buffer_info_reports = 0; session->frame_drop_reports = 0; + session->corrupted_frame_reports = 0; session->capture_issue_reports = 0; api.stream_update_params(session->stream, params, SPA_N_ELEMENTS(params)); @@ -1033,11 +1036,13 @@ static void osc_report_frame_drop(struct osc_pw_session *session, const struct s enum osc_frame_bounds_error error) { const char *reason = osc_frame_bounds_error_name(error); + int *reports = error == OSC_FRAME_BOUNDS_CORRUPTED ? &session->corrupted_frame_reports + : &session->frame_drop_reports; - if (session->frame_drop_reports >= OSC_FRAME_DROP_REPORTS) { + if (*reports >= OSC_FRAME_DROP_REPORTS) { return; } - session->frame_drop_reports++; + (*reports)++; /* * On the event stream, not only in a debug log. Every reason this reports is diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 1e451fd82..91cfa1c60 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -42,7 +42,7 @@ use std::collections::HashSet; use std::io::{BufRead, Write}; use std::os::fd::OwnedFd; use std::path::PathBuf; -use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -672,6 +672,7 @@ fn is_corrupted_dmabuf_frame(code: &str, detail: &str) -> bool { #[allow(clippy::too_many_arguments)] fn retry_on_shared_memory( emitter: &mut Emitter, + receiver: &Receiver, sender: &Sender, frames: &Option>, session: &mut Option, @@ -681,9 +682,15 @@ fn retry_on_shared_memory( shared_memory_fallback: &mut Option, reason: &str, ) -> Result<(), ()> { - // Joined before reconnecting; queued messages from the old stream precede - // events from the new PipeWire thread. + // Joined before reconnecting, so everything the old stream sent is queued. + // Its events are dropped, or a late error would end the new stream; the + // rest goes back on the channel. drop(session.take()); + for message in receiver.try_iter().collect::>() { + if !matches!(message, Message::Stream(_)) { + let _ = sender.send(message); + } + } *portal_stream = None; let _ = emitter.emit(&Event::Warning { code: "capture-retry-shared-memory".to_owned(), @@ -921,8 +928,6 @@ fn run( None => held_frame.take(), }; if let Some(frame) = next { - // A usable frame: the corrupted-frame retry is moot. - shared_memory_fallback = None; // Opening the encoder is deferred to here because only a // frame carries the crop, and the crop — not the negotiated // format — is the size of a window. @@ -1029,6 +1034,7 @@ fn run( let Some(capture) = capture.as_mut() else { // Cursor-only session: no encoder, nothing to stage. The // recycle below still has to run. + shared_memory_fallback = None; mailbox.recycle(frame.pixels); continue; }; @@ -1100,6 +1106,9 @@ fn run( // Only once a frame has actually staged — a first frame that // dropped leaves capture unstarted, so this waits for a real one. if first && capture.started() { + // A frame staged: the corrupted-frame retry is moot. Not + // earlier, as a first frame can still fail its GPU import. + shared_memory_fallback = None; let _ = emitter.emit(&Event::CaptureStarted { timestamp_ms: timestamp_ms(), path: config @@ -1323,6 +1332,7 @@ fn run( let fallback = shared_memory_fallback.take().expect("checked above"); if let Err(()) = retry_on_shared_memory( emitter, + &receiver, &sender, &frames, &mut session, @@ -1381,6 +1391,7 @@ fn run( let fallback = shared_memory_fallback.take().expect("checked above"); if let Err(()) = retry_on_shared_memory( emitter, + &receiver, &sender, &frames, &mut session, From da03b53d688ca8099d656242d7bd5d1678e26e3a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 12 Nov 2026 21:07:11 +0100 Subject: [PATCH 3/4] fix(linux): start the shared-memory retry from a clean slate When the corrupted-frame retry replaces the DMA-BUF stream, drop what the retired stream left behind: the frame held for its crop and the crop wait, the frame still in the mailbox and the buffers queued for return, the streaming edge the click gate compares against, and an encoder one of its frames opened but never started. That encoder may be bound to the DMA-BUF importer, which a shared-memory frame cannot feed. The audio sources are now cloned into the capture so a rebuilt one keeps them. Refs #1503 --- .../native/pipewire-capture/src/capture.rs | 1 + electron/native/pipewire-capture/src/main.rs | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/electron/native/pipewire-capture/src/capture.rs b/electron/native/pipewire-capture/src/capture.rs index 8eb8b7b01..84fcc8a26 100644 --- a/electron/native/pipewire-capture/src/capture.rs +++ b/electron/native/pipewire-capture/src/capture.rs @@ -43,6 +43,7 @@ use crate::ffmpeg as ff; use crate::shim::{self, AudioRing}; /// An audio capture to mux alongside the video. +#[derive(Clone)] pub struct AudioSource { /// "system" or "microphone" — the label a warning names. pub label: &'static str, diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 91cfa1c60..cf84911f3 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -686,6 +686,12 @@ fn retry_on_shared_memory( // Its events are dropped, or a late error would end the new stream; the // rest goes back on the channel. drop(session.take()); + // A frame still in the mailbox, and the buffers queued for return, belong + // to the stream just dropped: neither may reach the new one. + if let Some(mailbox) = frames.as_ref() { + let _ = mailbox.take(); + let _ = mailbox.drain_requeue(); + } for message in receiver.try_iter().collect::>() { if !matches!(message, Message::Stream(_)) { let _ = sender.send(message); @@ -804,7 +810,7 @@ fn run( // has settled by the time the first video frame arrives. Everything they // record before that first frame is discarded — see `Capture::stage`. // `_audio_sessions` is bound, not dropped: dropping one stops its thread. - let (_audio_sessions, mut audio_sources) = start_audio(emitter, &config.audio); + let (_audio_sessions, audio_sources) = start_audio(emitter, &config.audio); // Buffered until the format is negotiated: the encoder cannot be opened // before the frame size is known, and `pause` can arrive first. let mut paused = false; @@ -1001,7 +1007,9 @@ fn run( config.fps, config.bitrate, config.forced_encoder, - std::mem::take(&mut audio_sources), + // Cloned, not taken: an unstarted capture is rebuilt + // when the stream falls back to shared memory. + audio_sources.clone(), frame.dmabuf.as_ref(), ) { Ok((started, selection)) => { @@ -1330,6 +1338,19 @@ fn run( corrupted_frames += 1; if corrupted_frames >= CORRUPTED_FRAMES_BEFORE_RETRY { let fallback = shared_memory_fallback.take().expect("checked above"); + // Nothing of the retired stream carries over: a frame + // held for its crop, its crop wait, its streaming edge + // (the click gate), and an encoder that one of its + // frames opened but never started, which may be bound to + // the DMA-BUF importer the shm stream cannot feed. + if let Some(held) = held_frame.take() { + if let Some(mailbox) = frames.as_ref() { + mailbox.recycle(held.pixels); + } + } + crop_wait_since = None; + streaming_since = None; + capture = None; if let Err(()) = retry_on_shared_memory( emitter, &receiver, From fcc3c926f1bf0de99dc3019bc08dc10fd3568b31 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 12 Nov 2026 21:18:08 +0100 Subject: [PATCH 4/4] fix(linux): reopen a paused capture's audio rings before the shared-memory retry drops it The retry keeps the rings for the replacement capture. Pause had closed them, and a Resume landing before the replacement opens had no capture to resume, so the new capture recorded no audio. Refs #1503 --- .../native/pipewire-capture/src/capture.rs | 29 +++++++++++++++++++ electron/native/pipewire-capture/src/main.rs | 9 +++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/capture.rs b/electron/native/pipewire-capture/src/capture.rs index 84fcc8a26..17ebaba7d 100644 --- a/electron/native/pipewire-capture/src/capture.rs +++ b/electron/native/pipewire-capture/src/capture.rs @@ -1160,6 +1160,35 @@ mod tests { let _ = std::fs::remove_file(&output); } + #[test] + fn a_paused_capture_resumed_before_it_is_dropped_leaves_its_rings_open() { + // The shared-memory retry drops an unstarted capture but keeps its + // rings for the replacement. If that capture was paused, it must be + // resumed first, or the rings keep refusing samples for the next one. + let ring = Arc::new(AudioRing::new(1, AUDIO_SAMPLE_RATE as usize, AUDIO_CHANNELS)); + let output = std::env::temp_dir().join("openscreen-capture-retire-paused.mp4"); + let (mut capture, _) = Capture::start( + &output, + 320, + 240, + 30, + Some(1_000_000), + Some(Backend::Software), + vec![AudioSource { label: "system", ring: ring.clone(), gain: 1.0, bitrate: 128_000 }], + None, + ) + .expect("start"); + capture.pause(); + capture.resume(); + drop(capture); + + ring.push_for_test(&[0.5, -0.5]); + let mut drained = Vec::new(); + ring.drain_into(&mut drained); + assert!(!drained.is_empty(), "the ring must take samples after the capture is retired"); + let _ = std::fs::remove_file(&output); + } + #[test] fn microphone_gain_clamps_instead_of_wrapping() { // A boosted microphone that clips must clip flat. An out-of-range float diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index cf84911f3..b604fdb02 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -1350,7 +1350,14 @@ fn run( } crop_wait_since = None; streaming_since = None; - capture = None; + // Its audio rings outlive it through `audio_sources`. A + // pause closed them; reopen them before it goes, or a + // Resume that lands before the replacement opens has no + // capture to resume and the new one records no audio. + // The replacement re-pauses them if `paused` still holds. + if let Some(mut retired) = capture.take() { + retired.resume(); + } if let Err(()) = retry_on_shared_memory( emitter, &receiver,