#![allow(clippy::missing_const_for_fn)]
use std::sync::Arc;
use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeZone, Utc};
use rand::{RngCore, SeedableRng};
use rand_chacha::ChaCha8Rng;
use uuid::Uuid;
use crate::entropy::{Entropy, SeededEntropy, uuid_v4_from_bytes, uuid_v7_from_parts};
use crate::time::TickingClock;
#[cfg(feature = "sqlite")]
#[doc(hidden)]
pub mod substrate;
pub mod chaos;
#[cfg(feature = "mail")]
pub use chaos::MailFault;
pub use chaos::{Chaos, ChaosEvent, ChaosHook};
pub mod llm;
pub use llm::{LlmCall, LlmClient, LlmError, LlmRequest, LlmResponse, SeededLlm, SeededLlmBuilder};
pub mod crash;
pub mod assert;
pub use assert::{
SometimesRegistry, assert_all_sometimes_satisfied, reset_sometimes_registry,
sometimes_snapshot, sometimes_unsatisfied,
};
pub use crash::{CrashPoint, CrashSchedule};
#[cfg(feature = "sim-testing")]
pub mod op;
#[cfg(feature = "sim-testing")]
pub mod sweep;
#[cfg(feature = "sim-testing")]
pub use sweep::{SweepFailure, SweepOutcome, sweep_proptest};
const SIM_EPOCH_UNIX_SECS: i64 = 1_577_836_800;
#[non_exhaustive]
pub struct Sim {
pub seed: u64,
rng: SimRng,
clock: SimClock,
chaos: Chaos,
chaos_state: Option<Arc<chaos::ChaosState>>,
app: SimApp,
strict_budget: Option<std::time::Duration>,
}
impl Sim {
#[must_use]
pub fn from_seed(seed: u64) -> Self {
assert::reset_sometimes_registry();
let epoch = Utc
.timestamp_opt(SIM_EPOCH_UNIX_SECS, 0)
.single()
.unwrap_or_else(|| Utc.timestamp_nanos(0));
Self {
seed,
rng: SimRng::new(seed),
clock: SimClock::new(TickingClock::starting_at(epoch)),
chaos: Chaos::default(),
chaos_state: None,
app: SimApp::default(),
strict_budget: None,
}
}
pub fn chaos(&mut self, chaos: Chaos) -> &mut Self {
self.chaos = chaos;
self
}
#[must_use]
pub const fn seed(&self) -> u64 {
self.seed
}
#[must_use]
pub fn rng(&mut self) -> &mut SimRng {
&mut self.rng
}
#[must_use]
pub fn seeded_entropy(&self) -> Arc<dyn Entropy> {
SeededEntropy::shared(self.seed)
}
pub fn build(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
self.mount(app)
}
fn mount(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
let app = if self.chaos.is_active() {
let state = chaos::ChaosState::new(self.seed, &self.chaos);
self.chaos_state = Some(Arc::clone(&state));
chaos::install(app, &self.chaos, self.seed, self.clock.ticking(), state)
} else {
app.with_clock(self.clock.ticking())
};
let client = app.build();
self.app.client = Some(client);
self.app.client()
}
pub fn kill(&mut self) {
self.app.client = None;
self.chaos_state = None;
}
pub fn restart(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
self.mount(app)
}
pub fn crash_and_restart(&mut self, app: crate::test::TestApp) -> &crate::test::TestClient {
self.kill();
self.restart(app)
}
#[must_use]
pub fn crash_schedule(&self) -> CrashSchedule {
CrashSchedule::derive(self.seed, crash::DEFAULT_CRASH_SCHEDULE_LEN)
}
#[must_use]
pub fn crash_point(&self) -> Option<CrashPoint> {
self.crash_schedule().first().cloned()
}
#[doc(hidden)]
#[must_use]
pub fn __chaos_events(&self) -> Vec<ChaosEvent> {
self.chaos_state
.as_ref()
.map(|state| state.events())
.unwrap_or_default()
}
#[must_use]
pub fn client(&self) -> &crate::test::TestClient {
self.app.client()
}
#[must_use]
pub const fn try_client(&self) -> Option<&crate::test::TestClient> {
self.app.try_client()
}
pub fn strict_wall_clock(&mut self) -> &mut Self {
self.strict_budget = Some(strict_budget_from_env_or(DEFAULT_STRICT_WALL_CLOCK_BUDGET));
self
}
pub fn strict_wall_clock_budget(&mut self, budget: std::time::Duration) -> &mut Self {
self.strict_budget = Some(strict_budget_from_env_or(budget));
self
}
fn wall_clock_guard_start(&self) -> Option<std::time::Instant> {
self.strict_budget.map(|_| std::time::Instant::now())
}
fn enforce_wall_clock_budget(&self, start: Option<std::time::Instant>) {
if let (Some(budget), Some(start)) = (self.strict_budget, start) {
let elapsed = start.elapsed();
assert!(
elapsed <= budget,
"Sim::strict_wall_clock real-time leak guard tripped: {elapsed:?} of real \
wall-clock time elapsed inside a paused-sim step, exceeding the {budget:?} \
budget. This means real time leaked into the virtual timeline — usually a real \
`std::thread::sleep`, blocking I/O, or `spawn_blocking` that escaped tokio's \
paused timer. If this is CI scheduling jitter rather than a genuine leak, raise \
the budget via the AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS environment variable."
);
}
}
pub async fn advance(&self, duration: std::time::Duration) {
let guard_start = self.wall_clock_guard_start();
self.clock.advance(duration);
tokio::time::advance(duration).await;
self.enforce_wall_clock_budget(guard_start);
}
pub fn advance_to<Tz>(
&self,
target: &DateTime<Tz>,
) -> impl std::future::Future<Output = ()> + '_
where
Tz: TimeZone,
{
let target_utc = target.with_timezone(&Utc);
self.advance_to_utc(target_utc)
}
pub fn advance_to_local<Tz>(
&self,
local: NaiveDateTime,
tz: &Tz,
) -> impl std::future::Future<Output = ()> + '_
where
Tz: TimeZone,
{
self.advance_to_utc(resolve_local_to_utc(local, tz))
}
async fn advance_to_utc(&self, target: DateTime<Utc>) {
match plan_advance_to(self.clock.now(), target) {
AdvancePlan::NoOp => {}
AdvancePlan::Advance(delta) => self.advance(delta).await,
}
}
pub async fn run_to_idle(&self) {
let guard_start = self.wall_clock_guard_start();
#[cfg(feature = "db")]
let commit_hook_pool = self
.app
.try_client()
.and_then(|client| crate::db::DbState::pool(client.state()).cloned());
for _ in 0..MAX_DRAIN_STEPS {
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::ZERO).await;
#[cfg(feature = "db")]
if let Some(pool) = commit_hook_pool.as_ref() {
let _hooks_drained =
crate::test::drain_ready_repository_commit_hooks(pool, MAX_DRAIN_STEPS).await;
}
}
self.enforce_wall_clock_budget(guard_start);
}
}
const MAX_DRAIN_STEPS: usize = 1024;
const DEFAULT_STRICT_WALL_CLOCK_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
fn strict_budget_from_env_or(default: std::time::Duration) -> std::time::Duration {
std::env::var("AUTUMN_SIM_STRICT_WALL_CLOCK_BUDGET_MS")
.ok()
.and_then(|raw| parse_strict_budget_ms(&raw))
.unwrap_or(default)
}
fn parse_strict_budget_ms(raw: &str) -> Option<std::time::Duration> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed
.parse::<u64>()
.ok()
.map(std::time::Duration::from_millis)
}
#[derive(Debug, PartialEq, Eq)]
enum AdvancePlan {
NoOp,
Advance(std::time::Duration),
}
fn plan_advance_to(now: DateTime<Utc>, target: DateTime<Utc>) -> AdvancePlan {
let delta = target - now;
match delta.cmp(&chrono::Duration::zero()) {
std::cmp::Ordering::Equal => AdvancePlan::NoOp,
std::cmp::Ordering::Less => panic!(
"Sim::advance_to target {target} is strictly before the current sim instant {now}; \
virtual time is forward-only (advancing to a past instant is a test bug)"
),
std::cmp::Ordering::Greater => AdvancePlan::Advance(
delta
.to_std()
.expect("a strictly-positive chrono delta always converts to std::time::Duration"),
),
}
}
fn resolve_local_to_utc<Tz>(local: NaiveDateTime, tz: &Tz) -> DateTime<Utc>
where
Tz: TimeZone,
{
match tz.from_local_datetime(&local) {
LocalResult::Single(dt) => dt.with_timezone(&Utc),
LocalResult::Ambiguous(earlier, _later) => earlier.with_timezone(&Utc),
LocalResult::None => {
let offset_secs =
i64::from(tz.offset_from_utc_datetime(&local).fix().local_minus_utc());
(local - chrono::Duration::seconds(offset_secs)).and_utc()
}
}
}
pub struct SimRng {
seed: u64,
inner: ChaCha8Rng,
}
impl SimRng {
pub(crate) fn new(seed: u64) -> Self {
Self {
seed,
inner: ChaCha8Rng::seed_from_u64(seed),
}
}
#[must_use]
pub fn derive_uuid(&self, purpose_tag: impl AsRef<[u8]>) -> Uuid {
crate::entropy::derive_uuid_from(self.seed, purpose_tag.as_ref())
}
pub fn next_u64(&mut self) -> u64 {
self.inner.next_u64()
}
pub fn fill_bytes(&mut self, dest: &mut [u8]) {
self.inner.fill_bytes(dest);
}
#[must_use]
pub fn uuid_v4(&mut self) -> Uuid {
let mut bytes = [0u8; 16];
self.inner.fill_bytes(&mut bytes);
uuid_v4_from_bytes(bytes)
}
#[must_use]
pub fn uuid_v7(&mut self, unix_millis: u64) -> Uuid {
let mut rand_bytes = [0u8; 10];
self.inner.fill_bytes(&mut rand_bytes);
uuid_v7_from_parts(unix_millis, rand_bytes)
}
#[cfg(test)]
pub(crate) fn inner_mut(&mut self) -> &mut ChaCha8Rng {
&mut self.inner
}
}
pub struct SimClock {
inner: TickingClock,
}
impl SimClock {
pub(crate) fn new(inner: TickingClock) -> Self {
Self { inner }
}
pub(crate) fn advance(&self, duration: std::time::Duration) {
self.inner.advance(duration);
}
pub(crate) fn ticking(&self) -> TickingClock {
self.inner.clone()
}
pub(crate) fn now(&self) -> DateTime<Utc> {
crate::time::ClockSource::now(&self.inner)
}
}
#[non_exhaustive]
#[derive(Default)]
pub struct SimApp {
client: Option<crate::test::TestClient>,
}
impl SimApp {
#[must_use]
pub fn client(&self) -> &crate::test::TestClient {
self.try_client()
.expect("no app mounted: call `sim.build(TestApp::new()...)` before `client()`")
}
#[must_use]
pub const fn try_client(&self) -> Option<&crate::test::TestClient> {
self.client.as_ref()
}
}
#[doc(hidden)]
#[must_use]
pub fn __seed_from_env() -> u64 {
std::env::var("AUTUMN_SIM_SEED").map_or(0, |raw| parse_seed(&raw))
}
fn parse_seed(raw: &str) -> u64 {
let trimmed = raw.trim();
trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
.map_or_else(
|| trimmed.parse::<u64>().unwrap_or(0),
|hex| u64::from_str_radix(hex, 16).unwrap_or(0),
)
}
#[doc(hidden)]
#[must_use]
pub fn __replay_line(seed: u64, pkg: &str, test: &str) -> String {
format!("AUTUMN_SIM_SEED=0x{seed:x} cargo test -p {pkg} {test}")
}
#[cfg(test)]
mod tests {
use super::{
__replay_line, AdvancePlan, DEFAULT_STRICT_WALL_CLOCK_BUDGET, Sim, parse_seed,
parse_strict_budget_ms, plan_advance_to, resolve_local_to_utc, strict_budget_from_env_or,
};
use chrono::{NaiveDate, TimeZone, Utc};
use rand::RngCore;
#[test]
fn replay_line_zero_seed_is_exact() {
assert_eq!(
__replay_line(0, "autumn-web", "my_test"),
"AUTUMN_SIM_SEED=0x0 cargo test -p autumn-web my_test"
);
}
#[test]
fn replay_line_formats_seed_as_hex() {
let line = __replay_line(0x9f3a, "autumn-web", "my_test");
assert!(
line.contains("0x9f3a"),
"seed must be rendered in hex: {line}"
);
assert_eq!(
line,
"AUTUMN_SIM_SEED=0x9f3a cargo test -p autumn-web my_test"
);
}
#[test]
fn parse_seed_covers_hex_decimal_and_garbage() {
assert_eq!(parse_seed("0"), 0);
assert_eq!(parse_seed("0x9f3a"), 0x9f3a);
assert_eq!(parse_seed("0X9F3A"), 0x9f3a);
assert_eq!(parse_seed("42"), 42);
assert_eq!(parse_seed("garbage"), 0);
assert_eq!(parse_seed(""), 0);
assert_eq!(parse_seed(" 0x10 "), 0x10);
}
#[test]
fn from_seed_exposes_the_seed() {
assert_eq!(Sim::from_seed(0).seed, 0);
assert_eq!(Sim::from_seed(7).seed(), 7);
}
#[test]
fn same_seed_produces_identical_first_draw() {
let mut a = Sim::from_seed(7);
let mut b = Sim::from_seed(7);
let da = a.rng().inner_mut().next_u64();
let db = b.rng().inner_mut().next_u64();
assert_eq!(da, db, "same seed must yield the same first RNG draw");
}
#[test]
fn plan_advance_to_equal_target_is_noop() {
let now = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
assert_eq!(plan_advance_to(now, now), AdvancePlan::NoOp);
}
#[test]
fn plan_advance_to_future_target_is_exact_delta() {
let now = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let target = Utc.with_ymd_and_hms(2020, 1, 1, 1, 0, 0).unwrap();
assert_eq!(
plan_advance_to(now, target),
AdvancePlan::Advance(std::time::Duration::from_secs(3600))
);
}
#[test]
#[should_panic(expected = "forward-only")]
fn plan_advance_to_past_target_panics() {
let now = Utc.with_ymd_and_hms(2020, 1, 1, 1, 0, 0).unwrap();
let target = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
let _ = plan_advance_to(now, target);
}
#[test]
fn parse_strict_budget_ms_covers_valid_and_garbage() {
use std::time::Duration;
assert_eq!(
parse_strict_budget_ms("100"),
Some(Duration::from_millis(100))
);
assert_eq!(
parse_strict_budget_ms(" 250 "),
Some(Duration::from_millis(250))
);
assert_eq!(parse_strict_budget_ms("0"), Some(Duration::ZERO));
assert_eq!(parse_strict_budget_ms(""), None);
assert_eq!(parse_strict_budget_ms(" "), None);
assert_eq!(parse_strict_budget_ms("garbage"), None);
assert_eq!(parse_strict_budget_ms("-5"), None);
assert_eq!(parse_strict_budget_ms("1.5"), None);
}
#[test]
fn strict_budget_from_env_falls_back_to_default_when_unset() {
let default = std::time::Duration::from_millis(7);
assert_eq!(strict_budget_from_env_or(default), default);
assert_eq!(
strict_budget_from_env_or(DEFAULT_STRICT_WALL_CLOCK_BUDGET),
DEFAULT_STRICT_WALL_CLOCK_BUDGET
);
}
#[test]
fn strict_wall_clock_builders_set_the_budget() {
let mut sim = Sim::from_seed(0);
assert!(sim.strict_budget.is_none(), "guard is off by default");
sim.strict_wall_clock();
assert_eq!(sim.strict_budget, Some(DEFAULT_STRICT_WALL_CLOCK_BUDGET));
let mut custom = Sim::from_seed(0);
custom.strict_wall_clock_budget(std::time::Duration::from_millis(5));
assert_eq!(
custom.strict_budget,
Some(std::time::Duration::from_millis(5))
);
}
#[test]
fn resolve_local_unambiguous_maps_to_single_instant() {
let local = NaiveDate::from_ymd_opt(2020, 6, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let got = resolve_local_to_utc(local, &Utc);
assert_eq!(got, Utc.with_ymd_and_hms(2020, 6, 1, 12, 0, 0).unwrap());
}
}