Skip to main content

nautilus_common/live/
dst.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Deterministic simulation testing (DST) re-export module.
17//!
18//! Feature-switched re-exports of the tokio primitives that need deterministic
19//! behavior under simulation: `time`, `task`, `runtime`, and `signal`. Code on
20//! the DST path imports async primitives from here rather than directly from
21//! `tokio`, so that toggling the `simulation` feature switches the whole
22//! runtime in one place.
23//!
24//! # Feature-switched behavior
25//!
26//! The switch requires both the `simulation` Cargo feature (which enables the
27//! `madsim` dependency) and `RUSTFLAGS="--cfg madsim"` (which activates the
28//! simulation re-exports). Without both, all paths route to real tokio.
29//!
30//! Only four submodules switch: `time`, `task`, `runtime`, and `signal`. All
31//! other tokio modules (`sync`, `io`, `select!`, `fs`, `net`) use real tokio
32//! unconditionally. Transitive crates (`tokio-tungstenite`, `reqwest`, ...)
33//! are unaffected.
34//!
35//! # Surface
36//!
37//! The submodules re-export the following items. A compile-time probe at the
38//! bottom of this file keeps the list and the signatures of the non-generic
39//! free functions consistent across the tokio and madsim routes, and fires on
40//! `cargo build` (not only `cargo test`) so breakage surfaces upstream.
41//!
42//! - `time`: `Duration`, `Instant`, `Interval`, `MissedTickBehavior`, `Sleep`,
43//!   `error` (submodule), `interval`, `interval_at`, `sleep`, `sleep_until`,
44//!   `timeout`
45//! - `task`: `JoinHandle`, `spawn`, `spawn_local`, `yield_now`
46//! - `runtime`: `Builder`, `Handle`, `Runtime`
47//! - `signal`: `ctrl_c`, `terminate`
48//!
49//! # Related seam
50//!
51//! Monotonic time (`Instant`) goes through this module. Wall-clock time
52//! (`SystemTime` / Unix epoch) is a separate seam: see
53//! `nautilus_core::time::duration_since_unix_epoch`. Collapsing the two would
54//! lose epoch information and break order and fill timestamps.
55
56/// Deterministic time: virtual time under simulation, real time in production.
57///
58/// Under simulation (`simulation` + `cfg(madsim)`), `Instant` is
59/// `std::time::Instant` (madsim intercepts the system clock calls). `sleep`,
60/// `timeout`, `interval` advance in virtual time controlled by the
61/// deterministic scheduler.
62pub mod time {
63    pub use std::time::Duration;
64
65    #[cfg(all(feature = "simulation", madsim))]
66    pub use madsim::time::{
67        Instant, Interval, MissedTickBehavior, Sleep, error, interval, interval_at, sleep,
68        sleep_until, timeout,
69    };
70    #[cfg(not(all(feature = "simulation", madsim)))]
71    pub use tokio::time::{
72        Instant, Interval, MissedTickBehavior, Sleep, error, interval, interval_at, sleep,
73        sleep_until, timeout,
74    };
75}
76
77/// Deterministic task spawning: fixed-order scheduler under simulation.
78pub mod task {
79    #[cfg(all(feature = "simulation", madsim))]
80    pub use madsim::task::{JoinHandle, spawn, spawn_local, yield_now};
81    #[cfg(not(all(feature = "simulation", madsim)))]
82    pub use tokio::task::{JoinHandle, spawn, spawn_local, yield_now};
83}
84
85/// Deterministic runtime: single-threaded sim runtime under simulation.
86///
87/// Under simulation (`simulation` + `cfg(madsim)`),
88/// `Builder::new_multi_thread()` returns a single-threaded deterministic
89/// runtime. `worker_threads()` and `enable_all()` are no-ops.
90pub mod runtime {
91    #[cfg(not(all(feature = "simulation", madsim)))]
92    pub use tokio::runtime::{Builder, Handle, Runtime};
93
94    #[cfg(all(feature = "simulation", madsim))]
95    mod sim {
96        use std::io;
97
98        /// Tokio-compatible runtime builder that produces a madsim runtime.
99        #[derive(Debug)]
100        pub struct Builder {
101            _inner: (),
102        }
103
104        impl Builder {
105            #[must_use]
106            pub fn new_current_thread() -> Self {
107                Self { _inner: () }
108            }
109
110            #[must_use]
111            pub fn new_multi_thread() -> Self {
112                Self { _inner: () }
113            }
114
115            #[must_use]
116            pub fn worker_threads(&mut self, _val: usize) -> &mut Self {
117                self
118            }
119
120            #[must_use]
121            pub fn thread_name(&mut self, _val: impl Into<String>) -> &mut Self {
122                self
123            }
124
125            #[must_use]
126            pub fn enable_all(&mut self) -> &mut Self {
127                self
128            }
129
130            /// # Errors
131            ///
132            /// Returns an error if the runtime cannot be created.
133            pub fn build(&mut self) -> io::Result<madsim::runtime::Runtime> {
134                Ok(madsim::runtime::Runtime::new())
135            }
136        }
137
138        pub use madsim::runtime::Handle;
139        pub type Runtime = madsim::runtime::Runtime;
140    }
141
142    #[cfg(all(feature = "simulation", madsim))]
143    pub use sim::{Builder, Handle, Runtime};
144}
145
146/// Deterministic signal handling: injectable signals under simulation.
147///
148/// Under simulation (`simulation` + `cfg(madsim)`), `ctrl_c()` responds to
149/// `madsim::runtime::Handle::send_ctrl_c(node_id)` from test code. Unix
150/// builds also expose SIGTERM; simulation and non-Unix builds never complete
151/// that future.
152pub mod signal {
153    #[cfg(all(feature = "simulation", madsim))]
154    pub use madsim::signal::ctrl_c;
155    #[cfg(not(all(feature = "simulation", madsim)))]
156    pub use tokio::signal::ctrl_c;
157
158    /// Waits for SIGTERM on Unix builds.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if the SIGTERM listener cannot be installed.
163    #[cfg(all(not(all(feature = "simulation", madsim)), unix))]
164    pub async fn terminate() -> std::io::Result<()> {
165        let mut signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
166        signal.recv().await;
167        Ok(())
168    }
169
170    /// Waits forever on builds without a real SIGTERM listener.
171    ///
172    /// # Errors
173    ///
174    /// This function never returns on these builds.
175    #[cfg(any(all(feature = "simulation", madsim), not(unix)))]
176    pub async fn terminate() -> std::io::Result<()> {
177        std::future::pending::<std::io::Result<()>>().await
178    }
179}
180
181/// Compile-time probe of the DST re-export surface.
182///
183/// Names every item listed in the module-level "Surface" section and pins the
184/// signatures of the non-generic free functions via function pointer coercion.
185/// Removing, renaming, cfg-gating, or changing the signature of any of them on
186/// either the tokio or madsim route causes this module to fail to compile, so
187/// both routes stay in sync. Compiled unconditionally so plain `cargo build`
188/// fires it, not only `cargo test`.
189///
190/// Generic free functions (`timeout`, `spawn`, `spawn_local`) and `async fn`s
191/// (`yield_now`, `ctrl_c`) cannot be written as concrete function pointers
192/// here, so their `use` binding only locks the name, not the signature.
193///
194/// Visibility narrowing (e.g. `pub` -> `pub(crate)`) cannot be detected from
195/// inside the defining crate; that gap is structurally outside this probe.
196///
197/// Shape check only, not a behavior test.
198#[allow(unused_imports)]
199mod surface {
200    use super::{
201        runtime::{Builder, Handle, Runtime},
202        signal::{ctrl_c, terminate},
203        task::{JoinHandle, spawn, spawn_local, yield_now},
204        time::{
205            Duration, Instant, Interval, MissedTickBehavior, Sleep, error, interval, interval_at,
206            sleep, sleep_until, timeout,
207        },
208    };
209
210    const _: fn(Duration) -> Sleep = sleep;
211    const _: fn(Instant) -> Sleep = sleep_until;
212    const _: fn(Duration) -> Interval = interval;
213    const _: fn(Instant, Duration) -> Interval = interval_at;
214}
215
216#[cfg(test)]
217mod tests {
218    #[cfg(all(feature = "simulation", madsim))]
219    use nautilus_core::{datetime::NANOSECONDS_IN_SECOND, time::nanos_since_unix_epoch};
220    use rstest::rstest;
221
222    use super::*;
223
224    // -- Normal build tests (real tokio) --
225
226    #[cfg(not(all(feature = "simulation", madsim)))]
227    #[tokio::test]
228    async fn test_dst_sleep() {
229        let start = time::Instant::now();
230        time::sleep(time::Duration::from_millis(10)).await;
231        let elapsed = start.elapsed();
232        assert!(elapsed >= time::Duration::from_millis(5));
233    }
234
235    #[cfg(not(all(feature = "simulation", madsim)))]
236    #[tokio::test]
237    async fn test_dst_task_spawn() {
238        let handle = task::spawn(async { 42 });
239        let result = handle.await.unwrap();
240        assert_eq!(result, 42);
241    }
242
243    #[cfg(not(all(feature = "simulation", madsim)))]
244    #[tokio::test]
245    async fn test_real_tokio_sync_alongside_dst() {
246        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
247        tx.send(7).unwrap();
248        let result = time::timeout(time::Duration::from_secs(1), rx.recv()).await;
249        assert_eq!(result.unwrap(), Some(7));
250    }
251
252    #[cfg(not(all(feature = "simulation", madsim)))]
253    #[rstest]
254    fn test_dst_runtime_builder() {
255        let rt = runtime::Builder::new_multi_thread()
256            .worker_threads(2)
257            .enable_all()
258            .build()
259            .unwrap();
260        let result = rt.block_on(async { 99 });
261        assert_eq!(result, 99);
262    }
263
264    // -- Simulation build tests (madsim) --
265
266    #[cfg(all(feature = "simulation", madsim))]
267    #[madsim::test]
268    async fn test_dst_sleep() {
269        let start = time::Instant::now();
270        time::sleep(time::Duration::from_millis(100)).await;
271        let elapsed = start.elapsed();
272        // Virtual time: ~100ms with sub-ms scheduling epsilon.
273        // Real tokio would show 100-115ms from OS jitter.
274        assert!(elapsed >= time::Duration::from_millis(100));
275        assert!(elapsed < time::Duration::from_millis(101));
276    }
277
278    #[cfg(all(feature = "simulation", madsim))]
279    #[madsim::test]
280    async fn test_dst_task_spawn() {
281        let handle = task::spawn(async { 42 });
282        let result = handle.await.unwrap();
283        assert_eq!(result, 42);
284    }
285
286    #[cfg(all(feature = "simulation", madsim))]
287    #[madsim::test]
288    async fn test_real_tokio_sync_alongside_dst() {
289        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
290        tx.send(7).unwrap();
291        let result = time::timeout(time::Duration::from_secs(1), rx.recv()).await;
292        assert_eq!(result.unwrap(), Some(7));
293    }
294
295    #[cfg(all(feature = "simulation", madsim))]
296    #[rstest]
297    fn test_dst_runtime_builder() {
298        let rt = runtime::Builder::new_multi_thread()
299            .worker_threads(2)
300            .enable_all()
301            .build()
302            .unwrap();
303        let result = rt.block_on(async { 99 });
304        assert_eq!(result, 99);
305    }
306
307    // Pins the wall-clock seam end-to-end on the common leg: the `simulation`
308    // feature on `nautilus-common` propagates `nautilus-core/simulation`, so
309    // `nautilus_core::time::nanos_since_unix_epoch` routes through
310    // `madsim::time::TimeHandle::current().now_time()`. Sleeping for 60
311    // virtual seconds via the common DST `time::sleep` re-export must advance
312    // the wall-clock value by 60s. If either the cfg gate or the propagation
313    // regressed, the elapsed value would only reflect real wall-clock time
314    // (~0ms) and this assertion would fail.
315    #[cfg(all(feature = "simulation", madsim))]
316    #[madsim::test]
317    async fn test_dst_wall_clock_advances_with_virtual_time() {
318        let before = nanos_since_unix_epoch();
319        time::sleep(time::Duration::from_secs(60)).await;
320        let after = nanos_since_unix_epoch();
321
322        let elapsed_ns = after.saturating_sub(before);
323        let sixty_seconds_ns = 60 * NANOSECONDS_IN_SECOND;
324        assert!(
325            elapsed_ns >= sixty_seconds_ns,
326            "wall clock did not advance by full virtual sleep: elapsed={elapsed_ns}ns"
327        );
328    }
329}