Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 87 additions & 38 deletions src/capturer/engine/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{
mpsc::{self, sync_channel, SyncSender},
},
thread::JoinHandle,
time::Duration,
time::{Duration, SystemTime},
};

use pipewire as pw;
Expand All @@ -31,7 +31,7 @@ use pw::{

use crate::{
capturer::Options,
frame::{BGRxFrame, Frame, RGBFrame, RGBxFrame, XBGRFrame},
frame::{BGRxFrame, Frame, RGBFrame, RGBxFrame, VideoFrame, XBGRFrame},
};

use self::{error::LinCapError, portal::ScreenCastPortal};
Expand All @@ -40,11 +40,15 @@ mod error;
mod portal;

static CAPTURER_STATE: AtomicU8 = AtomicU8::new(0);
static STREAM_STATE_CHANGED_TO_ERROR: AtomicBool = AtomicBool::new(false);
// Signals pipewire_capturer's main loop to stop early. Set both on a
// genuine PipeWire stream error and (see process_callback) when the frame
// receiver has disconnected -- both are "the loop should end now"
// conditions, so one flag models the intent accurately rather than two.
static STREAM_SHOULD_EXIT: AtomicBool = AtomicBool::new(false);

#[derive(Clone)]
struct ListenerUserData {
pub tx: mpsc::Sender<Frame>,
pub tx: mpsc::SyncSender<Frame>,
pub format: spa::param::video::VideoInfoRaw,
}

Expand Down Expand Up @@ -85,7 +89,7 @@ fn state_changed_callback(
match new {
StreamState::Error(e) => {
eprintln!("pipewire: State changed to error({e})");
STREAM_STATE_CHANGED_TO_ERROR.store(true, std::sync::atomic::Ordering::Relaxed);
STREAM_SHOULD_EXIT.store(true, std::sync::atomic::Ordering::Relaxed);
}
_ => {}
}
Expand Down Expand Up @@ -133,35 +137,80 @@ fn process_callback(stream: &StreamRef, user_data: &mut ListenerUserData) {
.to_vec()
};

if let Err(e) = match user_data.format.format() {
VideoFormat::RGBx => user_data.tx.send(Frame::RGBx(RGBxFrame {
display_time: timestamp as u64,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})),
VideoFormat::RGB => user_data.tx.send(Frame::RGB(RGBFrame {
display_time: timestamp as u64,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})),
VideoFormat::xBGR => user_data.tx.send(Frame::XBGR(XBGRFrame {
display_time: timestamp as u64,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})),
VideoFormat::BGRx => user_data.tx.send(Frame::BGRx(BGRxFrame {
display_time: timestamp as u64,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})),
// `timestamp` (from spa_meta_header.pts) is a PipeWire monotonic
// nanosecond count since an arbitrary reference — not wall-clock.
// display_time's SystemTime contract is wall-clock, so we use
// SystemTime::now() here (matches what the macOS and Windows
// engines do today). Relative frame ordering survives via
// channel-send order; sub-millisecond buffer timing is lost.
let _ = timestamp; // suppress "unused" warning until we wire pts elsewhere
let display_time = SystemTime::now();

// `try_send`, not `send`: this callback runs on the same thread
// that `pipewire_capturer`'s loop uses to poll CAPTURER_STATE
// between `pw_loop.iterate()` calls. A blocking `send()` on a
// bounded channel would stall this thread whenever the consumer
// falls behind, and since `LinuxCapturer::stop_capture()` joins
// this exact thread, a full channel + a paused consumer would
// make `stop_capture()` hang forever. Dropping the frame under
// backpressure (rather than blocking the producer) keeps both
// the bounded-memory guarantee and a responsive stop path.
let send_result = match user_data.format.format() {
VideoFormat::RGBx => {
user_data
.tx
.try_send(Frame::Video(VideoFrame::RGBx(RGBxFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})))
}
VideoFormat::RGB => {
user_data
.tx
.try_send(Frame::Video(VideoFrame::RGB(RGBFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})))
}
VideoFormat::xBGR => {
user_data
.tx
.try_send(Frame::Video(VideoFrame::XBGR(XBGRFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})))
}
VideoFormat::BGRx => {
user_data
.tx
.try_send(Frame::Video(VideoFrame::BGRx(BGRxFrame {
display_time,
width: frame_size.width as i32,
height: frame_size.height as i32,
data: frame_data,
})))
}
_ => panic!("Unsupported frame format received"),
} {
eprintln!("{e}");
};

