Skip to main content

doom_fish_utils/
stream.rs

1//! Executor-agnostic bounded async streams for FFI callbacks.
2//!
3//! `BoundedAsyncStream<T>` is a generic, runtime-agnostic stream primitive
4//! designed for wrapping Apple SDK callback / delegate / KVO patterns:
5//!
6//! * **Bounded** — backed by a fixed-capacity `VecDeque`. When the buffer
7//!   is full and a new item arrives from the producer, the **oldest**
8//!   queued item is dropped to make room (lossy by design).
9//! * **Waker-driven** — implements `std::future::Future` via a stored
10//!   `Waker`; works with any executor (tokio, async-std, smol, futures,
11//!   etc.) without requiring a runtime feature.
12//! * **`Send + Sync`** — produces and consumes can live on different
13//!   threads, locked by a single `Mutex`.
14//!
15//! The lossy-oldest-drop policy is the right default for real-time event
16//! streams (UI input, frame capture, BLE notifications, location updates):
17//! a slow consumer should always see the latest event, not a stale queue.
18//! When you instead need back-pressure (every event must be delivered),
19//! use [`AsyncStreamSender::push_or_block`] which blocks the producer
20//! until the consumer drains capacity.
21//!
22//! # Example
23//!
24//! ```no_run
25//! use doom_fish_utils::stream::BoundedAsyncStream;
26//! use std::sync::Arc;
27//!
28//! # async fn run() {
29//! // 8-element ring buffer of `String` events.
30//! let (stream, sender) = BoundedAsyncStream::<String>::new(8);
31//!
32//! // Producer side: typically a Swift delegate / extern "C" callback
33//! // running on a background queue.
34//! std::thread::spawn(move || {
35//!     for i in 0..100 {
36//!         sender.push(format!("event #{i}"));
37//!     }
38//!     drop(sender); // closes the stream
39//! });
40//!
41//! // Consumer side: any async runtime.
42//! while let Some(event) = stream.next().await {
43//!     println!("got {event}");
44//! }
45//! # }
46//! ```
47
48use std::collections::VecDeque;
49use std::fmt;
50use std::future::Future;
51use std::pin::Pin;
52use std::sync::{Arc, Condvar, Mutex, MutexGuard};
53use std::task::{Context, Poll, Waker};
54
55/// Backing storage shared between the [`BoundedAsyncStream`] consumer and
56/// every [`AsyncStreamSender`] producer.
57struct State<T> {
58    buffer: VecDeque<T>,
59    waker: Option<Waker>,
60    /// Set to `true` when every sender has been dropped. The consumer's
61    /// `next()` then returns `None` once the buffer drains.
62    closed: bool,
63    /// Set to `true` when the stream is dropped — wakes any blocked
64    /// producers so they can bail out instead of waiting forever.
65    consumer_gone: bool,
66    sender_count: usize,
67    #[cfg(test)]
68    blocked_producers: usize,
69}
70
71struct Shared<T> {
72    state: Mutex<State<T>>,
73    capacity_available: Condvar,
74    capacity: usize,
75}
76
77impl<T> Shared<T> {
78    fn lock_state(&self) -> MutexGuard<'_, State<T>> {
79        self.state
80            .lock()
81            .unwrap_or_else(|_| panic!("BoundedAsyncStream state mutex poisoned"))
82    }
83
84    fn lock_state_for_drop(&self) -> MutexGuard<'_, State<T>> {
85        self.state
86            .lock()
87            .unwrap_or_else(std::sync::PoisonError::into_inner)
88    }
89
90    #[cfg(test)]
91    fn wait_for_blocked_producers(&self, expected: usize) {
92        let (state, timeout) = self
93            .capacity_available
94            .wait_timeout_while(
95                self.lock_state(),
96                std::time::Duration::from_secs(5),
97                |state| state.blocked_producers < expected,
98            )
99            .unwrap_or_else(|_| panic!("BoundedAsyncStream state mutex poisoned"));
100        assert!(
101            !timeout.timed_out(),
102            "timed out waiting for {expected} blocked producer(s)"
103        );
104        drop(state);
105    }
106}
107
108/// A bounded, lossy-by-default, executor-agnostic async stream.
109///
110/// Items are pushed by one or more [`AsyncStreamSender`] handles and pulled
111/// asynchronously via [`BoundedAsyncStream::next`].
112///
113/// See the [module-level docs](crate::stream) for the full design rationale.
114pub struct BoundedAsyncStream<T> {
115    shared: Arc<Shared<T>>,
116}
117
118/// Producer handle for a [`BoundedAsyncStream`].
119///
120/// Cheap to clone (`Arc` under the hood). Drop the last `AsyncStreamSender`
121/// to close the stream; the consumer's `next()` will yield `None` once the
122/// buffer is empty.
123pub struct AsyncStreamSender<T> {
124    shared: Arc<Shared<T>>,
125}
126
127impl<T> Clone for AsyncStreamSender<T> {
128    fn clone(&self) -> Self {
129        let mut state = self.shared.lock_state();
130        state.sender_count = state
131            .sender_count
132            .checked_add(1)
133            .expect("AsyncStreamSender count overflow");
134        drop(state);
135
136        Self {
137            shared: Arc::clone(&self.shared),
138        }
139    }
140}
141
142impl<T> fmt::Debug for BoundedAsyncStream<T> {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("BoundedAsyncStream")
145            .field("buffered", &self.buffered_count())
146            .field("capacity", &self.capacity())
147            .field("is_closed", &self.is_closed())
148            .finish_non_exhaustive()
149    }
150}
151
152impl<T> fmt::Debug for AsyncStreamSender<T> {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        f.debug_struct("AsyncStreamSender").finish_non_exhaustive()
155    }
156}
157
158impl<T> BoundedAsyncStream<T> {
159    /// Creates a new bounded stream with the given capacity.
160    ///
161    /// Returns the consumer side and a single producer; clone the sender
162    /// to fan out to multiple producers.
163    ///
164    /// # Panics
165    ///
166    /// Panics if `capacity` is 0 — a zero-capacity buffer would drop every
167    /// item before the consumer could observe it. Use capacity 1 if you
168    /// genuinely want "latest only" semantics.
169    #[must_use]
170    pub fn new(capacity: usize) -> (Self, AsyncStreamSender<T>) {
171        assert!(capacity > 0, "BoundedAsyncStream capacity must be > 0");
172
173        let shared = Arc::new(Shared {
174            capacity,
175            capacity_available: Condvar::new(),
176            state: Mutex::new(State {
177                buffer: VecDeque::with_capacity(capacity),
178                waker: None,
179                closed: false,
180                consumer_gone: false,
181                sender_count: 1,
182                #[cfg(test)]
183                blocked_producers: 0,
184            }),
185        });
186
187        let stream = Self {
188            shared: Arc::clone(&shared),
189        };
190        let sender = AsyncStreamSender { shared };
191        (stream, sender)
192    }
193
194    /// Returns a future that resolves to the next item, or `None` once the
195    /// stream is closed and drained.
196    #[must_use]
197    pub const fn next(&self) -> NextItem<'_, T> {
198        NextItem { stream: self }
199    }
200
201    /// Non-blocking pop. Returns `None` if the buffer is empty (regardless
202    /// of whether the stream is open or closed).
203    ///
204    /// # Panics
205    ///
206    /// Panics if the shared state mutex is poisoned.
207    #[must_use]
208    pub fn try_next(&self) -> Option<T> {
209        let item = {
210            let mut state = self.shared.lock_state();
211            state.buffer.pop_front()
212        };
213        if item.is_some() {
214            self.shared.capacity_available.notify_one();
215        }
216        item
217    }
218
219    /// Returns `true` if the stream has been closed (all senders dropped).
220    /// Note: a closed stream may still have buffered items to drain.
221    ///
222    /// # Panics
223    ///
224    /// Panics if the shared state mutex is poisoned.
225    #[must_use]
226    pub fn is_closed(&self) -> bool {
227        self.shared.lock_state().closed
228    }
229
230    /// Returns the number of items currently buffered (0..=capacity).
231    ///
232    /// # Panics
233    ///
234    /// Panics if the shared state mutex is poisoned.
235    #[must_use]
236    pub fn buffered_count(&self) -> usize {
237        self.shared.lock_state().buffer.len()
238    }
239
240    /// Returns the buffer capacity, as passed to [`Self::new`].
241    #[must_use]
242    pub fn capacity(&self) -> usize {
243        self.shared.capacity
244    }
245
246    /// Drops all currently buffered items without closing the stream.
247    ///
248    /// # Panics
249    ///
250    /// Panics if the shared state mutex is poisoned.
251    pub fn clear_buffer(&self) {
252        let mut cleared = VecDeque::with_capacity(self.shared.capacity);
253        {
254            let mut state = self.shared.lock_state();
255            std::mem::swap(&mut state.buffer, &mut cleared);
256        }
257        self.shared.capacity_available.notify_all();
258        drop(cleared);
259    }
260
261    fn poll_next_item(&self, cx: &Context<'_>) -> Poll<Option<T>> {
262        let mut next_waker = Some(cx.waker().clone());
263        let (result, replaced_waker, made_room) = {
264            let mut state = self.shared.lock_state();
265
266            let outcome = match state.buffer.pop_front() {
267                Some(item) => (Poll::Ready(Some(item)), None, true),
268                None if state.closed => (Poll::Ready(None), None, false),
269                None => {
270                    let replaced_waker = match state.waker.as_ref() {
271                        Some(existing) if existing.will_wake(cx.waker()) => None,
272                        _ => state
273                            .waker
274                            .replace(next_waker.take().expect("next waker present")),
275                    };
276                    (Poll::Pending, replaced_waker, false)
277                }
278            };
279            drop(state);
280            outcome
281        };
282
283        drop(next_waker);
284        drop(replaced_waker);
285        if made_room {
286            self.shared.capacity_available.notify_one();
287        }
288        result
289    }
290}
291
292impl<T> Drop for BoundedAsyncStream<T> {
293    fn drop(&mut self) {
294        let stale_waker = {
295            let mut state = self.shared.lock_state_for_drop();
296            state.consumer_gone = true;
297            state.waker.take()
298        };
299        self.shared.capacity_available.notify_all();
300        drop(stale_waker);
301    }
302}
303
304impl<T> AsyncStreamSender<T> {
305    /// Push an item; drops the oldest queued item if the buffer is at
306    /// capacity. This is the lossy default.
307    ///
308    /// # Panics
309    ///
310    /// Panics if the shared state mutex is poisoned.
311    pub fn push(&self, item: T) {
312        let (overwritten, waker) = {
313            let mut state = self.shared.lock_state();
314            let overwritten = if state.buffer.len() >= self.shared.capacity {
315                state.buffer.pop_front()
316            } else {
317                None
318            };
319            state.buffer.push_back(item);
320            (overwritten, state.waker.take())
321        };
322
323        if let Some(waker) = waker {
324            waker.wake();
325        }
326        drop(overwritten);
327    }
328
329    /// Push an item, blocking the current thread if the buffer is full
330    /// until the consumer drains an item.
331    ///
332    /// Returns `Err(item)` if the consumer side has been dropped — the
333    /// item is returned to the caller so it isn't leaked.
334    ///
335    /// # Errors
336    ///
337    /// Returns `Err(item)` if the consumer has been dropped.
338    ///
339    /// # Panics
340    ///
341    /// Panics if the shared state mutex is poisoned.
342    pub fn push_or_block(&self, item: T) -> Result<(), T> {
343        let mut state = self.shared.lock_state();
344        if !state.consumer_gone && state.buffer.len() >= self.shared.capacity {
345            #[cfg(test)]
346            {
347                state.blocked_producers += 1;
348                self.shared.capacity_available.notify_all();
349            }
350
351            state = self
352                .shared
353                .capacity_available
354                .wait_while(state, |state| {
355                    !state.consumer_gone && state.buffer.len() >= self.shared.capacity
356                })
357                .unwrap_or_else(|_| panic!("BoundedAsyncStream state mutex poisoned"));
358
359            #[cfg(test)]
360            {
361                state.blocked_producers -= 1;
362                self.shared.capacity_available.notify_all();
363            }
364        }
365
366        if state.consumer_gone {
367            drop(state);
368            return Err(item);
369        }
370
371        state.buffer.push_back(item);
372        let waker = state.waker.take();
373        drop(state);
374
375        if let Some(waker) = waker {
376            waker.wake();
377        }
378        Ok(())
379    }
380
381    /// Returns the number of items currently buffered.
382    ///
383    /// # Panics
384    ///
385    /// Panics if the shared state mutex is poisoned.
386    #[must_use]
387    pub fn buffered_count(&self) -> usize {
388        self.shared.lock_state().buffer.len()
389    }
390
391    /// Returns `true` if the consumer has been dropped.
392    ///
393    /// # Panics
394    ///
395    /// Panics if the shared state mutex is poisoned.
396    #[must_use]
397    pub fn is_consumer_gone(&self) -> bool {
398        self.shared.lock_state().consumer_gone
399    }
400}
401
402impl<T> Drop for AsyncStreamSender<T> {
403    fn drop(&mut self) {
404        let waker = {
405            let mut state = self.shared.lock_state_for_drop();
406            state.sender_count -= 1;
407            if state.sender_count == 0 {
408                state.closed = true;
409                state.waker.take()
410            } else {
411                None
412            }
413        };
414
415        if let Some(waker) = waker {
416            waker.wake();
417        }
418    }
419}
420
421/// Future returned by [`BoundedAsyncStream::next`].
422pub struct NextItem<'a, T> {
423    stream: &'a BoundedAsyncStream<T>,
424}
425
426impl<T> fmt::Debug for NextItem<'_, T> {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        f.debug_struct("NextItem").finish_non_exhaustive()
429    }
430}
431
432impl<T> Future for NextItem<'_, T> {
433    type Output = Option<T>;
434
435    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
436        self.stream.poll_next_item(cx)
437    }
438}
439
440#[cfg(feature = "futures-stream")]
441impl<T: 'static> futures_core::Stream for BoundedAsyncStream<T> {
442    type Item = T;
443
444    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<T>> {
445        self.poll_next_item(cx)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    #[cfg(feature = "futures-stream")]
452    use std::future::poll_fn;
453    use std::future::Future;
454    use std::panic::{catch_unwind, AssertUnwindSafe};
455    use std::pin::Pin;
456    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
457    use std::sync::{mpsc, Arc, Barrier, TryLockError, Weak};
458    use std::task::{Context, Poll, Wake, Waker};
459    use std::thread::{self, JoinHandle};
460    use std::time::Duration;
461
462    #[cfg(feature = "futures-stream")]
463    use futures_core::Stream;
464
465    use super::{AsyncStreamSender, BoundedAsyncStream, Shared};
466
467    const TEST_TIMEOUT: Duration = Duration::from_secs(5);
468
469    fn spawn_blocking_push(
470        sender: AsyncStreamSender<u32>,
471        item: u32,
472    ) -> (mpsc::Receiver<Result<(), u32>>, JoinHandle<()>) {
473        let (result_tx, result_rx) = mpsc::channel();
474        let handle = thread::spawn(move || {
475            let result = sender.push_or_block(item);
476            result_tx.send(result).unwrap();
477        });
478        (result_rx, handle)
479    }
480
481    #[test]
482    fn try_next_notifies_blocked_producer() {
483        let (stream, sender) = BoundedAsyncStream::new(1);
484        sender.push(1);
485        let (result_rx, handle) = spawn_blocking_push(sender, 2);
486        stream.shared.wait_for_blocked_producers(1);
487
488        assert_eq!(stream.try_next(), Some(1));
489        assert_eq!(result_rx.recv_timeout(TEST_TIMEOUT).unwrap(), Ok(()));
490        handle.join().unwrap();
491        assert_eq!(stream.try_next(), Some(2));
492    }
493
494    #[test]
495    fn next_future_notifies_blocked_producer() {
496        let (stream, sender) = BoundedAsyncStream::new(1);
497        sender.push(1);
498        let (result_rx, handle) = spawn_blocking_push(sender, 2);
499        stream.shared.wait_for_blocked_producers(1);
500
501        assert_eq!(pollster::block_on(stream.next()), Some(1));
502        assert_eq!(result_rx.recv_timeout(TEST_TIMEOUT).unwrap(), Ok(()));
503        handle.join().unwrap();
504        assert_eq!(pollster::block_on(stream.next()), Some(2));
505    }
506
507    #[cfg(feature = "futures-stream")]
508    #[test]
509    fn stream_poll_notifies_blocked_producer() {
510        let (mut stream, sender) = BoundedAsyncStream::new(1);
511        sender.push(1);
512        let (result_rx, handle) = spawn_blocking_push(sender, 2);
513        stream.shared.wait_for_blocked_producers(1);
514
515        let first = pollster::block_on(poll_fn(|cx| Pin::new(&mut stream).poll_next(cx)));
516        assert_eq!(first, Some(1));
517        assert_eq!(result_rx.recv_timeout(TEST_TIMEOUT).unwrap(), Ok(()));
518        handle.join().unwrap();
519
520        let second = pollster::block_on(poll_fn(|cx| Pin::new(&mut stream).poll_next(cx)));
521        assert_eq!(second, Some(2));
522    }
523
524    #[test]
525    fn clear_buffer_notifies_blocked_producer() {
526        let (stream, sender) = BoundedAsyncStream::new(1);
527        sender.push(1);
528        let (result_rx, handle) = spawn_blocking_push(sender, 2);
529        stream.shared.wait_for_blocked_producers(1);
530
531        stream.clear_buffer();
532        assert_eq!(result_rx.recv_timeout(TEST_TIMEOUT).unwrap(), Ok(()));
533        handle.join().unwrap();
534        assert_eq!(stream.try_next(), Some(2));
535    }
536
537    #[test]
538    fn consumer_drop_returns_blocked_item() {
539        let (stream, sender) = BoundedAsyncStream::new(1);
540        sender.push(1);
541        let (result_rx, handle) = spawn_blocking_push(sender, 2);
542        stream.shared.wait_for_blocked_producers(1);
543
544        drop(stream);
545        assert_eq!(result_rx.recv_timeout(TEST_TIMEOUT).unwrap(), Err(2));
546        handle.join().unwrap();
547    }
548
549    #[test]
550    fn concurrent_sender_clone_drops_close_stream() {
551        let (stream, sender) = BoundedAsyncStream::<u32>::new(1);
552        let sender_clone = sender.clone();
553        let barrier = Arc::new(Barrier::new(3));
554
555        let first_barrier = Arc::clone(&barrier);
556        let first = thread::spawn(move || {
557            first_barrier.wait();
558            drop(sender);
559        });
560        let second_barrier = Arc::clone(&barrier);
561        let second = thread::spawn(move || {
562            second_barrier.wait();
563            drop(sender_clone);
564        });
565
566        barrier.wait();
567        first.join().unwrap();
568        second.join().unwrap();
569
570        assert!(stream.is_closed());
571        assert_eq!(pollster::block_on(stream.next()), None);
572    }
573
574    fn shared_state_is_unlocked<T>(shared: &Weak<Shared<T>>) -> bool {
575        let Some(shared) = shared.upgrade() else {
576            return true;
577        };
578        let unlocked = match shared.state.try_lock() {
579            Ok(_state) => true,
580            Err(TryLockError::WouldBlock | TryLockError::Poisoned(_)) => false,
581        };
582        unlocked
583    }
584
585    struct ReentrantWaker {
586        shared: Weak<Shared<u32>>,
587        wake_was_unlocked: Arc<AtomicBool>,
588        drop_was_unlocked: Arc<AtomicBool>,
589    }
590
591    impl Wake for ReentrantWaker {
592        fn wake(self: Arc<Self>) {
593            self.wake_was_unlocked
594                .store(shared_state_is_unlocked(&self.shared), Ordering::SeqCst);
595        }
596    }
597
598    impl Drop for ReentrantWaker {
599        fn drop(&mut self) {
600            self.drop_was_unlocked
601                .store(shared_state_is_unlocked(&self.shared), Ordering::SeqCst);
602        }
603    }
604
605    #[test]
606    fn wakes_and_drops_waker_outside_state_mutex() {
607        let (stream, sender) = BoundedAsyncStream::new(1);
608        let wake_was_unlocked = Arc::new(AtomicBool::new(false));
609        let drop_was_unlocked = Arc::new(AtomicBool::new(false));
610        let probe = Arc::new(ReentrantWaker {
611            shared: Arc::downgrade(&stream.shared),
612            wake_was_unlocked: Arc::clone(&wake_was_unlocked),
613            drop_was_unlocked: Arc::clone(&drop_was_unlocked),
614        });
615        let waker = Waker::from(Arc::clone(&probe));
616        let mut next = stream.next();
617
618        {
619            let mut cx = Context::from_waker(&waker);
620            assert_eq!(Pin::new(&mut next).poll(&mut cx), Poll::Pending);
621        }
622        drop(waker);
623        drop(probe);
624
625        sender.push(1);
626
627        assert!(wake_was_unlocked.load(Ordering::SeqCst));
628        assert!(drop_was_unlocked.load(Ordering::SeqCst));
629    }
630
631    struct ReentrantItem {
632        shared: Weak<Shared<Self>>,
633        unlocked_drops: Arc<AtomicUsize>,
634        locked_drops: Arc<AtomicUsize>,
635    }
636
637    impl Drop for ReentrantItem {
638        fn drop(&mut self) {
639            if shared_state_is_unlocked(&self.shared) {
640                self.unlocked_drops.fetch_add(1, Ordering::SeqCst);
641            } else {
642                self.locked_drops.fetch_add(1, Ordering::SeqCst);
643            }
644        }
645    }
646
647    #[test]
648    fn overwritten_and_cleared_items_drop_outside_state_mutex() {
649        let (stream, sender) = BoundedAsyncStream::new(1);
650        let unlocked_drops = Arc::new(AtomicUsize::new(0));
651        let locked_drops = Arc::new(AtomicUsize::new(0));
652
653        let make_item = || ReentrantItem {
654            shared: Arc::downgrade(&stream.shared),
655            unlocked_drops: Arc::clone(&unlocked_drops),
656            locked_drops: Arc::clone(&locked_drops),
657        };
658
659        sender.push(make_item());
660        sender.push(make_item());
661        assert_eq!(unlocked_drops.load(Ordering::SeqCst), 1);
662
663        stream.clear_buffer();
664        assert_eq!(unlocked_drops.load(Ordering::SeqCst), 2);
665        assert_eq!(locked_drops.load(Ordering::SeqCst), 0);
666    }
667
668    #[test]
669    fn poisoned_state_does_not_masquerade_as_close_or_delivery() {
670        let (stream, sender) = BoundedAsyncStream::new(1);
671        let shared = Arc::clone(&stream.shared);
672        assert!(thread::spawn(move || {
673            let _state = shared.state.lock().unwrap();
674            panic!("poison stream state");
675        })
676        .join()
677        .is_err());
678
679        assert!(catch_unwind(AssertUnwindSafe(|| sender.push(1))).is_err());
680        assert!(catch_unwind(AssertUnwindSafe(|| stream.try_next())).is_err());
681        assert!(catch_unwind(AssertUnwindSafe(|| pollster::block_on(stream.next()))).is_err());
682    }
683}