Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ jobs:
# spawned-concurrency's new sibling versions are published in dependency
# order by release.yaml; its package is also validated before release.
cargo package --package spawned-macros --no-verify
cargo package --package spawned-rt --no-verify
# spawned-sim-libc (the simulator's interposers) ships with spawned-rt.
cargo package --package spawned-sim-libc --package spawned-rt --no-verify

- name: Verify spawned-test publication against unreleased siblings
run: |
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ jobs:
- name: Wait for crates.io indexing
run: sleep 30

- name: Publish spawned-sim-libc
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: |
OUTPUT=$(cargo publish --locked --package spawned-sim-libc 2>&1) || {
if echo "$OUTPUT" | grep -Eq "already (exists|uploaded)"; then
echo "spawned-sim-libc already published, continuing"
else
echo "$OUTPUT"
exit 1
fi
}

- name: Wait for crates.io indexing
run: sleep 30

- name: Publish spawned-rt
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@
not returning to its baseline after quiesce, and has a `--inject-leak`
negative control. Run it before each release (`docs/TESTING.md`).

- Determinism D1-D3 (priority tier 3). Under `--cfg spawned_sim`:
wall-clock and OS-entropy reads made inside a simulated run return virtual
time and seeded bytes, so `Instant::now`, `SystemTime::now`,
`rand::thread_rng`, `OsRng` and `HashMap` iteration order replay per seed
with no call-site change (D1; madsim's technique, in the new sim-only crate
`spawned-sim-libc`, which holds all the unsafe interposers so the other
crates stay `forbid(unsafe_code)`; Linux/glibc and macOS). Each harness run
gets a fresh thread, so bodies passed to `sim_test`/`run_once`/`explore`
must be `Sync`. `SPAWNED_SIM_STRICT=1` (`sim::set_strict`) fails the run on
such a read, naming the caller. `spawn_blocking` runs inline as one
simulator step instead of panicking (D2; not concurrent under simulation).
`spawned_rt::tasks` gains `sleep_until`, `interval`/`interval_at`
(`Interval`, `MissedTickBehavior`) and `JoinSet` (`AbortHandle`,
`JoinError`), virtual under the simulator and Tokio's otherwise, and a
direct `tokio::time` timer fails the run with a message naming the
replacement (D3). See `docs/TESTING.md` and `docs/MIGRATION.md`.

## 0.7.3 — 2026-11-10

