Skip to main content

moonpool_sim/executor/
mod.rs

1//! The moonpool deterministic executor: a single-threaded, seeded-random
2//! async task scheduler purpose-built for simulation.
3//!
4//! # Why moonpool owns an executor
5//!
6//! The simulation used to run on a tokio current-thread runtime whose only
7//! deterministic-scheduling lever was the unstable `RngSeed` API, forcing
8//! `--cfg tokio_unstable` onto every downstream consumer (issue #151). The
9//! deeper finding behind this module: tokio's current-thread scheduler is a
10//! plain FIFO queue, so its seed never randomized task *scheduling* at all.
11//! Owning the executor removes the unstable flag AND unlocks something tokio
12//! cannot offer: task polling order that is a deterministic function of the
13//! simulation seed, so the seed space explores task interleavings.
14//!
15//! # Why there is no reactor
16//!
17//! A production runtime pairs its executor with a reactor: an epoll/kqueue
18//! loop that turns OS events into waker calls, plus a timer wheel. In the
19//! simulation none of that exists; [`SimWorld`](crate::SimWorld)'s event
20//! queue *is* the reactor. Virtual time, network delivery, storage
21//! completion: every external event is a queue entry whose processing wakes
22//! the parked task through its ordinary [`Waker`]. The executor therefore
23//! only needs to answer one question: "of the tasks that are runnable right
24//! now, which do I poll next?"
25//!
26//! # Scheduling: randomized AND deterministic
27//!
28//! Those two words are not in tension. Deterministic means "same seed, same
29//! execution, bit for bit", not "one fixed schedule". The ready queue is a
30//! `Vec` and the next task is chosen by `swap_remove` at a seeded-random
31//! index (madsim uses the same distribution). The RNG is a private ChaCha8
32//! stream derived from the iteration seed with [`EXEC_RNG_SALT`]; it is
33//! deliberately NOT the counted `SIM_RNG` stream, so scheduling decisions
34//! never perturb fork-explorer breakpoint replay (`count@seed` timelines).
35//!
36//! The payoff: when one simulation event wakes two tasks, FIFO would run
37//! them in registration order on every seed forever, structurally hiding
38//! any race between them. Here, which task runs first is a per-seed choice:
39//! each seed replays exactly, and the *population* of seeds covers both
40//! orders. Ordering bugs live exactly there.
41//!
42//! # The driver contract: `block_on` + `until_stalled`
43//!
44//! [`Executor::block_on`] pins the main future (the simulation orchestrator)
45//! on the stack. It is not in the ready queue and is never scheduled
46//! randomly; it is the *driver*, alternating with the task pool:
47//!
48//! ```text
49//! loop {
50//!     poll driver ─ Ready? ──────────────► return
51//!     run_until_stalled()   // poll ready tasks in seeded-random order
52//!                           // until no task is runnable
53//!     (driver not woken AND queue empty) ─► panic: genuine deadlock
54//! }
55//! ```
56//!
57//! The orchestrator's step loops await [`until_stalled()`] between
58//! `sim.step()` calls. Because the driver is only re-polled after a full
59//! drain, resuming from `until_stalled().await` guarantees every task that
60//! was runnable has been polled to `Pending` or completion. This is strictly
61//! stronger than the contract the old tokio version relied on (`yield_now`
62//! re-queuing the driver at the back of a FIFO), and it stays correct under
63//! randomized scheduling, where "back of the queue" does not exist.
64//!
65//! Inside a task, use [`yield_now()`]: it reschedules the task at a seeded
66//! random position. `until_stalled()` is driver-only (debug-asserted): if a
67//! task awaited it, the drain-until-empty semantics would deadlock against
68//! itself.
69//!
70//! # Task lifecycle, panics, and kill-on-drop
71//!
72//! Spawning goes through [`async_task`]: each task splits into a `Runnable`
73//! (scheduled into the ready queue by its waker) and a
74//! [`JoinHandle`] wrapping the `FallibleTask` (awaitable output). Three
75//! contracts mirror tokio because the orchestrator depends on them:
76//!
77//! - **Detach on drop**: dropping a `JoinHandle` lets the task keep running.
78//! - **Abort**: [`JoinHandle::abort`] cancels the task; awaiting the handle
79//!   then yields `Err(JoinError::Cancelled)`.
80//! - **Panic isolation**: every spawned future is wrapped in
81//!   `catch_unwind`, so a panicking task never unwinds into the executor's
82//!   run loop; its handle yields `Err(JoinError::Panicked)` and sibling
83//!   tasks are unaffected.
84//!
85//! Dropping the executor kills every remaining task, replicating the
86//! "dropping the per-iteration tokio runtime kills leaked tasks" contract
87//! that isolates simulation iterations. The mechanism (borrowed from
88//! async-executor): a registry keeps one waker per live task; `Drop` wakes
89//! them all, which schedules every parked task into the ready queue, then
90//! pops and drops `Runnable`s until the queue is empty. Dropping a
91//! `Runnable` cancels its task and drops the future, and if that drop wakes
92//! further tasks they land in the queue and are consumed by the same loop.
93//!
94//! # Fork safety
95//!
96//! The fork-based explorer ([`moonpool-explorer`]) forks the process while
97//! tasks are being polled (assertion macros are fork points). Two rules keep
98//! that sound: everything is single-threaded, and **the queue lock is never
99//! held across `runnable.run()`**; wakes fired during a poll re-acquire the
100//! lock, and a child process inherits no held locks.
101//!
102//! [`moonpool-explorer`]: https://docs.rs/moonpool-explorer
103
104mod join;
105
106pub use join::{JoinHandle, YieldNow, until_stalled, yield_now};
107
108use std::cell::{Cell, RefCell};
109use std::collections::HashMap;
110use std::future::Future;
111use std::panic::AssertUnwindSafe;
112use std::sync::Arc;
113use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
114use std::task::{Context, Poll, Wake, Waker};
115
116use futures::FutureExt;
117use parking_lot::Mutex;
118use rand::{RngExt, SeedableRng};
119use rand_chacha::ChaCha8Rng;
120
121/// Salt mixed into the iteration seed before seeding the executor's
122/// scheduling RNG.
123///
124/// Decorrelates task-scheduling decisions from the in-run `SIM_RNG` stream
125/// (and the other salted streams) while keeping them fully reproducible from
126/// the same iteration seed. Like `CONFIG_RNG`/`SELECT_RNG`, this stream is
127/// uncounted: scheduling never perturbs fork-explorer breakpoint replay.
128const EXEC_RNG_SALT: u64 = 0x6578_6563_7363_6864; // "execschd"
129
130/// Ceiling on polls within one `run_until_stalled` drain (all builds).
131///
132/// A task that unconditionally re-wakes itself forever (a busy `yield_now`
133/// loop with no exit, or a select over a source it synchronously re-readies,
134/// which tokio's cooperative budget used to interrupt) would make
135/// drain-until-empty spin endlessly and starve the driver, so `sim.step()`
136/// and every deadlock detector become unreachable. Panicking with the task's
137/// name and the seed beats hanging, and one counter increment per poll is
138/// noise next to the poll itself, so the bound is unconditional rather than
139/// debug-only: release CI is exactly where a silent hang hurts most.
140const DRAIN_POLL_BOUND: u64 = 1_000_000;
141
142/// Per-task metadata attached through `async_task::Builder::metadata`.
143///
144/// The `id` keys the kill-on-drop waker registry; the `name` makes the
145/// per-poll trace events readable when debugging a seed.
146#[derive(Debug)]
147pub(crate) struct TaskMeta {
148    /// Registry key, unique per spawn within one executor.
149    pub(crate) id: u64,
150    /// Human-readable task name (from `spawn`'s `name` argument).
151    pub(crate) name: Arc<str>,
152}
153
154/// A schedulable task holding our metadata.
155pub(crate) type ExecRunnable = async_task::Runnable<TaskMeta>;
156
157thread_local! {
158    /// The executor currently driving this thread (installed by `block_on`).
159    static CURRENT: RefCell<Option<Handle>> = const { RefCell::new(None) };
160
161    /// True while a task is being polled by `run_until_stalled` (as opposed
162    /// to the driver). Guards the driver-only `until_stalled()` primitive.
163    static IN_TASK: Cell<bool> = const { Cell::new(false) };
164}
165
166/// Report whether the current code is running inside a task poll (true) or
167/// in the driver (false).
168pub(crate) fn in_task() -> bool {
169    IN_TASK.with(Cell::get)
170}
171
172/// State shared between the executor, its spawn handles, and task wakers.
173struct Shared {
174    /// The ready queue. `Vec` (not `VecDeque`) because the next task is
175    /// chosen by seeded `swap_remove`, not FIFO order.
176    queue: Mutex<Vec<ExecRunnable>>,
177    /// One waker per live (spawned, not yet completed/cancelled) task,
178    /// keyed by `TaskMeta::id`; consumed by kill-on-drop.
179    wakers: Mutex<HashMap<u64, Waker>>,
180    /// Next task id.
181    next_id: AtomicU64,
182}
183
184/// Removes a task's waker from the registry when its future is dropped
185/// (completion, cancellation, or executor drop).
186struct Unregister {
187    id: u64,
188    shared: Arc<Shared>,
189}
190
191impl Drop for Unregister {
192    fn drop(&mut self) {
193        self.shared.wakers.lock().remove(&self.id);
194    }
195}
196
197/// Wakes the driver by setting a flag `block_on` checks between drains.
198struct DriverWaker {
199    woken: AtomicBool,
200}
201
202impl Wake for DriverWaker {
203    fn wake(self: Arc<Self>) {
204        self.wake_by_ref();
205    }
206
207    fn wake_by_ref(self: &Arc<Self>) {
208        // Release pairs with the Acquire load in block_on's stall check, so a
209        // wake fired just before the check is never missed on ordering
210        // grounds. Wakes are expected from this thread only (the executor is
211        // single-threaded); see the stall panic message for the contract.
212        self.woken.store(true, Ordering::Release);
213    }
214}
215
216/// Spawning access to a live executor (cloneable, thread-local installed).
217#[derive(Clone)]
218struct Handle {
219    shared: Arc<Shared>,
220}
221
222impl Handle {
223    fn spawn<T, F>(&self, name: &str, future: F) -> JoinHandle<T>
224    where
225        F: Future<Output = T> + Send + 'static,
226        T: Send + 'static,
227    {
228        let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed);
229        let meta = TaskMeta {
230            id,
231            name: Arc::from(name),
232        };
233
234        // catch_unwind: a panicking task must resolve its JoinHandle with
235        // JoinError::Panicked instead of unwinding into the run loop.
236        let caught = AssertUnwindSafe(future).catch_unwind();
237        // The guard unregisters the task's waker whenever the future is
238        // dropped, keeping the kill-on-drop registry bounded by live tasks.
239        // Constructed HERE (not inside the async block) so it lives in the
240        // future's captures: a task aborted before its first poll drops the
241        // capture and still unregisters; a guard built inside the block would
242        // never exist for a never-polled future and leak the entry.
243        let unregister = Unregister {
244            id,
245            shared: Arc::clone(&self.shared),
246        };
247        let wrapped = async move {
248            let _unregister = unregister;
249            caught.await
250        };
251
252        let schedule_shared = Arc::clone(&self.shared);
253        let schedule = move |runnable: ExecRunnable| {
254            schedule_shared.queue.lock().push(runnable);
255        };
256
257        let (runnable, task) = async_task::Builder::new()
258            .metadata(meta)
259            .spawn(move |_| wrapped, schedule);
260
261        self.shared.wakers.lock().insert(id, runnable.waker());
262        runnable.schedule();
263
264        JoinHandle::new(task.fallible())
265    }
266}
267
268/// Installs a [`Handle`] as the thread's current executor for the duration
269/// of a `block_on` call.
270struct CurrentGuard;
271
272impl CurrentGuard {
273    fn install(handle: Handle) -> Self {
274        CURRENT.with(|current| {
275            let mut current = current.borrow_mut();
276            assert!(
277                current.is_none(),
278                "Executor::block_on is not reentrant: an executor is already running on this thread"
279            );
280            *current = Some(handle);
281        });
282        Self
283    }
284}
285
286impl Drop for CurrentGuard {
287    fn drop(&mut self) {
288        CURRENT.with(|current| current.borrow_mut().take());
289    }
290}
291
292/// Spawn a named task onto the executor currently running on this thread.
293///
294/// The returned [`JoinHandle`] mirrors tokio semantics: awaiting it yields
295/// the task's output (or a [`JoinError`](moonpool_core::JoinError) if the
296/// task was aborted or panicked), dropping it detaches the task, and
297/// [`JoinHandle::abort`] cancels it.
298///
299/// # Panics
300///
301/// Panics when called outside [`Executor::block_on`], mirroring
302/// `tokio::spawn` outside a runtime.
303pub fn spawn<T, F>(name: &str, future: F) -> JoinHandle<T>
304where
305    F: Future<Output = T> + Send + 'static,
306    T: Send + 'static,
307{
308    CURRENT.with(|current| {
309        current
310            .borrow()
311            .as_ref()
312            .expect("executor::spawn called outside Executor::block_on")
313            .spawn(name, future)
314    })
315}
316
317/// The deterministic single-threaded executor.
318///
319/// One instance is created per simulation iteration, seeded from the
320/// iteration seed; dropping it cancels every task that is still alive (see
321/// the [module docs](self) for the kill-on-drop mechanics).
322///
323/// # Examples
324///
325/// ```
326/// let mut executor = moonpool_sim::executor::Executor::new(42);
327/// let sum = executor.block_on(async {
328///     let task = moonpool_sim::executor::spawn("adder", async { 1 + 2 });
329///     task.await.expect("task completed")
330/// });
331/// assert_eq!(sum, 3);
332/// ```
333pub struct Executor {
334    shared: Arc<Shared>,
335    /// Seeded scheduling RNG (uncounted stream, see [`EXEC_RNG_SALT`]).
336    rng: ChaCha8Rng,
337    /// The iteration seed, kept for stall diagnostics.
338    seed: u64,
339}
340
341impl Executor {
342    /// Create an executor whose scheduling decisions derive from `seed`.
343    #[must_use]
344    pub fn new(seed: u64) -> Self {
345        Self {
346            shared: Arc::new(Shared {
347                queue: Mutex::new(Vec::new()),
348                wakers: Mutex::new(HashMap::new()),
349                next_id: AtomicU64::new(0),
350            }),
351            rng: ChaCha8Rng::seed_from_u64(seed ^ EXEC_RNG_SALT),
352            seed,
353        }
354    }
355
356    /// Run `main` as the driver future to completion, interleaving it with
357    /// the task pool (see the [module docs](self) for the drive loop).
358    ///
359    /// `main` needs no `Send` bound: it is pinned on this stack and never
360    /// enters the ready queue.
361    ///
362    /// # Panics
363    ///
364    /// - if called while another `block_on` is running on this thread;
365    /// - on a genuine deadlock: the driver is not woken, returns `Pending`,
366    ///   and no task is runnable (with the seed in the message, replacing
367    ///   tokio's silent forever-park).
368    pub fn block_on<F: Future>(&mut self, main: F) -> F::Output {
369        let _guard = CurrentGuard::install(Handle {
370            shared: Arc::clone(&self.shared),
371        });
372
373        let mut main = std::pin::pin!(main);
374        let driver_waker = Arc::new(DriverWaker {
375            woken: AtomicBool::new(false),
376        });
377        let waker = Waker::from(Arc::clone(&driver_waker));
378        let mut cx = Context::from_waker(&waker);
379
380        loop {
381            driver_waker.woken.store(false, Ordering::Relaxed);
382            if let Poll::Ready(output) = main.as_mut().poll(&mut cx) {
383                return output;
384            }
385
386            self.run_until_stalled();
387
388            assert!(
389                driver_waker.woken.load(Ordering::Acquire) || !self.shared.queue.lock().is_empty(),
390                "deterministic executor stalled (seed {}): the driver future is not \
391                 woken and no task is runnable, so every remaining future awaits a \
392                 wake that can never arrive (note: the executor is single-threaded; \
393                 a wake signaled from another OS thread is unsupported and can race \
394                 this check)",
395                self.seed
396            );
397        }
398    }
399
400    /// Poll ready tasks in seeded-random order until none is runnable.
401    ///
402    /// The queue lock is released around every `runnable.run()` (fork
403    /// safety; wakes fired during a poll re-acquire it to enqueue).
404    fn run_until_stalled(&mut self) {
405        let mut polls: u64 = 0;
406
407        loop {
408            let runnable = {
409                let mut queue = self.shared.queue.lock();
410                if queue.is_empty() {
411                    break;
412                }
413                // A single runnable is a forced choice: skip the RNG draw on
414                // the dominant one-event-wakes-one-task path (the stream is
415                // uncounted, so draw counts are not a stability contract).
416                let index = if queue.len() == 1 {
417                    0
418                } else {
419                    self.rng.random_range(0..queue.len())
420                };
421                queue.swap_remove(index)
422            };
423
424            polls += 1;
425            assert!(
426                polls < DRAIN_POLL_BOUND,
427                "executor drain exceeded {} polls (seed {}): task '{}' appears to \
428                 busy-yield forever without an external wake",
429                DRAIN_POLL_BOUND,
430                self.seed,
431                runnable.metadata().name,
432            );
433
434            tracing::trace!(
435                task_id = runnable.metadata().id,
436                task = %runnable.metadata().name,
437                "executor poll"
438            );
439
440            IN_TASK.with(|flag| flag.set(true));
441            runnable.run();
442            IN_TASK.with(|flag| flag.set(false));
443        }
444    }
445}
446
447impl Drop for Executor {
448    fn drop(&mut self) {
449        // Re-install this executor as CURRENT for the drain (block_on's guard
450        // is long gone): a task future whose Drop spawns through the provider
451        // then gets a valid, immediately-cancelled task (tokio's shutdown
452        // behavior) instead of a panic inside async-task's abort-on-panic
453        // destructor guard, which would abort the whole process. Skip if some
454        // executor is already installed (dropping mid-block_on of another).
455        let installed = CURRENT.with(|current| {
456            let mut current = current.borrow_mut();
457            if current.is_none() {
458                *current = Some(Handle {
459                    shared: Arc::clone(&self.shared),
460                });
461                true
462            } else {
463                false
464            }
465        });
466
467        // Wake every live task: completed tasks ignore it, parked tasks get
468        // their Runnable scheduled into the queue.
469        let wakers: Vec<Waker> = self
470            .shared
471            .wakers
472            .lock()
473            .drain()
474            .map(|(_, waker)| waker)
475            .collect();
476        for waker in wakers {
477            waker.wake();
478        }
479
480        // Drop Runnables until the queue is empty: dropping one cancels its
481        // task and drops the future; if that wakes further tasks (or spawns
482        // new, instantly-doomed ones) they are scheduled into the queue and
483        // consumed by this same loop.
484        loop {
485            let runnable = self.shared.queue.lock().pop();
486            match runnable {
487                Some(runnable) => drop(runnable),
488                None => break,
489            }
490        }
491
492        if installed {
493            CURRENT.with(|current| current.borrow_mut().take());
494        }
495    }
496}
497
498impl std::fmt::Debug for Executor {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        f.debug_struct("Executor")
501            .field("seed", &self.seed)
502            .field("ready_tasks", &self.shared.queue.lock().len())
503            .field("live_tasks", &self.shared.wakers.lock().len())
504            .finish_non_exhaustive()
505    }
506}