autumn_web/sim.rs
1//! Deterministic simulation testing (sim-testing, issue #1797).
2//!
3//! This module is the public 0.7.0 developer-experience surface for writing
4//! **deterministic** simulation tests: a single [`#[sim_test]`](crate::sim_test)
5//! attribute gives you a seeded [`Sim`] handle and a paused runtime, so a test
6//! runs identically on every machine and every run, and a failure prints a
7//! copy-pasteable line that reproduces it exactly.
8//!
9//! # Quick start
10//!
11//! ```rust,ignore
12//! use autumn_web::sim::Sim;
13//! use autumn_web::sim_test;
14//!
15//! #[sim_test]
16//! async fn deterministic(mut sim: Sim) {
17//! // The seed comes from `AUTUMN_SIM_SEED` (hex `0x..` or decimal,
18//! // default 0). Everything derived from `sim` is seed-driven and
19//! // reproducible.
20//! assert_eq!(sim.seed, 0);
21//! let _rng = sim.rng();
22//! }
23//! ```
24//!
25//! Reproduce a failing run by copying the replay line printed on panic, e.g.:
26//!
27//! ```text
28//! AUTUMN_SIM_SEED=0x9f3a cargo test -p my-crate deterministic
29//! ```
30//!
31//! # What a `Sim` gives you
32//!
33//! - **Virtual time.** [`Sim::build`] mounts a [`crate::test::TestApp`] with a
34//! virtual clock. [`Sim::advance`] and [`Sim::advance_to`] step it together
35//! with tokio's paused timer, so `#[job]` backoff and `#[scheduled]` ticks fire
36//! in virtual time. [`Sim::run_to_idle`] drains jobs, due ticks and durable
37//! commit hooks.
38//! - **Seeded identity.** [`Sim::rng`] ([`SimRng`]) draws deterministic values.
39//! [`Sim::build`] seeds the app's [`crate::entropy::Entropy`] from the seed,
40//! so framework-minted ids (jobs, request ids, idempotency keys, sessions)
41//! replay too. [`Sim::seeded_entropy`] returns the same source.
42//! - **Faults.** [`Chaos`] ([`Sim::chaos`]) injects seed-sampled faults.
43//! [`FaultPlan`] (#1680), attached with
44//! [`crate::test::TestApp::with_fault_plan`], injects authored ones and
45//! records a serializable [`FaultOutcome`]. [`Sim::kill`] and
46//! [`Sim::restart`] model a process crash; `sim::llm` is a seeded LLM stub.
47//! - **Assertions and sweeps.** [`always!`](crate::always) and
48//! [`sometimes!`](crate::sometimes) ([`mod@assert`]). Behind the
49//! `sim-testing` feature, `sim::op` generates workloads (`Sim::gen_ops`,
50//! `Sim::run_proptest` with shrinking) and `sim::sweep` runs one scenario
51//! across many seeds (`sweep_proptest`, driven in CI by the `sim-sweep` bin).
52//! - **Deadlocks.** With `AUTUMN_SIM_LIVENESS_BUDGET_SECS` set, a `#[sim_test]`
53//! whose tasks all park panics with its replay line instead of hanging. See
54//! [`__with_liveness_budget`] for the limits.
55//! - **Phase 2 (issue #2967).** `Sim::net` routes outbound HTTP through a
56//! seeded `SimNet`. [`Sim::interleave`] and [`Sim::spawn`] reorder ready work
57//! from the seed. [`crash_at`] drops an operation at any await.
58//! [`Sim::try_run_to_idle`] reports a drain that does not settle. Framework
59//! code with no clock in scope reads [`crate::time::ambient_now`] and its
60//! siblings, which follow the sim clock.
61//! - **Replicas (issue #3067).** [`Sim::mount_replica`] mounts several apps on
62//! one clock, each with its own [`Replica`] clock (offset, drift, steps).
63//! `Sim::db_link` (with `sqlite`) faults one replica's database session
64//! and writes. [`Sim::run_for`] moves time one event at a time. [`runtime`] gates
65//! database work, so a seed replays it. [`mod@trace`] compares two runs of
66//! one seed.
67//!
68//! The clock and app handles inside a `Sim` are crate-private:
69//!
70//! ```compile_fail
71//! use autumn_web::sim::SimClock;
72//! ```
73//!
74//! ```compile_fail
75//! use autumn_web::sim::SimApp;
76//! ```
77//!
78//! Everything here is designed to grow additively (builder-style) without
79//! breaking the frozen surface — hence the `#[non_exhaustive]` markers.
80
81// Several thin accessors here could be `const fn`; they stay non-const so the
82// frozen public surface can grow a non-const body later without a break.
83#![allow(clippy::missing_const_for_fn)]
84
85use std::sync::Arc;
86
87use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeZone, Utc};
88use rand::{Rng, SeedableRng};
89use rand_chacha::ChaCha8Rng;
90use uuid::Uuid;
91
92use crate::entropy::{Entropy, SeededEntropy, uuid_v4_from_bytes, uuid_v7_from_parts};
93use crate::time::TickingClock;
94
95// The per-sim SQLite DB lane (W4, issue #1797): a fresh, migrated, in-process
96// SQLite substrate the sim builds its app on. Self-contained and additive — W2
97// consumes its pool when it mounts the app on `SimApp`. Only meaningful under the
98// `sqlite` feature (the sim's DB substrate is SQLite by design).
99//
100// Exposed as `#[doc(hidden)] pub` — unstable test/sim plumbing, not the stable
101// surface — mirroring this module's `__seed_from_env` / `__replay_line` hidden
102// hooks. It is `pub` (not `pub(crate)`) purely so the W4 DoD consolidated
103// integration test, which is an external crate, can drive the end-to-end drain;
104// the stable mount API remains W2's `Sim::build`.
105#[cfg(feature = "sqlite")]
106#[doc(hidden)]
107pub mod substrate;
108
109// The chaos lane (W5, issue #1797): deterministic fault injection wired into
110// `Sim::build`. Additive and opt-in — a default (empty) `Chaos` installs
111// nothing. See the module docs for the determinism contract.
112pub mod chaos;
113
114#[cfg(feature = "mail")]
115pub use chaos::MailFault;
116pub use chaos::{Chaos, ChaosEvent, ChaosHook};
117
118// The authored fault lane (issue #1680): `FaultPlan`, an ordinal-targeted,
119// seed-deterministic fault schedule installed through `TestApp::with_fault_plan`
120// (not through `Sim::chaos`), plus the serializable `FaultOutcome` a scenario
121// asserts on. Additive and opt-in — a `TestApp` with no plan is untouched. See
122// the module docs for the determinism contract and how it differs from `Chaos`.
123pub mod fault;
124
125pub use fault::{
126 FaultEffect, FaultLedger, FaultOutcome, FaultPlan, FinalState, FiredFault, PlannedFault,
127 ReportedError,
128};
129
130// The seeded LLM stub (W5.b, item 6, issue #1797): a deterministic fake
131// completion client — canned responses + a seeded fault/latency schedule — for
132// exercising agent retry/fallback paths under the virtual clock. Standalone and
133// additive; it does not route through the `Chaos` builder. See the module docs
134// for the determinism contract.
135pub mod llm;
136
137pub use llm::{LlmCall, LlmClient, LlmError, LlmRequest, LlmResponse, SeededLlm, SeededLlmBuilder};
138
139// The crash lane (W5.c item 7, issue #1797): a seed-derived crash schedule plus
140// the `Sim` kill/restart primitive for durable crash-recovery tests. Additive —
141// the schedule is a pure function of the seed and installs nothing at build.
142pub mod crash;
143
144// The W6 semantic core (issue #1797): the `always!` / `sometimes!` assertion
145// macros and the thread-local non-vacuity registry. Public (documented) module —
146// the macros are `#[macro_export]`ed at the crate root (`autumn_web::always` /
147// `autumn_web::sometimes`), and their hidden plumbing plus the sweep-facing
148// registry API live here.
149pub mod assert;
150
151// The simulated network (issue #2967): seeded latency, drops and partitions
152// for outbound `http_client::Client` calls, served by in-process hosts.
153#[cfg(feature = "http-client")]
154pub mod net;
155
156#[cfg(feature = "http-client")]
157pub use net::{NetEvent, NetFault, SimNet};
158
159// The shared demo scenario (issue #2967): one `Op` vocabulary for the
160// `sim-sweep` proptest sweep and the `sim_ops` cargo-fuzz target. Hidden,
161// unstable harness plumbing.
162#[doc(hidden)]
163pub mod scenario;
164
165// The interleaving shuffler (issue #2967): seeded poll order for
166// `Sim::interleave` and seeded yields for `Sim::spawn`.
167mod shuffle;
168
169// Several apps on one sim clock (issue #3067).
170pub mod replica;
171
172pub use replica::{Replica, ReplicaClock};
173
174// Faults on one replica's link to the shared SQLite database (issue #3067).
175#[cfg(feature = "sqlite")]
176pub mod dblink;
177
178#[cfg(feature = "sqlite")]
179pub use dblink::{DbFaultEvent, DbFaultKind, DbLink};
180
181// The sim runtime and its blocking-work gate (issue #3067).
182pub(crate) mod gate;
183
184pub use gate::{SimRuntime, runtime};
185
186// Same-seed trace capture and diff (issue #3067).
187pub mod trace;
188
189pub use trace::{Trace, TraceDiff};
190
191// Framework scenarios (jobs, scheduler, lock) for the seed sweep (issue #3067).
192#[cfg(feature = "sqlite")]
193#[doc(hidden)]
194pub mod fleet;
195
196pub use assert::{
197 SometimesRegistry, assert_all_sometimes_satisfied, reset_sometimes_registry,
198 sometimes_snapshot, sometimes_unsatisfied,
199};
200pub use crash::{CrashOutcome, CrashPoint, CrashSchedule, crash_at};
201
202// The W6 op-driver (PR2, issue #1797): `Sim::gen_ops`/`Sim::gen_ops_with` (deterministic,
203// non-shrinking generation) and `Sim::run_proptest` (the shrink-capable
204// runner-owning entrypoint). Behind the `sim-testing` feature because it needs
205// `proptest` as a library (not just dev) dependency — see `autumn/Cargo.toml`.
206#[cfg(feature = "sim-testing")]
207pub mod op;
208
209// The W6 seed-sweep runner (PR3, issue #1797): `sweep_proptest` runs
210// `Sim::run_proptest` sequentially across a batch of seeds, reporting the
211// first failing seed (if any), and folds `sometimes!` reachability — across
212// every proptest case in every seed — across the whole swept range so a
213// green sweep is provably non-vacuous. Sequential, not parallel: see
214// `sim::sweep`'s module docs for why a `body` that mounts a real app makes
215// OS-thread parallelism unsafe here. The `sim-sweep` `[[bin]]`
216// (`autumn/src/bin/sim_sweep.rs`) is its CI-facing driver. Same
217// `sim-testing` feature gate as `op` — it builds directly on
218// `Sim::run_proptest_with_case_hook`.
219#[cfg(feature = "sim-testing")]
220pub mod sweep;
221
222#[cfg(feature = "sim-testing")]
223pub use sweep::{SweepFailure, SweepOutcome, sweep_proptest};
224
225/// The fixed, deterministic epoch the simulation clock starts at:
226/// `2020-01-01T00:00:00Z`.
227///
228/// Every sim run starts its virtual clock here so wall-clock-derived values are
229/// reproducible across machines and runs. W2 drives this clock forward via
230/// `SimClock`.
231const SIM_EPOCH_UNIX_SECS: i64 = 1_577_836_800; // 2020-01-01T00:00:00Z
232
233/// A deterministic simulation handle, constructed from a single `u64` seed.
234///
235/// `Sim` is the day-one **public, stability-frozen** entry point handed to a
236/// [`#[sim_test]`](crate::sim_test) body. Its [`seed`](Sim::seed) is public so a
237/// test can assert on or thread it; the injection handles it owns
238/// ([`SimRng`], the clock, [`Chaos`] and the mounted app) are private and reached
239/// through accessors, so their internals can evolve wave-over-wave without a
240/// breaking change.
241///
242/// Marked `#[non_exhaustive]` so future waves can add handles without breaking
243/// construction — always build one via [`Sim::from_seed`].
244#[non_exhaustive]
245pub struct Sim {
246 /// The seed this simulation was constructed from.
247 ///
248 /// Reproduce a run by exporting `AUTUMN_SIM_SEED=0x<seed>` (the replay line
249 /// printed on panic does this for you).
250 pub seed: u64,
251
252 /// Seeded deterministic RNG, reached through [`Sim::rng`].
253 rng: SimRng,
254
255 /// Virtual clock, started at the fixed sim epoch
256 /// (`2020-01-01T00:00:00Z`). Stepped by [`Sim::advance`] in lockstep with
257 /// tokio's paused timer.
258 clock: SimClock,
259
260 /// Fault-injection configuration installed at [`Sim::build`]. A default
261 /// (empty) [`Chaos`] is inactive and installs nothing.
262 chaos: Chaos,
263
264 /// Shared chaos runtime state (decision stream + event log), populated by
265 /// [`Sim::build`] when [`chaos`](Self::chaos) is active. Read through
266 /// [`Sim::__chaos_events`].
267 chaos_state: Option<Arc<chaos::ChaosState>>,
268
269 /// Built [`crate::test::TestClient`] handle, mounted by [`Sim::build`] on
270 /// the paused runtime with the virtual clock installed.
271 app: SimApp,
272
273 /// Real wall-clock budget for the `strict_wall_clock` real-time leak guard,
274 /// or `None` (the default) when the guard is off. Set once via
275 /// [`strict_wall_clock`](Sim::strict_wall_clock) /
276 /// [`strict_wall_clock_budget`](Sim::strict_wall_clock_budget) *before* the
277 /// run and only **read** by [`advance`](Sim::advance) /
278 /// [`run_to_idle`](Sim::run_to_idle), so it is a plain `Option` with no
279 /// interior mutability — that keeps `Sim: Sync` and the `&self`
280 /// advance/drain futures `Send` (a `Cell`/`RefCell` field would break both).
281 strict_budget: Option<std::time::Duration>,
282
283 /// The simulated network installed at each mount, if any.
284 #[cfg(feature = "http-client")]
285 net: Option<net::SimNet>,
286
287 /// How many shuffler calls ([`interleave`](Sim::interleave),
288 /// [`spawn`](Sim::spawn)) this sim made. Each call draws its own seeded
289 /// stream from the seed and this count. Atomic so `&self` stays `Sync`.
290 shuffle_calls: std::sync::atomic::AtomicU64,
291
292 /// The clock installed as this thread's ambient clock (issue #2967). See
293 /// [`crate::time::ambient_now`].
294 ambient: Arc<AmbientSimClock>,
295
296 /// Keeps [`ambient`](Self::ambient) installed while the sim lives.
297 _ambient_guard: crate::time::AmbientGuard,
298
299 /// Keeps this sim on its thread's sim stack, which splits tokio time
300 /// between the sims that share a runtime.
301 stack_guard: SimStackGuard,
302
303 /// How many times [`mount`](Sim::mount) has run. The first mount seeds the
304 /// app's entropy from [`seed`](Sim::seed); each restart derives a new seed
305 /// from it, so a restarted process does not replay the crashed one's ids.
306 mounts: u64,
307
308 /// The apps from [`mount_replica`](Sim::mount_replica), in mount order
309 /// (issue #3067).
310 replicas: Vec<ReplicaSlot>,
311
312 /// The database link of each replica, made on first use.
313 #[cfg(feature = "sqlite")]
314 db_links: std::sync::Mutex<std::collections::BTreeMap<String, DbLink>>,
315
316 /// This sim's place in its sim runtime's live count (issue #3067).
317 gate_seat: gate::SimSeat,
318}
319
320/// The name a replica has as a network host. Two replica names with one key
321/// would share one `SimNet` host, so they cannot both be mounted.
322fn replica_host_key(name: &str) -> String {
323 #[cfg(feature = "http-client")]
324 {
325 net::canonical_host(name)
326 }
327 #[cfg(not(feature = "http-client"))]
328 {
329 name.to_ascii_lowercase()
330 }
331}
332
333/// One named replica: its spec, its clock, and its app while it is alive.
334struct ReplicaSlot {
335 name: String,
336 clock: replica::NodeClock,
337 client: Option<crate::test::TestClient>,
338 /// The network this replica is a host of. [`Sim::net`] can set a new
339 /// network later, so the kill and the drop clean this one.
340 #[cfg(feature = "http-client")]
341 net: Option<net::SimNet>,
342}
343
344impl Sim {
345 /// Construct a simulation from `seed`.
346 ///
347 /// Infallible and cheap: it seeds the RNG and starts the virtual clock but
348 /// does **not** boot a database or an app, so an empty
349 /// [`#[sim_test]`](crate::sim_test) runs with zero setup. Mount an app with
350 /// [`build`](Sim::build).
351 ///
352 /// Built inside its paused runtime, the sim's elapsed time starts here.
353 /// Built before that runtime, call [`anchor`](Sim::anchor) first thing in
354 /// it; `#[sim_test]` does this for you.
355 #[must_use]
356 pub fn from_seed(seed: u64) -> Self {
357 // Each seed run starts with a clean reachability registry, so the sweep
358 // can attribute observed/satisfied `sometimes!` labels to exactly one
359 // seed before folding them into its cross-seed aggregate (W6, #1797).
360 assert::reset_sometimes_registry();
361 let clock = SimClock::new(TickingClock::starting_at(sim_epoch()));
362 let ambient_clock = Arc::new(AmbientSimClock {
363 wall: std::sync::RwLock::new(Arc::new(clock.ticking())),
364 own_advanced: std::sync::Mutex::new(std::time::Duration::ZERO),
365 auto_advanced: std::sync::Mutex::new(std::time::Duration::ZERO),
366 alive: std::sync::atomic::AtomicBool::new(true),
367 });
368 let gate_seat = gate::SimSeat::default();
369 gate_seat.take();
370 let ambient_guard = crate::time::install_ambient(ambient_clock.clone());
371 let sim_stack = SimStackGuard::enter(Arc::clone(&ambient_clock));
372 Self {
373 seed,
374 rng: SimRng::new(seed),
375 clock,
376 ambient: ambient_clock,
377 _ambient_guard: ambient_guard,
378 stack_guard: sim_stack,
379 chaos: Chaos::default(),
380 chaos_state: None,
381 app: SimApp::default(),
382 strict_budget: None,
383 #[cfg(feature = "http-client")]
384 net: None,
385 shuffle_calls: std::sync::atomic::AtomicU64::new(0),
386 mounts: 0,
387 replicas: Vec::new(),
388 #[cfg(feature = "sqlite")]
389 db_links: std::sync::Mutex::default(),
390 gate_seat,
391 }
392 }
393
394 /// Start this sim's elapsed time at tokio's current instant, if it has not
395 /// started yet. Call it first thing in the runtime that drives a sim built
396 /// before that runtime: tokio time that passes before the sim is anchored
397 /// is not in its elapsed time. Calling it again changes nothing, and
398 /// outside a runtime it does nothing.
399 ///
400 /// A sim built inside its runtime, or run by
401 /// [`#[sim_test]`](crate::sim_test), is anchored already.
402 pub fn anchor(&self) {
403 if tokio::runtime::Handle::try_current().is_ok() {
404 lock_sim_stack(&self.stack_guard.home, SimStack::settle);
405 // A sim built before `sim::runtime()` joins its gate here.
406 self.gate_seat.take();
407 }
408 }
409
410 /// Configure deterministic fault injection for this simulation.
411 ///
412 /// The `chaos` builder's hooks (transient DB checkout errors, job duplicate
413 /// delivery, clock skew) are installed at [`build`](Sim::build) time, each
414 /// fault decision drawn from a dedicated seed-derived stream so the same
415 /// seed and configuration replay the same fault schedule. A default
416 /// [`Chaos`] is inactive and changes nothing.
417 ///
418 /// ```rust,ignore
419 /// use autumn_web::sim::Chaos;
420 /// sim.chaos(Chaos::default().db_transient_errors(0.1).job_duplicate_delivery(0.2));
421 /// let client = sim.build(TestApp::new().routes(routes![touch]).jobs(jobs![work]));
422 /// ```
423 pub fn chaos(&mut self, chaos: Chaos) -> &mut Self {
424 self.chaos = chaos;
425 self
426 }
427
428 /// Route the app's outbound HTTP through a simulated network (issue
429 /// #2967).
430 ///
431 /// [`build`](Sim::build) installs `net` in the app, so the
432 /// [`crate::http_client::Client`] extractor sends through it: seeded
433 /// latency and drops, partitions, and in-process hosts. Keep a clone to
434 /// partition hosts and read [`SimNet::events`] later. See [`mod@net`].
435 #[cfg(feature = "http-client")]
436 pub fn net(&mut self, net: SimNet) -> &mut Self {
437 self.net = Some(net);
438 self
439 }
440
441 /// The seed this simulation was constructed from.
442 ///
443 /// Provided for API symmetry alongside the public [`seed`](Sim::seed)
444 /// field.
445 #[must_use]
446 pub const fn seed(&self) -> u64 {
447 self.seed
448 }
449
450 /// Borrow the seeded deterministic RNG handle.
451 ///
452 /// Draw deterministic values and UUIDs through it — e.g.
453 /// [`SimRng::uuid_v4`] / [`SimRng::next_u64`]. The same seed always yields
454 /// the same draw sequence.
455 #[must_use]
456 pub fn rng(&mut self) -> &mut SimRng {
457 &mut self.rng
458 }
459
460 /// Build a shared, seeded [`Entropy`] source for this simulation's seed,
461 /// ready to inject into a mounted app via
462 /// [`crate::state::AppState::with_entropy`].
463 ///
464 /// [`build`](Self::build) already installs this source in the app it
465 /// mounts, unless the test passed its own with
466 /// [`crate::test::TestApp::with_entropy`]. Use this to seed state you build
467 /// yourself.
468 ///
469 /// This is the bridge W3 provides for W2's app mounting: the app the
470 /// simulation drives resolves the [`crate::entropy::Rng`] extractor and
471 /// every framework-minted identifier (job ids, request ids, idempotency
472 /// lock owners, session ids) from this source, so a fixed seed replays the
473 /// whole identifier stream byte-for-byte.
474 ///
475 /// The returned source is seeded independently from the [`rng`](Self::rng)
476 /// handle (both from [`seed`](Self::seed)), so drawing from one does not
477 /// perturb the other's sequence.
478 #[must_use]
479 pub fn seeded_entropy(&self) -> Arc<dyn Entropy> {
480 SeededEntropy::shared(self.seed)
481 }
482
483 /// Mount `app` on the paused runtime with the simulation's virtual clock
484 /// installed, and return the resulting [`crate::test::TestClient`].
485 ///
486 /// The simulation's virtual clock is threaded in via
487 /// [`crate::test::TestApp::with_clock`], so every handler that reads a
488 /// [`crate::time::Clock`] extractor sees the virtual instant — starting at
489 /// the fixed sim epoch (`2020-01-01T00:00:00Z`) and moving only when
490 /// [`Sim::advance`] steps it. The built app also starts the in-process job
491 /// runtime (the in-memory backend), so [`run_to_idle`](Sim::run_to_idle)
492 /// can drain enqueued jobs deterministically, and starts its `#[scheduled]`
493 /// tasks, whose ticks fire as virtual time crosses their deadlines.
494 ///
495 /// Unless `app` already has an entropy source from
496 /// [`crate::test::TestApp::with_entropy`], `build` installs one seeded from
497 /// [`seed`](Sim::seed), so framework-minted ids replay from the seed. A
498 /// later mount through [`restart`](Sim::restart) derives a new seed from it,
499 /// so a restarted process does not repeat the crashed one's ids.
500 ///
501 /// Configure `app` fully before handing it over: routes, jobs, tasks and a
502 /// sim database are attached to the [`crate::test::TestApp`] before this
503 /// call. Do **not** call [`crate::test::TestApp::with_clock`] yourself;
504 /// `build` owns the clock so time stays in lockstep.
505 ///
506 /// The returned borrow is convenient for an immediate request; to interleave
507 /// requests with [`advance`](Sim::advance) / [`run_to_idle`](Sim::run_to_idle)
508 /// calls, reach the client through [`client`](Sim::client) instead.
509 ///
510 /// ```rust,ignore
511 /// let client = sim.build(TestApp::new().routes(routes![hello]).jobs(jobs![work]));
512 /// client.get("/hello").send().await.assert_ok();
513 /// ```
514 pub fn build(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
515 self.mount(app)
516 }
517
518 /// Mount `app` on the paused runtime with the simulation's virtual clock (and
519 /// active chaos hooks) installed, replacing any previously-mounted client.
520 ///
521 /// Shared by [`build`](Self::build) and [`restart`](Self::restart) so the
522 /// initial mount and a post-crash restart go through byte-for-byte the same
523 /// path. When chaos is active this re-derives the chaos decision state from
524 /// the seed, so a restart's fault schedule replays deterministically.
525 fn mount(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
526 // Seed the app's entropy unless the test injected its own source, so
527 // framework-minted ids replay from the seed with no extra call.
528 let mount_seed = mount_entropy_seed(self.seed, self.mounts);
529 let app = app.with_default_entropy(SeededEntropy::shared(mount_seed));
530 self.mounts += 1;
531 // Install the simulated network, with a stream fresh for this mount.
532 #[cfg(feature = "http-client")]
533 let app = match &self.net {
534 Some(net) => {
535 net.reseed(mount_seed);
536 app.with_sim_net(net.clone())
537 }
538 None => app,
539 };
540 // When chaos is active, install its deterministic hooks (which also own
541 // the clock so a skew wrapper can be applied); otherwise the build is
542 // byte-for-byte the pre-W5 path — just the virtual clock.
543 // The ambient wall clock matches the app clock, skewed or not.
544 *self
545 .ambient
546 .wall
547 .write()
548 .unwrap_or_else(std::sync::PoisonError::into_inner) =
549 chaos::wall_clock(&self.chaos, self.seed, self.clock.ticking());
550 let app = if self.chaos.is_active() {
551 let state = chaos::ChaosState::new(self.seed, &self.chaos);
552 self.chaos_state = Some(Arc::clone(&state));
553 chaos::install(app, &self.chaos, self.seed, self.clock.ticking(), state)
554 } else {
555 app.with_clock(self.clock.ticking())
556 };
557 let client = app.build();
558 self.app.client = Some(client);
559 self.app.client()
560 }
561
562 /// Simulate a process crash: stop the app's job and scheduler tasks and
563 /// drop the mounted app, so in-flight work is **cancelled without
564 /// completing** (the client's [`Drop`] also cancels the runtime's shutdown
565 /// token and clears the global job client), ready for durable recovery on
566 /// [`restart`](Self::restart).
567 ///
568 /// This is the kill half of the W5.c crash-recovery primitive (item 7). It
569 /// deliberately drops **only** the app/runtime, never the durable database:
570 /// the caller holds the sim's DB substrate (e.g. an
571 /// `SqliteSubstrate`) and its `pool()`, so every committed row — crucially
572 /// the durable `autumn_repository_commit_hooks` queue — survives the crash
573 /// and is still there when a fresh app is mounted on the same pool.
574 ///
575 /// A crash after [`build`](Self::build) has not run is a no-op.
576 ///
577 /// # Durability boundary (stated plainly)
578 ///
579 /// Under the `sqlite` sim substrate the app runs the **in-memory `local`
580 /// job backend**, which is **not durable** — a kill drops its mid-flight and
581 /// still-queued jobs by design, exactly as a real process crash would drop an
582 /// in-memory queue. Item 7's durable guarantee is therefore asserted against
583 /// the DB-backed repository commit-hook queue, **not** the local job queue;
584 /// the in-memory job queue's by-design loss is documented, never pretended
585 /// durable. See the [`crash`] module docs.
586 pub fn kill(&mut self) {
587 // Stop the app's tasks, as a process death does. Dropping the client
588 // then runs `TestJobRuntime::drop` (shutdown.cancel() +
589 // clear_global_job_client()).
590 if let Some(client) = self.app.client.take() {
591 abort_app_tasks(&client);
592 drop(client);
593 }
594 // A fresh process has no in-memory chaos decision log; a restart
595 // re-derives it deterministically from the seed.
596 self.chaos_state = None;
597 }
598
599 /// Restart after a [`kill`](Self::kill): mount a fresh `app` on the paused
600 /// runtime, modelling a process restart on the **same durable database**.
601 ///
602 /// The caller rebuilds the `TestApp` against the *same* substrate pool
603 /// (`TestApp::new()…with_db(substrate.pool())`), so the restarted app sees
604 /// every row the crashed process committed. Following the restart with
605 /// [`run_to_idle`](Self::run_to_idle) drains the durable repository
606 /// commit-hook queue, recovering and running any hook the crash left
607 /// un-drained (at-least-once / idempotent). Registering the app's hook
608 /// runners on the fresh app models a real app re-registering them on boot.
609 pub fn restart(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
610 self.mount(app)
611 }
612
613 /// Kill the running app and immediately [`restart`](Self::restart) it on a
614 /// fresh `app` — the kill-then-restart convenience over
615 /// [`kill`](Self::kill) + [`restart`](Self::restart).
616 ///
617 /// The `app` must be rebuilt against the same durable substrate pool so the
618 /// restarted process recovers the crashed one's committed rows.
619 pub fn crash_and_restart(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
620 self.kill();
621 self.restart(app)
622 }
623
624 /// Mount `app` as the replica `replica`, next to the other replicas
625 /// (issue #3067).
626 ///
627 /// Each replica is its own app on the sim's one clock: its own state, job
628 /// runtime and scheduled tasks. It reads its own [`Replica`] clock. Give
629 /// each replica its own pool on one database, so they share data as a
630 /// fleet does:
631 ///
632 /// ```rust,ignore
633 /// let substrate = SqliteSubstrate::new()?;
634 /// for name in ["a", "b"] {
635 /// let pool = substrate.replica_pool(&sim.db_link(name))?;
636 /// sim.mount_replica(name, TestApp::new().with_db(pool).jobs(jobs![work]));
637 /// }
638 /// sim.run_for(Duration::from_secs(30)).await;
639 /// ```
640 ///
641 /// With [`net`](Sim::net) set, the replica is also a [`SimNet`] host under
642 /// its name, so `http://b/` reaches the replica `b`.
643 ///
644 /// Process-wide state is not per replica: the cache, the event bus and the
645 /// global job client belong to the replica mounted last. Enqueue through a
646 /// replica's own `JobClient` extension instead of `job::enqueue`.
647 ///
648 /// # Panics
649 ///
650 /// Panics if a replica with this name is already mounted. A name that
651 /// is the same host name, such as one that differs only in case, is the
652 /// same name. Use
653 /// [`restart_replica`](Sim::restart_replica) to mount it again.
654 pub fn mount_replica(
655 &mut self,
656 replica: impl Into<Replica>,
657 app: crate::test::TestApp,
658 ) -> &crate::test::TestClient {
659 let replica = replica.into();
660 let key = replica_host_key(replica.name());
661 if let Some(slot) = self
662 .replicas
663 .iter()
664 .find(|slot| replica_host_key(&slot.name) == key)
665 {
666 panic!(
667 "replica `{}` is already mounted as `{}`; use `restart_replica` to mount it again",
668 replica.name(),
669 slot.name
670 );
671 }
672 let clock = replica::NodeClock::new(
673 Arc::clone(&self.ambient) as Arc<dyn crate::time::ClockSource>,
674 sim_epoch(),
675 replica.resolve(self.seed),
676 );
677 self.replicas.push(ReplicaSlot {
678 name: replica.name().to_owned(),
679 clock,
680 client: None,
681 #[cfg(feature = "http-client")]
682 net: None,
683 });
684 let index = self.replicas.len() - 1;
685 self.mount_replica_at(index, app)
686 }
687
688 /// Kill the replica `name` if it is alive, then mount `app` in its place.
689 /// The replica keeps its clock, as a restarted process keeps its host's
690 /// clock. Rebuild `app` on the same database to recover its rows.
691 ///
692 /// # Panics
693 ///
694 /// Panics if no replica with this name was mounted.
695 pub fn restart_replica(
696 &mut self,
697 name: &str,
698 app: crate::test::TestApp,
699 ) -> &crate::test::TestClient {
700 self.kill_replica(name);
701 let index = self.expect_replica_index(name);
702 self.mount_replica_at(index, app)
703 }
704
705 /// Kill the replica `name`: stop its tasks and drop its app, as a process
706 /// crash does. A job handler that was running stops at its next await and
707 /// does not settle its claim. The database keeps every committed row. A
708 /// kill of a dead replica changes nothing.
709 ///
710 /// # Panics
711 ///
712 /// Panics if no replica with this name was mounted.
713 pub fn kill_replica(&mut self, name: &str) {
714 let index = self.expect_replica_index(name);
715 let Some(client) = self.replicas[index].client.take() else {
716 return;
717 };
718 #[cfg(feature = "http-client")]
719 if let Some(net) = self.replicas[index].net.take() {
720 net.remove_host(name);
721 }
722 abort_app_tasks(&client);
723 drop(client);
724 }
725
726 /// The client of the live replica `name`.
727 ///
728 /// # Panics
729 ///
730 /// Panics if no live replica has this name.
731 #[must_use]
732 pub fn replica(&self, name: &str) -> &crate::test::TestClient {
733 self.try_replica(name)
734 .unwrap_or_else(|| panic!("no live replica `{name}`: mount it with `mount_replica`"))
735 }
736
737 /// The client of the live replica `name`, or `None`.
738 #[must_use]
739 pub fn try_replica(&self, name: &str) -> Option<&crate::test::TestClient> {
740 self.replica_index(name)
741 .and_then(|index| self.replicas[index].client.as_ref())
742 }
743
744 /// The names of the live replicas, in mount order.
745 #[must_use]
746 pub fn replica_names(&self) -> Vec<&str> {
747 self.replicas
748 .iter()
749 .filter(|slot| slot.client.is_some())
750 .map(|slot| slot.name.as_str())
751 .collect()
752 }
753
754 /// The clock settings of the replica `name` (alive or killed), or `None`.
755 #[must_use]
756 pub fn replica_clock(&self, name: &str) -> Option<ReplicaClock> {
757 self.replica_index(name)
758 .map(|index| self.replicas[index].clock.settings())
759 }
760
761 /// Jump the wall clock of the replica `name` by `by`, as an NTP step does.
762 /// `by` can be negative: a step can go back. The monotonic clock does not
763 /// move. Timers the replica already set do not move.
764 ///
765 /// # Panics
766 ///
767 /// Panics if no replica with this name was mounted.
768 pub fn step_replica_clock(&self, name: &str, by: chrono::TimeDelta) {
769 let index = self.expect_replica_index(name);
770 self.replicas[index].clock.step(by);
771 }
772
773 /// Let `duration` of virtual time pass, one event at a time (issue #3067).
774 ///
775 /// The sim sleeps. Tokio moves its paused clock to the next timer only
776 /// when all tasks wait and no blocking work is queued, such as a `SQLite`
777 /// query. So database work ends before time moves on, and a seed replays
778 /// the same order. With replicas on `SQLite`, use it, not
779 /// [`advance`](Sim::advance).
780 ///
781 /// Replica clocks move with each step. The clock of the app from
782 /// [`build`](Sim::build) moves once, at the end.
783 pub async fn run_for(&self, duration: std::time::Duration) {
784 let guard_start = self.wall_clock_guard_start();
785 let start = tokio::time::Instant::now();
786 tokio::time::sleep(duration).await;
787 let moved = tokio::time::Instant::now().saturating_duration_since(start);
788 // Tokio time already moved, so this is not an explicit advance: the
789 // ambient clock counts it as auto-advanced time.
790 self.clock.advance(moved);
791 self.enforce_wall_clock_budget(guard_start);
792 }
793
794 /// The database link of the replica `name`, made on first use and seeded
795 /// from the sim seed and the name. Pass it to
796 /// [`SqliteSubstrate::replica_pool`](substrate::SqliteSubstrate::replica_pool),
797 /// then fault the replica's database through it. See [`DbLink`].
798 ///
799 /// The link must exist before the mount, so this does not check the name.
800 /// A wrong name gives a link that no replica uses.
801 #[cfg(feature = "sqlite")]
802 #[must_use]
803 pub fn db_link(&self, name: &str) -> DbLink {
804 self.db_links
805 .lock()
806 .unwrap_or_else(std::sync::PoisonError::into_inner)
807 .entry(name.to_owned())
808 .or_insert_with(|| {
809 DbLink::new(name, derive_seed(self.seed, &format!("sim-db-link:{name}")))
810 })
811 .clone()
812 }
813
814 /// The app from [`build`](Sim::build), then each live replica.
815 fn live_clients(&self) -> impl Iterator<Item = &crate::test::TestClient> {
816 self.app
817 .try_client()
818 .into_iter()
819 .chain(self.replicas.iter().filter_map(|slot| slot.client.as_ref()))
820 }
821
822 fn replica_index(&self, name: &str) -> Option<usize> {
823 self.replicas.iter().position(|slot| slot.name == name)
824 }
825
826 fn expect_replica_index(&self, name: &str) -> usize {
827 self.replica_index(name)
828 .unwrap_or_else(|| panic!("no replica `{name}` was mounted"))
829 }
830
831 /// Build `app` for the replica slot `index` and store its client.
832 fn mount_replica_at(
833 &mut self,
834 index: usize,
835 app: crate::test::TestApp,
836 ) -> &crate::test::TestClient {
837 let mount_seed = mount_entropy_seed(self.seed, self.mounts);
838 let first_mount = self.mounts == 0;
839 let app = app.with_default_entropy(SeededEntropy::shared(mount_seed));
840 self.mounts += 1;
841 // One network for every replica: only the first mount seeds it.
842 #[cfg(feature = "http-client")]
843 let app = match &self.net {
844 Some(net) => {
845 if first_mount {
846 net.reseed(mount_seed);
847 }
848 app.with_sim_net(net.clone())
849 }
850 None => app,
851 };
852 #[cfg(not(feature = "http-client"))]
853 let _ = first_mount;
854 let slot = &mut self.replicas[index];
855 #[cfg(feature = "http-client")]
856 let app = if self.net.is_some() {
857 app.trust_host(&slot.name)
858 } else {
859 app
860 };
861 let client = app.with_clock(slot.clock.clone()).build();
862 #[cfg(feature = "http-client")]
863 if let Some(net) = &self.net {
864 net.insert_host(&slot.name, client.router());
865 slot.net = Some(net.clone());
866 }
867 slot.client.insert(client)
868 }
869
870 /// Run `ops` concurrently and poll them in a seeded order (issue #2967).
871 ///
872 /// Each round polls the pending ops in an order drawn from the seed, and
873 /// can hold one back for a round. So ops that share state (a lock, a row, a
874 /// counter) meet in a different order per seed, and the same seed replays
875 /// the same order. Outputs keep the input order.
876 ///
877 /// ```rust,ignore
878 /// let client = sim.client();
879 /// let ops = vec![client.post("/a").send(), client.post("/b").send()];
880 /// let responses = sim.interleave(ops).await;
881 /// ```
882 pub async fn interleave<F: std::future::Future>(&self, ops: Vec<F>) -> Vec<F::Output> {
883 shuffle::Interleave::new(ops, self.next_shuffle_stream()).await
884 }
885
886 /// Spawn `op` on the sim runtime with seeded yields (issue #2967).
887 ///
888 /// Before a poll, the task can yield from the seeded stream, which moves
889 /// it behind the other ready tasks. Tasks spawned this way therefore run
890 /// in a seed-driven order. Tasks the framework spawns keep tokio's order.
891 pub fn spawn<F>(&self, op: F) -> tokio::task::JoinHandle<F::Output>
892 where
893 F: std::future::Future + Send + 'static,
894 F::Output: Send + 'static,
895 {
896 tokio::spawn(shuffle::Shuffled::new(op, self.next_shuffle_stream()))
897 }
898
899 /// The seeded stream for the next shuffler call.
900 fn next_shuffle_stream(&self) -> Arc<dyn Entropy> {
901 let call = self
902 .shuffle_calls
903 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
904 shuffle::stream(self.seed, call)
905 }
906
907 /// The seed-derived [`CrashSchedule`] for this simulation.
908 ///
909 /// A pure function of the [`seed`](Self::seed): two same-seed sims return an
910 /// equal schedule. Pass a point's [`CrashPoint::await_index`] to
911 /// [`crash_at`] to crash there.
912 #[must_use]
913 pub fn crash_schedule(&self) -> CrashSchedule {
914 CrashSchedule::derive(self.seed, crash::DEFAULT_CRASH_SCHEDULE_LEN)
915 }
916
917 /// The first entry of the seed-derived
918 /// [`crash_schedule`](Self::crash_schedule).
919 ///
920 /// `None` only if the schedule is empty (it never is under the default
921 /// length). Deterministic for a given seed.
922 #[must_use]
923 pub fn crash_point(&self) -> Option<CrashPoint> {
924 self.crash_schedule().first().cloned()
925 }
926
927 /// The recorded chaos fault schedule for this simulation.
928 ///
929 /// Returns one [`ChaosEvent`] per chaos-hook invocation, in the order the
930 /// hooks fired — the reproducible *fault schedule* the run produced. Empty
931 /// when chaos was inactive or [`build`](Sim::build) has not run.
932 ///
933 /// Unstable sim plumbing (hidden from the stable surface, like the module's
934 /// other `__`-prefixed hooks); the W5 Definition-of-Done test asserts two
935 /// same-seed runs return equal schedules.
936 #[doc(hidden)]
937 #[must_use]
938 pub fn __chaos_events(&self) -> Vec<ChaosEvent> {
939 self.chaos_state
940 .as_ref()
941 .map(|state| state.events())
942 .unwrap_or_default()
943 }
944
945 /// Borrow the [`crate::test::TestClient`] mounted by [`build`](Sim::build).
946 ///
947 /// # Panics
948 ///
949 /// Panics if [`build`](Sim::build) has not been called yet.
950 #[must_use]
951 pub fn client(&self) -> &crate::test::TestClient {
952 self.app.client()
953 }
954
955 /// Borrow the mounted [`crate::test::TestClient`], or `None` before
956 /// [`build`](Sim::build).
957 #[must_use]
958 pub const fn try_client(&self) -> Option<&crate::test::TestClient> {
959 self.app.try_client()
960 }
961
962 /// Enable the **real-time leak guard** (`strict_wall_clock`) with the
963 /// default budget, panicking if a paused-sim step burns more than that much
964 /// *real* wall-clock time.
965 ///
966 /// # What this guards (and what it deliberately does not)
967 ///
968 /// A paused sim runs on tokio's virtual timer: a
969 /// [`tokio::time::sleep`](https://docs.rs/tokio) / job backoff / delayed
970 /// enqueue consumes **zero** real time and only advances when
971 /// [`advance`](Sim::advance) steps the clock. The one thing that breaks that
972 /// invariant is code that escapes the virtual timer and blocks the real
973 /// thread — a `std::thread::sleep`, a `spawn_blocking`, or a blocking
974 /// syscall. This guard catches the **observable consequence** of that: real
975 /// wall-clock time leaking into a step that should have taken virtually none.
976 ///
977 /// It is **not** off-seam-read detection. It cannot tell you that some code
978 /// read `Utc::now()` / `Instant::now()` directly instead of through the
979 /// injected clock — a free-function `now()` call has no runtime interception
980 /// point in safe Rust, so its *absence* is not observable at runtime. Finding
981 /// those reads is a static-analysis (deny-lint) job; this guard is the
982 /// complementary runtime backstop for the worst pattern (a real blocking
983 /// sleep). Enabling it does not slow a legitimate virtual advance: jumping a
984 /// day of virtual time still costs microseconds of real time, well under
985 /// budget.
986 ///
987 /// # Budget & the `AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS` override
988 ///
989 /// The default budget is deliberately generous (2000 ms) so ordinary CI
990 /// scheduling jitter never trips it — the target is a *real* sleep (seconds),
991 /// not sub-millisecond noise. Set the environment variable
992 /// `AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS` (whole milliseconds) to override
993 /// the budget globally for a run; a blank or unparseable value is ignored and
994 /// the default (or the value passed to
995 /// [`strict_wall_clock_budget`](Sim::strict_wall_clock_budget)) applies. This
996 /// mirrors the `AUTUMN_SIM_SEED` idiom, so a too-tight budget on a slow
997 /// runner can be loosened without editing test code.
998 ///
999 /// The guard is read-only during the run: enable it (chainably) *before* the
1000 /// first [`advance`](Sim::advance) / [`run_to_idle`](Sim::run_to_idle).
1001 ///
1002 /// ```rust,ignore
1003 /// let mut sim = Sim::from_seed(0);
1004 /// sim.strict_wall_clock();
1005 /// sim.build(TestApp::new());
1006 /// sim.advance(std::time::Duration::from_secs(24 * 3600)).await; // virtual, cheap
1007 /// sim.run_to_idle().await;
1008 /// ```
1009 pub fn strict_wall_clock(&mut self) -> &mut Self {
1010 self.strict_budget = Some(strict_budget_from_env_or(DEFAULT_STRICT_WALL_CLOCK_BUDGET));
1011 self
1012 }
1013
1014 /// Enable the real-time leak guard with an explicit `budget`.
1015 ///
1016 /// Identical to [`strict_wall_clock`](Sim::strict_wall_clock) but starts from
1017 /// `budget` instead of the 100 ms default. The
1018 /// `AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS` environment variable still
1019 /// overrides `budget` when it holds a valid whole-millisecond value (so CI
1020 /// can loosen the guard globally); a blank/unparseable value leaves `budget`
1021 /// in effect. See [`strict_wall_clock`](Sim::strict_wall_clock) for what the
1022 /// guard does and does not catch.
1023 ///
1024 /// ```rust,ignore
1025 /// let mut sim = Sim::from_seed(0);
1026 /// sim.strict_wall_clock_budget(std::time::Duration::from_millis(5));
1027 /// ```
1028 pub fn strict_wall_clock_budget(&mut self, budget: std::time::Duration) -> &mut Self {
1029 self.strict_budget = Some(strict_budget_from_env_or(budget));
1030 self
1031 }
1032
1033 /// Sample a real [`std::time::Instant`] at the start of a guarded step, or
1034 /// `None` when the leak guard is off. Held across the step's `.await`s (an
1035 /// `Instant` is `Send + Sync`, so the guarded `&self` futures stay `Send`).
1036 fn wall_clock_guard_start(&self) -> Option<std::time::Instant> {
1037 self.strict_budget.map(|_| std::time::Instant::now())
1038 }
1039
1040 /// Enforce the real-time leak guard at the end of a step: panic if more than
1041 /// the configured budget of *real* wall-clock time elapsed since `start`.
1042 ///
1043 /// A no-op when the guard is off (`start` / `strict_budget` are `None`). The
1044 /// panic flows up through the [`#[sim_test]`](crate::sim_test) macro's
1045 /// `catch_unwind`, so the `AUTUMN_SIM_SEED=…` replay line still prints.
1046 ///
1047 /// # Panics
1048 ///
1049 /// Panics when the leak guard is enabled and real elapsed time exceeds the
1050 /// budget (see [`strict_wall_clock`](Sim::strict_wall_clock)).
1051 fn enforce_wall_clock_budget(&self, start: Option<std::time::Instant>) {
1052 if let (Some(budget), Some(start)) = (self.strict_budget, start) {
1053 let elapsed = start.elapsed();
1054 assert!(
1055 elapsed <= budget,
1056 "Sim::strict_wall_clock real-time leak guard tripped: {elapsed:?} of real \
1057 wall-clock time elapsed inside a paused-sim step, exceeding the {budget:?} \
1058 budget. This means real time leaked into the virtual timeline — usually a real \
1059 `std::thread::sleep`, blocking I/O, or `spawn_blocking` that escaped tokio's \
1060 paused timer. If this is CI scheduling jitter rather than a genuine leak, raise \
1061 the budget via the AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS environment variable."
1062 );
1063 }
1064 }
1065
1066 /// Advance virtual time by `duration`, stepping the injected wall clock and
1067 /// tokio's paused timer wheel **together**.
1068 ///
1069 /// The framework [`crate::time::Clock`] extractor (backed by the
1070 /// simulation's virtual clock) and tokio's virtual timer (`tokio::time::sleep`,
1071 /// job backoff delays, delayed enqueues) move by exactly the same amount, so
1072 /// `Utc::now()`-via-extractor and a sleeping task stay in lockstep — a job
1073 /// whose retry backs off 24 hours fires the instant this advances 24 hours,
1074 /// with zero wall-clock delay. Timers that come due within the window fire
1075 /// and their tasks are polled before this returns.
1076 ///
1077 /// Pair with [`run_to_idle`](Sim::run_to_idle) to then drain the work the
1078 /// fired timers enqueued.
1079 pub async fn advance(&self, duration: std::time::Duration) {
1080 // Real-time leak guard (no-op unless `strict_wall_clock` is enabled):
1081 // sample a REAL instant (not tokio's paused time) at entry and check the
1082 // real elapsed against the budget before returning. `advance_to` routes
1083 // through here, so it inherits the guard for free.
1084 let guard_start = self.wall_clock_guard_start();
1085 // `advance` yields and never parks, so database work runs without the
1086 // gate's turns, as before the gate (#3067).
1087 let _open = gate::Open::new();
1088 // Let every ready task take one step at the current instant before time
1089 // moves. A task spawned since the last yield (a `#[scheduled]` loop that
1090 // `build` started, a job worker) then registers its first timer at the
1091 // instant it was started, not at the end of this advance.
1092 tokio::task::yield_now().await;
1093 // Step the framework clock first so any task woken by the tokio timer
1094 // that reads the clock observes the already-advanced instant.
1095 self.clock.advance(duration);
1096 // Advance tokio's paused timer wheel; this fires due timers and yields
1097 // so their tasks are polled before returning.
1098 // `tokio::time::advance` moves the clock in its first poll, the same
1099 // poll as this note, and only then yields.
1100 note_own_advance(&self.ambient, duration);
1101 tokio::time::advance(duration).await;
1102 self.enforce_wall_clock_budget(guard_start);
1103 }
1104
1105 /// Advance virtual time **to** a specific zoned instant, resolving the
1106 /// timezone (including any DST transition) to the correct UTC instant and
1107 /// then stepping forward by the delta from the current sim instant.
1108 ///
1109 /// This is the timezone/DST-aware companion to [`advance`](Sim::advance):
1110 /// where `advance` takes a raw [`std::time::Duration`], `advance_to` takes a
1111 /// *wall-clock target in any timezone* and computes the real (UTC) delta for
1112 /// you. It is generic over any [`chrono::TimeZone`] — pass a
1113 /// `chrono::DateTime<Utc>`, a `chrono::DateTime<chrono::FixedOffset>`, or a
1114 /// `chrono::DateTime<chrono_tz::Tz>` from the `chrono-tz` crate — so a caller
1115 /// can express a zoned target without this crate hard-depending on any
1116 /// particular timezone database.
1117 ///
1118 /// The target is converted to UTC via [`chrono::DateTime::with_timezone`],
1119 /// which is unambiguous (every zoned `DateTime` already names a single
1120 /// instant), and the sim then reuses [`advance`](Sim::advance) internally so
1121 /// the injected wall clock and tokio's paused timer wheel stay in **exact
1122 /// lockstep** — any `tokio::time::sleep` / job-backoff timer whose deadline
1123 /// falls inside the crossed wall-clock window (a DST "spring-forward" gap
1124 /// included) fires during the advance, then its task is polled before this
1125 /// returns. Because the injected clock is UTC and monotonic, a spring-forward
1126 /// boundary is just a shorter real interval — the timers inside it still fire
1127 /// correctly.
1128 ///
1129 /// # Forward-only semantics
1130 ///
1131 /// Virtual time never moves backward:
1132 ///
1133 /// - **Target equals the current sim instant** → this is a **no-op** (no
1134 /// clock/timer step at all).
1135 /// - **Target is strictly before the current sim instant** → this
1136 /// **panics** with a clear message. Silently doing nothing would hide a
1137 /// test bug (a target computed to be in the past almost always means the
1138 /// test's arithmetic is wrong), so the panic is deliberate.
1139 ///
1140 /// Pair with [`run_to_idle`](Sim::run_to_idle) afterward to drain the work
1141 /// the fired timers enqueued.
1142 ///
1143 /// # Panics
1144 ///
1145 /// Panics if `target` resolves to a UTC instant strictly before the current
1146 /// sim instant (see *Forward-only semantics*).
1147 ///
1148 /// ```rust,ignore
1149 /// use chrono::{TimeZone, Utc};
1150 /// // Advance the sim clock to a specific UTC instant.
1151 /// let target = Utc.with_ymd_and_hms(2020, 3, 8, 12, 0, 0).unwrap();
1152 /// sim.advance_to(&target).await;
1153 /// sim.run_to_idle().await;
1154 /// ```
1155 ///
1156 /// Written as a non-`async fn` returning a future so the generic zoned
1157 /// `target` is resolved to UTC **synchronously** and never captured across an
1158 /// `.await` — the returned future holds only `&self` and the resolved
1159 /// `DateTime<Utc>`, so it stays `Send` for any `Tz` (a borrowed or non-`Sync`
1160 /// `Tz` would otherwise poison the future).
1161 pub fn advance_to<Tz>(
1162 &self,
1163 target: &DateTime<Tz>,
1164 ) -> impl std::future::Future<Output = ()> + '_
1165 where
1166 Tz: TimeZone,
1167 {
1168 let target_utc = target.with_timezone(&Utc);
1169 self.advance_to_utc(target_utc)
1170 }
1171
1172 /// Advance virtual time to a **naive local wall-clock time** interpreted in
1173 /// timezone `tz`, resolving DST edge cases explicitly (no `.unwrap()` on a
1174 /// [`chrono::LocalResult`]).
1175 ///
1176 /// Convenience wrapper over [`advance_to`](Sim::advance_to) for the common
1177 /// "advance to 2:30 AM local on this date" shape, where the naive local time
1178 /// must be mapped to a single UTC instant. `tz` is any
1179 /// [`chrono::TimeZone`] (e.g. a `chrono_tz::Tz`); the forward-only /
1180 /// panic-on-past semantics of [`advance_to`](Sim::advance_to) apply once the
1181 /// instant is resolved.
1182 ///
1183 /// # DST resolution (deterministic)
1184 ///
1185 /// A naive local time need not correspond to exactly one UTC instant:
1186 ///
1187 /// - **Unambiguous** ([`LocalResult::Single`])
1188 /// → that instant.
1189 /// - **Fall-back / ambiguous** ([`LocalResult::Ambiguous`],
1190 /// the wall time occurs twice as the clock rolls back) → the **earlier**
1191 /// of the two UTC instants.
1192 /// - **Spring-forward gap** ([`LocalResult::None`],
1193 /// the wall time never occurs because the clock jumps forward) → the
1194 /// nonexistent wall time is carried **forward across** the gap to a single
1195 /// deterministic post-transition instant (it is resolved by looking up the
1196 /// zone's offset at the matching UTC-clock reading and applying it, which
1197 /// shifts the requested time past the boundary rather than erroring). For
1198 /// example a request for the nonexistent `02:30` on a US spring-forward
1199 /// day resolves to `03:30` local (the same instant, one gap-length later).
1200 ///
1201 /// # Panics
1202 ///
1203 /// Panics if the resolved instant is strictly before the current sim instant
1204 /// (see [`advance_to`](Sim::advance_to)).
1205 ///
1206 /// ```rust,ignore
1207 /// use chrono::NaiveDate;
1208 /// let local = NaiveDate::from_ymd_opt(2020, 3, 8).unwrap()
1209 /// .and_hms_opt(3, 30, 0).unwrap();
1210 /// sim.advance_to_local(local, &chrono_tz::America::New_York).await;
1211 /// ```
1212 ///
1213 /// Like [`advance_to`](Sim::advance_to), this is a non-`async fn` returning a
1214 /// future: the `&tz` reference is used only while resolving the instant
1215 /// synchronously and is **not** captured by the returned future, so the
1216 /// future stays `Send` even though `Tz` need not be `Sync`.
1217 pub fn advance_to_local<Tz>(
1218 &self,
1219 local: NaiveDateTime,
1220 tz: &Tz,
1221 ) -> impl std::future::Future<Output = ()> + '_
1222 where
1223 Tz: TimeZone,
1224 {
1225 self.advance_to_utc(resolve_local_to_utc(local, tz))
1226 }
1227
1228 /// Shared UTC-target advance used by [`advance_to`](Sim::advance_to) and
1229 /// [`advance_to_local`](Sim::advance_to_local): plan the step against the
1230 /// current sim instant, then reuse [`advance`](Sim::advance) so the clock and
1231 /// timer wheel stay in lockstep.
1232 async fn advance_to_utc(&self, target: DateTime<Utc>) {
1233 match plan_advance_to(self.clock.now(), target) {
1234 AdvancePlan::NoOp => {}
1235 AdvancePlan::Advance(delta) => self.advance(delta).await,
1236 }
1237 }
1238
1239 /// Drain all ready work — enqueued jobs the in-process runtime can run now,
1240 /// plus tasks woken by timers that have already come due — until the runtime
1241 /// is quiescent.
1242 ///
1243 /// The sim runtime is a single-threaded, current-thread runtime with the
1244 /// clock paused, so background tasks (the job worker consuming its queue, a
1245 /// retry timer that just fired, a delayed enqueue delivering) make progress
1246 /// only when the running task yields. This cooperatively yields for
1247 /// `MAX_DRAIN_STEPS` rounds.
1248 ///
1249 /// It does **not** fast-forward to a *future* timer — advancing the clock to
1250 /// reach a not-yet-due backoff/sleep is [`advance`](Sim::advance)'s job
1251 /// (tokio exposes no next-deadline hook, and a blind auto-advance would
1252 /// break the clock lockstep). The idiom is therefore
1253 /// [`advance`](Sim::advance) to the next interesting instant, then
1254 /// `run_to_idle` to settle the work it released.
1255 ///
1256 /// # Panics
1257 ///
1258 /// Panics with the seed when the drain does not settle: work still ran in
1259 /// its last rounds (issue #2967). A job that enqueues itself again is the
1260 /// usual cause. Use [`try_run_to_idle`](Sim::try_run_to_idle) to get the
1261 /// [`SimStall`] instead.
1262 pub async fn run_to_idle(&self) {
1263 if let Err(stall) = self.try_run_to_idle().await {
1264 panic!("{stall}");
1265 }
1266 }
1267
1268 /// Like [`run_to_idle`](Sim::run_to_idle), but returns the stall.
1269 ///
1270 /// # Errors
1271 ///
1272 /// Returns [`SimStall`] when the drain never sees 64 quiet rounds in a row
1273 /// within 2048 rounds. A round is quiet when no job starts, no scheduled
1274 /// tick fires, no commit hook or outbox message drains and the task count
1275 /// does not change.
1276 /// A single long-running job, or a task that loops without spawning,
1277 /// does not count as work.
1278 pub async fn try_run_to_idle(&self) -> Result<(), SimStall> {
1279 // Real-time leak guard (no-op unless `strict_wall_clock` is enabled):
1280 // sample a REAL instant at entry and enforce the budget before
1281 // returning, catching a real blocking sleep in a drained task.
1282 let guard_start = self.wall_clock_guard_start();
1283 // The drain yields and never parks, so database work runs without the
1284 // gate's turns, as before the gate (#3067).
1285 let _open = gate::Open::new();
1286
1287 // Resolve the mounted app's DB pool once (a cheap, cloned Arc-backed
1288 // handle). Durable repository commit hooks are rows, so there is
1289 // nothing to drain when the app was built without a database (e.g. the
1290 // in-memory job DoD path) — the lane is skipped entirely then.
1291 #[cfg(feature = "db")]
1292 let commit_hook_pools: Vec<_> = self
1293 .live_clients()
1294 .filter_map(|client| crate::db::DbState::pool(client.state()).cloned())
1295 .collect();
1296
1297 // Run the full `MAX_DRAIN_STEPS`. Then stop at the first
1298 // `STALL_WINDOW` quiet rounds in a row; work that ends late still
1299 // settles. Work that never goes quiet is a stall.
1300 let mut quiet_rounds = 0;
1301 let mut rounds = 0;
1302 while rounds < MAX_DRAIN_STEPS
1303 || (quiet_rounds < STALL_WINDOW && rounds < MAX_DRAIN_STEPS * 2)
1304 {
1305 rounds += 1;
1306 let before = drain_fingerprint();
1307 // One yield lets each currently-ready spawned task take a step; a
1308 // zero-duration timer advance flushes any timers registered for the
1309 // current instant and yields again, so a chain of ready timer/task
1310 // wakeups settles without advancing the clock. This quiesces the
1311 // in-process job runtime (TestJobRuntime / JobAdminMemoryBackend)
1312 // and any tasks woken by already-due scheduler ticks.
1313 tokio::task::yield_now().await;
1314 tokio::time::advance(std::time::Duration::ZERO).await;
1315
1316 // Third ready-work source: durable repository commit hooks. Drain
1317 // the ready set through the public test-harness wrapper
1318 // (`crate::test::drain_ready_repository_commit_hooks`), which runs
1319 // the same claim → run → ack wiring the background commit-hook
1320 // worker uses, but deterministically and worker-free. A hook may
1321 // itself enqueue a job, so draining inside the settle loop lets a
1322 // subsequent iteration pick that job up.
1323 #[cfg(feature = "db")]
1324 for pool in &commit_hook_pools {
1325 let hooks_drained =
1326 crate::test::drain_ready_repository_commit_hooks(pool, MAX_DRAIN_STEPS).await;
1327 if hooks_drained > 0 {
1328 note_drain_progress();
1329 }
1330 }
1331
1332 // Fourth source: the transactional outbox (issue #3062). The same
1333 // claim → handle → mark path the relay worker runs, worker-free.
1334 #[cfg(feature = "db")]
1335 for state in self
1336 .live_clients()
1337 .map(crate::test::TestClient::state)
1338 .filter(|state| state.extension::<crate::outbox::OutboxRelay>().is_some())
1339 {
1340 match crate::outbox::drain(state, MAX_DRAIN_STEPS).await {
1341 Ok(0) => {}
1342 Ok(_) => note_drain_progress(),
1343 Err(error) => tracing::warn!(%error, "sim outbox drain failed"),
1344 }
1345 }
1346
1347 if drain_fingerprint() == before {
1348 quiet_rounds += 1;
1349 } else {
1350 quiet_rounds = 0;
1351 }
1352 }
1353
1354 self.enforce_wall_clock_budget(guard_start);
1355 if quiet_rounds < STALL_WINDOW {
1356 return Err(SimStall {
1357 seed: self.seed,
1358 steps: rounds,
1359 });
1360 }
1361 Ok(())
1362 }
1363}
1364
1365impl Drop for Sim {
1366 fn drop(&mut self) {
1367 // A replica is a host of the sim network, and its app holds the
1368 // network: a cycle. Remove the hosts, so the replica apps drop.
1369 #[cfg(feature = "http-client")]
1370 for slot in &mut self.replicas {
1371 if let Some(net) = slot.net.take() {
1372 net.remove_host(&slot.name);
1373 }
1374 }
1375 // The runtime drops the app tasks after the sim. A task with a query
1376 // in flight then waits for it, so let gated queries run (#3067).
1377 self.gate_seat.leave();
1378 }
1379}
1380
1381/// A [`Sim::run_to_idle`] drain that did not settle (issue #2967).
1382///
1383/// Work still ran in the last rounds of the drain, so the app was not idle
1384/// when the drain gave up.
1385#[non_exhaustive]
1386#[derive(Debug, Clone, PartialEq, Eq)]
1387pub struct SimStall {
1388 /// The seed of the stalled run.
1389 pub seed: u64,
1390 /// The drain rounds that ran.
1391 pub steps: usize,
1392}
1393
1394impl std::fmt::Display for SimStall {
1395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1396 write!(
1397 f,
1398 "sim drain stall: work still ran after {steps} drain rounds (seed=0x{seed:x}). \
1399 A job, hook or task keeps making new work, so the app is never idle. \
1400 Replay with AUTUMN_SIM_SEED=0x{seed:x}.",
1401 steps = self.steps,
1402 seed = self.seed,
1403 )
1404 }
1405}
1406
1407impl std::error::Error for SimStall {}
1408
1409thread_local! {
1410 /// Work the drain can see on this thread: jobs run, ticks fired, hooks
1411 /// drained. [`Sim::try_run_to_idle`] compares it across a round.
1412 static DRAIN_PROGRESS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1413}
1414
1415/// Record one unit of drain-visible work on this thread. The job runner, the
1416/// task scheduler and the commit-hook drain call it.
1417pub(crate) fn note_drain_progress() {
1418 DRAIN_PROGRESS.with(|progress| progress.set(progress.get().wrapping_add(1)));
1419}
1420
1421/// This thread's drain progress plus the runtime's live task count. A change
1422/// across a drain round means work ran in it.
1423fn drain_fingerprint() -> (u64, usize) {
1424 let tasks = tokio::runtime::Handle::try_current()
1425 .map_or(0, |handle| handle.metrics().num_alive_tasks());
1426 (DRAIN_PROGRESS.with(std::cell::Cell::get), tasks)
1427}
1428
1429/// The fixed sim epoch as a `DateTime`.
1430fn sim_epoch() -> DateTime<Utc> {
1431 Utc.timestamp_opt(SIM_EPOCH_UNIX_SECS, 0)
1432 .single()
1433 .unwrap_or_else(|| Utc.timestamp_nanos(0))
1434}
1435
1436/// A `u64` derived from `seed` and `purpose`.
1437#[cfg(feature = "sqlite")]
1438fn derive_seed(seed: u64, purpose: &str) -> u64 {
1439 SeededEntropy::new(seed)
1440 .derive_uuid(purpose)
1441 .as_u64_pair()
1442 .0
1443}
1444
1445/// The background tasks one app spawned (issue #3067).
1446///
1447/// `TestApp::build` puts one in the app state. The job and scheduler loops
1448/// register here, so a sim kill can stop them as a process death does. A
1449/// production app has none, and its tasks run as before.
1450#[derive(Default)]
1451pub(crate) struct AppTasks {
1452 handles: std::sync::Mutex<Vec<tokio::task::AbortHandle>>,
1453}
1454
1455impl AppTasks {
1456 fn track(&self, handle: tokio::task::AbortHandle) {
1457 let mut handles = self
1458 .handles
1459 .lock()
1460 .unwrap_or_else(std::sync::PoisonError::into_inner);
1461 handles.retain(|handle| !handle.is_finished());
1462 handles.push(handle);
1463 }
1464
1465 fn abort_all(&self) {
1466 let handles = std::mem::take(
1467 &mut *self
1468 .handles
1469 .lock()
1470 .unwrap_or_else(std::sync::PoisonError::into_inner),
1471 );
1472 for handle in handles {
1473 handle.abort();
1474 }
1475 }
1476}
1477
1478/// Spawn an app background task. In a test app, a sim kill stops it.
1479pub(crate) fn spawn_app_task<F>(
1480 state: &crate::state::AppState,
1481 future: F,
1482) -> tokio::task::JoinHandle<F::Output>
1483where
1484 F: std::future::Future + Send + 'static,
1485 F::Output: Send + 'static,
1486{
1487 let handle = tokio::spawn(future);
1488 if let Some(tasks) = state.extension::<AppTasks>() {
1489 tasks.track(handle.abort_handle());
1490 }
1491 handle
1492}
1493
1494/// Stop every background task of the app behind `client`.
1495fn abort_app_tasks(client: &crate::test::TestClient) {
1496 if let Some(tasks) = client.state().extension::<AppTasks>() {
1497 tasks.abort_all();
1498 }
1499}
1500
1501/// The entropy seed for the `mount`-th app a simulation mounts.
1502///
1503/// Mount 0 uses `seed` itself, so the default equals
1504/// `with_entropy(SeededEntropy::new(sim.seed))`. A later mount (a restart after
1505/// [`Sim::kill`]) derives its seed from `seed` and the mount number: a real
1506/// restarted process draws new ids, and this keeps them seed-driven.
1507fn mount_entropy_seed(seed: u64, mount: u64) -> u64 {
1508 if mount == 0 {
1509 return seed;
1510 }
1511 let derived = SeededEntropy::new(seed).derive_uuid(format!("sim-mount-{mount}"));
1512 let bytes: [u8; 8] = derived.as_bytes()[..8]
1513 .try_into()
1514 .expect("a uuid has 16 bytes");
1515 u64::from_le_bytes(bytes)
1516}
1517
1518/// The longest timer tokio accepts (about 2.2 years). A larger liveness
1519/// budget is clamped to it.
1520const MAX_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(68_719_476_734);
1521
1522/// Resolve the liveness budget from the raw `AUTUMN_SIM_LIVENESS_BUDGET_SECS`
1523/// value. A positive whole number of seconds arms the watchdog. Unset, blank,
1524/// `0` or unparseable leaves it off.
1525fn parse_liveness_budget(raw: Option<&str>) -> Option<std::time::Duration> {
1526 let secs = raw.map(str::trim)?.parse::<u64>().ok()?;
1527 if secs == 0 {
1528 return None;
1529 }
1530 Some(std::time::Duration::from_secs(secs).min(MAX_LIVENESS_BUDGET))
1531}
1532
1533/// Run a `#[sim_test]` body under the liveness watchdog, when
1534/// `AUTUMN_SIM_LIVENESS_BUDGET_SECS` arms it.
1535///
1536/// Hidden macro plumbing, like [`__seed_from_env`]. See
1537/// [`__with_liveness_budget`] for what the watchdog detects.
1538#[doc(hidden)]
1539pub async fn __with_liveness_watchdog<F: std::future::Future>(seed: u64, body: F) -> F::Output {
1540 // `#[sim_test]` builds the sim before its runtime. Start its elapsed
1541 // time as the runtime starts, before the body runs.
1542 anchor_current_sim();
1543 let raw = std::env::var("AUTUMN_SIM_LIVENESS_BUDGET_SECS").ok();
1544 __with_liveness_budget(seed, parse_liveness_budget(raw.as_deref()), body).await
1545}
1546
1547/// Run `body`, and panic with a message that names `seed` when it is still
1548/// running after `budget` of virtual time. `None` runs `body` with no watchdog.
1549///
1550/// A deadlock parks every task with no timer that could wake one, so without a
1551/// watchdog the test hangs. The watchdog is itself a timer, so the paused
1552/// runtime advances straight to it and the test fails at once; the
1553/// `#[sim_test]` macro then prints the replay line.
1554///
1555/// Two limits. A busy loop that never parks keeps the runtime from advancing,
1556/// so it is not detected. And the paused runtime also advances while a task
1557/// waits on something outside it (a lock another thread holds, real I/O), so
1558/// arm the watchdog only where sim tests run one at a time
1559/// (`--test-threads=1`) and do no real I/O.
1560#[doc(hidden)]
1561pub async fn __with_liveness_budget<F: std::future::Future>(
1562 seed: u64,
1563 budget: Option<std::time::Duration>,
1564 body: F,
1565) -> F::Output {
1566 let Some(budget) = budget else {
1567 return body.await;
1568 };
1569 tokio::time::timeout(budget, body)
1570 .await
1571 .unwrap_or_else(|_elapsed| {
1572 panic!(
1573 "sim liveness: the test body did not finish within {budget:?} of virtual time \
1574 (seed=0x{seed:x}). Every task was parked with no timer to wake one, which is \
1575 a deadlock, or the body waited past the budget. Raise \
1576 AUTUMN_SIM_LIVENESS_BUDGET_SECS for a legitimately long run."
1577 )
1578 })
1579}
1580
1581/// Upper bound on cooperative yield rounds [`Sim::run_to_idle`] performs before
1582/// returning, so a misbehaving always-ready task can never hang the drain.
1583/// Generous relative to the handful of hops a job takes from the queue through
1584/// its handler to completion under the single-threaded paused runtime.
1585const MAX_DRAIN_STEPS: usize = 1024;
1586
1587/// Quiet rounds in a row that settle a drain after `MAX_DRAIN_STEPS`. A drain
1588/// that does not reach them by twice `MAX_DRAIN_STEPS` is a [`SimStall`].
1589const STALL_WINDOW: usize = 64;
1590
1591/// Default real wall-clock budget for the `strict_wall_clock` leak guard
1592/// ([`Sim::strict_wall_clock`]).
1593///
1594/// Deliberately generous (2000 ms): the guard exists to catch a *real* blocking
1595/// sleep escaping the virtual timer (seconds of wall time), so the budget must
1596/// sit far above ordinary current-thread scheduling jitter to avoid false
1597/// positives on a slow/contended CI runner. A legitimate virtual advance — even
1598/// jumping a day of virtual time — costs microseconds of real time, orders of
1599/// magnitude under this.
1600const DEFAULT_STRICT_WALL_CLOCK_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
1601
1602/// Resolve the effective `strict_wall_clock` budget: the
1603/// `AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS` environment override when it holds a
1604/// valid whole-millisecond value, otherwise `default`.
1605///
1606/// Mirrors the [`__seed_from_env`] / [`parse_seed`] idiom so a too-tight budget
1607/// on a slow runner can be loosened globally without editing test code; a blank
1608/// or unparseable value falls back to `default`.
1609fn strict_budget_from_env_or(default: std::time::Duration) -> std::time::Duration {
1610 std::env::var("AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS")
1611 .ok()
1612 .and_then(|raw| parse_strict_budget_ms(&raw))
1613 .unwrap_or(default)
1614}
1615
1616/// Parse a whole-millisecond budget string into a [`std::time::Duration`],
1617/// returning `None` for empty/whitespace-only or unparseable input (so the
1618/// caller can fall back to the configured default).
1619fn parse_strict_budget_ms(raw: &str) -> Option<std::time::Duration> {
1620 let trimmed = raw.trim();
1621 if trimmed.is_empty() {
1622 return None;
1623 }
1624 trimmed
1625 .parse::<u64>()
1626 .ok()
1627 .map(std::time::Duration::from_millis)
1628}
1629
1630/// The forward-only step [`Sim::advance_to`] resolves a target instant into.
1631///
1632/// Kept as a small pure enum (rather than inlining the branch) so the
1633/// forward-only / no-op / panic-on-past decision is unit-testable without a
1634/// runtime or a paused clock.
1635#[derive(Debug, PartialEq, Eq)]
1636enum AdvancePlan {
1637 /// Target equals the current instant — advancing does nothing.
1638 NoOp,
1639 /// Target is in the future — step forward by exactly this real delta.
1640 Advance(std::time::Duration),
1641}
1642
1643/// Decide how to advance from `now` to `target` under the forward-only clock
1644/// contract of [`Sim::advance_to`].
1645///
1646/// Returns [`AdvancePlan::NoOp`] when `target == now` and
1647/// [`AdvancePlan::Advance`] with the positive delta when `target` is in the
1648/// future.
1649///
1650/// # Panics
1651///
1652/// Panics when `target` is strictly before `now`: virtual time is forward-only,
1653/// and a past target signals a test-arithmetic bug that silent no-op behavior
1654/// would hide.
1655fn plan_advance_to(now: DateTime<Utc>, target: DateTime<Utc>) -> AdvancePlan {
1656 let delta = target - now;
1657 match delta.cmp(&chrono::Duration::zero()) {
1658 std::cmp::Ordering::Equal => AdvancePlan::NoOp,
1659 std::cmp::Ordering::Less => panic!(
1660 "Sim::advance_to target {target} is strictly before the current sim instant {now}; \
1661 virtual time is forward-only (advancing to a past instant is a test bug)"
1662 ),
1663 std::cmp::Ordering::Greater => AdvancePlan::Advance(
1664 delta
1665 .to_std()
1666 .expect("a strictly-positive chrono delta always converts to std::time::Duration"),
1667 ),
1668 }
1669}
1670
1671/// Resolve a naive local wall-clock time in timezone `tz` to a single UTC
1672/// instant, handling DST edges deterministically (see
1673/// [`Sim::advance_to_local`] for the documented policy).
1674///
1675/// Ambiguous (fall-back) local times resolve to the **earlier** instant; a
1676/// nonexistent (spring-forward gap) local time is carried across the gap using
1677/// the post-transition UTC offset. Never `.unwrap()`s a
1678/// [`chrono::LocalResult`].
1679fn resolve_local_to_utc<Tz>(local: NaiveDateTime, tz: &Tz) -> DateTime<Utc>
1680where
1681 Tz: TimeZone,
1682{
1683 match tz.from_local_datetime(&local) {
1684 LocalResult::Single(dt) => dt.with_timezone(&Utc),
1685 LocalResult::Ambiguous(earlier, _later) => earlier.with_timezone(&Utc),
1686 LocalResult::None => {
1687 // Spring-forward gap: the wall time never occurs. Resolve it by
1688 // looking up the zone's offset at the UTC-clock reading numerically
1689 // equal to the wall time and applying it — a total, deterministic
1690 // mapping that carries the nonexistent time forward across the gap to
1691 // a single post-transition instant.
1692 let offset_secs =
1693 i64::from(tz.offset_from_utc_datetime(&local).fix().local_minus_utc());
1694 (local - chrono::Duration::seconds(offset_secs)).and_utc()
1695 }
1696 }
1697}
1698
1699/// A seeded, deterministic random number generator handle.
1700///
1701/// Wraps a `ChaCha8Rng` seeded from the simulation seed, so the same seed
1702/// always yields the same draw sequence. Draw deterministic bytes and UUIDs
1703/// through the generation helpers below; they share their `Uuid` bit-stamping
1704/// with the [`crate::entropy::Entropy`] source an app is seeded with, so a
1705/// `SimRng` draw and an equivalently-seeded app draw agree.
1706pub struct SimRng {
1707 seed: u64,
1708 inner: ChaCha8Rng,
1709}
1710
1711impl SimRng {
1712 /// Seed a fresh deterministic RNG from `seed`.
1713 pub(crate) fn new(seed: u64) -> Self {
1714 Self {
1715 seed,
1716 inner: ChaCha8Rng::seed_from_u64(seed),
1717 }
1718 }
1719
1720 /// Derive a stable [`Uuid`] from this simulation's seed and a `purpose_tag`
1721 /// namespace, **independently of the draw stream** (seed-derived ids).
1722 ///
1723 /// Unlike [`uuid_v4`](Self::uuid_v4), this does **not** advance the RNG: the
1724 /// same seed and `purpose_tag` always produce the same UUID no matter how
1725 /// many other values have been drawn, so `derive_uuid("tenant:acme")` is a
1726 /// stable, byte-reproducible id for "acme" across runs and machines. Ideal
1727 /// for seeding multi-tenant fixtures without perturbing the deterministic id
1728 /// stream. See [`crate::entropy::SeededEntropy::derive_uuid`] for the shared
1729 /// mechanism and the version bits it sets (v4).
1730 #[must_use]
1731 pub fn derive_uuid(&self, purpose_tag: impl AsRef<[u8]>) -> Uuid {
1732 crate::entropy::derive_uuid_from(self.seed, purpose_tag.as_ref())
1733 }
1734
1735 /// Draw the next deterministic `u64`.
1736 pub fn next_u64(&mut self) -> u64 {
1737 self.inner.next_u64()
1738 }
1739
1740 /// Fill `dest` with deterministic bytes.
1741 pub fn fill_bytes(&mut self, dest: &mut [u8]) {
1742 self.inner.fill_bytes(dest);
1743 }
1744
1745 /// Draw a deterministic version-4 (fully random) [`Uuid`].
1746 ///
1747 /// The same seed and the same number of prior draws always yield the same
1748 /// UUID.
1749 #[must_use]
1750 pub fn uuid_v4(&mut self) -> Uuid {
1751 let mut bytes = [0u8; 16];
1752 self.inner.fill_bytes(&mut bytes);
1753 uuid_v4_from_bytes(bytes)
1754 }
1755
1756 /// Draw a version-7 (time-ordered) [`Uuid`] whose 48-bit timestamp is
1757 /// `unix_millis` and whose remaining bits are drawn deterministically.
1758 #[must_use]
1759 pub fn uuid_v7(&mut self, unix_millis: u64) -> Uuid {
1760 let mut rand_bytes = [0u8; 10];
1761 self.inner.fill_bytes(&mut rand_bytes);
1762 uuid_v7_from_parts(unix_millis, rand_bytes)
1763 }
1764
1765 /// Borrow the underlying `ChaCha8Rng`.
1766 ///
1767 /// Internal escape hatch for the determinism smoke test.
1768 #[cfg(test)]
1769 pub(crate) fn inner_mut(&mut self) -> &mut ChaCha8Rng {
1770 &mut self.inner
1771 }
1772}
1773
1774/// A virtual clock handle for the simulation. Crate-private: no public API
1775/// returns it (issue #2967).
1776///
1777/// Wraps a [`TickingClock`] started at the fixed sim epoch. [`Sim::advance`]
1778/// steps this clock (the wall-clock time a [`crate::time::Clock`] extractor
1779/// reports) in lockstep with tokio's paused virtual timer, so
1780/// `Utc::now()`-via-extractor and `tokio::time::sleep` never drift apart.
1781pub(crate) struct SimClock {
1782 inner: TickingClock,
1783}
1784
1785impl SimClock {
1786 /// Wrap a ticking clock as the simulation's virtual clock.
1787 pub(crate) fn new(inner: TickingClock) -> Self {
1788 Self { inner }
1789 }
1790
1791 /// Step the injected wall clock forward by `duration`.
1792 ///
1793 /// This moves only the framework clock (the [`crate::time::Clock`]
1794 /// extractor / `ClockSource`); [`Sim::advance`] pairs it with
1795 /// `tokio::time::advance` so the tokio timer wheel moves the same amount.
1796 pub(crate) fn advance(&self, duration: std::time::Duration) {
1797 self.inner.advance(duration);
1798 }
1799
1800 /// A [`TickingClock`] handle sharing this clock's instant.
1801 ///
1802 /// Handed to [`crate::test::TestApp::with_clock`] at [`Sim::build`] time so
1803 /// mounted handlers read the same virtual instant [`Sim::advance`] steps.
1804 pub(crate) fn ticking(&self) -> TickingClock {
1805 self.inner.clone()
1806 }
1807
1808 /// The clock's current virtual UTC instant.
1809 ///
1810 /// Read by [`Sim::advance_to`] to compute the forward delta to a zoned
1811 /// target.
1812 pub(crate) fn now(&self) -> DateTime<Utc> {
1813 crate::time::ClockSource::now(&self.inner)
1814 }
1815}
1816
1817/// The clock a `Sim` installs as its thread's ambient clock (issue #2967).
1818///
1819/// Wall time is the sim clock, so it moves only on [`Sim::advance`].
1820///
1821/// Elapsed time is kept per sim. Tokio's paused clock is one per runtime, and
1822/// several sims can share a runtime, so a sim's elapsed time is:
1823///
1824/// - the time its own [`Sim::advance`] calls moved tokio's clock, plus
1825/// - the time tokio's clock moved by itself (a `sleep` the paused runtime
1826/// auto-advanced) while this sim was the ambient clock.
1827///
1828/// So an ambient deadline and a `tokio::time::sleep` stay on one timeline, and
1829/// another sim's `advance` never moves this sim's elapsed time.
1830struct AmbientSimClock {
1831 /// The sim clock, or its chaos-skewed view once a skewed app mounts.
1832 wall: std::sync::RwLock<Arc<dyn crate::time::ClockSource>>,
1833 /// Time this sim's own `Sim::advance` calls moved tokio's clock.
1834 own_advanced: std::sync::Mutex<std::time::Duration>,
1835 /// Time tokio's clock moved by itself while this sim was ambient.
1836 auto_advanced: std::sync::Mutex<std::time::Duration>,
1837 /// Cleared when the sim drops, on any thread.
1838 alive: std::sync::atomic::AtomicBool,
1839}
1840
1841impl AmbientSimClock {
1842 fn add(slot: &std::sync::Mutex<std::time::Duration>, duration: std::time::Duration) {
1843 let mut total = slot
1844 .lock()
1845 .unwrap_or_else(std::sync::PoisonError::into_inner);
1846 *total = total.saturating_add(duration);
1847 }
1848
1849 fn read(slot: &std::sync::Mutex<std::time::Duration>) -> std::time::Duration {
1850 *slot
1851 .lock()
1852 .unwrap_or_else(std::sync::PoisonError::into_inner)
1853 }
1854
1855 fn is_alive(&self) -> bool {
1856 self.alive.load(std::sync::atomic::Ordering::Acquire)
1857 }
1858}
1859
1860/// The sims alive on one thread, and tokio's instant at the last settle.
1861#[derive(Default)]
1862struct SimStack {
1863 /// Newest last. The newest live sim is the ambient one.
1864 clocks: Vec<Arc<AmbientSimClock>>,
1865 /// Tokio's instant up to which auto-advanced time is attributed. `None`
1866 /// until a sim's runtime starts on this thread.
1867 checkpoint: Option<tokio::time::Instant>,
1868 /// Whether this thread already warned about a sim that was not anchored.
1869 warned_unanchored: bool,
1870 /// `Sim::advance` time not yet taken out of a settle. The next settles
1871 /// take it out of the tokio time they see first, so an advance is never
1872 /// also counted as auto-advanced time: not when it is cancelled after the
1873 /// clock moved, and not when advances run at the same time.
1874 pending_advance: std::time::Duration,
1875 /// The runtime these sims run on, so a sim dropped on another thread can
1876 /// still read this runtime's clock and settle up to its drop.
1877 runtime: Option<tokio::runtime::Handle>,
1878}
1879
1880impl SimStack {
1881 /// [`settle`](Self::settle), from a clock read or an advance. When no
1882 /// sim on this thread was anchored, warn once: tokio time that passed
1883 /// before this call is not in the sim's elapsed time.
1884 fn settle_lazily(&mut self) {
1885 if self.checkpoint.is_none()
1886 && !self.warned_unanchored
1887 && self.clocks.iter().any(|clock| clock.is_alive())
1888 && tokio::runtime::Handle::try_current().is_ok()
1889 {
1890 self.warned_unanchored = true;
1891 tracing::warn!(
1892 "a Sim built outside its runtime was not anchored; its elapsed \
1893 time starts now. Call `Sim::anchor` first in the runtime, or use \
1894 #[sim_test]"
1895 );
1896 }
1897 self.settle();
1898 }
1899
1900 /// Give the tokio time since the last settle to the ambient sim, and
1901 /// drop entries whose sim has dropped (on any thread).
1902 fn settle(&mut self) {
1903 if let Ok(current) = tokio::runtime::Handle::try_current() {
1904 self.runtime = Some(current);
1905 }
1906 let Some(runtime) = self.runtime.clone() else {
1907 self.clocks.retain(|clock| clock.is_alive());
1908 return;
1909 };
1910 // Read this stack's runtime clock, also from another thread.
1911 let now = {
1912 let _enter = runtime.enter();
1913 tokio::time::Instant::now()
1914 };
1915 if let Some(checkpoint) = self.checkpoint {
1916 let moved = now.saturating_duration_since(checkpoint);
1917 let explicit = moved.min(self.pending_advance);
1918 self.pending_advance = self.pending_advance.saturating_sub(explicit);
1919 if let Some(top) = self.clocks.last() {
1920 AmbientSimClock::add(&top.auto_advanced, moved.saturating_sub(explicit));
1921 }
1922 }
1923 // Prune only after attributing: the time up to now belongs to the sim
1924 // that was ambient, even if it has since dropped on another thread.
1925 self.clocks.retain(|clock| clock.is_alive());
1926 if self.clocks.is_empty() {
1927 self.checkpoint = None;
1928 self.pending_advance = std::time::Duration::ZERO;
1929 self.runtime = None;
1930 } else {
1931 self.checkpoint = Some(now);
1932 }
1933 }
1934}
1935
1936/// A thread's sim stack. Shared, so a sim dropped on another thread can
1937/// settle the stack of the thread it was built on.
1938type SharedSimStack = Arc<std::sync::Mutex<SimStack>>;
1939
1940thread_local! {
1941 static SIM_STACK: SharedSimStack = SharedSimStack::default();
1942}
1943
1944/// Run `f` on `stack`.
1945fn lock_sim_stack<T>(stack: &SharedSimStack, f: impl FnOnce(&mut SimStack) -> T) -> T {
1946 f(&mut stack
1947 .lock()
1948 .unwrap_or_else(std::sync::PoisonError::into_inner))
1949}
1950
1951/// Run `f` on this thread's sim stack. `None` during thread teardown.
1952fn with_sim_stack<T>(f: impl FnOnce(&mut SimStack) -> T) -> Option<T> {
1953 SIM_STACK.try_with(|stack| lock_sim_stack(stack, f)).ok()
1954}
1955
1956/// Start attributing tokio time on this thread from now, if nothing has
1957/// yet. `#[sim_test]` builds the sim before its runtime, so it calls this as
1958/// the runtime starts. Settling first keeps any time already attributed.
1959fn anchor_current_sim() {
1960 if tokio::runtime::Handle::try_current().is_err() {
1961 return;
1962 }
1963 with_sim_stack(SimStack::settle);
1964}
1965
1966/// Keeps a sim on its thread's sim stack while the sim lives.
1967struct SimStackGuard {
1968 own: Arc<AmbientSimClock>,
1969 /// The stack of the thread the sim was built on.
1970 home: SharedSimStack,
1971}
1972
1973impl SimStackGuard {
1974 fn enter(own: Arc<AmbientSimClock>) -> Self {
1975 let home = SIM_STACK.with(Arc::clone);
1976 lock_sim_stack(&home, |stack| {
1977 // Time so far belongs to the sim that was ambient until now.
1978 stack.settle();
1979 stack.clocks.push(Arc::clone(&own));
1980 if stack.checkpoint.is_none() && tokio::runtime::Handle::try_current().is_ok() {
1981 stack.checkpoint = Some(tokio::time::Instant::now());
1982 }
1983 });
1984 Self { own, home }
1985 }
1986}
1987
1988impl Drop for SimStackGuard {
1989 fn drop(&mut self) {
1990 // On the home stack, from any thread: settle, so time up to the drop
1991 // goes to this sim while it is still ambient; then mark it dead and
1992 // settle again, which removes it. Later time goes to the next sim.
1993 lock_sim_stack(&self.home, |stack| {
1994 stack.settle();
1995 self.own
1996 .alive
1997 .store(false, std::sync::atomic::Ordering::Release);
1998 stack.settle();
1999 });
2000 }
2001}
2002
2003/// Record `duration` as `sim`'s own advance. Call in the same poll that moves
2004/// tokio's clock, so a cancel cannot split the two.
2005fn note_own_advance(sim: &AmbientSimClock, duration: std::time::Duration) {
2006 with_sim_stack(|stack| {
2007 stack.settle_lazily();
2008 stack.pending_advance = stack.pending_advance.saturating_add(duration);
2009 });
2010 AmbientSimClock::add(&sim.own_advanced, duration);
2011}
2012
2013impl crate::time::ClockSource for AmbientSimClock {
2014 fn now(&self) -> DateTime<Utc> {
2015 self.wall
2016 .read()
2017 .unwrap_or_else(std::sync::PoisonError::into_inner)
2018 .now()
2019 }
2020
2021 fn monotonic(&self) -> crate::time::MonotonicInstant {
2022 // With no runtime there is no paused clock to settle from. The total
2023 // as last settled still holds the auto-advanced time, so a thread
2024 // with no runtime reads the same time as the sim's.
2025 if tokio::runtime::Handle::try_current().is_ok() {
2026 with_sim_stack(SimStack::settle_lazily);
2027 }
2028 crate::time::MonotonicInstant::from_origin_elapsed(
2029 Self::read(&self.own_advanced).saturating_add(Self::read(&self.auto_advanced)),
2030 )
2031 }
2032}
2033
2034/// The built application handle for a simulation.
2035///
2036/// Holds the [`crate::test::TestClient`] mounted by [`Sim::build`] on the paused
2037/// runtime with the simulation's virtual clock installed. Empty until
2038/// [`Sim::build`] is called (an empty [`#[sim_test]`](crate::sim_test) that only
2039/// drives time / RNG never mounts an app).
2040///
2041/// Crate-private: no public API returns it (issue #2967).
2042#[derive(Default)]
2043pub(crate) struct SimApp {
2044 /// The mounted test client, or `None` before [`Sim::build`].
2045 client: Option<crate::test::TestClient>,
2046}
2047
2048impl SimApp {
2049 /// Borrow the mounted [`crate::test::TestClient`].
2050 ///
2051 /// # Panics
2052 ///
2053 /// Panics if no app has been mounted yet — call [`Sim::build`] first.
2054 #[must_use]
2055 pub fn client(&self) -> &crate::test::TestClient {
2056 self.try_client()
2057 .expect("no app mounted: call `sim.build(TestApp::new()...)` before `client()`")
2058 }
2059
2060 /// Borrow the mounted [`crate::test::TestClient`], or `None` before
2061 /// [`Sim::build`].
2062 #[must_use]
2063 pub const fn try_client(&self) -> Option<&crate::test::TestClient> {
2064 self.client.as_ref()
2065 }
2066}
2067
2068/// Read and parse the simulation seed from the `AUTUMN_SIM_SEED` environment
2069/// variable.
2070///
2071/// Accepts a hex (`0x`-prefixed) or decimal `u64`; an absent or unparseable
2072/// value falls back to `0`. Called by the [`#[sim_test]`](crate::sim_test)
2073/// macro so the parsing is unit-tested in one place and the macro stays tiny.
2074#[doc(hidden)]
2075#[must_use]
2076pub fn __seed_from_env() -> u64 {
2077 std::env::var("AUTUMN_SIM_SEED").map_or(0, |raw| parse_seed(&raw))
2078}
2079
2080/// Parse a seed string: hex (`0x`/`0X` prefixed) or decimal, defaulting to `0`
2081/// on any parse failure or empty input.
2082fn parse_seed(raw: &str) -> u64 {
2083 let trimmed = raw.trim();
2084 trimmed
2085 .strip_prefix("0x")
2086 .or_else(|| trimmed.strip_prefix("0X"))
2087 .map_or_else(
2088 || trimmed.parse::<u64>().unwrap_or(0),
2089 |hex| u64::from_str_radix(hex, 16).unwrap_or(0),
2090 )
2091}
2092
2093/// Build the deterministic replay line printed on a sim-test panic.
2094///
2095/// Returns exactly
2096/// `AUTUMN_SIM_SEED=0x<seed-hex> cargo test -p <pkg> <test>` — copy-paste it to
2097/// reproduce the failing run bit-for-bit. Called by the
2098/// [`#[sim_test]`](crate::sim_test) macro.
2099#[doc(hidden)]
2100#[must_use]
2101pub fn __replay_line(seed: u64, pkg: &str, test: &str) -> String {
2102 format!("AUTUMN_SIM_SEED=0x{seed:x} cargo test -p {pkg} {test}")
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107 use super::{
2108 __replay_line, AdvancePlan, DEFAULT_STRICT_WALL_CLOCK_BUDGET, MAX_LIVENESS_BUDGET, Sim,
2109 mount_entropy_seed, parse_liveness_budget, parse_seed, parse_strict_budget_ms,
2110 plan_advance_to, resolve_local_to_utc, strict_budget_from_env_or,
2111 };
2112 use chrono::{NaiveDate, TimeZone, Utc};
2113 use rand::Rng;
2114
2115 #[tokio::test(start_paused = true)]
2116 async fn blocking_work_reads_auto_advanced_sim_time() {
2117 let sim = Sim::from_seed(11);
2118 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2119 sim.advance(std::time::Duration::from_secs(2)).await;
2120 let here = (
2121 crate::time::ambient_monotonic(),
2122 crate::time::ambient_instant(),
2123 crate::time::ambient_now(),
2124 );
2125 assert_eq!(here.0.since_origin(), std::time::Duration::from_secs(7));
2126 let there = crate::time::spawn_blocking(|| {
2127 (
2128 crate::time::ambient_monotonic(),
2129 crate::time::ambient_instant(),
2130 crate::time::ambient_now(),
2131 )
2132 })
2133 .await
2134 .unwrap();
2135 assert_eq!(there, here, "a blocking worker reads the caller's sim time");
2136 }
2137
2138 #[tokio::test(start_paused = true)]
2139 async fn a_thread_with_no_runtime_reads_auto_advanced_sim_time() {
2140 let sim = Sim::from_seed(12);
2141 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2142 let here = crate::time::ambient_monotonic();
2143 assert_eq!(here.since_origin(), std::time::Duration::from_secs(5));
2144 let clock: std::sync::Arc<dyn crate::time::ClockSource> = sim.ambient.clone();
2145 let there = std::thread::spawn(move || clock.monotonic())
2146 .join()
2147 .unwrap();
2148 assert_eq!(there, here);
2149 }
2150
2151 #[test]
2152 fn liveness_budget_is_off_unless_armed() {
2153 assert_eq!(parse_liveness_budget(None), None);
2154 assert_eq!(parse_liveness_budget(Some("")), None);
2155 assert_eq!(parse_liveness_budget(Some("0")), None);
2156 assert_eq!(parse_liveness_budget(Some("soon")), None);
2157 assert_eq!(parse_liveness_budget(Some("-5")), None);
2158 }
2159
2160 #[test]
2161 fn liveness_budget_takes_seconds_and_clamps_to_the_tokio_maximum() {
2162 assert_eq!(
2163 parse_liveness_budget(Some(" 90 ")),
2164 Some(std::time::Duration::from_secs(90))
2165 );
2166 assert_eq!(
2167 parse_liveness_budget(Some("18446744073709551615")),
2168 Some(MAX_LIVENESS_BUDGET)
2169 );
2170 }
2171
2172 #[test]
2173 fn mount_entropy_seed_is_the_sim_seed_first_then_derived() {
2174 assert_eq!(mount_entropy_seed(7, 0), 7);
2175 let restart = mount_entropy_seed(7, 1);
2176 assert_ne!(restart, 7, "a restart draws a new stream");
2177 assert_eq!(restart, mount_entropy_seed(7, 1), "and it is deterministic");
2178 assert_ne!(restart, mount_entropy_seed(7, 2));
2179 assert_ne!(restart, mount_entropy_seed(8, 1));
2180 }
2181
2182 #[test]
2183 fn replay_line_zero_seed_is_exact() {
2184 assert_eq!(
2185 __replay_line(0, "autumn-web", "my_test"),
2186 "AUTUMN_SIM_SEED=0x0 cargo test -p autumn-web my_test"
2187 );
2188 }
2189
2190 #[test]
2191 fn replay_line_formats_seed_as_hex() {
2192 let line = __replay_line(0x9f3a, "autumn-web", "my_test");
2193 assert!(
2194 line.contains("0x9f3a"),
2195 "seed must be rendered in hex: {line}"
2196 );
2197 assert_eq!(
2198 line,
2199 "AUTUMN_SIM_SEED=0x9f3a cargo test -p autumn-web my_test"
2200 );
2201 }
2202
2203 #[test]
2204 fn parse_seed_covers_hex_decimal_and_garbage() {
2205 assert_eq!(parse_seed("0"), 0);
2206 assert_eq!(parse_seed("0x9f3a"), 0x9f3a);
2207 assert_eq!(parse_seed("0X9F3A"), 0x9f3a);
2208 assert_eq!(parse_seed("42"), 42);
2209 assert_eq!(parse_seed("garbage"), 0);
2210 assert_eq!(parse_seed(""), 0);
2211 assert_eq!(parse_seed(" 0x10 "), 0x10);
2212 }
2213
2214 #[test]
2215 fn from_seed_exposes_the_seed() {
2216 assert_eq!(Sim::from_seed(0).seed, 0);
2217 assert_eq!(Sim::from_seed(7).seed(), 7);
2218 }
2219
2220 #[test]
2221 fn same_seed_produces_identical_first_draw() {
2222 let mut a = Sim::from_seed(7);
2223 let mut b = Sim::from_seed(7);
2224 let da = a.rng().inner_mut().next_u64();
2225 let db = b.rng().inner_mut().next_u64();
2226 assert_eq!(da, db, "same seed must yield the same first RNG draw");
2227 }
2228
2229 #[test]
2230 fn plan_advance_to_equal_target_is_noop() {
2231 let now = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
2232 assert_eq!(plan_advance_to(now, now), AdvancePlan::NoOp);
2233 }
2234
2235 #[test]
2236 fn plan_advance_to_future_target_is_exact_delta() {
2237 let now = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
2238 let target = Utc.with_ymd_and_hms(2020, 1, 1, 1, 0, 0).unwrap();
2239 assert_eq!(
2240 plan_advance_to(now, target),
2241 AdvancePlan::Advance(std::time::Duration::from_secs(3600))
2242 );
2243 }
2244
2245 #[test]
2246 #[should_panic(expected = "forward-only")]
2247 fn plan_advance_to_past_target_panics() {
2248 let now = Utc.with_ymd_and_hms(2020, 1, 1, 1, 0, 0).unwrap();
2249 let target = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
2250 let _ = plan_advance_to(now, target);
2251 }
2252
2253 #[test]
2254 fn parse_strict_budget_ms_covers_valid_and_garbage() {
2255 use std::time::Duration;
2256 assert_eq!(
2257 parse_strict_budget_ms("100"),
2258 Some(Duration::from_millis(100))
2259 );
2260 assert_eq!(
2261 parse_strict_budget_ms(" 250 "),
2262 Some(Duration::from_millis(250))
2263 );
2264 assert_eq!(parse_strict_budget_ms("0"), Some(Duration::ZERO));
2265 assert_eq!(parse_strict_budget_ms(""), None);
2266 assert_eq!(parse_strict_budget_ms(" "), None);
2267 assert_eq!(parse_strict_budget_ms("garbage"), None);
2268 assert_eq!(parse_strict_budget_ms("-5"), None);
2269 assert_eq!(parse_strict_budget_ms("1.5"), None);
2270 }
2271
2272 #[test]
2273 fn strict_budget_from_env_falls_back_to_default_when_unset() {
2274 // The env var is not set in this unit-test process, so the default is
2275 // returned verbatim (the override path is exercised via the public
2276 // integration tests, which never set the var).
2277 let default = std::time::Duration::from_millis(7);
2278 assert_eq!(strict_budget_from_env_or(default), default);
2279 assert_eq!(
2280 strict_budget_from_env_or(DEFAULT_STRICT_WALL_CLOCK_BUDGET),
2281 DEFAULT_STRICT_WALL_CLOCK_BUDGET
2282 );
2283 }
2284
2285 #[test]
2286 fn strict_wall_clock_builders_set_the_budget() {
2287 let mut sim = Sim::from_seed(0);
2288 assert!(sim.strict_budget.is_none(), "guard is off by default");
2289 sim.strict_wall_clock();
2290 assert_eq!(sim.strict_budget, Some(DEFAULT_STRICT_WALL_CLOCK_BUDGET));
2291
2292 let mut custom = Sim::from_seed(0);
2293 custom.strict_wall_clock_budget(std::time::Duration::from_millis(5));
2294 assert_eq!(
2295 custom.strict_budget,
2296 Some(std::time::Duration::from_millis(5))
2297 );
2298 }
2299
2300 #[test]
2301 fn resolve_local_unambiguous_maps_to_single_instant() {
2302 // A plain UTC-offset zone: 12:00 at +00:00 is exactly 12:00Z.
2303 let local = NaiveDate::from_ymd_opt(2020, 6, 1)
2304 .unwrap()
2305 .and_hms_opt(12, 0, 0)
2306 .unwrap();
2307 let got = resolve_local_to_utc(local, &Utc);
2308 assert_eq!(got, Utc.with_ymd_and_hms(2020, 6, 1, 12, 0, 0).unwrap());
2309 }
2310
2311 /// A replica on the sim network is a host of that network, and its app
2312 /// holds the network. The sim must break that cycle when it drops, or a
2313 /// seed sweep keeps every replica app of every seed in memory.
2314 #[cfg(feature = "http-client")]
2315 #[tokio::test(start_paused = true)]
2316 async fn a_dropped_sim_frees_its_replica_apps() {
2317 let marker = std::sync::Arc::new(());
2318 let freed = std::sync::Arc::downgrade(&marker);
2319 {
2320 let mut sim = Sim::from_seed(21);
2321 sim.net(super::net::SimNet::new());
2322 let held = std::sync::Arc::clone(&marker);
2323 let router = axum::Router::new().route(
2324 "/held",
2325 axum::routing::get(move || {
2326 let holders = std::sync::Arc::strong_count(&held);
2327 async move { holders.to_string() }
2328 }),
2329 );
2330 sim.mount_replica("a", crate::test::TestApp::new().merge(router));
2331 sim.mount_replica("b", crate::test::TestApp::new());
2332 }
2333 drop(marker);
2334 assert!(
2335 freed.upgrade().is_none(),
2336 "the replica app is still alive after its sim dropped"
2337 );
2338 }
2339
2340 /// A replica stays a host of the network it joined, even after
2341 /// [`Sim::net`] sets a new network. The kill and the drop must clean that
2342 /// network, and leave a host of the same name on the new one.
2343 #[cfg(feature = "http-client")]
2344 #[tokio::test(start_paused = true)]
2345 async fn a_replica_is_cleaned_from_the_network_it_joined() {
2346 let marker = std::sync::Arc::new(());
2347 let freed = std::sync::Arc::downgrade(&marker);
2348 let first = super::net::SimNet::new();
2349 let held = std::sync::Arc::clone(&marker);
2350 let router = axum::Router::new().route(
2351 "/held",
2352 axum::routing::get(move || {
2353 let holders = std::sync::Arc::strong_count(&held);
2354 async move { holders.to_string() }
2355 }),
2356 );
2357 let second = super::net::SimNet::new().host("a", axum::Router::new());
2358 {
2359 let mut sim = Sim::from_seed(22);
2360 sim.net(first.clone());
2361 sim.mount_replica("a", crate::test::TestApp::new().merge(router));
2362 sim.net(second.clone());
2363 }
2364 drop(marker);
2365 assert!(
2366 freed.upgrade().is_none(),
2367 "the replica app is still alive in the network it joined"
2368 );
2369 assert!(first.service("a").is_none());
2370 assert!(
2371 second.service("a").is_some(),
2372 "a host of another network stays"
2373 );
2374 }
2375}