Skip to main content

cranpose_core/
concurrency.rs

1//! Composition-scoped structured concurrency.
2//!
3//! `LaunchedEffect` covers work that starts because a key changed. This covers
4//! the rest: work started from an event handler, timed work, work that feeds a
5//! piece of state, and blocking work that must not run on the UI thread.
6//! Everything here is owned by the composition — a scope cancels its tasks when
7//! it leaves, and a timer stops when nothing is waiting on it — so an
8//! application never keeps its own task list or its own "is this still alive"
9//! flag.
10
11#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
12use std::sync::{Condvar, Mutex, PoisonError};
13use std::{
14    cell::{Cell, RefCell},
15    future::Future,
16    pin::Pin,
17    rc::Rc,
18    sync::{
19        Arc, OnceLock,
20        atomic::{AtomicBool, Ordering},
21    },
22    task::{Context, Poll, Waker},
23    time::Duration,
24};
25
26#[cfg(target_arch = "wasm32")]
27use wasm_bindgen::JsCast;
28use web_time::Instant;
29
30use crate::{
31    hooks::{mutableStateOfNeverEqual, remember},
32    runtime::{RuntimeHandle, TaskHandle, current_runtime_handle},
33    state::{MutableState, State},
34};
35
36/// Spawns `future` on the current runtime's UI task queue.
37///
38/// Framework-internal: application code launches through a
39/// [`CoroutineScope`] so the work is cancelled with its
40/// composition. Returns `None` when there is no runtime on this thread.
41pub fn spawn_ui_task(future: impl Future<Output = ()> + 'static) -> Option<TaskHandle> {
42    current_runtime_handle().and_then(|runtime| runtime.spawn_ui(future))
43}
44
45/// A cancellation scope for work launched outside the composition pass.
46///
47/// Tasks launched through a scope are cancelled when the scope leaves the
48/// composition, so a click handler can start an asynchronous job without the
49/// job outliving the screen that started it.
50#[derive(Clone)]
51pub struct CoroutineScope {
52    inner: Rc<ScopeInner>,
53}
54
55struct ScopeInner {
56    runtime: Option<RuntimeHandle>,
57    tasks: RefCell<Vec<TaskHandle>>,
58    closed: Cell<bool>,
59}
60
61impl Drop for ScopeInner {
62    fn drop(&mut self) {
63        for task in self.tasks.get_mut().drain(..) {
64            task.cancel();
65        }
66    }
67}
68
69struct CompositionScopeOwner(CoroutineScope);
70
71impl Drop for CompositionScopeOwner {
72    fn drop(&mut self) {
73        self.0.inner.closed.set(true);
74        self.0.cancel();
75    }
76}
77
78impl CoroutineScope {
79    /// Launches `future`, keeping it alive until it finishes or the scope is
80    /// cancelled.
81    pub fn launch(&self, future: impl Future<Output = ()> + 'static) {
82        if self.inner.closed.get() {
83            return;
84        }
85        let Some(runtime) = self.inner.runtime.clone() else {
86            log::warn!("cranpose: a coroutine scope with no runtime dropped its work");
87            return;
88        };
89        self.inner
90            .tasks
91            .borrow_mut()
92            .retain(|task| !task.is_finished());
93        if let Some(handle) = runtime.spawn_ui(future) {
94            self.inner.tasks.borrow_mut().push(handle);
95        }
96    }
97
98    /// Cancels every task this scope launched.
99    pub fn cancel(&self) {
100        let tasks = std::mem::take(&mut *self.inner.tasks.borrow_mut());
101        for task in tasks {
102            task.cancel();
103        }
104    }
105
106    #[cfg(test)]
107    pub(crate) fn probe_identity(&self) -> usize {
108        Rc::as_ptr(&self.inner) as *const () as usize
109    }
110}
111
112/// Remembers a [`CoroutineScope`] bound to this position in the composition.
113#[expect(non_snake_case)]
114#[track_caller]
115pub fn rememberCoroutineScope() -> CoroutineScope {
116    remember(|| {
117        CompositionScopeOwner(CoroutineScope {
118            inner: Rc::new(ScopeInner {
119                runtime: current_runtime_handle(),
120                tasks: RefCell::new(Vec::new()),
121                closed: Cell::new(false),
122            }),
123        })
124    })
125    .with(|owner| owner.0.clone())
126}
127
128/// Resolves after `duration` has elapsed.
129///
130/// The wait is served by the framework's timer, which posts the wake-up onto
131/// the runtime's UI queue. Nothing spins and no frames are requested while a
132/// delay is pending, so a one-minute timer costs nothing for a minute.
133pub fn delay(duration: Duration) -> Delay {
134    Delay {
135        deadline: Instant::now() + duration,
136        armed: false,
137        fired: Arc::new(AtomicBool::new(false)),
138    }
139}
140
141/// The future returned by [`delay`].
142pub struct Delay {
143    deadline: Instant,
144    armed: bool,
145    fired: Arc<AtomicBool>,
146}
147
148impl Future for Delay {
149    type Output = ();
150
151    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
152        if self.fired.load(Ordering::Acquire) || Instant::now() >= self.deadline {
153            return Poll::Ready(());
154        }
155        let this = self.get_mut();
156        if !this.armed {
157            this.armed = true;
158            timer().arm(
159                this.deadline,
160                context.waker().clone(),
161                Arc::clone(&this.fired),
162            );
163        }
164        Poll::Pending
165    }
166}
167
168/// Runs `tick` every `period` until the returned future is dropped.
169///
170/// The first tick happens after one full period, matching a repeating timer
171/// rather than a leading-edge one.
172pub async fn interval(period: Duration, mut tick: impl FnMut()) {
173    loop {
174        delay(period).await;
175        tick();
176    }
177}
178
179#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
180struct Alarm {
181    deadline: Instant,
182    waker: Waker,
183    fired: Arc<AtomicBool>,
184}
185
186struct Timer {
187    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
188    alarms: Mutex<Vec<Alarm>>,
189    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
190    wake: Condvar,
191}
192
193fn timer() -> &'static Timer {
194    static TIMER: OnceLock<&'static Timer> = OnceLock::new();
195    TIMER.get_or_init(|| {
196        let timer: &'static Timer = Box::leak(Box::new(Timer::new()));
197        timer.start();
198        timer
199    })
200}
201
202#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
203impl Timer {
204    fn new() -> Self {
205        Self {
206            alarms: Mutex::new(Vec::new()),
207            wake: Condvar::new(),
208        }
209    }
210
211    fn start(&'static self) {
212        std::thread::Builder::new()
213            .name("cranpose-timer".to_string())
214            .spawn(move || self.run())
215            .expect("the timer thread starts");
216    }
217
218    fn run(&self) {
219        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
220        loop {
221            let now = Instant::now();
222            let mut due = Vec::new();
223            let mut next: Option<Duration> = None;
224            alarms.retain(|alarm| {
225                if alarm.deadline <= now {
226                    due.push((alarm.waker.clone(), Arc::clone(&alarm.fired)));
227                    false
228                } else {
229                    let remaining = alarm.deadline - now;
230                    next = Some(next.map_or(remaining, |current| current.min(remaining)));
231                    true
232                }
233            });
234
235            if !due.is_empty() {
236                drop(alarms);
237                for (waker, fired) in due {
238                    fired.store(true, Ordering::Release);
239                    waker.wake();
240                }
241                alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
242                continue;
243            }
244
245            alarms = match next {
246                Some(timeout) => {
247                    self.wake
248                        .wait_timeout(alarms, timeout)
249                        .unwrap_or_else(PoisonError::into_inner)
250                        .0
251                }
252                None => self
253                    .wake
254                    .wait(alarms)
255                    .unwrap_or_else(PoisonError::into_inner),
256            };
257        }
258    }
259
260    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
261        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
262        alarms.push(Alarm {
263            deadline,
264            waker,
265            fired,
266        });
267        self.wake.notify_one();
268    }
269}
270
271/// Apple platforms hand each deadline to Grand Central Dispatch on the
272/// user-interactive queue. A thread sleeping on a condition variable at the
273/// default quality of service wakes about 8 ms late there, as macOS coalesces
274/// its timers, so a one-frame `delay` often missed its frame; GCD at
275/// user-interactive quality of service wakes within a millisecond.
276#[cfg(target_vendor = "apple")]
277impl Timer {
278    fn new() -> Self {
279        Self {}
280    }
281
282    fn start(&'static self) {}
283
284    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
285        use dispatch2::{DispatchQoS, DispatchQueue, DispatchTime, GlobalQueueIdentifier};
286
287        let nanos = deadline
288            .saturating_duration_since(Instant::now())
289            .as_nanos()
290            .min(i64::MAX as u128) as i64;
291        let queue = DispatchQueue::global_queue(GlobalQueueIdentifier::QualityOfService(
292            DispatchQoS::UserInteractive,
293        ));
294        let fire = move || {
295            fired.store(true, Ordering::Release);
296            waker.wake();
297        };
298        if queue.after(DispatchTime::NOW.time(nanos), fire).is_err() {
299            log::error!("cranpose: GCD refused a timer; the delay never resolves");
300        }
301    }
302}
303
304#[cfg(target_arch = "wasm32")]
305impl Timer {
306    fn new() -> Self {
307        Self {}
308    }
309
310    fn start(&'static self) {}
311
312    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
313        let millis = deadline
314            .saturating_duration_since(Instant::now())
315            .as_millis()
316            .min(i32::MAX as u128) as i32;
317        let callback = wasm_bindgen::closure::Closure::once_into_js(move || {
318            fired.store(true, Ordering::Release);
319            waker.wake();
320        });
321        let scheduled = web_sys::window().and_then(|window| {
322            window
323                .set_timeout_with_callback_and_timeout_and_arguments_0(
324                    callback.unchecked_ref(),
325                    millis,
326                )
327                .ok()
328        });
329        if scheduled.is_none() {
330            log::warn!("cranpose: no window timer is available; the delay resolves immediately");
331        }
332    }
333}
334
335/// The producing half of an [`EventStream`].
336///
337/// A service that receives events from outside the composition — a platform
338/// callback, a worker thread, a socket — publishes through a channel, and every
339/// pending collector is woken. This is the shape that replaces
340/// "register an observer, then drain a queue" everywhere in the framework.
341pub struct EventChannel<T: 'static> {
342    shared: Rc<ChannelShared<T>>,
343}
344
345struct ChannelShared<T: 'static> {
346    ready: RefCell<std::collections::VecDeque<T>>,
347    closed: std::cell::Cell<bool>,
348    delivered: std::cell::Cell<usize>,
349    wakers: RefCell<Vec<Waker>>,
350}
351
352impl<T: 'static> ChannelShared<T> {
353    fn wake_all(&self) {
354        for waker in self.wakers.borrow_mut().drain(..) {
355            waker.wake();
356        }
357    }
358}
359
360impl<T: 'static> Default for EventChannel<T> {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl<T: 'static> EventChannel<T> {
367    /// Creates an open channel.
368    pub fn new() -> Self {
369        Self {
370            shared: Rc::new(ChannelShared {
371                ready: RefCell::new(std::collections::VecDeque::new()),
372                closed: std::cell::Cell::new(false),
373                delivered: std::cell::Cell::new(0),
374                wakers: RefCell::new(Vec::new()),
375            }),
376        }
377    }
378
379    /// The consuming half, handed to collectors.
380    pub fn stream(&self) -> EventStream<T> {
381        EventStream {
382            shared: Rc::clone(&self.shared),
383        }
384    }
385
386    /// Publishes one event and wakes every pending collector.
387    pub fn send(&self, event: T) {
388        if self.shared.closed.get() {
389            return;
390        }
391        self.shared.ready.borrow_mut().push_back(event);
392        self.shared.wake_all();
393    }
394
395    /// Ends the stream. Collectors drain what is queued and then finish.
396    pub fn close(&self) {
397        if self.shared.closed.get() {
398            return;
399        }
400        self.shared.closed.set(true);
401        self.shared.wake_all();
402    }
403
404    /// Whether the channel has been closed.
405    pub fn is_closed(&self) -> bool {
406        self.shared.closed.get()
407    }
408
409    /// How many events are queued but not yet taken.
410    pub fn pending(&self) -> usize {
411        self.shared.ready.borrow().len()
412    }
413}
414
415/// The consuming half of an [`EventChannel`].
416///
417/// Collectors take events one at a time; an event goes to exactly one
418/// collector, so two collectors share the stream rather than each seeing every
419/// event.
420pub struct EventStream<T: 'static> {
421    shared: Rc<ChannelShared<T>>,
422}
423
424impl<T: 'static> Clone for EventStream<T> {
425    fn clone(&self) -> Self {
426        Self {
427            shared: Rc::clone(&self.shared),
428        }
429    }
430}
431
432impl<T: 'static> EventStream<T> {
433    /// Resolves with the next event, or `None` once the stream is closed and
434    /// drained.
435    pub fn next(&self) -> EventStreamNext<T> {
436        EventStreamNext {
437            shared: Rc::clone(&self.shared),
438        }
439    }
440
441    /// How many events this stream has handed out.
442    pub fn delivered(&self) -> usize {
443        self.shared.delivered.get()
444    }
445}
446
447/// The future returned by [`EventStream::next`].
448pub struct EventStreamNext<T: 'static> {
449    shared: Rc<ChannelShared<T>>,
450}
451
452impl<T: 'static> Future for EventStreamNext<T> {
453    type Output = Option<T>;
454
455    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<T>> {
456        if let Some(event) = self.shared.ready.borrow_mut().pop_front() {
457            self.shared.delivered.set(self.shared.delivered.get() + 1);
458            return Poll::Ready(Some(event));
459        }
460        if self.shared.closed.get() {
461            return Poll::Ready(None);
462        }
463        self.shared
464            .wakers
465            .borrow_mut()
466            .push(context.waker().clone());
467        Poll::Pending
468    }
469}
470
471/// Collects `stream` for as long as this call stays in the composition,
472/// handing each event to `on_event`.
473///
474/// `key` re-starts the collection when it changes, exactly like
475/// `LaunchedEffect`.
476#[expect(non_snake_case)]
477#[track_caller]
478pub fn CollectEvents<T, K>(stream: EventStream<T>, key: K, on_event: impl FnMut(T) + 'static)
479where
480    T: 'static,
481    K: PartialEq + 'static,
482{
483    crate::__launched_effect_async_impl(
484        crate::caller_location_key(),
485        std::panic::Location::caller().into(),
486        key,
487        move |_scope| {
488            let mut on_event = on_event;
489            Box::pin(async move {
490                while let Some(event) = stream.next().await {
491                    on_event(event);
492                }
493            })
494        },
495    );
496}
497
498/// Collects `stream` into state, starting at `initial`.
499///
500/// The composition reads the latest value the stream produced, and recomposes
501/// when a new one arrives.
502#[expect(non_snake_case)]
503#[track_caller]
504pub fn collectAsState<T, K>(stream: EventStream<T>, key: K, initial: T) -> State<T>
505where
506    T: Clone + 'static,
507    K: PartialEq + 'static,
508{
509    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
510    let sink = state;
511    CollectEvents(stream, key, move |event| sink.set(event));
512    state.as_state()
513}
514
515/// A `Send` publishing handle for a composition-scoped [`EventStream`].
516///
517/// Platform services publish events from whatever thread they run on — a JNI
518/// callback, a worker, a socket reader. The sender hops each event onto the UI
519/// thread through the runtime's dispatcher and pushes it into the stream the
520/// composition is collecting, so no service and no application ever writes that
521/// hop again.
522pub struct EventSender<T: Send + 'static> {
523    #[cfg(not(target_arch = "wasm32"))]
524    dispatcher: crate::runtime::UiDispatcher,
525    bridge: u64,
526    _events: std::marker::PhantomData<fn(T)>,
527}
528
529impl<T: Send + 'static> Clone for EventSender<T> {
530    fn clone(&self) -> Self {
531        Self {
532            #[cfg(not(target_arch = "wasm32"))]
533            dispatcher: self.dispatcher.clone(),
534            bridge: self.bridge,
535            _events: std::marker::PhantomData,
536        }
537    }
538}
539
540impl<T: Send + 'static> EventSender<T> {
541    /// Publishes `event` to the composition that owns this bridge.
542    pub fn send(&self, event: T) {
543        let bridge = self.bridge;
544        #[cfg(not(target_arch = "wasm32"))]
545        self.dispatcher
546            .post(move || deliver_bridged::<T>(bridge, event));
547        #[cfg(target_arch = "wasm32")]
548        deliver_bridged::<T>(bridge, event);
549    }
550}
551
552thread_local! {
553    static BRIDGES: RefCell<std::collections::HashMap<u64, Rc<dyn std::any::Any>>> =
554        RefCell::new(std::collections::HashMap::new());
555}
556
557static NEXT_BRIDGE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
558
559fn deliver_bridged<T: Send + 'static>(bridge: u64, event: T) {
560    let channel = BRIDGES.with(|bridges| bridges.borrow().get(&bridge).cloned());
561    let Some(channel) = channel else {
562        log::debug!("event bridge {bridge} is gone, one event dropped");
563        return;
564    };
565    if let Ok(channel) = channel.downcast::<EventChannel<T>>() {
566        channel.send(event);
567    }
568}
569
570struct Bridge<T: Send + 'static> {
571    id: u64,
572    channel: Rc<EventChannel<T>>,
573}
574
575impl<T: Send + 'static> Bridge<T> {
576    fn new() -> Self {
577        let id = NEXT_BRIDGE.fetch_add(1, Ordering::Relaxed);
578        let channel = Rc::new(EventChannel::<T>::new());
579        BRIDGES.with(|bridges| {
580            bridges
581                .borrow_mut()
582                .insert(id, Rc::clone(&channel) as Rc<dyn std::any::Any>)
583        });
584        Self { id, channel }
585    }
586}
587
588impl<T: Send + 'static> Drop for Bridge<T> {
589    fn drop(&mut self) {
590        BRIDGES.with(|bridges| bridges.borrow_mut().remove(&self.id));
591        self.channel.close();
592    }
593}
594
595/// Turns a platform subscription into a composition-scoped [`EventStream`].
596///
597/// `subscribe` receives a `Send` [`EventSender`] and returns whatever
598/// registration handle the service uses; that handle is dropped — unsubscribing
599/// the service — when `key` changes or the composition leaves. This is the one
600/// place the framework bridges "a service publishes from another thread" to
601/// "a composition collects".
602#[expect(non_snake_case)]
603#[track_caller]
604pub fn rememberEventStream<T, K, R, S>(key: K, subscribe: S) -> EventStream<T>
605where
606    T: Send + 'static,
607    K: PartialEq + 'static,
608    R: 'static,
609    S: FnOnce(EventSender<T>) -> R + 'static,
610{
611    let bridge = remember(Bridge::<T>::new);
612    let (id, stream) = bridge.with(|bridge| (bridge.id, bridge.channel.stream()));
613    #[cfg(not(target_arch = "wasm32"))]
614    let dispatcher = current_runtime_handle().map(|runtime| runtime.dispatcher());
615
616    crate::__disposable_effect_impl(crate::caller_location_key(), key, move |scope| {
617        #[cfg(not(target_arch = "wasm32"))]
618        let Some(dispatcher) = dispatcher else {
619            log::warn!("cranpose: an event stream was remembered without a runtime");
620            return scope.on_dispose(|| {});
621        };
622        let registration = subscribe(EventSender {
623            #[cfg(not(target_arch = "wasm32"))]
624            dispatcher,
625            bridge: id,
626            _events: std::marker::PhantomData,
627        });
628        scope.on_dispose(move || drop(registration))
629    });
630
631    stream
632}
633
634/// Runs `producer` when `key` changes and exposes what it publishes as state.
635///
636/// The Compose `produceState` contract: the producer receives a handle it uses
637/// to publish values, and is cancelled when the key changes or the composition
638/// leaves.
639#[expect(non_snake_case)]
640#[track_caller]
641pub fn produceState<T, K, F>(initial: T, key: K, producer: F) -> State<T>
642where
643    T: Clone + 'static,
644    K: PartialEq + 'static,
645    F: FnOnce(ProduceScope<T>) -> Pin<Box<dyn Future<Output = ()>>> + 'static,
646{
647    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
648    let handle = ProduceScope { state };
649    crate::__launched_effect_async_impl(
650        crate::caller_location_key(),
651        std::panic::Location::caller().into(),
652        key,
653        move |_scope| producer(handle),
654    );
655    state.as_state()
656}
657
658/// The publishing half handed to a [`produceState`] producer.
659pub struct ProduceScope<T: Clone + 'static> {
660    state: MutableState<T>,
661}
662
663impl<T: Clone + 'static> ProduceScope<T> {
664    /// Publishes `value` to the produced state.
665    pub fn set(&self, value: T) {
666        self.state.set(value);
667    }
668}
669
670#[cfg(test)]
671#[path = "tests/concurrency_tests.rs"]
672mod tests;
673
674#[cfg(test)]
675#[path = "tests/concurrency_stream_tests.rs"]
676mod stream_tests;
677
678#[cfg(test)]
679#[path = "tests/concurrency_timer_race_tests.rs"]
680mod timer_race_tests;