Patch release with one supervision behaviour change: a failed restart (a
Expand Down
68 changes: 58 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"concurrency",
"macros",
"testing",
"sim-libc",
"examples/bank",
"examples/bank_threads",
"examples/name_server",
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ From the [determinism audit](docs/research/determinism-audit.md) (2026-11-11) of

| # | Item | Effort |
|---|---|---|
| D1 | Interpose `clock_gettime`/`gettimeofday` and `getrandom`/`getentropy` under `spawned_sim` (madsim's technique): virtual time and seeded bytes on a thread that owns a world, so `Instant::now`, `SystemTime::now`, `thread_rng`, `OsRng` and `HashMap` keys become deterministic with no call-site change; `SPAWNED_SIM_STRICT=1` panics with the caller instead (the `tokio::spawn`-style check). Covers ~270 clock and ~50 rand sites in the clients. | M |
| D2 | `spawn_blocking` runs inline under sim as one atomic step (today it panics): synchronous DB and compute offload (64 client sites) becomes simulable. | S |
| D3 | Complete `spawned_rt::tasks` (`interval`, `sleep_until`, a `JoinSet`) and turn Tokio's "timers are disabled" panic into a message naming the `spawned_rt` replacement. | S |
| D1 | **Implemented (#267).** Interpose `clock_gettime`/`gettimeofday` and `getrandom`/`getentropy` under `spawned_sim` (madsim's technique): virtual time and seeded bytes on a thread that owns a world, so `Instant::now`, `SystemTime::now`, `thread_rng`, `OsRng` and `HashMap` keys become deterministic with no call-site change; `SPAWNED_SIM_STRICT=1` panics with the caller instead (the `tokio::spawn`-style check). Covers ~270 clock and ~50 rand sites in the clients. Done in the sim-only `spawned-sim-libc` crate (Linux/glibc and macOS; each harness run on a fresh thread so per-thread seeding is per run); left out: `getrandom` 0.3+ on Linux, which finds the symbol with `dlsym`. | M |
| D2 | **Implemented (#267).** `spawn_blocking` runs inline under sim as one atomic step (today it panics): synchronous DB and compute offload (64 client sites) becomes simulable. | S |
| D3 | **Implemented (#267).** Complete `spawned_rt::tasks` (`interval`, `sleep_until`, a `JoinSet`) and turn Tokio's "timers are disabled" panic into a message naming the `spawned_rt` replacement. | S |
| D4 | Route the clients' direct Tokio calls: Ethrex 34 `tokio::spawn`/`JoinSet` and 48 `tokio::time`, Ethlambda 8 and 8 (lists in the audit). | M |
| D5 | Library fixes: per-world id counters under sim (byte-identical traces, so S2 can run in CI); `PendingReplies::drain` in insertion order (0.8); `FailScenario::setup()` seeded from the world. | S |
| D6 | First client `#[sim_test]`s (none exist today): Ethlambda's blockchain actor, Ethrex `tx_broadcaster` and `peer_table`, after D1. | S-M |
Expand Down
34 changes: 29 additions & 5 deletions concurrency/tests/sim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ fn same_seed_same_trace_different_seeds_differ() {
#[test]
fn virtual_time_advances_only_when_idle() {
let rt = Runtime::with_seed(1);
let wall = Instant::now();
let wall = sim::wall_now();
rt.block_on(async {
spawned_rt::tasks::sleep(Duration::from_secs(3600)).await;
let late = spawned_rt::tasks::timeout(
Expand All @@ -337,13 +337,37 @@ fn virtual_time_advances_only_when_idle() {
assert!(wall.elapsed() < Duration::from_secs(1));
}

/// Determinism D2: `spawn_blocking` runs inline as one simulator step.
#[test]
#[should_panic(expected = "spawned_sim: spawn_blocking is not supported")]
fn spawn_blocking_is_rejected() {
fn spawn_blocking_runs_inline() {
let rt = Runtime::with_seed(1);
rt.block_on(async {
drop(spawned_rt::tasks::spawn_blocking(|| ()));
let thread = std::thread::current().id();
let ran_on = rt.block_on(async {
spawned_rt::tasks::spawn_blocking(|| std::thread::current().id())
.await
.unwrap()
});
assert_eq!(ran_on, thread);
}

/// A Blocking-backend actor drives its loop with a nested `block_on`, which
/// cannot run on the simulation thread: the run fails naming it instead of
/// the actor silently never starting.
#[test]
fn blocking_backend_actor_fails_the_run() {
let outcome = sim::run_once(1, None, &|_| async {
let log = Log::default();
let node = Node {
id: 1,
crash_after: None,
log,
}
.start_with_backend(spawned_concurrency::tasks::Backend::Blocking);
spawned_rt::tasks::sleep(Duration::from_millis(10)).await;
drop(node);
});
let failure = outcome.failure.expect("a Blocking actor must fail the run");
assert!(failure.contains("Backend::Blocking"), "{failure}");
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions concurrency/tests/sim_explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ async fn unstarted_child_never_ends(wanted: &str) {

fn run<F, Fut>(strategy: &str, budget: usize, body: &F) -> Report
where
F: Fn(u64) -> Fut,
F: Fn(u64) -> Fut + Sync,
Fut: std::future::Future<Output = ()>,
{
let strategy = Strategy::parse(strategy).unwrap();
Expand Down Expand Up @@ -311,7 +311,7 @@ fn assert_found(report: &Report, bug: &str) {
/// through the existing tooling.
fn assert_replays<F, Fut>(report: &Report, body: &F)
where
F: Fn(u64) -> Fut,
F: Fn(u64) -> Fut + Sync,
Fut: std::future::Future<Output = ()>,
{
let found = report.found.as_ref().expect("bug not found");
Expand Down Expand Up @@ -543,7 +543,7 @@ fn explore_budget_table() {

fn table<F, Fut>(title: &str, budget: usize, bug: &str, body: F)
where
F: Fn(u64) -> Fut,
F: Fn(u64) -> Fut + Sync,
Fut: std::future::Future<Output = ()>,
{
println!("{title}:");
Expand Down
8 changes: 5 additions & 3 deletions concurrency/tests/sim_turn_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use spawned_concurrency::{
tasks::{Actor, ActorStart, Context, Handler},
};
use spawned_rt::tasks::{sim, SimTest};
use std::time::{Duration, Instant};
use std::time::Duration;

/// Budget used by the in-process tests; the blocking sections run well past
/// it and the yielding ones stay well under it.
Expand Down Expand Up @@ -40,9 +40,11 @@ impl Message for Spin {
}
impl Handler<Spin> for Worker {
async fn handle(&mut self, _msg: Spin, _ctx: &Context<Self>) -> u64 {
let start = Instant::now();
// The real clock: `std::time::Instant` is virtual inside a
// simulated poll and does not move while a handler spins.
let start = spawned_rt::tasks::sim::wall_now();
let mut n = 0u64;
while start.elapsed() < OVER {
while spawned_rt::tasks::sim::wall_now() - start < OVER {
n = std::hint::black_box(n.wrapping_add(1));
}
n
Expand Down
10 changes: 8 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,16 +600,22 @@ pub enum ActorError {

### tasks module (`spawned_rt::tasks`)

Wraps tokio primitives:
Wraps tokio primitives. Under `--cfg spawned_sim` every timer and task here
runs on the deterministic simulator (virtual time), so code that uses these
instead of `tokio::time` / `tokio::spawn` directly is testable with
`#[sim_test]` ([docs/TESTING.md](TESTING.md)):

| Item | Description |
|------|-------------|
| `run(future)` | Create a tokio runtime, initialize tracing, and block on the future |
| `block_on(future)` | Block on a future using the current tokio runtime handle |
| `spawn(future)` | Spawn an async task |
| `spawn_blocking(f)` | Spawn a blocking closure on tokio's blocking pool |
| `spawn_blocking(f)` | Spawn a blocking closure on tokio's blocking pool (inline, as one step, under the simulator) |
| `sleep(duration)` | Async sleep |
| `sleep_until(deadline)` | Async sleep until a `spawned_rt::time::Instant` |
| `timeout(duration, future)` | Wrap a future with a timeout |
| `interval(period)` / `interval_at(start, period)` | Periodic ticks (`Interval`, `MissedTickBehavior`), Tokio's semantics |
| `JoinSet` / `AbortHandle` / `JoinError` | A set of spawned tasks whose results are collected as they finish |
| `Runtime` | Tokio runtime (re-export) |
| `JoinHandle` | Handle to a spawned task |
| `CancellationToken` | Cooperative cancellation (from tokio-util) |
Expand Down
20 changes: 20 additions & 0 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
Upgrade notes per release, newest first. The [CHANGELOG](../CHANGELOG.md) lists
every change; this guide covers what you need to edit when upgrading.

## Unreleased

**Simulation builds only (`--cfg spawned_sim`; determinism D1-D3).**

- `sim_test` / `SimTest::run`, `sim::run_once`, `run_with`, `shrink` and
`explore` bodies must be `Sync` (each simulated run gets a fresh thread). A
closure that captures an `Rc` or `Cell` needs an `Arc`/`Mutex` instead; the
bound applies in normal builds too, so a test compiles the same way in both.
State a body leaves in a `thread_local!` is on the run's thread, not the
test's: collect results through an `Arc<Mutex<..>>` or a static keyed by
`std::thread::current().name()` (the run thread is named like the test).
- `std::time::Instant::now()` and `SystemTime::now()` inside a simulated run
read virtual time, and OS entropy (`HashMap` keys, `thread_rng`, `OsRng`) is
seeded by the run. A test that measured real time inside a run must use
`sim::wall_now()`; a loop that spins on `Instant::elapsed()` with no
`.await` never ends (the watchdog aborts it).
- `spawn_blocking` no longer panics: it runs inline as one step.
`Backend::Blocking` actors and nested `block_on` still fail, now by failing
the run.

## 0.7.3

**Behaviour change (supervision).** A Transient child's replacement (any generation after the child was first
Expand Down
Loading
Loading