Skip to content

Commit 9360019

Browse files
1313Copilot
andcommitted
feat(async)!: make AsyncSCStream lifecycle methods truly async
start_capture/stop_capture/update_configuration/update_content_filter on AsyncSCStream now return a waker-based StreamControlFuture instead of blocking the calling thread on a condition variable. Awaiting parks the task via its Waker and resumes from the Swift completion callback, so they no longer stall single-threaded/current-thread executors. This makes the async surface fully waker-based and consistent with the Swift Task { try await } entry points. - add stream_control_callback bridging (context,success,msg) -> AsyncCompletion - StreamControlFuture is Send (spawnable); asserted by a compile-time test - update doctests, 08_async example, and tests (sync setup -> inner(), async -> await) - document the breaking change in CHANGELOG and MIGRATION Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 64fa004 commit 9360019

5 files changed

Lines changed: 255 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- [**breaking**] `AsyncSCStream::start_capture`, `stop_capture`, `update_configuration`,
13+
and `update_content_filter` are now genuinely async: they return a
14+
`StreamControlFuture` (resolving to `Result<(), SCError>`) that you `.await`,
15+
instead of blocking the calling thread on a condition variable. Awaiting them
16+
parks the task via its `Waker` and resumes from the Swift completion callback,
17+
so they no longer stall single-threaded/current-thread executors. This makes
18+
the async surface fully waker-based and consistent with the underlying Swift
19+
`Task { try await … }` entry points.
20+
21+
Migration: add `.await` (e.g. `stream.start_capture().await?`). For a
22+
blocking call, use the synchronous `SCStream` directly via
23+
`stream.inner().start_capture()`.
24+
25+
### Added
26+
27+
- `async_api::StreamControlFuture` — the `Send` future returned by the
28+
`AsyncSCStream` lifecycle methods.
29+
1030
## [7.0.1](https://github.com/doom-fish/screencapturekit-rs/compare/v7.0.0...v7.0.1) - 2026-06-06
1131

1232
### Fixed

docs/MIGRATION.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,39 @@ low-level changes:
212212
Everything else is internal: `MaybeUninit` scratch buffers for batched FFI
213213
calls, null-checked constructors, and consolidated retain/release wrappers.
214214

215+
## Migrating to the next major version (unreleased)
216+
217+
The `async` stream lifecycle methods are now genuinely asynchronous. Previously
218+
`AsyncSCStream::start_capture` / `stop_capture` / `update_configuration` /
219+
`update_content_filter` returned `Result<(), SCError>` and **blocked the calling
220+
thread** on a condition variable until ScreenCaptureKit acknowledged the
221+
operation — which stalls single-threaded / current-thread executors. They now
222+
return a `StreamControlFuture` you `.await`:
223+
224+
```diff
225+
- stream.start_capture()?;
226+
- stream.stop_capture()?;
227+
+ stream.start_capture().await?;
228+
+ stream.stop_capture().await?;
229+
```
230+
231+
```diff
232+
- stream.update_configuration(&config)?;
233+
- stream.update_content_filter(&filter)?;
234+
+ stream.update_configuration(&config).await?;
235+
+ stream.update_content_filter(&filter).await?;
236+
```
237+
238+
Awaiting now parks the task via its `Waker` and resumes from the Swift
239+
completion callback, so it never blocks the executor — matching the rest of the
240+
`async_api` (content queries, screenshots, picker, frame iteration) and the
241+
underlying Swift `Task { try await … }` entry points. The returned
242+
`StreamControlFuture` is `Send`, so it can be moved across `tokio::spawn`.
243+
244+
If you specifically want a **blocking** call (e.g. from synchronous code), reach
245+
through to the synchronous stream with `stream.inner().start_capture()` — the
246+
`SCStream` methods are unchanged.
247+
215248
## Migrating from 0.x to 1.0
216249

217250
Version 1.0 introduced a complete API redesign with builder patterns, async support, and new macOS features.

examples/08_async.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ async fn async_stream_iteration() -> Result<(), Box<dyn std::error::Error>> {
159159

160160
// Create async stream with 30-frame buffer
161161
let stream = AsyncSCStream::new(&filter, &config, 30, SCStreamOutputType::Screen);
162-
stream.start_capture()?;
162+
// start/stop are truly async: awaiting parks the task via its Waker and
163+
// resumes from the Swift completion callback — the executor is never blocked.
164+
stream.start_capture().await?;
163165

164166
println!(" Capturing frames asynchronously...");
165167

@@ -174,7 +176,7 @@ async fn async_stream_iteration() -> Result<(), Box<dyn std::error::Error>> {
174176
}
175177
}
176178

