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}