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
JoinHandlelets the task keep running. - Abort: [
JoinHandle::abort] cancels the task; awaiting the handle then yieldsErr(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 yieldsErr(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.
- Join
Handle - Owned handle to a spawned task, mirroring
tokio::task::JoinHandlesemantics. - Yield
Now - Future returned by
yield_nowanduntil_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
Pendingor completion. - yield_
now - Yield control from inside a task.