177-
stream.stop_capture()?;
179+
stream.stop_capture().await?;
178180
println!(" ✅ Captured {count} frames");
179181
} else {
180182
println!(" ⚠️ No displays available");

src/async_api.rs

Lines changed: 153 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
//! let config = SCStreamConfiguration::new().with_width(1920).with_height(1080);
5353
//!
5454
//! let stream = AsyncSCStream::new(&filter, &config, 30, SCStreamOutputType::Screen);
55-
//! stream.start_capture()?;
55+
//! stream.start_capture().await?;
5656
//!
5757
//! // Process frames asynchronously
5858
//! for _ in 0..100 {
@@ -61,7 +61,7 @@
6161
//! }
6262
//! }
6363
//!
64-
//! stream.stop_capture()?;
64+
//! stream.stop_capture().await?;
6565
//! # Ok(())
6666
//! # }
6767
//! ```
@@ -399,6 +399,67 @@ impl Future for NextSample<'_> {
399399
unsafe impl Send for AsyncSampleSender {}
400400
unsafe impl Sync for AsyncSampleSender {}
401401

402+
// ----------------------------------------------------------------------------
403+
// Stream lifecycle control futures (start / stop / update)
404+
// ----------------------------------------------------------------------------
405+
406+
/// FFI completion callback for [`AsyncSCStream`] lifecycle operations.
407+
///
408+
/// Translates the Swift `(context, success, message)` completion into the
409+
/// waker-based [`AsyncCompletion`] machinery, so awaiting a control future
410+
/// resumes the task via its [`Waker`] instead of parking a thread. This is the
411+
/// same primitive used by the content / screenshot / picker futures.
412+
extern "C" fn stream_control_callback(context: *mut c_void, success: bool, msg: *const i8) {
413+
crate::utils::panic_safe::catch_user_panic("stream_control_callback", move || {
414+
if success {
415+
// SAFETY: `context` is the one-shot completion pointer from
416+
// `AsyncCompletion::<()>::create()`; Swift invokes this callback
417+
// exactly once, after which the pointer is consumed.
418+
unsafe { AsyncCompletion::<()>::complete_ok(context, ()) };
419+
} else {
420+
let error = unsafe { error_from_cstr(msg) };
421+
// SAFETY: see above — one-shot completion pointer, fired once.
422+
unsafe { AsyncCompletion::<()>::complete_err(context, error) };
423+
}
424+
});
425+
}
426+
427+
/// Future for an [`AsyncSCStream`] lifecycle operation — `start_capture`,
428+
/// `stop_capture`, `update_configuration`, or `update_content_filter`.
429+
///
430+
/// Resolves once `ScreenCaptureKit` acknowledges the operation. Awaiting it
431+
/// **never blocks the executor thread**: the task is parked via its [`Waker`]
432+
/// and resumed from the Swift completion callback. This mirrors the underlying
433+
/// Swift `Task { try await … }` entry points, keeping the async surface
434+
/// consistent end to end.
435+
///
436+
/// The operation is kicked off eagerly when the method is called (a "hot"
437+
/// future), matching the rest of this module — e.g.
438+
/// [`AsyncSCShareableContent::get`]. Dropping the future without awaiting is
439+
/// safe; it simply means success/failure is not observed.
440+
#[must_use = "the operation starts eagerly, but you must .await the future to observe success or failure"]
441+
pub struct StreamControlFuture {
442+
inner: AsyncCompletionFuture<()>,
443+
map_err: fn(String) -> SCError,
444+
}
445+
446+
impl std::fmt::Debug for StreamControlFuture {
447+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448+
f.debug_struct("StreamControlFuture").finish_non_exhaustive()
449+
}
450+
}
451+
452+
impl Future for StreamControlFuture {
453+
type Output = Result<(), SCError>;
454+
455+
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
456+
let map_err = self.map_err;
457+
Pin::new(&mut self.inner)
458+
.poll(cx)
459+
.map(|r| r.map_err(map_err))
460+
}
461+
}
462+
402463
/// Async wrapper for `SCStream` with integrated frame iteration
403464
///
404465
/// Provides async methods for stream lifecycle and frame iteration.
@@ -421,7 +482,7 @@ unsafe impl Sync for AsyncSampleSender {}
421482
/// .with_height(1080);
422483
///
423484
/// let stream = AsyncSCStream::new(&filter, &config, 30, SCStreamOutputType::Screen);
424-
/// stream.start_capture()?;
485+
/// stream.start_capture().await?;
425486
///
426487
/// // Process frames asynchronously
427488
/// while let Some(frame) = stream.next().await {
@@ -527,40 +588,113 @@ impl AsyncSCStream {
527588
}
528589
}
529590

