Skip to main content

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}