diff --git a/Cargo.lock b/Cargo.lock index 41920bbdebd..ef0f375ecec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8553,13 +8553,20 @@ dependencies = [ name = "spacetimedb-runtime" version = "2.7.1" dependencies = [ - "async-task", "futures", "libc", - "spin", + "spacetimedb-runtime-core", "tokio", ] +[[package]] +name = "spacetimedb-runtime-core" +version = "2.7.1" +dependencies = [ + "async-task", + "spin", +] + [[package]] name = "spacetimedb-sats" version = "2.7.1" diff --git a/Cargo.toml b/Cargo.toml index 1af80d1498b..d0b567fecac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/primitives", "crates/query", "crates/runtime", + "crates/runtime-core", "crates/sats", "crates/schema", "crates/smoketests", @@ -152,6 +153,7 @@ spacetimedb-primitives = { path = "crates/primitives", version = "=2.7.1" } spacetimedb-query = { path = "crates/query", version = "=2.7.1" } spacetimedb-query-builder = { path = "crates/query-builder", version = "=2.7.1" } spacetimedb-runtime = { path = "crates/runtime", version = "=2.7.1" } +spacetimedb-runtime-core = { path = "crates/runtime-core", version = "=2.7.1" } spacetimedb-sats = { path = "crates/sats", version = "=2.7.1" } spacetimedb-schema = { path = "crates/schema", version = "=2.7.1" } spacetimedb-snapshot = { path = "crates/snapshot", version = "=2.7.1" } diff --git a/crates/runtime-core/Cargo.toml b/crates/runtime-core/Cargo.toml new file mode 100644 index 00000000000..a3369a69f89 --- /dev/null +++ b/crates/runtime-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "spacetimedb-runtime-core" +version.workspace = true +edition.workspace = true +license-file = "LICENSE" +description = "Portable no_std runtime core utilities for SpacetimeDB" +rust-version.workspace = true + +[lints] +workspace = true + +[features] +default = [] +sim = ["dep:async-task", "dep:spin"] + +[dependencies] +async-task = { version = "4.4", default-features = false, optional = true } +spin = { version = "0.9", default-features = false, features = ["mutex", "spin_mutex"], optional = true } diff --git a/crates/runtime-core/LICENSE b/crates/runtime-core/LICENSE new file mode 120000 index 00000000000..8540cf8a991 --- /dev/null +++ b/crates/runtime-core/LICENSE @@ -0,0 +1 @@ +../../licenses/BSL.txt \ No newline at end of file diff --git a/crates/runtime-core/src/lib.rs b/crates/runtime-core/src/lib.rs new file mode 100644 index 00000000000..f7590ada98b --- /dev/null +++ b/crates/runtime-core/src/lib.rs @@ -0,0 +1,9 @@ +#![no_std] + +#[cfg(feature = "sim")] +extern crate alloc; +#[cfg(test)] +extern crate std; + +#[cfg(feature = "sim")] +pub mod sim; diff --git a/crates/runtime/src/sim/buggify.rs b/crates/runtime-core/src/sim/buggify.rs similarity index 98% rename from crates/runtime/src/sim/buggify.rs rename to crates/runtime-core/src/sim/buggify.rs index 07188c6c207..ca4558162a6 100644 --- a/crates/runtime/src/sim/buggify.rs +++ b/crates/runtime-core/src/sim/buggify.rs @@ -1,4 +1,4 @@ -use crate::sim::Runtime; +use super::Runtime; /// Probabilistic fault-injection helpers for simulation code. /// diff --git a/crates/runtime/src/sim/executor/mod.rs b/crates/runtime-core/src/sim/executor/mod.rs similarity index 92% rename from crates/runtime/src/sim/executor/mod.rs rename to crates/runtime-core/src/sim/executor/mod.rs index 4a1a8394701..fbb7f7c0cf2 100644 --- a/crates/runtime/src/sim/executor/mod.rs +++ b/crates/runtime-core/src/sim/executor/mod.rs @@ -10,7 +10,7 @@ use core::{ use spin::Mutex; -use crate::sim::{time::TimeHandle, Rng}; +use super::{time::TimeHandle, Rng}; mod task; use task::Abortable; @@ -220,22 +220,26 @@ impl Runtime { } #[allow(dead_code)] - pub(crate) fn enable_determinism_log(&self) { + #[doc(hidden)] + pub fn enable_determinism_log(&self) { self.executor.rng.enable_determinism_log(); } #[allow(dead_code)] - pub(crate) fn enable_determinism_check(&self, log: crate::sim::DeterminismLog) { + #[doc(hidden)] + pub fn enable_determinism_check(&self, log: super::DeterminismLog) { self.executor.rng.enable_determinism_check(log); } #[allow(dead_code)] - pub(crate) fn take_determinism_log(&self) -> Option { + #[doc(hidden)] + pub fn take_determinism_log(&self) -> Option { self.executor.rng.take_determinism_log() } #[allow(dead_code)] - pub(crate) fn finish_determinism_check(&self) -> Result<(), alloc::string::String> { + #[doc(hidden)] + pub fn finish_determinism_check(&self) -> Result<(), alloc::string::String> { self.executor.rng.finish_determinism_check() } } @@ -306,7 +310,7 @@ impl Handle { } /// Create a future that becomes ready after `duration` of virtual time. - pub fn sleep(&self, duration: Duration) -> crate::sim::time::Sleep { + pub fn sleep(&self, duration: Duration) -> super::time::Sleep { self.executor.time.sleep(duration) } @@ -315,7 +319,7 @@ impl Handle { &self, duration: Duration, future: impl Future, - ) -> Result { + ) -> Result { self.executor.time.timeout(duration, future).await } @@ -645,7 +649,7 @@ impl Receiver { #[cfg(test)] mod tests { use std::sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, + atomic::{AtomicUsize, Ordering}, Arc, }; @@ -734,13 +738,12 @@ mod tests { assert_eq!(err, JoinError); } - #[cfg(feature = "simulation")] #[test] - fn sim_std_block_on_can_spawn_local_task_with_explicit_handle() { + fn block_on_can_spawn_local_task_with_explicit_handle() { let mut runtime = Runtime::new(5); let handle = runtime.handle(); let node = handle.create_node().name("local").build(); - let value = crate::sim_std::block_on(&mut runtime, async move { + let value = runtime.block_on(async move { let captured = std::rc::Rc::new(17); node.spawn_local(async move { yield_now().await; @@ -763,34 +766,4 @@ mod tests { assert_eq!(named.name(), Some("replica-1")); assert_ne!(unnamed.id(), named.id()); } - - #[cfg(feature = "simulation")] - #[test] - fn check_determinism_runs_future_twice() { - static CALLS: AtomicUsize = AtomicUsize::new(0); - CALLS.store(0, Ordering::SeqCst); - - let value = crate::sim_std::check_determinism(3, || async { - CALLS.fetch_add(1, Ordering::SeqCst); - yield_now().await; - 13 - }); - - assert_eq!(value, 13); - assert_eq!(CALLS.load(Ordering::SeqCst), 2); - } - - #[cfg(feature = "simulation")] - #[test] - #[should_panic(expected = "non-determinism detected")] - fn check_determinism_rejects_different_scheduler_sequence() { - static FIRST_RUN: AtomicBool = AtomicBool::new(true); - FIRST_RUN.store(true, Ordering::SeqCst); - - crate::sim_std::check_determinism(4, || async { - if FIRST_RUN.swap(false, Ordering::SeqCst) { - yield_now().await; - } - }); - } } diff --git a/crates/runtime/src/sim/executor/task.rs b/crates/runtime-core/src/sim/executor/task.rs similarity index 96% rename from crates/runtime/src/sim/executor/task.rs rename to crates/runtime-core/src/sim/executor/task.rs index bf03a8293d3..3d52349baa1 100644 --- a/crates/runtime/src/sim/executor/task.rs +++ b/crates/runtime-core/src/sim/executor/task.rs @@ -38,7 +38,8 @@ impl JoinHandle { } /// Poll the underlying async_task::Task for its output. - pub(crate) fn poll_join(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[doc(hidden)] + pub fn poll_join(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { // async_task::Task implements Future. Polling it drives the wrapped // Abortable future inside the executor. Pin::new(&mut self.task).poll(cx) @@ -96,8 +97,7 @@ impl fmt::Display for JoinError { } } -#[cfg(feature = "simulation")] -impl std::error::Error for JoinError {} +impl core::error::Error for JoinError {} // Shared state between AbortHandle and Abortable. struct AbortState { diff --git a/crates/runtime/src/sim/mod.rs b/crates/runtime-core/src/sim/mod.rs similarity index 83% rename from crates/runtime/src/sim/mod.rs rename to crates/runtime-core/src/sim/mod.rs index ccdcc104991..e2c231828a1 100644 --- a/crates/runtime/src/sim/mod.rs +++ b/crates/runtime-core/src/sim/mod.rs @@ -6,5 +6,6 @@ pub mod time; pub use executor::{ yield_now, AbortHandle, Handle, JoinError, JoinHandle, Node, NodeBuilder, NodeId, Runtime, RuntimeConfig, }; -pub(crate) use rng::DeterminismLog; +#[doc(hidden)] +pub use rng::DeterminismLog; pub use rng::{GlobalRng, Rng}; diff --git a/crates/runtime/src/sim/rng.rs b/crates/runtime-core/src/sim/rng.rs similarity index 93% rename from crates/runtime/src/sim/rng.rs rename to crates/runtime-core/src/sim/rng.rs index c210ff8b781..3555953e764 100644 --- a/crates/runtime/src/sim/rng.rs +++ b/crates/runtime-core/src/sim/rng.rs @@ -9,7 +9,7 @@ pub type Rng = GlobalRng; /// The simulator owns one runtime-wide RNG handle and uses it for scheduler /// choices, probabilistic fault injection, and determinism checks. Hosted /// conveniences such as thread-local current-RNG access and libc random hooks -/// live in `crate::sim_std`, not here. +/// live in the hosted runtime facade, not here. #[derive(Clone, Debug)] pub struct GlobalRng { inner: Arc>, @@ -143,21 +143,24 @@ impl GlobalRng { } #[allow(dead_code)] - pub(crate) fn enable_determinism_log(&self) { + #[doc(hidden)] + pub fn enable_determinism_log(&self) { let mut inner = self.inner.lock(); inner.log = Some(Vec::new()); inner.check = None; } #[allow(dead_code)] - pub(crate) fn enable_determinism_check(&self, log: DeterminismLog) { + #[doc(hidden)] + pub fn enable_determinism_check(&self, log: DeterminismLog) { let mut inner = self.inner.lock(); inner.check = Some((log.0, 0)); inner.log = None; } #[allow(dead_code)] - pub(crate) fn take_determinism_log(&self) -> Option { + #[doc(hidden)] + pub fn take_determinism_log(&self) -> Option { let mut inner = self.inner.lock(); inner .log @@ -167,7 +170,8 @@ impl GlobalRng { } #[allow(dead_code)] - pub(crate) fn finish_determinism_check(&self) -> Result<(), String> { + #[doc(hidden)] + pub fn finish_determinism_check(&self) -> Result<(), String> { let inner = self.inner.lock(); if let Some((log, consumed)) = &inner.check && *consumed != log.len() @@ -183,7 +187,7 @@ impl GlobalRng { } #[derive(Debug, Clone, Eq, PartialEq)] -pub(crate) struct DeterminismLog(Vec); +pub struct DeterminismLog(Vec); fn probability_sample(value: u64, probability: f64) -> bool { if probability <= 0.0 { diff --git a/crates/runtime/src/sim/time/mod.rs b/crates/runtime-core/src/sim/time/mod.rs similarity index 99% rename from crates/runtime/src/sim/time/mod.rs rename to crates/runtime-core/src/sim/time/mod.rs index e454e84561b..242ff0ab84a 100644 --- a/crates/runtime/src/sim/time/mod.rs +++ b/crates/runtime-core/src/sim/time/mod.rs @@ -207,10 +207,11 @@ impl fmt::Display for TimeoutElapsed { } } -impl std::error::Error for TimeoutElapsed {} +impl core::error::Error for TimeoutElapsed {} #[cfg(test)] mod tests { + use alloc::{vec, vec::Vec}; use std::{sync::Arc, time::Duration}; use crate::sim; diff --git a/crates/runtime/src/sim/time/sleep.rs b/crates/runtime-core/src/sim/time/sleep.rs similarity index 100% rename from crates/runtime/src/sim/time/sleep.rs rename to crates/runtime-core/src/sim/time/sleep.rs diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 13b3e94e7e7..c8affea0f48 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -11,12 +11,11 @@ workspace = true [dependencies] tokio.workspace = true -async-task = { version = "4.4", default-features = false, optional = true } -spin = { version = "0.9", default-features = false, features = ["mutex", "spin_mutex"], optional = true } +spacetimedb-runtime-core = { workspace = true, optional = true } libc = { version = "0.2", optional = true } [dev-dependencies] futures.workspace = true [features] -simulation = ["dep:async-task", "dep:spin", "dep:libc"] +simulation = ["dep:spacetimedb-runtime-core", "spacetimedb-runtime-core/sim", "dep:libc"] diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 9511cf52c50..c6192e1b738 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,6 +1,3 @@ -#[cfg(feature = "simulation")] -extern crate alloc; - use core::{ fmt, future::Future, @@ -11,7 +8,7 @@ use core::{ }; #[cfg(feature = "simulation")] -pub mod sim; +pub use spacetimedb_runtime_core::sim; #[cfg(feature = "simulation")] pub mod sim_std; diff --git a/crates/runtime/src/sim_std.rs b/crates/runtime/src/sim_std.rs index 59293f1635b..8153482519e 100644 --- a/crates/runtime/src/sim_std.rs +++ b/crates/runtime/src/sim_std.rs @@ -1,14 +1,14 @@ //! Std-hosted entry points for running the deterministic simulator in tests. //! //! The portable simulator lives in [`crate::sim`]. This module is deliberately -//! host-specific: it installs thread-local context while a simulation is -//! running, checks determinism by replaying a seed in fresh OS threads, and -//! intercepts a few libc calls so std code cannot silently escape determinism. +//! host-specific: it marks a thread as simulation-owned while hosted tests run, +//! checks determinism by replaying a seed in fresh OS threads, and intercepts a +//! few libc calls so std code cannot silently escape determinism. #![allow(clippy::disallowed_macros)] -use alloc::boxed::Box; use core::{cell::Cell, future::Future}; +use std::boxed::Box; use std::sync::OnceLock; use crate::sim; @@ -21,7 +21,7 @@ use crate::sim; /// tests that execute inside a hosted process. While the future runs, this /// marks the thread as inside simulation so OS thread spawns can be rejected. pub fn block_on(runtime: &mut sim::Runtime, future: F) -> F::Output { - let _system_thread_context = enter_simulation_thread(); + let _guard = enter(); runtime.block_on(future) } @@ -68,34 +68,33 @@ fn panic_with_seed(seed: u64, payload: Box) -> ! { // Simulation thread context. -// Ambient state used only while `sim_std::block_on` is driving a simulation. +// Ambient hosted state used only while sim_std is driving a simulation runtime. // -// The simulator itself stays explicit-handle based. This thread-local only -// marks whether the current OS thread is owned by a running simulation so -// host thread creation can be rejected. +// The simulator itself stays explicit-handle based. This flag only marks the +// current OS thread as simulation-owned so host thread creation and randomness +// hooks can reject accidental escapes from deterministic simulation. thread_local! { - // Marks the current OS thread as simulation-owned so thread creation hooks - // can reject accidental escapes to the host scheduler. static IN_SIMULATION: Cell = const { Cell::new(false) }; } -struct SimulationThreadGuard { - previous: bool, -} +#[must_use = "simulation runtime context exits immediately unless the guard is held"] +struct EnterGuard; -fn enter_simulation_thread() -> SimulationThreadGuard { - let previous = IN_SIMULATION.with(|state| state.replace(true)); - SimulationThreadGuard { previous } +fn enter() -> EnterGuard { + IN_SIMULATION.with(|current| { + assert!(!current.replace(true), "nested hosted simulation block_on"); + }); + EnterGuard } fn in_simulation() -> bool { IN_SIMULATION.with(Cell::get) } -impl Drop for SimulationThreadGuard { +impl Drop for EnterGuard { fn drop(&mut self) { - IN_SIMULATION.with(|state| { - state.set(self.previous); + IN_SIMULATION.with(|current| { + assert!(current.replace(false), "simulation context guard dropped without enter"); }); } } diff --git a/crates/runtime/tests/sim_e2e.rs b/crates/runtime/tests/sim_e2e.rs index 973b9ccac15..46270d208ce 100644 --- a/crates/runtime/tests/sim_e2e.rs +++ b/crates/runtime/tests/sim_e2e.rs @@ -1,14 +1,16 @@ #![cfg(feature = "simulation")] #![allow(clippy::disallowed_macros)] -use std::{sync::Arc, time::Duration}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; use futures::{ channel::{mpsc, oneshot}, StreamExt, }; use spacetimedb_runtime::sim::{buggify, Rng, Runtime}; -use spin::Mutex; /// One reply produced by the simulated server. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -156,7 +158,7 @@ fn run_buggified_client_server(seed: u64) -> ClientServerRun { // Receive all 5 requests before processing any replies for _ in 0..5 { let request = request_rx.next().await.expect("client should send request"); - server_events_for_server.lock().push(ServerEvent::Received { + server_events_for_server.lock().unwrap().push(ServerEvent::Received { id: request.id, at: server_handle.now(), }); @@ -169,7 +171,7 @@ fn run_buggified_client_server(seed: u64) -> ClientServerRun { worker_handle.sleep(Duration::from_millis(request.id + 1)).await; // buggify decides whether to drop this request (40% probability) if worker_handle.buggify_with_prob(0.4) { - worker_events.lock().push(ServerEvent::Dropped { + worker_events.lock().unwrap().push(ServerEvent::Dropped { id: request.id, at: worker_handle.now(), }); @@ -182,7 +184,7 @@ fn run_buggified_client_server(seed: u64) -> ClientServerRun { value: request.input * 10, at: worker_handle.now(), }; - worker_events.lock().push(ServerEvent::Replied { + worker_events.lock().unwrap().push(ServerEvent::Replied { id: request.id, at: response.at, }); @@ -230,7 +232,7 @@ fn run_buggified_client_server(seed: u64) -> ClientServerRun { // Drive both client and server to completion let responses = client.await.expect("client task should complete"); server.await.expect("server task should complete"); - (responses, server_events.lock().clone()) + (responses, server_events.lock().unwrap().clone()) }); // --- package the results: client responses, server trace, and total virtual time --- @@ -260,21 +262,21 @@ fn multi_node_runtime_coordinates_pause_resume_and_virtual_time() { let a_handle = handle.clone(); let a_events = Arc::clone(&events); let a = node_a.spawn(async move { - a_events.lock().push(("a_started", a_handle.now())); + a_events.lock().unwrap().push(("a_started", a_handle.now())); a_handle.sleep(Duration::from_millis(3)).await; - a_events.lock().push(("a_finished", a_handle.now())); + a_events.lock().unwrap().push(("a_finished", a_handle.now())); }); let b_handle = handle.clone(); let b_events = Arc::clone(&events); let b = node_b.spawn(async move { - b_events.lock().push(("b_started", b_handle.now())); + b_events.lock().unwrap().push(("b_started", b_handle.now())); b_handle.sleep(Duration::from_millis(2)).await; - b_events.lock().push(("b_finished", b_handle.now())); + b_events.lock().unwrap().push(("b_finished", b_handle.now())); }); handle.sleep(Duration::from_millis(1)).await; - events.lock().push(("main_resumed_b", handle.now())); + events.lock().unwrap().push(("main_resumed_b", handle.now())); node_b.resume(); a.await.expect("node a task should complete"); @@ -282,7 +284,7 @@ fn multi_node_runtime_coordinates_pause_resume_and_virtual_time() { } }); - let events = events.lock(); + let events = events.lock().unwrap(); let get = |name: &str| events.iter().find(|(n, _)| *n == name).map(|(_, t)| *t).unwrap(); // a starts with only per-task overhead accumulated before its first poll