Skip to content

Commit 45191ae

Browse files
committed
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
1 parent 345e225 commit 45191ae

1 file changed

Lines changed: 53 additions & 13 deletions

File tree

  • electron/native/pipewire-capture/src

‎electron/native/pipewire-capture/src/main.rs‎

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -662,8 +662,31 @@ fn run<W: Write>(
662662
// means the import never works, which would otherwise record nothing — see
663663
// MAX_CONSECUTIVE_IMPORT_FAILURES.
664664
let mut consecutive_drops: u32 = 0;
665+
// When the capture clock last advanced. See the heartbeat at the top of the loop.
666+
let mut last_advance = Instant::now();
665667

666668
loop {
669+
// THE HEARTBEAT, CHECKED ON EVERY PASS, NOT ONLY ON A TIMEOUT. `advance`
670+
// holds the last picture forward and drains the audio rings, and it used
671+
// to run only on a new frame or when `recv_timeout` expired. Any other
672+
// message restarts that timeout, and cursor messages arrive at the
673+
// compositor's rate while the mouse moves: a static screen with a moving
674+
// mouse never timed out, so nothing drained and the 2 s rings dropped
675+
// their oldest audio (getopenscreen/openscreen#936).
676+
if heartbeat_due(last_advance, Instant::now(), config.tick) {
677+
last_advance = Instant::now();
678+
if let Some(capture) = capture.as_mut() {
679+
if let Err(message) = capture.advance() {
680+
let _ = emitter.emit(&Event::Error {
681+
code: "encode-failed".to_owned(),
682+
message,
683+
});
684+
exit_code = 1;
685+
break;
686+
}
687+
}
688+
}
689+
667690
// Return PipeWire buffers whose dmabuf imports completed last iteration
668691
// (or that were superseded on the capture thread). This MUST run on this
669692
// loop, not the PipeWire thread — `Session::requeue` takes the thread-loop
@@ -918,6 +941,7 @@ fn run<W: Write>(
918941
}
919942
}
920943
if let Some(capture) = capture.as_mut() {
944+
last_advance = Instant::now();
921945
if let Err(message) = capture.advance() {
922946
let _ = emitter.emit(&Event::Error {
923947
code: "encode-failed".to_owned(),
@@ -1239,19 +1263,8 @@ fn run<W: Write>(
12391263
emit_sample(emitter, &cursor, content_rect(&capture, size), &mut pending_asset, None);
12401264
last_emit = Instant::now();
12411265
}
1242-
// The heartbeat that keeps the output at a constant frame rate
1243-
// while the screen is static: no frame arrived, but the clock
1244-
// moved, so the last picture is held forward.
1245-
if let Some(capture) = capture.as_mut() {
1246-
if let Err(message) = capture.advance() {
1247-
let _ = emitter.emit(&Event::Error {
1248-
code: "encode-failed".to_owned(),
1249-
message,
1250-
});
1251-
exit_code = 1;
1252-
break;
1253-
}
1254-
}
1266+
// No advance here: a timeout means a tick has passed, so the
1267+
// heartbeat at the top of the next pass holds the picture forward.
12551268
}
12561269

12571270
Err(RecvTimeoutError::Disconnected) => break,
@@ -1349,6 +1362,11 @@ fn finish_capture<W: Write>(
13491362
/// only rectangle the file shows. Before then, and for a cursor-only session
13501363
/// that opens no encoder at all, the whole negotiated stream, which is what the
13511364
/// consumer of a cursor-only recording is compositing over.
1365+
/// Whether a tick has passed since the capture clock last advanced.
1366+
fn heartbeat_due(last_advance: Instant, now: Instant, tick: Duration) -> bool {
1367+
now.saturating_duration_since(last_advance) >= tick
1368+
}
1369+
13521370
fn content_rect(capture: &Option<Capture>, size: Option<(i32, i32)>) -> Option<shim::CropRect> {
13531371
match capture {
13541372
Some(capture) if capture.started() => Some(capture.content_rect()),
@@ -1563,6 +1581,28 @@ mod cursor_sample_tests {
15631581
assert!(buffer.is_empty(), "emitted {}", String::from_utf8_lossy(&buffer));
15641582
}
15651583

1584+
/// #936: messages arriving faster than the tick must not hold the heartbeat
1585+
/// off. The loop checks it on every pass, so what matters is that it comes
1586+
/// due once a tick has passed, however many passes happened in between.
1587+
#[test]
1588+
fn the_heartbeat_comes_due_every_tick_whatever_else_arrives() {
1589+
let tick = Duration::from_millis(16);
1590+
let start = Instant::now();
1591+
let mut last_advance = start;
1592+
let mut advances = 0;
1593+
// A cursor message every 7 ms (144 Hz) for one second of loop passes.
1594+
for pass in 0..=(1000 / 7) {
1595+
let now = start + Duration::from_millis(pass * 7);
1596+
if heartbeat_due(last_advance, now, tick) {
1597+
last_advance = now;
1598+
advances += 1;
1599+
}
1600+
}
1601+
// Every 21 ms at worst (three 7 ms passes): about 47 a second, never zero.
1602+
assert!(advances >= 1000 / 21, "{advances} advances in a second of cursor traffic");
1603+
assert!(!heartbeat_due(start, start + Duration::from_millis(15), tick));
1604+
}
1605+
15661606
/// A cursor-only session opens no encoder, so it falls back to the whole
15671607
/// stream; once pixels are flowing the encoder's rectangle wins.
15681608
#[test]

0 commit comments

Comments
 (0)