Skip to main content

Module executor

Module executor 

Source
Expand description

The deterministic single-threaded executor (seeded-random scheduling). The moonpool deterministic executor: a single-threaded, seeded-random async task scheduler purpose-built for simulation.

§Why moonpool owns an executor

The simulation used to run on a tokio current-thread runtime whose only deterministic-scheduling lever was the unstable RngSeed API, forcing --cfg tokio_unstable onto every downstream consumer (issue #151). The deeper finding behind this module: tokio’s current-thread scheduler is a plain FIFO queue, so its seed never randomized task scheduling at all. Owning the executor removes the unstable flag AND unlocks something tokio cannot offer: task polling order that is a deterministic function of the simulation seed, so the seed space explores task interleavings.

§Why there is no reactor

A production runtime pairs its executor with a reactor: an epoll/kqueue loop that turns OS events into waker calls, plus a timer wheel. In the simulation none of that exists; SimWorld’s event queue is the reactor. Virtual time, network delivery, storage completion: every external event is a queue entry whose processing wakes the parked task through its ordinary [Waker]. The executor therefore only needs to answer one question: “of the tasks that are runnable right now, which do I poll next?”

§Scheduling: randomized AND deterministic

Those two words are not in tension. Deterministic means “same seed, same execution, bit for bit”, not “one fixed schedule”. The ready queue is a Vec and the next task is chosen by swap_remove at a seeded-random index (madsim uses the same distribution). The RNG is a private ChaCha8 stream derived from the iteration seed with [EXEC_RNG_SALT]; it is deliberately NOT the counted SIM_RNG stream, so scheduling decisions never perturb fork-explorer breakpoint replay (count@seed timelines).

The payoff: when one simulation event wakes two tasks, FIFO would run them in registration order on every seed forever, structurally hiding any race between them. Here, which task runs first is a per-seed choice: each seed replays exactly, and the population of seeds covers both orders. Ordering bugs live exactly there.

§The driver contract: block_on + until_stalled

[Executor::block_on] pins the main future (the simulation orchestrator) on the stack. It is not in the ready queue and is never scheduled randomly; it is the driver, alternating with the task pool:

loop {
    poll driver ─ Ready? ──────────────► return
    run_until_stalled()   // poll ready tasks in seeded-random order
                          // until no task is runnable
    (driver not woken AND queue empty) ─► panic: genuine deadlock
}

The orchestrator’s step loops await [until_stalled()] between sim.step() calls. Because the driver is only re-polled after a full drain, resuming from until_stalled().await guarantees every task that was runnable has been polled to Pending or completion. This is strictly stronger than the contract the old tokio version relied on (yield_now re-queuing the driver at the back of a FIFO), and it stays correct under randomized scheduling, where “back of the queue” does not exist.

Inside a task, use [yield_now()]: it reschedules the task at a seeded random position. until_stalled() is driver-only (debug-asserted): if a task awaited it, the drain-until-empty semantics would deadlock against itself.

§Task lifecycle, panics, and kill-on-drop

Spawning goes through async_task: each task splits into a Runnable (scheduled into the ready queue by its waker) and a [JoinHandle] wrapping the FallibleTask (awaitable output). Three contracts mirror tokio because the orchestrator depends on them:

  • Detach on drop: dropping a JoinHandle lets the task keep running.
  • Abort: [JoinHandle::abort] cancels the task; awaiting the handle then yields Err(JoinError::Cancelled).
  • Panic isolation: every spawned future is wrapped in catch_unwind, so a panicking task never unwinds into the executor’s run loop; its handle yields Err(JoinError::Panicked) and sibling tasks are unaffected.

Dropping the executor kills every remaining task, replicating the “dropping the per-iteration tokio runtime kills leaked tasks” contract that isolates simulation iterations. The mechanism (borrowed from async-executor): a registry keeps one waker per live task; Drop wakes them all, which schedules every parked task into the ready queue, then pops and drops Runnables until the queue is empty. Dropping a Runnable cancels its task and drops the future, and if that drop wakes further tasks they land in the queue and are consumed by the same loop.

§Fork safety

The fork-based explorer (moonpool-explorer) forks the process while tasks are being polled (assertion macros are fork points). Two rules keep that sound: everything is single-threaded, and the queue lock is never held across runnable.run(); wakes fired during a poll re-acquire the lock, and a child process inherits no held locks.

Structs§

Executor
The deterministic single-threaded executor.
JoinHandle
Owned handle to a spawned task, mirroring tokio::task::JoinHandle semantics.
YieldNow
Future returned by yield_now and until_stalled: pends once, waking its own waker, then completes on the next poll.

Functions§

spawn
Spawn a named task onto the executor currently running on this thread.
until_stalled
Driver-only: resume after every currently-runnable task has been polled to Pending or completion.
yield_now
Yield control from inside a task.