From 45191aee666d55c71d61824055326dce341cf559 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 1 Oct 2026 00:19:55 +0200 Subject: [PATCH] fix(linux): keep the capture clock ticking while cursor messages flow The PipeWire helper's loop advanced the capture clock, which holds the last picture forward and drains the audio rings, only on a new frame or when recv_timeout expired. Every other message restarts that timeout, and cursor messages arrive at the compositor's rate while the mouse moves: on a static screen with a moving mouse nothing advanced, and the 2 s audio rings dropped their oldest samples. The heartbeat is now checked at the top of every pass, whatever woke the loop, and is the one place a tick advances the clock. Fixes #936 --- electron/native/pipewire-capture/src/main.rs | 66 ++++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/electron/native/pipewire-capture/src/main.rs b/electron/native/pipewire-capture/src/main.rs index 44d96ae7d..d045f5716 100644 --- a/electron/native/pipewire-capture/src/main.rs +++ b/electron/native/pipewire-capture/src/main.rs @@ -662,8 +662,31 @@ fn run( // means the import never works, which would otherwise record nothing — see // MAX_CONSECUTIVE_IMPORT_FAILURES. let mut consecutive_drops: u32 = 0; + // When the capture clock last advanced. See the heartbeat at the top of the loop. + let mut last_advance = Instant::now(); loop { + // THE HEARTBEAT, CHECKED ON EVERY PASS, NOT ONLY ON A TIMEOUT. `advance` + // holds the last picture forward and drains the audio rings, and it used + // to run only on a new frame or when `recv_timeout` expired. Any other + // message restarts that timeout, and cursor messages arrive at the + // compositor's rate while the mouse moves: a static screen with a moving + // mouse never timed out, so nothing drained and the 2 s rings dropped + // their oldest audio (getopenscreen/openscreen#936). + if heartbeat_due(last_advance, Instant::now(), config.tick) { + last_advance = Instant::now(); + if let Some(capture) = capture.as_mut() { + if let Err(message) = capture.advance() { + let _ = emitter.emit(&Event::Error { + code: "encode-failed".to_owned(), + message, + }); + exit_code = 1; + break; + } + } + } + // Return PipeWire buffers whose dmabuf imports completed last iteration // (or that were superseded on the capture thread). This MUST run on this // loop, not the PipeWire thread — `Session::requeue` takes the thread-loop @@ -918,6 +941,7 @@ fn run( } } if let Some(capture) = capture.as_mut() { + last_advance = Instant::now(); if let Err(message) = capture.advance() { let _ = emitter.emit(&Event::Error { code: "encode-failed".to_owned(), @@ -1239,19 +1263,8 @@ fn run( emit_sample(emitter, &cursor, content_rect(&capture, size), &mut pending_asset, None); last_emit = Instant::now(); } - // The heartbeat that keeps the output at a constant frame rate - // while the screen is static: no frame arrived, but the clock - // moved, so the last picture is held forward. - if let Some(capture) = capture.as_mut() { - if let Err(message) = capture.advance() { - let _ = emitter.emit(&Event::Error { - code: "encode-failed".to_owned(), - message, - }); - exit_code = 1; - break; - } - } + // No advance here: a timeout means a tick has passed, so the + // heartbeat at the top of the next pass holds the picture forward. } Err(RecvTimeoutError::Disconnected) => break, @@ -1349,6 +1362,11 @@ fn finish_capture( /// only rectangle the file shows. Before then, and for a cursor-only session /// that opens no encoder at all, the whole negotiated stream, which is what the /// consumer of a cursor-only recording is compositing over. +/// Whether a tick has passed since the capture clock last advanced. +fn heartbeat_due(last_advance: Instant, now: Instant, tick: Duration) -> bool { + now.saturating_duration_since(last_advance) >= tick +} + fn content_rect(capture: &Option, size: Option<(i32, i32)>) -> Option { match capture { Some(capture) if capture.started() => Some(capture.content_rect()), @@ -1563,6 +1581,28 @@ mod cursor_sample_tests { assert!(buffer.is_empty(), "emitted {}", String::from_utf8_lossy(&buffer)); } + /// #936: messages arriving faster than the tick must not hold the heartbeat + /// off. The loop checks it on every pass, so what matters is that it comes + /// due once a tick has passed, however many passes happened in between. + #[test] + fn the_heartbeat_comes_due_every_tick_whatever_else_arrives() { + let tick = Duration::from_millis(16); + let start = Instant::now(); + let mut last_advance = start; + let mut advances = 0; + // A cursor message every 7 ms (144 Hz) for one second of loop passes. + for pass in 0..=(1000 / 7) { + let now = start + Duration::from_millis(pass * 7); + if heartbeat_due(last_advance, now, tick) { + last_advance = now; + advances += 1; + } + } + // Every 21 ms at worst (three 7 ms passes): about 47 a second, never zero. + assert!(advances >= 1000 / 21, "{advances} advances in a second of cursor traffic"); + assert!(!heartbeat_due(start, start + Duration::from_millis(15), tick)); + } + /// A cursor-only session opens no encoder, so it falls back to the whole /// stream; once pixels are flowing the encoder's rectangle wins. #[test]