if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the receiver is disconnected, this will eprintln! once per frame until the capture thread stops. Since you already have STREAM_STATE_CHANGED_TO_ERROR, you can set it here (to exit the loop) and use swap to log only once.

Suggested change
if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {
if let Err(mpsc::TrySendError::Disconnected(_)) = send_result {
if !STREAM_STATE_CHANGED_TO_ERROR.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!("Frame receiver disconnected");
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — applied exactly as suggested in 2ae046c. Reused STREAM_STATE_CHANGED_TO_ERROR so a disconnected receiver now ends the capture loop instead of spinning forever, and swapped to log the disconnect once.

// The consumer (Capturer/rx) is gone, e.g. dropped without
// calling stop_capture(). Signal the main loop above to
// exit instead of spinning pw_loop.iterate() forever on a
// stream nobody will ever read from, and log it once (swap
// instead of store) rather than once per incoming frame.
if !STREAM_SHOULD_EXIT.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!("Frame receiver disconnected");
}
}
// TrySendError::Full is a silent intentional drop under
// backpressure — see comment above.
}
} else {
eprintln!("Out of buffers");
Expand All @@ -173,7 +222,7 @@ fn process_callback(stream: &StreamRef, user_data: &mut ListenerUserData) {
// TODO: Format negotiation
fn pipewire_capturer(
options: Options,
tx: mpsc::Sender<Frame>,
tx: mpsc::SyncSender<Frame>,
ready_sender: &SyncSender<bool>,
stream_id: u32,
) -> Result<(), LinCapError> {
Expand Down Expand Up @@ -299,8 +348,8 @@ fn pipewire_capturer(

// User has called Capturer::start() and we start the main loop
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* If the stream state got changed to `Error`, we exit. TODO: tell user that we exited */
!STREAM_STATE_CHANGED_TO_ERROR.load(std::sync::atomic::Ordering::Relaxed)
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{
Comment on lines 349 to 353

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case with STREAM_SHOULD_EXIT being a static: if the receiver is dropped without stop_capture(), the flag gets set to true and never reset, so a later capture session in the same process can exit immediately.

Suggested change
// User has called Capturer::start() and we start the main loop
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* If the stream state got changed to `Error`, we exit. TODO: tell user that we exited */
!STREAM_STATE_CHANGED_TO_ERROR.load(std::sync::atomic::Ordering::Relaxed)
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{
// User has called Capturer::start() and we start the main loop
STREAM_SHOULD_EXIT.store(false, std::sync::atomic::Ordering::Relaxed);
while CAPTURER_STATE.load(std::sync::atomic::Ordering::Relaxed) == 1
&& /* Exit early on a PipeWire stream error or a disconnected frame receiver. TODO: tell user that we exited */
!STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed)
{

pw_loop.iterate(Duration::from_millis(100));
}
Expand All @@ -317,7 +366,7 @@ pub struct LinuxCapturer {

impl LinuxCapturer {
// TODO: Error handling
pub fn new(options: &Options, tx: mpsc::Sender<Frame>) -> Self {
pub fn new(options: &Options, tx: mpsc::SyncSender<Frame>) -> Self {
let connection =
dbus::blocking::Connection::new_session().expect("Failed to create dbus connection");
let stream_id = ScreenCastPortal::new(&connection)
Expand Down Expand Up @@ -360,10 +409,10 @@ impl LinuxCapturer {
}
}
CAPTURER_STATE.store(0, std::sync::atomic::Ordering::Relaxed);
STREAM_STATE_CHANGED_TO_ERROR.store(false, std::sync::atomic::Ordering::Relaxed);
STREAM_SHOULD_EXIT.store(false, std::sync::atomic::Ordering::Relaxed);
}
}

pub fn create_capturer(options: &Options, tx: mpsc::Sender<Frame>) -> LinuxCapturer {
pub fn create_capturer(options: &Options, tx: mpsc::SyncSender<Frame>) -> LinuxCapturer {
LinuxCapturer::new(options, tx)
}
38 changes: 34 additions & 4 deletions src/capturer/engine/mac/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ impl sc::stream::DelegateImpl for ErrorHandler {

#[repr(C)]
pub struct CapturerInner {
pub tx: mpsc::Sender<ChannelItem>,
pub tx: mpsc::SyncSender<ChannelItem>,
// Set once tx.try_send() observes a disconnected receiver, so later
// callbacks can skip the retain+send work entirely instead of repeating
// it with nowhere for the result to go. There's no return-an-error path
// here (unlike Windows' on_frame_arrived) to actually stop the
// sc::Stream from this callback, so this is the same "flip a shared
// flag" compromise used for the equivalent case on Linux/Windows.
disconnected: Arc<AtomicBool>,
}

define_obj_type!(pub Capturer + StreamOutputImpl, CapturerInner, CAPTURER);
Expand All @@ -69,7 +76,27 @@ impl sc::stream::OutputImpl for Capturer {
sample_buf: &mut cm::SampleBuf,
kind: sc::OutputType,
) {
let _ = self.inner_mut().tx.send((sample_buf.retained(), kind));
let inner = self.inner_mut();
if inner
.disconnected
.load(std::sync::atomic::Ordering::Relaxed)
{
return;
}

// try_send, not send: don't block ScreenCaptureKit's delivery queue
// when the consumer is behind — drop the sample buffer instead
// (same rationale as the Linux/Windows engines).
if let Err(mpsc::TrySendError::Disconnected(_)) =
inner.tx.try_send((sample_buf.retained(), kind))
{
if !inner
.disconnected
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
eprintln!("Frame receiver disconnected");
}
}
}
}

Expand All @@ -85,7 +112,7 @@ pub(crate) enum CreateCapturerError {

pub(crate) fn create_capturer(
options: &Options,
tx: mpsc::Sender<ChannelItem>,
tx: mpsc::SyncSender<ChannelItem>,
error_flag: Arc<AtomicBool>,
) -> Result<(arc::R<Capturer>, arc::R<ErrorHandler>, arc::R<sc::Stream>), CreateCapturerError> {
// If no target is specified, capture the main display
Expand Down Expand Up @@ -187,7 +214,10 @@ pub(crate) fn create_capturer(
let error_handler = ErrorHandler::with(ErrorHandlerInner { error_flag });
let stream = sc::Stream::with_delegate(&filter, &stream_config, error_handler.as_ref());

let capturer = CapturerInner { tx };
let capturer = CapturerInner {
tx,
disconnected: Arc::new(AtomicBool::new(false)),
};

let queue = dispatch::Queue::serial_with_ar_pool();

Expand Down
2 changes: 1 addition & 1 deletion src/capturer/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub struct Engine {
}

impl Engine {
pub fn new(options: &Options, tx: mpsc::Sender<ChannelItem>) -> Engine {
pub fn new(options: &Options, tx: mpsc::SyncSender<ChannelItem>) -> Engine {
#[cfg(target_os = "macos")]
{
let error_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
Expand Down
35 changes: 26 additions & 9 deletions src/capturer/engine/win/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::{cmp, time::Duration};
use std::{
os::windows,
ptr::null_mut,
sync::mpsc::{self, Receiver, RecvTimeoutError, Sender},
sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender},
};
use windows_capture::{
capture::{CaptureControl, Context, GraphicsCaptureApiHandler},
Expand All @@ -29,7 +29,7 @@ use windows_capture::{

#[derive(Debug)]
struct Capturer {
pub tx: mpsc::Sender<Frame>,
pub tx: mpsc::SyncSender<Frame>,
pub crop: Option<Area>,
pub start_time: (i64, SystemTime),
pub perf_freq: i64,
Expand Down Expand Up @@ -116,7 +116,15 @@ impl GraphicsCaptureApiHandler for Capturer {
data: raw_frame_buffer.to_vec(),
};

let _ = self.tx.send(Frame::Video(VideoFrame::BGRA(bgr_frame)));
// try_send, not send: don't block the OS capture callback
// thread when the consumer is behind — drop the frame
// instead (same rationale as the Linux engine).
match self.tx.try_send(Frame::Video(VideoFrame::BGRA(bgr_frame))) {
Ok(()) | Err(mpsc::TrySendError::Full(_)) => {}
Err(mpsc::TrySendError::Disconnected(_)) => {
return Err("frame channel disconnected".into());
}
}
}
None => {
// get raw frame buffer
Expand All @@ -134,7 +142,12 @@ impl GraphicsCaptureApiHandler for Capturer {
data: frame_data,
};

let _ = self.tx.send(Frame::Video(VideoFrame::BGRA(bgr_frame)));
match self.tx.try_send(Frame::Video(VideoFrame::BGRA(bgr_frame))) {
Ok(()) | Err(mpsc::TrySendError::Full(_)) => {}
Err(mpsc::TrySendError::Disconnected(_)) => {
return Err("frame channel disconnected".into());
}
}
}
}
Ok(())
Expand Down Expand Up @@ -172,7 +185,7 @@ impl WCStream {

#[derive(Clone, Debug)]
struct FlagStruct {
pub tx: mpsc::Sender<Frame>,
pub tx: mpsc::SyncSender<Frame>,
pub crop: Option<Area>,
}

Expand All @@ -184,7 +197,7 @@ pub enum CreateCapturerError {

pub fn create_capturer(
options: &Options,
tx: mpsc::Sender<Frame>,
tx: mpsc::SyncSender<Frame>,
) -> Result<WCStream, CreateCapturerError> {
let target = options
.target
Expand Down Expand Up @@ -380,7 +393,7 @@ fn build_audio_stream(
}

fn spawn_audio_stream(
tx: Sender<Frame>,
tx: SyncSender<Frame>,
ready_tx: Sender<Result<(), CreateCapturerError>>,
ctrl_rx: Receiver<AudioStreamControl>,
) {
Expand Down Expand Up @@ -445,8 +458,12 @@ fn spawn_audio_stream(
timestamp,
);

if let Err(_) = tx.send(Frame::Audio(frame)) {
return;
match tx.try_send(Frame::Audio(frame)) {
Ok(()) => {}
// Consumer is behind: drop this sample under backpressure,
// keep the audio thread alive (matches video's try_send).
Err(mpsc::TrySendError::Full(_)) => {}
Err(mpsc::TrySendError::Disconnected(_)) => return,
};
}
});
Expand Down
20 changes: 19 additions & 1 deletion src/capturer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ use crate::{

pub use engine::get_output_frame_size;

/// Capacity of the internal channel each platform engine sends `Frame`s
/// (or `ChannelItem`s) through. See the comment at its use site in
/// [`Capturer::build`] for why this is bounded at all.
const FRAME_CHANNEL_CAPACITY: usize = 2;

#[derive(Debug, Clone, Copy, Default)]
pub enum Resolution {
_480p,
Expand Down Expand Up @@ -111,7 +116,20 @@ impl Capturer {
return Err(CapturerBuildError::PermissionNotGranted);
}

let (tx, rx) = mpsc::channel();
// Bounded (not `mpsc::channel()`): each platform's capture callback
// pushes frames into this channel unconditionally, independent of
// how fast `get_next_frame()` is drained downstream. An unbounded
// channel here means any transient slowdown in the consumer
// (encode/network backpressure) grows this queue without limit --
// confirmed via heaptrack on Linux: ~1.44GiB retained over a 25s run,
// entirely attributed to undrained `Frame` allocations queued from
// `engine::linux::process_callback`. Each engine sends with
// `try_send` (not blocking `send`) and drops the frame when this
// channel is full, so a slow consumer loses frames instead of
// growing memory unboundedly or blocking the platform capture
// thread (which on Linux would otherwise deadlock `stop_capture()`,
// since it joins that same thread).
let (tx, rx) = mpsc::sync_channel(FRAME_CHANNEL_CAPACITY);
let engine = engine::Engine::new(&options, tx);

Ok(Capturer { engine, rx })
Expand Down