Skip to content

Commit c3f47b4

Browse files
1313Copilot
andcommitted
feat(async)!: propagate stop errors in AsyncSCStream; consolidate stop delegate
Async completeness: - AsyncSCStream now installs a delegate that, on an error stop (display disconnected, permission revoked, ...), closes the sample iterator so next().await resolves to None instead of pending forever, and records the SCError. Add AsyncSCStream::take_error() to retrieve it after the loop. - AsyncSCStream::new no longer silently swallows a failed output-handler registration: it closes the stream and records the error. Callback ergonomics: - The stream engine now dispatches only the canonical did_stop_with_error on an error stop instead of also firing stream_did_stop for the same event. - Deprecate SCStreamDelegateTrait::stream_did_stop (ScreenCaptureKit reports stops only via didStopWithError). StreamCallbacks::on_stop keeps working, now driven by did_stop_with_error. All 821 tests pass; clippy clean. Updated CHANGELOG and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9360019 commit c3f47b4

6 files changed

Lines changed: 179 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
blocking call, use the synchronous `SCStream` directly via
2323
`stream.inner().start_capture()`.
2424

25+
- `AsyncSCStream` now installs a stream delegate so that when `ScreenCaptureKit`
26+
stops the stream with an error (display disconnected, permission revoked, …)
27+
the sample iterator is closed — `next().await` resolves to `None` instead of
28+
pending forever — and the error is recorded (see `take_error`). `AsyncSCStream::new`
29+
likewise no longer silently swallows a failed output-handler registration: it
30+
closes the stream and records the error.
31+
32+
- The stream engine now dispatches a single canonical stop callback,
33+
`SCStreamDelegateTrait::did_stop_with_error`, on an error stop. It no longer
34+
also calls `stream_did_stop` for the same event (the previous behavior fired
35+
both). `StreamCallbacks::on_stop` keeps working (it is now driven by
36+
`did_stop_with_error`).
37+
2538
### Added
2639

2740
- `async_api::StreamControlFuture` — the `Send` future returned by the
2841
`AsyncSCStream` lifecycle methods.
42+
- `AsyncSCStream::take_error` — returns the `SCError` that stopped the stream,
43+
if any, after `next()` reports the iterator closed.
44+
45+
### Deprecated
46+
47+
- `SCStreamDelegateTrait::stream_did_stop``ScreenCaptureKit` only reports
48+
stops via `did_stop_with_error`, which is now the single source of truth;
49+
the engine no longer invokes `stream_did_stop`. Implement `did_stop_with_error`
50+
instead.
2951

3052
## [7.0.1](https://github.com/doom-fish/screencapturekit-rs/compare/v7.0.0...v7.0.1) - 2026-06-06
3153

src/async_api.rs

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ struct AsyncSampleIteratorState {
308308
waker: Option<Waker>,
309309
closed: bool,
310310
capacity: usize,
311+
stop_error: Option<SCError>,
311312
}
312313

313314
/// Internal sender for async sample iterator
@@ -399,6 +400,37 @@ impl Future for NextSample<'_> {
399400
unsafe impl Send for AsyncSampleSender {}
400401
unsafe impl Sync for AsyncSampleSender {}
401402

403+
/// Stream delegate for [`AsyncSCStream`] that closes the sample iterator when
404+
/// `ScreenCaptureKit` stops the stream with an error.
405+
///
406+
/// Without this, a stream that fails mid-capture (captured display
407+
/// disconnected, permission revoked, …) would leave [`NextSample`] pending
408+
/// forever. On an error stop this records the [`SCError`], marks the iterator
409+
/// closed (so `next()` resolves to `None` once buffered frames drain), and
410+
/// wakes any parked task. The error is retrievable via
411+
/// [`AsyncSCStream::take_error`].
412+
struct AsyncStreamDelegate {
413+
state: Arc<Mutex<AsyncSampleIteratorState>>,
414+
}
415+
416+
impl crate::stream::delegate_trait::SCStreamDelegateTrait for AsyncStreamDelegate {
417+
fn did_stop_with_error(&self, error: SCError) {
418+
if let Ok(mut state) = self.state.lock() {
419+
state.stop_error = Some(error);
420+
state.closed = true;
421+
if let Some(waker) = state.waker.take() {
422+
waker.wake();
423+
}
424+
}
425+
}
426+
}
427+
428+
// SAFETY: mirrors `AsyncSampleSender` — `AsyncStreamDelegate` holds the same
429+
// `Arc<Mutex<AsyncSampleIteratorState>>`, whose contents (`CMSampleBuffer`,
430+
// `Waker`, `SCError`) are all safe to send and share across threads.
431+
unsafe impl Send for AsyncStreamDelegate {}
432+
unsafe impl Sync for AsyncStreamDelegate {}
433+
402434
// ----------------------------------------------------------------------------
403435
// Stream lifecycle control futures (start / stop / update)
404436
// ----------------------------------------------------------------------------
@@ -539,14 +571,28 @@ impl AsyncSCStream {
539571
waker: None,
540572
closed: false,
541573
capacity: buffer_capacity,
574+
stop_error: None,
542575
}));
543576

