Skip to main content

teksilo_async/
executor.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A tiny single-threaded (`!Send`) cooperative task executor, driven once per
5//! event-loop turn by the [`on_loop_tick`](teksilo_app::TeksiloAppBuilder::on_loop_tick)
6//! hook in `teksilo-app`.
7//!
8//! ## Why hand-rolled
9//!
10//! The executor must (a) hold `!Send` futures that capture `Rc`-based `Signal`
11//! handles, (b) be polled cooperatively from winit's `about_to_wait` without
12//! ever blocking, and (c) be woken from *another* thread when a
13//! [`spawn_blocking`](crate::spawn_blocking) worker finishes. Requirement (c)
14//! is the crux: the wake may arrive off-thread, but the task queue is `Rc`
15//! (`!Send`). We solve it with a single shared [`Waker`] (`Arc<ExecWaker>`,
16//! `Send + Sync`) handed to every task's leaf futures. On wake it only sets an
17//! atomic flag and nudges the event loop through the (`Send + Sync`)
18//! [`AppEventPoster`] — it never touches the `!Send` queue. The main thread
19//! then re-polls live tasks on the next tick. Polling every live task per wake
20//! is `O(n)`, but `n` (concurrent UI async tasks) is tiny.
21
22use std::cell::{Cell, RefCell};
23use std::future::Future;
24use std::pin::Pin;
25use std::rc::Rc;
26use std::sync::Arc;
27use std::sync::OnceLock;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::task::{Context, Poll, Wake, Waker};
30
31use teksilo_core::{
32    AppEventPoster, AsyncCompletionHandle, AsyncCompletionPayload, EventContext, TeksiloWindowId,
33};
34
35/// Unit payload posted through the [`AppEventPoster`] to wake a sleeping event
36/// loop when a task becomes runnable from another thread. `teksilo-app` does
37/// not recognise the type — it falls through the `AppEvent::External` downcast
38/// chain and is dropped — but the wake side effect (the loop runs one more
39/// turn, calling the registered loop-tick) is exactly what we need.
40struct AsyncWake;
41
42/// Shared wake state. The [`Waker`] handed to every task's leaf futures is
43/// built from an `Arc<ExecWaker>`; when a leaf wakes (possibly from a worker
44/// thread) it sets `woken` and nudges the event loop.
45struct ExecWaker {
46    woken: AtomicBool,
47    poster: OnceLock<Arc<dyn AppEventPoster>>,
48}
49
50impl Wake for ExecWaker {
51    fn wake(self: Arc<Self>) {
52        self.wake_by_ref();
53    }
54
55    fn wake_by_ref(self: &Arc<Self>) {
56        self.woken.store(true, Ordering::SeqCst);
57        if let Some(poster) = self.poster.get() {
58            poster.post_external(Box::new(AsyncWake));
59        }
60    }
61}
62
63type BoxFuture = Pin<Box<dyn Future<Output = ()>>>;
64
65struct Task {
66    future: BoxFuture,
67    cancelled: Rc<Cell<bool>>,
68}
69
70/// Handle to a spawned task. Dropping it cancels the task (the future is
71/// dropped on the next tick); call [`detach`](TaskHandle::detach) to let the
72/// task run to completion independently of the handle.
73#[must_use = "dropping the TaskHandle cancels the task — call `.detach()` to let it keep running"]
74pub struct TaskHandle {
75    cancelled: Option<Rc<Cell<bool>>>,
76}
77
78impl TaskHandle {
79    /// Let the task run to completion; dropping this handle no longer cancels
80    /// it. The classic fire-and-forget terminator: `ctx.spawn_local(..).detach()`.
81    pub fn detach(mut self) {
82        self.cancelled = None;
83    }
84}
85
86impl Drop for TaskHandle {
87    fn drop(&mut self) {
88        if let Some(flag) = &self.cancelled {
89            flag.set(true);
90        }
91    }
92}
93
94struct ExecInner {
95    tasks: RefCell<Vec<Task>>,
96    /// Tasks spawned since the last `flush` — kept separate so a task may spawn
97    /// another during its own poll without re-borrowing `tasks`.
98    spawn_queue: RefCell<Vec<Task>>,
99    wake: Arc<ExecWaker>,
100    waker: Waker,
101    poll_source: Rc<Cell<bool>>,
102    completions: AsyncCompletionHandle,
103    /// Re-entrancy guard for [`ExecInner::tick`].
104    ticking: Cell<bool>,
105}
106
107/// RAII reset for `tick`'s re-entrancy guard — clears the flag on scope exit,
108/// including while unwinding if a task poll panics.
109struct TickGuard<'a>(&'a Cell<bool>);
110
111impl Drop for TickGuard<'_> {
112    fn drop(&mut self) {
113        self.0.set(false);
114    }
115}
116
117impl ExecInner {
118    fn flush_spawns(&self) {
119        let mut queued = self.spawn_queue.borrow_mut();
120        if !queued.is_empty() {
121            self.tasks.borrow_mut().append(&mut queued);
122        }
123    }
124
125    fn spawn(&self, future: BoxFuture) -> TaskHandle {
126        let cancelled = Rc::new(Cell::new(false));
127        self.spawn_queue.borrow_mut().push(Task {
128            future,
129            cancelled: cancelled.clone(),
130        });
131        // A spawn always happens inside an event dispatch, and `about_to_wait`
132        // (→ the loop tick) runs before the loop next sleeps, so the task gets
133        // its first poll without an explicit proxy nudge.
134        self.wake.woken.store(true, Ordering::SeqCst);
135        TaskHandle {
136            cancelled: Some(cancelled),
137        }
138    }
139
140    fn tick(&self) -> bool {
141        // Re-entrancy guard: only the event-loop hook should drive ticks. A
142        // re-entrant call (e.g. a future's poll calling back in) would corrupt
143        // the take/restore of `tasks`, so ignore it. The RAII reset keeps the
144        // flag correct even if a task poll panics and unwinds.
145        if self.ticking.get() {
146            return false;
147        }
148        self.ticking.set(true);
149        let _reset = TickGuard(&self.ticking);
150
151        self.flush_spawns();
152        // Nothing woke us since the last tick → idle. Clear the poll source so
153        // the loop sleeps in `ControlFlow::Wait` until the next wake nudges it.
154        if !self.wake.woken.swap(false, Ordering::SeqCst) {
155            self.poll_source.set(false);
156            return false;
157        }
158
159        let mut cx = Context::from_waker(&self.waker);
160        // Take the live tasks out so a task may freely spawn/cancel during its
161        // own poll without re-borrowing `tasks`.
162        let taken = std::mem::take(&mut *self.tasks.borrow_mut());
163        let mut survivors = Vec::with_capacity(taken.len());
164        for mut task in taken {
165            if task.cancelled.get() {
166                continue; // dropped/detached-then-dropped — discard the future
167            }
168            match task.future.as_mut().poll(&mut cx) {
169                Poll::Ready(()) => {} // done — drop the future
170                Poll::Pending => survivors.push(task),
171            }
172        }
173        *self.tasks.borrow_mut() = survivors;
174        // Pull in tasks spawned during this poll so they run next turn.
175        self.flush_spawns();
176
177        // If a task re-woke synchronously (spawned another, yielded, …) keep
178        // polling next turn via `ControlFlow::Poll`; otherwise the loop sleeps
179        // until the next wake nudges it through the proxy.
180        self.poll_source.set(self.wake.woken.load(Ordering::SeqCst));
181        true
182    }
183}
184
185/// Handle to the main-thread async runtime. Registered in app-state by
186/// [`install_async`](crate::TeksiloAppBuilderAsyncExt::install_async) and
187/// reached from a handler via `ctx.spawn_local(...)`
188/// ([`EventContextAsyncExt`](crate::EventContextAsyncExt)). `Clone` shares the
189/// same executor (`Rc`); `!Send` — it only ever lives on the UI thread.
190#[derive(Clone)]
191pub struct AsyncRuntimeHandle {
192    inner: Rc<ExecInner>,
193}
194
195impl Default for AsyncRuntimeHandle {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl AsyncRuntimeHandle {
202    pub fn new() -> Self {
203        let wake = Arc::new(ExecWaker {
204            woken: AtomicBool::new(false),
205            poster: OnceLock::new(),
206        });
207        let waker = Waker::from(wake.clone());
208        Self {
209            inner: Rc::new(ExecInner {
210                tasks: RefCell::new(Vec::new()),
211                spawn_queue: RefCell::new(Vec::new()),
212                wake,
213                waker,
214                poll_source: Rc::new(Cell::new(false)),
215                completions: AsyncCompletionHandle::new(),
216                ticking: Cell::new(false),
217            }),
218        }
219    }
220
221    /// The shared poll flag passed to `on_loop_tick`; set while the executor
222    /// wants continuous polling (a task re-woke synchronously), cleared when
223    /// idle so the loop can sleep.
224    pub fn poll_source(&self) -> Rc<Cell<bool>> {
225        self.inner.poll_source.clone()
226    }
227
228    /// The completion registry shared with `teksilo-app` so it can deliver
229    /// [`spawn_local_with`](Self::spawn_local_with) results with a fresh
230    /// [`EventContext`]. Registered in app-state under
231    /// [`AsyncCompletionHandle`].
232    pub fn completions(&self) -> AsyncCompletionHandle {
233        self.inner.completions.clone()
234    }
235
236    /// Install the event-loop poster used as the cross-thread wake target and
237    /// to post completions. Idempotent (set once). Called lazily on the first
238    /// spawn from `ctx.poster()`.
239    pub fn set_poster(&self, poster: Arc<dyn AppEventPoster>) {
240        let _ = self.inner.wake.poster.set(poster);
241    }
242
243    /// Advance the executor by one turn: poll every live task that a wake made
244    /// runnable, dropping completed/cancelled ones. Returns `true` if it
245    /// polled tasks (the caller repaints). Normally driven by the `on_loop_tick`
246    /// hook; exposed for headless drivers and tests.
247    pub fn tick(&self) -> bool {
248        self.inner.tick()
249    }
250
251    /// Spawn a `!Send` future on the main-thread executor. The future may
252    /// capture and mutate `Signal`s and other `Rc` handles directly. Returns a
253    /// [`TaskHandle`]: drop to cancel, `.detach()` to fire-and-forget.
254    pub fn spawn_local(&self, future: impl Future<Output = ()> + 'static) -> TaskHandle {
255        self.inner.spawn(Box::pin(future))
256    }
257
258    /// Spawn a future whose result is delivered to `on_complete` with a *fresh*
259    /// [`EventContext`] bound to `window_id`'s tree — the supported way to run
260    /// a one-shot ambient op (`open_window`, `send_intent`, …) after `await`.
261    /// The future body itself runs handle-only (no `EventContext`).
262    pub fn spawn_local_with<R: 'static>(
263        &self,
264        window_id: TeksiloWindowId,
265        future: impl Future<Output = R> + 'static,
266        on_complete: impl FnOnce(R, &mut EventContext) + 'static,
267    ) -> TaskHandle {
268        // Capture only the (separate-`Rc`) completion registry and the
269        // (`Arc`) poster, NOT the executor `Rc`, so the wrapper future does
270        // not form a reference cycle with `ExecInner`.
271        let completions = self.inner.completions.clone();
272        let poster = self.inner.wake.poster.get().cloned();
273        let wrapper = async move {
274            let result = future.await;
275            let callback: Box<dyn FnOnce(&mut EventContext)> =
276                Box::new(move |ctx| on_complete(result, ctx));
277            let id = completions.register(window_id, callback);
278            if let Some(poster) = &poster {
279                poster.post_external(Box::new(AsyncCompletionPayload { id, window_id }));
280            }
281        };
282        self.inner.spawn(Box::pin(wrapper))
283    }
284}