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}