544577
let sender = AsyncSampleSender {
545578
inner: Arc::clone(&state),
546579
};
547580

548-
let mut stream = crate::stream::SCStream::new(filter, config);
549-
stream.add_output_handler(sender, output_type);
581+
let delegate = AsyncStreamDelegate {
582+
state: Arc::clone(&state),
583+
};
584+
585+
let mut stream = crate::stream::SCStream::new_with_delegate(filter, config, delegate);
586+
if stream.add_output_handler(sender, output_type).is_none() {
587+
// Registration failed: close the iterator immediately so `next()`
588+
// resolves to `None` instead of pending forever, and record why.
589+
if let Ok(mut s) = state.lock() {
590+
s.closed = true;
591+
s.stop_error = Some(SCError::StreamError(
592+
"failed to register stream output handler".to_string(),
593+
));
594+
}
595+
}
550596

551597
Self {
552598
stream,
@@ -570,11 +616,41 @@ impl AsyncSCStream {
570616
}
571617

572618
/// Check if the stream has been closed
619+
///
620+
/// Returns `true` once the stream has stopped — either because this
621+
/// `AsyncSCStream` was dropped or because `ScreenCaptureKit` stopped it
622+
/// with an error (see [`take_error`](Self::take_error)).
573623
#[must_use]
574624
pub fn is_closed(&self) -> bool {
575625
self.iterator_state.lock().map_or(true, |s| s.closed)
576626
}
577627

628+
/// Take the error that stopped the stream, if any.
629+
///
630+
/// When `ScreenCaptureKit` stops the stream with an error (e.g. the
631+
/// captured display is disconnected or screen-recording permission is
632+
/// revoked), the sample iterator is closed — [`next`](Self::next) resolves
633+
/// to `None` after any buffered frames drain — and the [`SCError`] is stored
634+
/// here. Call this once the iteration loop ends to distinguish an error stop
635+
/// from a normal end of stream:
636+
///
637+
/// ```no_run
638+
/// # async fn example(stream: screencapturekit::async_api::AsyncSCStream) {
639+
/// while let Some(_frame) = stream.next().await {
640+
/// // process frames …
641+
/// }
642+
/// if let Some(err) = stream.take_error() {
643+
/// eprintln!("capture stopped with error: {err}");
644+
/// }
645+
/// # }
646+
/// ```
647+
///
648+
/// The stored error is cleared once taken.
649+
#[must_use]
650+
pub fn take_error(&self) -> Option<SCError> {
651+
self.iterator_state.lock().ok()?.stop_error.take()
652+
}
653+
578654
/// Get the number of buffered samples
579655
#[must_use]
580656
pub fn buffered_count(&self) -> usize {

src/stream/delegate_trait.rs

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,8 @@ use crate::error::SCError;
2323
/// struct MyDelegate;
2424
///
2525
/// impl SCStreamDelegateTrait for MyDelegate {
26-
/// fn stream_did_stop(&self, error: Option<String>) {
27-
/// if let Some(err) = error {
28-
/// eprintln!("Stream stopped with error: {}", err);
29-
/// } else {
30-
/// println!("Stream stopped normally");
31-
/// }
32-
/// }
33-
///
3426
/// fn did_stop_with_error(&self, error: SCError) {
35-
/// eprintln!("Stream error: {}", error);
27+
/// eprintln!("Stream stopped with error: {}", error);
3628
/// }
3729
/// }
3830
/// ```
@@ -88,14 +80,28 @@ pub trait SCStreamDelegateTrait: Send + Sync {
8880
/// This callback occurs for all content filter types.
8981
fn stream_did_become_inactive(&self) {}
9082

91-
/// Called when stream stops with an error
83+
/// Called when the stream stops with an error.
84+
///
85+
/// This is the canonical stop notification and mirrors Apple's
86+
/// `stream(_:didStopWithError:)` — the *only* way `ScreenCaptureKit`
87+
/// reports a stop to the delegate. It fires when the stream stops
88+
/// unexpectedly (the captured window/display goes away, screen-recording
89+
/// permission is revoked, the system tears the stream down, …).
90+
///
91+
/// A *clean* stop that you requested via
92+
/// [`SCStream::stop_capture`](crate::stream::SCStream::stop_capture) is
93+
/// **not** reported here — observe it through that method's return value.
9294
fn did_stop_with_error(&self, _error: SCError) {}
9395

94-
/// Called when stream stops
96+
/// Called when stream stops.
9597
///
9698
/// # Parameters
9799
///
98100
/// - `error`: Optional error message if the stream stopped due to an error
101+
#[deprecated(
102+
note = "ScreenCaptureKit reports stops only via `did_stop_with_error`; the stream \
103+
engine no longer invokes this method. Implement `did_stop_with_error` instead."
104+
)]
99105
fn stream_did_stop(&self, _error: Option<String>) {}
100106
}
101107

