-
-
Notifications
You must be signed in to change notification settings - Fork 136
fix: Linux PipeWire engine two-level Frame enum + bounded frame channel (all platforms) #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
42e0745
be78534
9ed0211
2ae046c
9b30e91
95e1e9e
d118c15
32569f1
72c78cd
6a41eb3
3ecd32a
41aafe4
b5b4050
850b414
b46a61b
17b70ab
a06d01f
58b6a62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,7 +5,7 @@ use std::{ | |||||||||||||||||||||||||||
| mpsc::{self, sync_channel, SyncSender}, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| thread::JoinHandle, | ||||||||||||||||||||||||||||
| time::Duration, | ||||||||||||||||||||||||||||
| time::{Duration, SystemTime}, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| use pipewire as pw; | ||||||||||||||||||||||||||||
|
|
@@ -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}; | ||||||||||||||||||||||||||||
|
|
@@ -40,11 +40,16 @@ mod error; | |||||||||||||||||||||||||||
| mod portal; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| static CAPTURER_STATE: AtomicU8 = AtomicU8::new(0); | ||||||||||||||||||||||||||||
| static STREAM_STATE_CHANGED_TO_ERROR: AtomicBool = AtomicBool::new(false); | ||||||||||||||||||||||||||||
| // NOTE: these are process-wide statics; the Linux backend assumes a single active capturer per process. | ||||||||||||||||||||||||||||
| // Signals pipewire_capturer's main loop to stop early. Set on both 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, | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
@@ -85,7 +90,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); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| _ => {} | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
@@ -118,6 +123,14 @@ fn process_callback(stream: &StreamRef, user_data: &mut ListenerUserData) { | |||||||||||||||||||||||||||
| if buffer.is_null() { | ||||||||||||||||||||||||||||
| break 'outside; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // Once STREAM_SHOULD_EXIT is set (stream error or disconnected | ||||||||||||||||||||||||||||
| // receiver), the outer loop in pipewire_capturer is about to | ||||||||||||||||||||||||||||
| // stop anyway -- skip the frame_data copy (the actual | ||||||||||||||||||||||||||||
| // per-frame cost here) and everything after it, and just | ||||||||||||||||||||||||||||
| // requeue the buffer below. | ||||||||||||||||||||||||||||
| if STREAM_SHOULD_EXIT.load(std::sync::atomic::Ordering::Relaxed) { | ||||||||||||||||||||||||||||
| break 'outside; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| let timestamp = unsafe { get_timestamp(buffer) }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let n_datas = unsafe { (*buffer).n_datas }; | ||||||||||||||||||||||||||||
|
|
@@ -133,35 +146,85 @@ 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, | ||||||||||||||||||||||||||||
| })), | ||||||||||||||||||||||||||||
| _ => panic!("Unsupported frame format received"), | ||||||||||||||||||||||||||||
| } { | ||||||||||||||||||||||||||||
| eprintln!("{e}"); | ||||||||||||||||||||||||||||
| // `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, | ||||||||||||||||||||||||||||
| }))) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| _ => { | ||||||||||||||||||||||||||||
| if !STREAM_SHOULD_EXIT.swap(true, std::sync::atomic::Ordering::Relaxed) { | ||||||||||||||||||||||||||||
| eprintln!("Unsupported frame format received"); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Ok(()) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if let Err(mpsc::TrySendError::Disconnected(_)) = send_result { | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the receiver is disconnected, this will
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — applied exactly as suggested in |
||||||||||||||||||||||||||||
| // 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"); | ||||||||||||||||||||||||||||
|
|
@@ -173,10 +236,24 @@ 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> { | ||||||||||||||||||||||||||||
| // STREAM_SHOULD_EXIT is a process-wide static; the only other writer | ||||||||||||||||||||||||||||
| // is process_callback/state_changed_callback (both set it to `true`). | ||||||||||||||||||||||||||||
| // Reset it here, before stream.connect() or any loop iteration, not | ||||||||||||||||||||||||||||
| // just before the main capture loop below: connect()'s handshake can | ||||||||||||||||||||||||||||
| // plausibly dispatch pipewire callbacks on this same thread before we | ||||||||||||||||||||||||||||
| // ever call pw_loop.iterate() ourselves (MainLoop wraps pw_main_loop | ||||||||||||||||||||||||||||
| // in an Rc, not Arc -- there is no separate background dispatch | ||||||||||||||||||||||||||||
| // thread, so any dispatch that happens before our own iterate() calls | ||||||||||||||||||||||||||||
| // must be happening synchronously inside calls like connect()). | ||||||||||||||||||||||||||||
| // Resetting only right before the main loop (as an earlier commit did) | ||||||||||||||||||||||||||||
| // would silently clear a real pre-start error or disconnect instead of | ||||||||||||||||||||||||||||
| // letting it end the loop immediately, as it should. | ||||||||||||||||||||||||||||
| STREAM_SHOULD_EXIT.store(false, std::sync::atomic::Ordering::Relaxed); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| pw::init(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let mainloop = MainLoop::new(None)?; | ||||||||||||||||||||||||||||
|
|
@@ -299,8 +376,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
377
to
381
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One edge case with
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — fixed in 6a41eb3 exactly as suggested. Reset STREAM_SHOULD_EXIT at the top of the main loop rather than relying solely on stop_capture(), so a session that ends via the disconnected-receiver path (which never calls stop_capture()) doesn't leave a stale exit signal for the next LinuxCapturer in the same process. |
||||||||||||||||||||||||||||
| pw_loop.iterate(Duration::from_millis(100)); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
@@ -317,7 +394,20 @@ 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 { | ||||||||||||||||||||||||||||
| // Same class of bug as STREAM_SHOULD_EXIT (see the reset in | ||||||||||||||||||||||||||||
| // pipewire_capturer): CAPTURER_STATE is a process-wide static, and | ||||||||||||||||||||||||||||
| // the only other writer is stop_capture(), which never runs if a | ||||||||||||||||||||||||||||
| // previous LinuxCapturer's receiver was dropped instead of properly | ||||||||||||||||||||||||||||
| // stopped. Left stale at 1 (or 2), a new instance's background | ||||||||||||||||||||||||||||
| // thread would skip its "wait for start_capture()" gate below and | ||||||||||||||||||||||||||||
| // start iterating immediately -- capturing before the caller ever | ||||||||||||||||||||||||||||
| // called start_capture() on *this* instance. Reset synchronously | ||||||||||||||||||||||||||||
| // here, before the background thread exists and before the caller | ||||||||||||||||||||||||||||
| // can possibly call start_capture() on the handle this returns, so | ||||||||||||||||||||||||||||
| // there's no race with either. | ||||||||||||||||||||||||||||
| CAPTURER_STATE.store(0, std::sync::atomic::Ordering::Relaxed); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let connection = | ||||||||||||||||||||||||||||
| dbus::blocking::Connection::new_session().expect("Failed to create dbus connection"); | ||||||||||||||||||||||||||||
| let stream_id = ScreenCastPortal::new(&connection) | ||||||||||||||||||||||||||||
|
|
@@ -360,10 +450,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) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: once
STREAM_SHOULD_EXITis set (error/disconnected), we can stop doing theSystemTime::now()+ enum construction work and just re-queue buffers until the outer loop exits.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in b5b4050, but moved the check earlier than your suggested placement: right after the buffer-null check, before get_timestamp/frame_data, rather than right before SystemTime::now(). The frame_data to_vec() copy just above your suggested spot is the actual expensive per-frame cost (a full buffer memcpy) -- skipping only the SystemTime::now()+enum-construction+try_send after it still would have left that copy running every frame. stream.queue_raw_buffer(buffer) runs unconditionally after the 'outside block regardless of where inside it we break, so this is a pure skip, no behavior change.