530-
/// Start capture (synchronous - returns immediately)
591+
/// Start capture asynchronously.
592+
///
593+
/// Resolves when `ScreenCaptureKit` confirms the stream has started.
594+
/// Unlike [`SCStream::start_capture`](crate::stream::SCStream::start_capture),
595+
/// awaiting this **does not block the executor thread** — the task is parked
596+
/// via its [`Waker`] and resumed from the Swift completion callback.
597+
///
598+
/// The capture is initiated eagerly when this method is called; `.await`
599+
/// observes the completion (or error).
531600
///
532601
/// # Errors
533602
///
534-
/// Returns an error if capture fails to start.
535-
pub fn start_capture(&self) -> Result<(), SCError> {
536-
self.stream.start_capture()
603+
/// The awaited result is `Err(SCError::CaptureStartFailed)` if the stream
604+
/// fails to start.
605+
pub fn start_capture(&self) -> StreamControlFuture {
606+
let (future, context) = AsyncCompletion::<()>::create();
607+
// SAFETY: `self.stream.as_ptr()` is a valid, live `SCStream` pointer for
608+
// the duration of this call; `context` is the one-shot completion
609+
// pointer from `AsyncCompletion::create()`, invoked exactly once.
610+
unsafe {
611+
crate::ffi::sc_stream_start_capture(
612+
self.stream.as_ptr(),
613+
context,
614+
stream_control_callback,
615+
);
616+
}
617+
StreamControlFuture {
618+
inner: future,
619+
map_err: SCError::CaptureStartFailed,
620+
}
537621
}
538622

539-
/// Stop capture (synchronous - returns immediately)
623+
/// Stop capture asynchronously.
624+
///
625+
/// Resolves when `ScreenCaptureKit` confirms the stream has stopped. Awaiting
626+
/// this **does not block the executor thread**.
540627
///
541628
/// # Errors
542629
///
543-
/// Returns an error if capture fails to stop.
544-
pub fn stop_capture(&self) -> Result<(), SCError> {
545-
self.stream.stop_capture()
630+
/// The awaited result is `Err(SCError::CaptureStopFailed)` if the stream
631+
/// fails to stop.
632+
pub fn stop_capture(&self) -> StreamControlFuture {
633+
let (future, context) = AsyncCompletion::<()>::create();
634+
// SAFETY: see `start_capture` — live stream pointer, one-shot context.
635+
unsafe {
636+
crate::ffi::sc_stream_stop_capture(
637+
self.stream.as_ptr(),
638+
context,
639+
stream_control_callback,
640+
);
641+
}
642+
StreamControlFuture {
643+
inner: future,
644+
map_err: SCError::CaptureStopFailed,
645+
}
546646
}
547647

548-
/// Update stream configuration
648+
/// Update stream configuration asynchronously.
649+
///
650+
/// Resolves when the reconfiguration completes. Awaiting this **does not
651+
/// block the executor thread**.
549652
///
550653
/// # Errors
551654
///
552-
/// Returns an error if the update fails.
553-
pub fn update_configuration(&self, config: &SCStreamConfiguration) -> Result<(), SCError> {
554-
self.stream.update_configuration(config)
655+
/// The awaited result is `Err(SCError::StreamError)` if the update fails.
656+
pub fn update_configuration(&self, config: &SCStreamConfiguration) -> StreamControlFuture {
657+
let (future, context) = AsyncCompletion::<()>::create();
658+
// SAFETY: `self.stream.as_ptr()` and `config.as_ptr()` are valid for the
659+
// duration of this call; `context` is the one-shot completion pointer.
660+
unsafe {
661+
crate::ffi::sc_stream_update_configuration(
662+
self.stream.as_ptr(),
663+
config.as_ptr(),
664+
context,
665+
stream_control_callback,
666+
);
667+
}
668+
StreamControlFuture {
669+
inner: future,
670+
map_err: SCError::StreamError,
671+
}
555672
}
556673

557-
/// Update content filter
674+
/// Update content filter asynchronously.
675+
///
676+
/// Resolves when the filter swap completes. Awaiting this **does not block
677+
/// the executor thread**.
558678
///
559679
/// # Errors
560680
///
561-
/// Returns an error if the update fails.
562-
pub fn update_content_filter(&self, filter: &SCContentFilter) -> Result<(), SCError> {
563-
self.stream.update_content_filter(filter)
681+
/// The awaited result is `Err(SCError::StreamError)` if the update fails.
682+
pub fn update_content_filter(&self, filter: &SCContentFilter) -> StreamControlFuture {
683+
let (future, context) = AsyncCompletion::<()>::create();
684+
// SAFETY: `self.stream.as_ptr()` and `filter.as_ptr()` are valid for the
685+
// duration of this call; `context` is the one-shot completion pointer.
686+
unsafe {
687+
crate::ffi::sc_stream_update_content_filter(
688+
self.stream.as_ptr(),
689+
filter.as_ptr(),
690+
context,
691+
stream_control_callback,
692+
);
693+
}
694+
StreamControlFuture {
695+
inner: future,
696+
map_err: SCError::StreamError,
697+
}
564698
}
565699

566700
/// Get a reference to the underlying stream

0 commit comments

Comments
 (0)