@@ -216,7 +222,15 @@ impl StreamCallbacks {
216222
}
217223
}
218224

219-
/// Set the callback for when the stream stops
225+
/// Set the callback for when the stream stops.
226+
///
227+
/// The closure receives `Some(message)` describing the error that stopped
228+
/// the stream. Because `ScreenCaptureKit` only reports *error* stops to the
229+
/// delegate, this fires alongside [`on_error`](Self::on_error) on an error
230+
/// stop; a clean stop you requested via
231+
/// [`SCStream::stop_capture`](crate::stream::SCStream::stop_capture) is not
232+
/// delivered here. Prefer [`on_error`](Self::on_error) when you want the
233+
/// typed [`SCError`].
220234
#[must_use]
221235
pub fn on_stop<F>(mut self, f: F) -> Self
222236
where
@@ -300,13 +314,22 @@ impl std::fmt::Debug for StreamCallbacks {
300314
}
301315

302316
impl SCStreamDelegateTrait for StreamCallbacks {
317+
// Retained so direct/manual callers (and legacy code) that still invoke
318+
// `stream_did_stop` continue to route to `on_stop`. The stream engine no
319+
// longer calls this; error stops flow through `did_stop_with_error` below.
320+
#[allow(deprecated)]
303321
fn stream_did_stop(&self, error: Option<String>) {
304322
if let Some(ref f) = self.on_stop {
305323
f(error);
306324
}
307325
}
308326

309327
fn did_stop_with_error(&self, error: SCError) {
328+
// ScreenCaptureKit only reports error stops, so drive both `on_error`
329+
// (typed) and `on_stop` (message) from this single engine callback.
330+
if let Some(ref f) = self.on_stop {
331+
f(Some(error.to_string()));
332+
}
310333
if let Some(ref f) = self.on_error {
311334
f(error);
312335
}

src/stream/sc_stream.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ extern "C" fn delegate_error_callback(context: *mut c_void, error_code: i32, msg
170170
},
171171
)
172172
} else {
173-
SCError::StreamError(message.clone())
173+
SCError::StreamError(message)
174174
};
175175

