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(all(target_arch = "wasm32", target_os = "unknown"))]
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(
180    all(target_arch = "wasm32", target_os = "unknown"),
181    target_vendor = "apple"
182)))]
183pub(crate) struct Alarm {
184    deadline: Instant,
185    waker: Waker,
186    fired: Arc<AtomicBool>,
187}
188
189struct Timer {
190    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
191    alarms: Mutex<Vec<Alarm>>,
192    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
193    wake: Condvar,
194}
195
196fn timer() -> &'static Timer {
197    static TIMER: OnceLock<&'static Timer> = OnceLock::new();
198    TIMER.get_or_init(|| {
199        let timer: &'static Timer = Box::leak(Box::new(Timer::new()));
200        timer.start();
201        timer
202    })
203}
204
205#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
206impl Timer {
207    fn new() -> Self {
208        Self {
209            alarms: Mutex::new(Vec::new()),
210            wake: Condvar::new(),
211        }
212    }
213
214    fn start(&'static self) {
215        std::thread::Builder::new()
216            .name("cranpose-timer".to_string())
217            .spawn(move || self.run())
218            .expect("the timer thread starts");
219    }
220
221    fn run(&self) {
222        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
223        loop {
224            let now = Instant::now();
225            let mut due = Vec::new();
226            let mut next: Option<Duration> = None;
227            alarms.retain(|alarm| {
228                if alarm.deadline <= now {
229                    due.push((alarm.waker.clone(), Arc::clone(&alarm.fired)));
230                    false
231                } else {
232                    let remaining = alarm.deadline - now;
233                    next = Some(next.map_or(remaining, |current| current.min(remaining)));
234                    true
235                }
236            });
237
238            if !due.is_empty() {
239                drop(alarms);
240                for (waker, fired) in due {
241                    fired.store(true, Ordering::Release);
242                    waker.wake();
243                }
244                alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
245                continue;
246            }
247
248            alarms = match next {
249                Some(timeout) => {
250                    self.wake
251                        .wait_timeout(alarms, timeout)
252                        .unwrap_or_else(PoisonError::into_inner)
253                        .0
254                }
255                None => self
256                    .wake
257                    .wait(alarms)
258                    .unwrap_or_else(PoisonError::into_inner),
259            };
260        }
261    }
262
263    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
264        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
265        alarms.push(Alarm {
266            deadline,
267            waker,
268            fired,
269        });
270        self.wake.notify_one();
271    }
272}
273
274/// Apple platforms hand each deadline to Grand Central Dispatch on the
275/// user-interactive queue. A thread sleeping on a condition variable at the
276/// default quality of service wakes about 8 ms late there, as macOS coalesces
277/// its timers, so a one-frame `delay` often missed its frame; GCD at
278/// user-interactive quality of service wakes within a millisecond.
279#[cfg(target_vendor = "apple")]
280impl Timer {
281    fn new() -> Self {
282        Self {}
283    }
284
285    fn start(&'static self) {}
286
287    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
288        use dispatch2::{DispatchQoS, DispatchQueue, DispatchTime, GlobalQueueIdentifier};
289
290        let nanos = deadline
291            .saturating_duration_since(Instant::now())
292            .as_nanos()
293            .min(i64::MAX as u128) as i64;
294        let queue = DispatchQueue::global_queue(GlobalQueueIdentifier::QualityOfService(
295            DispatchQoS::UserInteractive,
296        ));
297        let fire = move || {
298            fired.store(true, Ordering::Release);
299            waker.wake();
300        };
301        if queue.after(DispatchTime::NOW.time(nanos), fire).is_err() {
302            log::error!("cranpose: GCD refused a timer; the delay never resolves");
303        }
304    }
305}
306
307#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
308impl Timer {
309    fn new() -> Self {
310        Self {}
311    }
312
313    fn start(&'static self) {}
314
315    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
316        let millis = deadline
317            .saturating_duration_since(Instant::now())
318            .as_millis()
319            .min(i32::MAX as u128) as i32;
320        let callback = wasm_bindgen::closure::Closure::once_into_js(move || {
321            fired.store(true, Ordering::Release);
322            waker.wake();
323        });
324        let scheduled = web_sys::window().and_then(|window| {
325            window
326                .set_timeout_with_callback_and_timeout_and_arguments_0(
327                    callback.unchecked_ref(),
328                    millis,
329                )
330                .ok()
331        });
332        if scheduled.is_none() {
333            log::warn!("cranpose: no window timer is available; the delay resolves immediately");
334        }
335    }
336}
337
338// Under Emscripten there is no browser timer to call back and no thread to
339// wait, so alarms wait in the thread's runtime state, which the runtime
340// checks each time it drains its UI work. The host that embeds Cranpose,
341// such as a game engine, drives those drains with its frames.
342#[cfg(target_os = "emscripten")]
343impl Timer {
344    fn new() -> Self {
345        Self {}
346    }
347
348    fn start(&'static self) {}
349
350    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
351        crate::thread_state::runtimes(|state| {
352            state.alarms.borrow_mut().push(Alarm {
353                deadline,
354                waker,
355                fired,
356            })
357        });
358    }
359}
360
361/// Wakes the delays whose deadline passed.
362#[cfg(target_os = "emscripten")]
363pub(crate) fn fire_due_alarms() {
364    let now = Instant::now();
365    let due: Vec<Alarm> = crate::thread_state::runtimes(|state| {
366        let mut alarms = state.alarms.borrow_mut();
367        if alarms.iter().all(|alarm| alarm.deadline > now) {
368            return Vec::new();
369        }
370        let (due, waiting) = std::mem::take(&mut *alarms)
371            .into_iter()
372            .partition(|alarm| alarm.deadline <= now);
373        *alarms = waiting;
374        due
375    });
376    for alarm in due {
377        alarm.fired.store(true, Ordering::Release);
378        alarm.waker.wake();
379    }
380}
381
382/// The producing half of an [`EventStream`].
383///
384/// A service that receives events from outside the composition — a platform
385/// callback, a worker thread, a socket — publishes through a channel, and every
386/// pending collector is woken. This is the shape that replaces
387/// "register an observer, then drain a queue" everywhere in the framework.
388pub struct EventChannel<T: 'static> {
389    shared: Rc<ChannelShared<T>>,
390}
391
392struct ChannelShared<T: 'static> {
393    ready: RefCell<std::collections::VecDeque<T>>,
394    closed: std::cell::Cell<bool>,
395    delivered: std::cell::Cell<usize>,
396    wakers: RefCell<Vec<Waker>>,
397}
398
399impl<T: 'static> ChannelShared<T> {
400    fn wake_all(&self) {
401        for waker in self.wakers.borrow_mut().drain(..) {
402            waker.wake();
403        }
404    }
405}
406
407impl<T: 'static> Default for EventChannel<T> {
408    fn default() -> Self {
409        Self::new()
410    }
411}
412
413impl<T: 'static> EventChannel<T> {
414    /// Creates an open channel.
415    pub fn new() -> Self {
416        Self {
417            shared: Rc::new(ChannelShared {
418                ready: RefCell::new(std::collections::VecDeque::new()),
419                closed: std::cell::Cell::new(false),
420                delivered: std::cell::Cell::new(0),
421                wakers: RefCell::new(Vec::new()),
422            }),
423        }
424    }
425
426    /// The consuming half, handed to collectors.
427    pub fn stream(&self) -> EventStream<T> {
428        EventStream {
429            shared: Rc::clone(&self.shared),
430        }
431    }
432
433    /// Publishes one event and wakes every pending collector.
434    pub fn send(&self, event: T) {
435        if self.shared.closed.get() {
436            return;
437        }
438        self.shared.ready.borrow_mut().push_back(event);
439        self.shared.wake_all();
440    }
441
442    /// Ends the stream. Collectors drain what is queued and then finish.
443    pub fn close(&self) {
444        if self.shared.closed.get() {
445            return;
446        }
447        self.shared.closed.set(true);
448        self.shared.wake_all();
449    }
450
451    /// Whether the channel has been closed.
452    pub fn is_closed(&self) -> bool {
453        self.shared.closed.get()
454    }
455
456    /// How many events are queued but not yet taken.
457    pub fn pending(&self) -> usize {
458        self.shared.ready.borrow().len()
459    }
460}
461
462/// The consuming half of an [`EventChannel`].
463///
464/// Collectors take events one at a time; an event goes to exactly one
465/// collector, so two collectors share the stream rather than each seeing every
466/// event.
467pub struct EventStream<T: 'static> {
468    shared: Rc<ChannelShared<T>>,
469}
470
471impl<T: 'static> Clone for EventStream<T> {
472    fn clone(&self) -> Self {
473        Self {
474            shared: Rc::clone(&self.shared),
475        }
476    }
477}
478
479impl<T: 'static> EventStream<T> {
480    /// Resolves with the next event, or `None` once the stream is closed and
481    /// drained.
482    pub fn next(&self) -> EventStreamNext<T> {
483        EventStreamNext {
484            shared: Rc::clone(&self.shared),
485        }
486    }
487
488    /// How many events this stream has handed out.
489    pub fn delivered(&self) -> usize {
490        self.shared.delivered.get()
491    }
492}
493
494/// The future returned by [`EventStream::next`].
495pub struct EventStreamNext<T: 'static> {
496    shared: Rc<ChannelShared<T>>,
497}
498
499impl<T: 'static> Future for EventStreamNext<T> {
500    type Output = Option<T>;
501
502    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<T>> {
503        if let Some(event) = self.shared.ready.borrow_mut().pop_front() {
504            self.shared.delivered.set(self.shared.delivered.get() + 1);
505            return Poll::Ready(Some(event));
506        }
507        if self.shared.closed.get() {
508            return Poll::Ready(None);
509        }
510        self.shared
511            .wakers
512            .borrow_mut()
513            .push(context.waker().clone());
514        Poll::Pending
515    }
516}
517
518/// Collects `stream` for as long as this call stays in the composition,
519/// handing each event to `on_event`.
520///
521/// `key` re-starts the collection when it changes, exactly like
522/// `LaunchedEffect`.
523#[expect(non_snake_case)]
524#[track_caller]
525pub fn CollectEvents<T, K>(stream: EventStream<T>, key: K, on_event: impl FnMut(T) + 'static)
526where
527    T: 'static,
528    K: PartialEq + 'static,
529{
530    crate::__launched_effect_async_impl(
531        crate::caller_location_key(),
532        std::panic::Location::caller().into(),
533        key,
534        move |_scope| {
535            let mut on_event = on_event;
536            Box::pin(async move {
537                while let Some(event) = stream.next().await {
538                    on_event(event);
539                }
540            })
541        },
542    );
543}
544
545/// Collects `stream` into state, starting at `initial`.
546///
547/// The composition reads the latest value the stream produced, and recomposes
548/// when a new one arrives.
549#[expect(non_snake_case)]
550#[track_caller]
551pub fn collectAsState<T, K>(stream: EventStream<T>, key: K, initial: T) -> State<T>
552where
553    T: Clone + 'static,
554    K: PartialEq + 'static,
555{
556    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
557    let sink = state;
558    CollectEvents(stream, key, move |event| sink.set(event));
559    state.as_state()
560}
561
562/// A `Send` publishing handle for a composition-scoped [`EventStream`].
563///
564/// Platform services publish events from whatever thread they run on — a JNI
565/// callback, a worker, a socket reader. The sender hops each event onto the UI
566/// thread through the runtime's dispatcher and pushes it into the stream the
567/// composition is collecting, so no service and no application ever writes that
568/// hop again.
569pub struct EventSender<T: Send + 'static> {
570    #[cfg(not(target_arch = "wasm32"))]
571    dispatcher: crate::runtime::UiDispatcher,
572    bridge: u64,
573    _events: std::marker::PhantomData<fn(T)>,
574}
575
576impl<T: Send + 'static> Clone for EventSender<T> {
577    fn clone(&self) -> Self {
578        Self {
579            #[cfg(not(target_arch = "wasm32"))]
580            dispatcher: self.dispatcher.clone(),
581            bridge: self.bridge,
582            _events: std::marker::PhantomData,
583        }
584    }
585}
586
587impl<T: Send + 'static> EventSender<T> {
588    /// Publishes `event` to the composition that owns this bridge.
589    pub fn send(&self, event: T) {
590        let bridge = self.bridge;
591        #[cfg(not(target_arch = "wasm32"))]
592        self.dispatcher
593            .post(move || deliver_bridged::<T>(bridge, event));
594        #[cfg(target_arch = "wasm32")]
595        deliver_bridged::<T>(bridge, event);
596    }
597}
598
599static NEXT_BRIDGE: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(1);
600
601fn deliver_bridged<T: Send + 'static>(bridge: u64, event: T) {
602    let channel =
603        crate::thread_state::runtimes(|state| state.bridges.borrow().get(&bridge).cloned());
604    let Some(channel) = channel else {
605        log::debug!("event bridge {bridge} is gone, one event dropped");
606        return;
607    };
608    if let Ok(channel) = channel.downcast::<EventChannel<T>>() {
609        channel.send(event);
610    }
611}
612
613struct Bridge<T: Send + 'static> {
614    id: u64,
615    channel: Rc<EventChannel<T>>,
616}
617
618impl<T: Send + 'static> Bridge<T> {
619    fn new() -> Self {
620        let id = NEXT_BRIDGE.fetch_add(1, Ordering::Relaxed);
621        let channel = Rc::new(EventChannel::<T>::new());
622        crate::thread_state::runtimes(|state| {
623            state
624                .bridges
625                .borrow_mut()
626                .insert(id, Rc::clone(&channel) as Rc<dyn std::any::Any>)
627        });
628        Self { id, channel }
629    }
630}
631
632impl<T: Send + 'static> Drop for Bridge<T> {
633    fn drop(&mut self) {
634        crate::thread_state::try_runtimes(|state| state.bridges.borrow_mut().remove(&self.id));
635        self.channel.close();
636    }
637}
638
639/// Turns a platform subscription into a composition-scoped [`EventStream`].
640///
641/// `subscribe` receives a `Send` [`EventSender`] and returns whatever
642/// registration handle the service uses; that handle is dropped — unsubscribing
643/// the service — when `key` changes or the composition leaves. This is the one
644/// place the framework bridges "a service publishes from another thread" to
645/// "a composition collects".
646#[expect(non_snake_case)]
647#[track_caller]
648pub fn rememberEventStream<T, K, R, S>(key: K, subscribe: S) -> EventStream<T>
649where
650    T: Send + 'static,
651    K: PartialEq + 'static,
652    R: 'static,
653    S: FnOnce(EventSender<T>) -> R + 'static,
654{
655    let bridge = remember(Bridge::<T>::new);
656    let (id, stream) = bridge.with(|bridge| (bridge.id, bridge.channel.stream()));
657    #[cfg(not(target_arch = "wasm32"))]
658    let dispatcher = current_runtime_handle().map(|runtime| runtime.dispatcher());
659
660    crate::__disposable_effect_impl(crate::caller_location_key(), key, move |scope| {
661        #[cfg(not(target_arch = "wasm32"))]
662        let Some(dispatcher) = dispatcher else {
663            log::warn!("cranpose: an event stream was remembered without a runtime");
664            return scope.on_dispose(|| {});
665        };
666        let registration = subscribe(EventSender {
667            #[cfg(not(target_arch = "wasm32"))]
668            dispatcher,
669            bridge: id,
670            _events: std::marker::PhantomData,
671        });
672        scope.on_dispose(move || drop(registration))
673    });
674
675    stream
676}
677
678/// Runs `producer` when `key` changes and exposes what it publishes as state.
679///
680/// The Compose `produceState` contract: the producer receives a handle it uses
681/// to publish values, and is cancelled when the key changes or the composition
682/// leaves.
683#[expect(non_snake_case)]
684#[track_caller]
685pub fn produceState<T, K, F>(initial: T, key: K, producer: F) -> State<T>
686where
687    T: Clone + 'static,
688    K: PartialEq + 'static,
689    F: FnOnce(ProduceScope<T>) -> Pin<Box<dyn Future<Output = ()>>> + 'static,
690{
691    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
692    let handle = ProduceScope { state };
693    crate::__launched_effect_async_impl(
694        crate::caller_location_key(),
695        std::panic::Location::caller().into(),
696        key,
697        move |_scope| producer(handle),
698    );
699    state.as_state()
700}
701
702/// The publishing half handed to a [`produceState`] producer.
703pub struct ProduceScope<T: Clone + 'static> {
704    state: MutableState<T>,
705}
706
707impl<T: Clone + 'static> ProduceScope<T> {
708    /// Publishes `value` to the produced state.
709    pub fn set(&self, value: T) {
710        self.state.set(value);
711    }
712}
713
714#[cfg(test)]
715#[path = "tests/concurrency_tests.rs"]
716mod tests;
717
718#[cfg(test)]
719#[path = "tests/concurrency_stream_tests.rs"]
720mod stream_tests;
721
722#[cfg(test)]
723#[path = "tests/concurrency_timer_race_tests.rs"]
724mod timer_race_tests;