Skip to main content

telar_reactive_core/task/
mod.rs

1//! `spawn_task` / `spawn_stream` — the supported bridge from a worker thread back into the single-threaded
2//! reactive world.
3//!
4//! Signals are `!Send` by design, so a background result cannot be written where it is produced. The shape
5//! that does work is always the same: run the work on a thread, send the **data** back, and let the UI thread
6//! write the signal. This module is that shape, once, in the framework: the spawn functions take `Send` work
7//! and a `!Send` callback, keep the callback on the calling (UI) thread, and [`drain_tasks`] runs it there
8//! once a value arrives — the runner calls that per frame.
9//!
10//! Two shapes, because background work comes in two: [`spawn_task`] for work that produces one result, and
11//! [`spawn_stream`] for a worker that emits many (a watcher, a scan reporting progress).
12//!
13//! A callback re-enters the [`SurfaceHandle`] that was active at spawn time, exactly as the effect flush
14//! does, so one that touches its surface's layout/overlay/focus world resolves against the right one even
15//! when another surface's frame is what drained it.
16
17mod pool;
18
19use std::any::Any;
20use std::cell::{Cell, Ref, RefCell, RefMut};
21use std::marker::PhantomData;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{Arc, Mutex, RwLock};
24
25use rustc_hash::FxHashMap;
26
27use crate::runtime::{SurfaceHandle, current_surface};
28
29type TaskId = u64;
30type Waker = Arc<dyn Fn() + Send + Sync>;
31
32/// Set by the runner so a finishing worker can wake the UI loop. Absent in headless and test contexts,
33/// where the caller drives [`drain_tasks`] itself.
34static TASK_WAKER: RwLock<Option<Waker>> = RwLock::new(None);
35
36/// Installs the process-global "wake the UI loop" used after a task posts a value. The runner passes the
37/// same wake an app gets from `AppCtx::redraw_waker`.
38pub fn set_task_waker(wake: impl Fn() + Send + Sync + 'static) {
39    *TASK_WAKER.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(wake));
40}
41
42fn wake_loop() {
43    let waker = TASK_WAKER.read().unwrap_or_else(|e| e.into_inner()).clone();
44    if let Some(wake) = waker {
45        wake();
46    }
47}
48
49enum Message {
50    Item(Box<dyn Any + Send>),
51    /// The worker returned (or unwound). Releases the callback.
52    End,
53}
54
55/// What the UI thread runs for a delivered value. The distinction is lifetime: a task's callback is consumed
56/// by its one value, a stream's outlives every item and is followed by a close.
57enum Callback {
58    Once(Box<dyn FnOnce(Box<dyn Any + Send>)>),
59    Stream {
60        item: Box<dyn FnMut(Box<dyn Any + Send>)>,
61        end: Box<dyn FnOnce()>,
62    },
63}
64
65/// Values waiting for the UI thread to pick them up. Shared with every worker this thread spawned, so a
66/// worker that outlives the UI thread's interest still has somewhere valid to post into.
67#[derive(Default)]
68struct Mailbox {
69    // Workers only push and the drain only takes, so a poisoned lock carries no torn state — recovering
70    // from it keeps one panicking task from wedging every future one. Push order is delivery order, which
71    // is what makes a stream's items arrive in the order it emitted them.
72    posted: Mutex<Vec<(TaskId, Message)>>,
73}
74
75impl Mailbox {
76    fn post(&self, id: TaskId, message: Message) {
77        self.posted
78            .lock()
79            .unwrap_or_else(|e| e.into_inner())
80            .push((id, message));
81    }
82}
83
84struct PendingTask {
85    callback: Callback,
86    surface: SurfaceHandle,
87    cancelled: Arc<AtomicBool>,
88}
89
90#[derive(Default)]
91struct TaskRegistry {
92    next_id: TaskId,
93    pending: FxHashMap<TaskId, PendingTask>,
94    mailbox: Arc<Mailbox>,
95}
96
97// Same raw-pointer idiom as the reactive runtime cell: no `Drop`, so no TLS destructor is registered and
98// dlclosing a hot-reload dylib on thread exit stays safe. The one allocation per thread is intentionally
99// leaked.
100struct TaskCell(Cell<*mut RefCell<TaskRegistry>>);
101
102impl TaskCell {
103    fn borrow(&self) -> Ref<'_, TaskRegistry> {
104        unsafe { (*self.0.get()).borrow() }
105    }
106    fn borrow_mut(&self) -> RefMut<'_, TaskRegistry> {
107        unsafe { (*self.0.get()).borrow_mut() }
108    }
109}
110
111thread_local! {
112    static TASKS: TaskCell = TaskCell(Cell::new(
113        Box::into_raw(Box::new(RefCell::new(TaskRegistry::default())))
114    ));
115}
116
117/// The worker's end of a task. Posting `End` from `Drop` is what releases the callback on *both* exits — a
118/// normal return and an unwind — so work that panics abandons its task instead of leaving it pending forever.
119struct WorkerEnd {
120    mailbox: Arc<Mailbox>,
121    id: TaskId,
122    cancelled: Arc<AtomicBool>,
123}
124
125impl WorkerEnd {
126    fn post(&self, message: Message) {
127        self.mailbox.post(self.id, message);
128        wake_loop();
129    }
130}
131
132impl Drop for WorkerEnd {
133    fn drop(&mut self) {
134        self.post(Message::End);
135    }
136}
137
138/// A spawned task or stream. Dropping it detaches — the work keeps running and its callback keeps firing.
139/// Keep it to [`cancel`](Task::cancel) when whatever the callback would write is going away.
140///
141/// Deliberately `!Send`: the callback it controls lives in the spawning thread's registry, so a handle taken
142/// to another thread could only cancel that thread's tasks instead.
143pub struct Task {
144    id: TaskId,
145    cancelled: Arc<AtomicBool>,
146    _ui_thread_only: PhantomData<*const ()>,
147}
148
149impl Task {
150    /// Drops the callback, so anything the worker still posts is discarded. The thread is not interrupted —
151    /// `std::thread` has no way to do that — but a [`spawn_stream`] worker polling
152    /// [`Emitter::is_cancelled`] can stop on its own.
153    pub fn cancel(self) {
154        self.cancelled.store(true, Ordering::Relaxed);
155        let dropped = TASKS.with(|t| t.borrow_mut().pending.remove(&self.id));
156        drop(dropped);
157    }
158
159    /// Whether the callback is still registered — the value has yet to arrive, or the stream is still open.
160    pub fn is_pending(&self) -> bool {
161        TASKS.with(|t| t.borrow().pending.contains_key(&self.id))
162    }
163}
164
165fn register(surface: SurfaceHandle, callback: Callback) -> (TaskId, Arc<Mailbox>, Arc<AtomicBool>) {
166    TASKS.with(|t| {
167        let mut registry = t.borrow_mut();
168        let id = registry.next_id;
169        registry.next_id += 1;
170        let cancelled = Arc::new(AtomicBool::new(false));
171        registry.pending.insert(
172            id,
173            PendingTask {
174                callback,
175                surface,
176                cancelled: Arc::clone(&cancelled),
177            },
178        );
179        (id, Arc::clone(&registry.mailbox), cancelled)
180    })
181}
182
183/// Runs `work` on a background thread and `on_done` with its result **on this thread**, during a later
184/// frame's [`drain_tasks`].
185///
186/// This is the supported way to get a background result into a signal: `on_done` stays here, so it may close
187/// over `!Send` state and write signals directly, while `work` and the value it produces cross the thread
188/// boundary and must be `Send`.
189///
190/// ```ignore
191/// spawn_task(
192///     || expensive_query(),          // worker thread
193///     move |rows| results.set(rows), // UI thread, a later frame
194/// );
195/// ```
196///
197/// Work runs on a pooled thread that may block freely — the pool grows rather than starve. A panic inside
198/// `work` abandons the task: `on_done` is dropped without running.
199pub fn spawn_task<T, W, F>(work: W, on_done: F) -> Task
200where
201    T: Send + 'static,
202    W: FnOnce() -> T + Send + 'static,
203    F: FnOnce(T) + 'static,
204{
205    let callback = Callback::Once(Box::new(move |value| {
206        if let Ok(value) = value.downcast::<T>() {
207            on_done(*value);
208        }
209    }));
210    let (id, mailbox, cancelled) = register(current_surface(), callback);
211
212    let end = WorkerEnd {
213        mailbox,
214        id,
215        cancelled: Arc::clone(&cancelled),
216    };
217    pool::submit(Box::new(move || {
218        let value = work();
219        end.post(Message::Item(Box::new(value)));
220    }));
221
222    Task {
223        id,
224        cancelled,
225        _ui_thread_only: PhantomData,
226    }
227}
228
229/// The worker's handle to a [`spawn_stream`], for posting items back to the UI thread. `Send` and cloneable,
230/// so the work can hand it to nested helpers or a callback-driven library.
231pub struct Emitter<T> {
232    end: Arc<WorkerEnd>,
233    _item: PhantomData<fn(T)>,
234}
235
236impl<T: Send + 'static> Emitter<T> {
237    /// Posts one item and wakes the UI loop. A no-op once the stream is cancelled.
238    pub fn emit(&self, item: T) {
239        if self.is_cancelled() {
240            return;
241        }
242        self.end.post(Message::Item(Box::new(item)));
243    }
244
245    /// Whether the [`Task`] was cancelled. A long-running worker should poll this and return — nothing else
246    /// can stop it, and everything it emits from here on is discarded.
247    pub fn is_cancelled(&self) -> bool {
248        self.end.cancelled.load(Ordering::Relaxed)
249    }
250}
251
252impl<T> Clone for Emitter<T> {
253    fn clone(&self) -> Self {
254        Self {
255            end: Arc::clone(&self.end),
256            _item: PhantomData,
257        }
258    }
259}
260
261/// Runs `work` on a background thread, handing it an [`Emitter`], and runs `on_item` **on this thread** for
262/// every item it emits — in order, during the frames that follow. `on_end` runs once the worker returns.
263///
264/// The [`spawn_task`] shape for work that produces many values rather than one: a file watcher, a scan
265/// reporting progress, a paged download.
266///
267/// ```ignore
268/// spawn_stream(
269///     |out| for path in walk_project() { out.emit(path); },
270///     move |path| found.update(|list| list.push(path)),
271///     move || scanning.set(false),
272/// );
273/// ```
274///
275/// `on_end` fires after the last item, whether the worker returned or unwound — a stream cannot report *why*
276/// it stopped, only that it did. Cancelling instead drops both callbacks, so `on_end` does **not** run: the
277/// caller already knows, and is usually the one tearing that state down.
278///
279/// Everything posted between two frames is run in the next one, so a worker that emits faster than the UI
280/// can absorb makes for long frames. Emit coarse progress, not one item per unit of work.
281pub fn spawn_stream<T, W, F, E>(work: W, mut on_item: F, on_end: E) -> Task
282where
283    T: Send + 'static,
284    W: FnOnce(Emitter<T>) + Send + 'static,
285    F: FnMut(T) + 'static,
286    E: FnOnce() + 'static,
287{
288    let callback = Callback::Stream {
289        item: Box::new(move |value| {
290            if let Ok(value) = value.downcast::<T>() {
291                on_item(*value);
292            }
293        }),
294        end: Box::new(on_end),
295    };
296    let (id, mailbox, cancelled) = register(current_surface(), callback);
297
298    let emitter = Emitter::<T> {
299        end: Arc::new(WorkerEnd {
300            mailbox,
301            id,
302            cancelled: Arc::clone(&cancelled),
303        }),
304        _item: PhantomData,
305    };
306    pool::submit(Box::new(move || work(emitter)));
307
308    Task {
309        id,
310        cancelled,
311        _ui_thread_only: PhantomData,
312    }
313}
314
315/// Runs the callbacks for every value posted since the last call. The runner calls this once per frame, on
316/// the UI thread, before `App::on_frame`.
317///
318/// Callbacks run inside a batch, so a frame's worth of deliveries costs one flush.
319pub fn drain_tasks() {
320    let posted = TASKS.with(|t| {
321        let registry = t.borrow();
322        let mut posted = registry
323            .mailbox
324            .posted
325            .lock()
326            .unwrap_or_else(|e| e.into_inner());
327        std::mem::take(&mut *posted)
328    });
329    if posted.is_empty() {
330        return;
331    }
332
333    crate::batch(|| {
334        for (id, message) in posted {
335            // The entry is taken out of the registry while its callback runs, so the callback is free to
336            // spawn or cancel tasks — and a stream cannot be re-entered by a nested drain.
337            let Some(task) = TASKS.with(|t| t.borrow_mut().pending.remove(&id)) else {
338                continue; // Cancelled; the value goes with it.
339            };
340            let value = match message {
341                Message::Item(value) => value,
342                Message::End => {
343                    if let Callback::Stream { end, .. } = task.callback {
344                        let _surface = task.surface.enter();
345                        end();
346                    }
347                    continue;
348                }
349            };
350            let still_open = {
351                let _surface = task.surface.enter();
352                match task.callback {
353                    Callback::Once(run) => {
354                        run(value);
355                        None
356                    }
357                    Callback::Stream { mut item, end } => {
358                        item(value);
359                        Some(Callback::Stream { item, end })
360                    }
361                }
362            };
363            if let Some(callback) = still_open {
364                TASKS.with(|t| {
365                    t.borrow_mut().pending.insert(
366                        id,
367                        PendingTask {
368                            callback,
369                            surface: task.surface,
370                            cancelled: task.cancelled,
371                        },
372                    )
373                });
374            }
375        }
376    });
377}
378
379/// Cancels everything spawned while `surface` was the active one, discarding their callbacks and telling
380/// stream workers to stop. Use it when a surface goes away, so work started for it cannot write into the
381/// world it left.
382pub fn cancel_tasks_for(surface: SurfaceHandle) {
383    // Collected out of the registry before being dropped: a callback's captured state may itself spawn or
384    // cancel tasks from its `Drop`, which would re-enter this borrow.
385    let cancelled: Vec<PendingTask> = TASKS.with(|t| {
386        let mut registry = t.borrow_mut();
387        let ids: Vec<TaskId> = registry
388            .pending
389            .iter()
390            .filter(|(_, task)| task.surface == surface)
391            .map(|(id, _)| *id)
392            .collect();
393        ids.iter()
394            .filter_map(|id| registry.pending.remove(id))
395            .inspect(|task| task.cancelled.store(true, Ordering::Relaxed))
396            .collect()
397    });
398    drop(cancelled);
399}
400
401/// Stops the worker pool and drops every pending callback. Called before a hot-reload dylib is closed: its
402/// threads are parked in, and its callbacks are made of, code that is about to be unmapped.
403///
404/// Joining the pool means a reload waits for in-flight background work — see [`pool::shutdown_and_join`].
405pub fn reset_tasks() {
406    let flags: Vec<Arc<AtomicBool>> = TASKS.with(|t| {
407        t.borrow()
408            .pending
409            .values()
410            .map(|task| Arc::clone(&task.cancelled))
411            .collect()
412    });
413    for flag in flags {
414        flag.store(true, Ordering::Relaxed);
415    }
416    // Before the registry is cleared, so a worker still finishing has a live mailbox to post its `End` into.
417    pool::shutdown_and_join();
418
419    let (pending, mailbox) = TASKS.with(|t| {
420        let mut registry = t.borrow_mut();
421        (
422            std::mem::take(&mut registry.pending),
423            std::mem::take(&mut registry.mailbox),
424        )
425    });
426    drop(pending);
427    drop(mailbox);
428}
429
430/// How many spawned tasks and open streams still hold a callback. Test-only: nothing in a normal build asks.
431#[cfg(test)]
432fn pending_task_count() -> usize {
433    TASKS.with(|t| t.borrow().pending.len())
434}
435
436#[cfg(test)]
437mod tests {
438    use std::rc::Rc;
439    use std::sync::atomic::AtomicUsize;
440    use std::time::{Duration, Instant};
441
442    use super::*;
443    use crate::runtime::{SurfaceEnterGuard, set_current_surface, set_surface_enter_hook};
444
445    // `TASK_WAKER` and the pool are process-global while the registries are per-thread, so tests that assert
446    // on either must not have another test disturb them mid-run. Every test here serializes on this.
447    static SERIAL: Mutex<()> = Mutex::new(());
448
449    fn serial() -> std::sync::MutexGuard<'static, ()> {
450        SERIAL.lock().unwrap_or_else(|e| e.into_inner())
451    }
452
453    fn drain_until(done: impl Fn() -> bool) {
454        let deadline = Instant::now() + Duration::from_secs(5);
455        while !done() {
456            assert!(Instant::now() < deadline, "task never completed");
457            std::thread::sleep(Duration::from_millis(1));
458            drain_tasks();
459        }
460    }
461
462    #[test]
463    fn result_crosses_back_to_the_spawning_thread() {
464        let _serial = serial();
465        // An `Rc` in the callback is the point: completion state stays on this thread and never needs `Send`.
466        let got = Rc::new(std::cell::Cell::new(0i32));
467        let sink = Rc::clone(&got);
468        spawn_task(|| 6 * 7, move |v| sink.set(v));
469        drain_until(|| got.get() != 0);
470        assert_eq!(got.get(), 42);
471        assert_eq!(pending_task_count(), 0);
472    }
473
474    #[test]
475    fn cancel_discards_the_result() {
476        let _serial = serial();
477        let ran = Rc::new(std::cell::Cell::new(false));
478        let sink = Rc::clone(&ran);
479        let task = spawn_task(|| 1u8, move |_| sink.set(true));
480        // Synchronous: the callback is gone before the worker can possibly post, so this cannot race.
481        task.cancel();
482        assert_eq!(pending_task_count(), 0);
483        std::thread::sleep(Duration::from_millis(20));
484        drain_tasks();
485        assert!(!ran.get());
486    }
487
488    #[test]
489    fn a_panicking_worker_abandons_its_callback() {
490        let _serial = serial();
491        let ran = Rc::new(std::cell::Cell::new(false));
492        let sink = Rc::clone(&ran);
493        spawn_task(
494            || -> u8 { panic!("worker blew up") },
495            move |_| sink.set(true),
496        );
497        // The registry must not keep the callback pending forever just because the work produced no value.
498        drain_until(|| pending_task_count() == 0);
499        assert!(!ran.get());
500    }
501
502    // Mirrors the effect flush: a callback must resolve against the surface world that spawned it, whichever
503    // surface's frame happens to drain the queue.
504    #[test]
505    fn completion_reenters_the_spawning_surface() {
506        let _serial = serial();
507        set_surface_enter_hook(|handle| {
508            let prev = set_current_surface(handle);
509            SurfaceEnterGuard::new(move || {
510                set_current_surface(prev);
511            })
512        });
513
514        let seen = Rc::new(std::cell::Cell::new(SurfaceHandle::NONE));
515        let sink = Rc::clone(&seen);
516        {
517            let _surface_a = SurfaceHandle(7).enter();
518            spawn_task(|| (), move |()| sink.set(current_surface()));
519        }
520
521        let _surface_b = SurfaceHandle(9).enter();
522        drain_until(|| !seen.get().is_none());
523        assert_eq!(seen.get(), SurfaceHandle(7));
524    }
525
526    #[test]
527    fn a_finishing_worker_wakes_the_loop() {
528        let _serial = serial();
529        static WAKES: AtomicUsize = AtomicUsize::new(0);
530        WAKES.store(0, Ordering::SeqCst);
531        set_task_waker(|| {
532            WAKES.fetch_add(1, Ordering::SeqCst);
533        });
534
535        spawn_task(|| (), |()| {});
536        drain_until(|| pending_task_count() == 0);
537        assert!(WAKES.load(Ordering::SeqCst) >= 1);
538    }
539
540    #[test]
541    fn a_stream_delivers_every_item_in_order_then_closes() {
542        let _serial = serial();
543        let seen: Rc<RefCell<Vec<u32>>> = Rc::new(RefCell::new(Vec::new()));
544        let sink = Rc::clone(&seen);
545        let closed = Rc::new(std::cell::Cell::new(false));
546        let closed_sink = Rc::clone(&closed);
547        spawn_stream(
548            |out| {
549                for i in 0..8u32 {
550                    out.emit(i);
551                }
552            },
553            move |i| sink.borrow_mut().push(i),
554            move || closed_sink.set(true),
555        );
556        // The stream stays registered until the worker returns, so this waits for the close, not the items.
557        drain_until(|| pending_task_count() == 0);
558        assert_eq!(*seen.borrow(), (0..8).collect::<Vec<u32>>());
559        assert!(closed.get(), "on_end must run once the worker returns");
560    }
561
562    #[test]
563    fn cancelling_a_stream_stops_its_worker() {
564        let _serial = serial();
565        let seen = Rc::new(std::cell::Cell::new(0u32));
566        let sink = Rc::clone(&seen);
567        let (started_tx, started_rx) = std::sync::mpsc::channel();
568        static EMITTED: AtomicUsize = AtomicUsize::new(0);
569        EMITTED.store(0, Ordering::SeqCst);
570
571        let task = spawn_stream(
572            move |out| {
573                started_tx.send(()).ok();
574                // Unbounded but for the cancel flag: if `is_cancelled` never flipped, this would not end.
575                while !out.is_cancelled() {
576                    out.emit(1u32);
577                    EMITTED.fetch_add(1, Ordering::SeqCst);
578                    std::thread::sleep(Duration::from_millis(1));
579                }
580            },
581            move |i| sink.set(sink.get() + i),
582            || unreachable!("on_end must not run for a cancelled stream"),
583        );
584
585        started_rx.recv().expect("worker never started");
586        task.cancel();
587        assert_eq!(
588            pending_task_count(),
589            0,
590            "cancel releases the callback at once"
591        );
592
593        // The worker loops every millisecond, so if the flag reached it these two reads match.
594        std::thread::sleep(Duration::from_millis(60));
595        let settled = EMITTED.load(Ordering::SeqCst);
596        std::thread::sleep(Duration::from_millis(60));
597        assert_eq!(
598            settled,
599            EMITTED.load(Ordering::SeqCst),
600            "cancel must stop the stream worker, not just mute it"
601        );
602        drain_tasks();
603        assert_eq!(seen.get(), 0, "no item may be delivered after cancel");
604    }
605
606    // The pool exists so bursty small work reuses threads instead of paying a spawn each time. Two waves with
607    // a settle between them must land on the same thread.
608    #[test]
609    fn the_pool_reuses_an_idle_thread() {
610        let _serial = serial();
611        let threads: Rc<RefCell<Vec<std::thread::ThreadId>>> = Rc::new(RefCell::new(Vec::new()));
612        for _ in 0..2 {
613            let sink = Rc::clone(&threads);
614            spawn_task(
615                || std::thread::current().id(),
616                move |id| sink.borrow_mut().push(id),
617            );
618            drain_until(|| pending_task_count() == 0);
619        }
620        let threads = threads.borrow();
621        assert_eq!(threads.len(), 2);
622        assert_eq!(
623            threads[0], threads[1],
624            "the second task should have reused the idle worker"
625        );
626    }
627}