176176
// Take a read lock and dispatch under it. Multiple delegate callbacks
@@ -183,13 +183,14 @@ extern "C" fn delegate_error_callback(context: *mut c_void, error_code: i32, msg
183183
.unwrap_or_else(std::sync::PoisonError::into_inner);
184184

185185
if let Some(ref delegate) = *delegate_guard {
186-
// Wrap user code in catch_unwind so panics never propagate into Swift.
186+
// ScreenCaptureKit reports stops only through `stream(_:didStopWithError:)`,
187+
// so we dispatch the single canonical `did_stop_with_error` callback.
188+
// The deprecated `stream_did_stop` is intentionally NOT invoked here — it
189+
// would double-notify for one event. Wrap user code in catch_unwind so a
190+
// panic never propagates into Swift.
187191
catch_user_panic("delegate.did_stop_with_error", || {
188192
delegate.did_stop_with_error(error);
189193
});
190-
catch_user_panic("delegate.stream_did_stop", || {
191-
delegate.stream_did_stop(Some(message));
192-
});
193194
return;
194195
}
195196

@@ -362,9 +363,12 @@ impl SCStream {
362363

363364
/// Create a new stream with a content filter, configuration, and delegate
364365
///
365-
/// The delegate receives callbacks for stream lifecycle events:
366-
/// - `did_stop_with_error` - Called when the stream stops due to an error
367-
/// - `stream_did_stop` - Called when the stream stops (with optional error message)
366+
/// The delegate receives callbacks for stream lifecycle events. The key
367+
/// one is [`did_stop_with_error`](crate::stream::delegate_trait::SCStreamDelegateTrait::did_stop_with_error),
368+
/// invoked when `ScreenCaptureKit` stops the stream with an error (e.g. the
369+
/// captured window closes or permission is revoked). A *clean* stop you
370+
/// requested via [`stop_capture`](Self::stop_capture) is observed through
371+
/// that call's return value, not the delegate.
368372
///
369373
/// # Examples
370374
///
@@ -384,12 +388,7 @@ impl SCStream {
384388
/// .with_height(1080);
385389
///
386390
/// let delegate = StreamCallbacks::new()
387-
/// .on_error(|e| eprintln!("Stream error: {}", e))
388-
/// .on_stop(|err| {
389-
/// if let Some(msg) = err {
390-
/// eprintln!("Stream stopped with error: {}", msg);
391-
/// }
392-
/// });
391+
/// .on_error(|e| eprintln!("Stream stopped with error: {}", e));
393392
///
394393
/// let stream = SCStream::new_with_delegate(&filter, &config, delegate);
395394
/// stream.start_capture()?;

tests/async_api_tests.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,28 @@ fn test_async_stream_output_type() {
336336
assert!(debug_audio.contains("Audio"));
337337
}
338338

339+
#[test]
340+
fn test_async_stream_take_error_initially_none() {
341+
use screencapturekit::shareable_content::SCShareableContent;
342+
use screencapturekit::stream::configuration::SCStreamConfiguration;
343+
use screencapturekit::stream::content_filter::SCContentFilter;
344+
345+
if let Ok(content) = SCShareableContent::get() {
346+
if let Some(display) = content.displays().first() {
347+
let filter = SCContentFilter::create()
348+
.with_display(display)
349+
.with_excluding_windows(&[])
350+
.build();
351+
let config = SCStreamConfiguration::new().with_width(100).with_height(100);
352+
let stream = AsyncSCStream::new(&filter, &config, 4, SCStreamOutputType::Screen);
353+
354+
// A freshly created stream is open and has no stop error.
355+
assert!(!stream.is_closed());
356+
assert!(stream.take_error().is_none());
357+
}
358+
}
359+
}
360+
339361
#[cfg(feature = "macos_14_0")]
340362
mod macos_14_tests {
341363
use super::*;
@@ -1228,4 +1250,10 @@ async fn test_async_frame_delivery_assertive() {
12281250
assert!(second.is_some(), "expected continuous frame delivery");
12291251

12301252
stream.stop_capture().await.expect("stop_capture failed");
1253+
1254+
// A clean stop must not surface a stop error.
1255+
assert!(
1256+
stream.take_error().is_none(),
1257+
"clean capture should leave no stop error"
1258+
);
12311259
}

tests/delegate_trait_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
//! `SCStreamDelegateTrait` tests
22
33
#![allow(clippy::struct_field_names)]
4+
// Several tests still exercise the deprecated `stream_did_stop` directly to
5+
// keep its routing covered; suppress the deprecation lint for the whole file.
6+
#![allow(deprecated)]
47

58
use screencapturekit::error::SCError;
69
use screencapturekit::stream::delegate_trait::{

0 commit comments

Comments
 (0)