Skip to main content

simvar_utils/
lib.rs

1//! Utility functions for simulation testing and cancellation management.
2//!
3//! This crate provides utilities for managing worker threads and cancellation tokens
4//! in simulation environments. It supports both thread-local and global cancellation,
5//! allowing tests to gracefully terminate simulations and async operations.
6//!
7//! # Features
8//!
9//! * **Thread Management**: Unique worker thread ID tracking
10//! * **Cancellation Tokens**: Thread-local and global cancellation support
11//! * **Async Utilities**: Run futures until simulation cancellation
12//!
13//! # Example
14//!
15//! ```rust
16//! use simvar_utils::{worker_thread_id, run_until_simulation_cancelled};
17//!
18//! // Get unique thread ID
19//! let thread_id = worker_thread_id();
20//! println!("Worker thread ID: {}", thread_id);
21//!
22//! # async fn example() {
23//! # async fn simulate_work() -> u32 { 42 }
24//! // Run future until cancelled
25//! let result = run_until_simulation_cancelled(async {
26//!     simulate_work().await
27//! }).await;
28//!
29//! match result {
30//!     Some(output) => println!("Completed: {}", output),
31//!     None => println!("Cancelled"),
32//! }
33//! # }
34//! ```
35
36#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
37#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
38#![allow(clippy::multiple_crate_versions)]
39
40use std::{
41    cell::RefCell,
42    future::Future,
43    sync::{LazyLock, RwLock, atomic::AtomicU64},
44};
45
46use switchy::unsync::util::CancellationToken;
47
48static WORKER_THREAD_ID_COUNTER: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(1));
49
50thread_local! {
51    static WORKER_THREAD_ID: RefCell<u64> = RefCell::new(WORKER_THREAD_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
52}
53
54/// Returns the unique identifier for the current worker thread.
55///
56/// Each thread gets a unique, monotonically increasing ID starting from 1.
57#[must_use]
58pub fn worker_thread_id() -> u64 {
59    WORKER_THREAD_ID.with_borrow(|x| *x)
60}
61
62thread_local! {
63    static SIMULATOR_CANCELLATION_TOKEN: RefCell<RwLock<CancellationToken>> =
64        RefCell::new(RwLock::new(CancellationToken::new()));
65}
66
67/// Resets the thread-local simulation cancellation token.
68///
69/// Creates a new cancellation token for the current thread, clearing any previous
70/// cancellation state. Use this to prepare for a new simulation run.
71///
72/// # Panics
73///
74/// * If the `SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to write to
75pub fn reset_simulator_cancellation_token() {
76    SIMULATOR_CANCELLATION_TOKEN
77        .with_borrow_mut(|x| *x.write().unwrap() = CancellationToken::new());
78}
79
80/// Checks if the current thread's simulation has been cancelled.
81///
82/// Returns `true` if either the global or thread-local cancellation token has been triggered.
83///
84/// # Panics
85///
86/// * If the `SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
87#[must_use]
88pub fn is_simulator_cancelled() -> bool {
89    is_global_simulator_cancelled()
90        || SIMULATOR_CANCELLATION_TOKEN.with_borrow(|x| x.read().unwrap().is_cancelled())
91}
92
93/// Cancels the current thread's simulation.
94///
95/// Triggers the thread-local cancellation token, causing any futures running with
96/// [`run_until_simulation_cancelled`] to terminate.
97///
98/// # Panics
99///
100/// * If the `SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
101pub fn cancel_simulation() {
102    SIMULATOR_CANCELLATION_TOKEN.with_borrow(|x| x.read().unwrap().cancel());
103}
104
105static GLOBAL_SIMULATOR_CANCELLATION_TOKEN: LazyLock<RwLock<CancellationToken>> =
106    LazyLock::new(|| RwLock::new(CancellationToken::new()));
107
108/// Resets the global simulation cancellation token.
109///
110/// Creates a new global cancellation token, clearing any previous cancellation state
111/// across all threads. Use this to prepare for a new simulation run.
112///
113/// # Panics
114///
115/// * If the `GLOBAL_SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to write to
116pub fn reset_global_simulator_cancellation_token() {
117    *GLOBAL_SIMULATOR_CANCELLATION_TOKEN.write().unwrap() = CancellationToken::new();
118}
119
120/// Checks if the global simulation has been cancelled.
121///
122/// Returns `true` if the global cancellation token has been triggered, affecting all threads.
123///
124/// # Panics
125///
126/// * If the `GLOBAL_SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
127#[must_use]
128pub fn is_global_simulator_cancelled() -> bool {
129    GLOBAL_SIMULATOR_CANCELLATION_TOKEN
130        .read()
131        .unwrap()
132        .is_cancelled()
133}
134
135/// Cancels all simulations globally.
136///
137/// Triggers the global cancellation token, affecting all threads and causing any futures
138/// running with [`run_until_simulation_cancelled`] to terminate across the entire process.
139///
140/// # Panics
141///
142/// * If the `GLOBAL_SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
143pub fn cancel_global_simulation() {
144    GLOBAL_SIMULATOR_CANCELLATION_TOKEN.read().unwrap().cancel();
145}
146
147/// Runs a future until it completes or simulation is cancelled.
148///
149/// Returns `Some(output)` if the future completes, or `None` if either the global
150/// or thread-local simulation cancellation token is triggered.
151///
152/// # Examples
153///
154/// ```rust
155/// use simvar_utils::{reset_global_simulator_cancellation_token, reset_simulator_cancellation_token, run_until_simulation_cancelled};
156///
157/// # async fn example() {
158/// reset_global_simulator_cancellation_token();
159/// reset_simulator_cancellation_token();
160///
161/// let output = run_until_simulation_cancelled(async { 7_u8 }).await;
162/// assert_eq!(output, Some(7));
163/// # }
164/// ```
165///
166/// # Panics
167///
168/// * If the `GLOBAL_SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
169/// * If the `SIMULATOR_CANCELLATION_TOKEN` `RwLock` fails to read from
170pub async fn run_until_simulation_cancelled<F>(fut: F) -> Option<F::Output>
171where
172    F: Future,
173{
174    let global_token = GLOBAL_SIMULATOR_CANCELLATION_TOKEN.read().unwrap().clone();
175    let local_token = SIMULATOR_CANCELLATION_TOKEN.with_borrow(|x| x.read().unwrap().clone());
176
177    switchy::unsync::select! {
178        resp = fut => Some(resp),
179        () = global_token.cancelled() => None,
180        () = local_token.cancelled() => None,
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use std::sync::{
187        Arc,
188        atomic::{AtomicBool, Ordering},
189    };
190
191    use serial_test::serial;
192
193    use super::*;
194
195    // Note: All tests in this module use #[serial] because they interact with the global
196    // SIMULATOR_CANCELLATION_TOKEN state. Running these tests in parallel would cause
197    // race conditions where one test's state changes affect another test's expectations.
198    // The serial_test crate ensures these tests run one at a time.
199
200    #[test_log::test]
201    #[serial]
202    fn test_worker_thread_id_returns_unique_ids() {
203        let id1 = worker_thread_id();
204        let id2 = worker_thread_id();
205        // Same thread should return same ID
206        assert_eq!(id1, id2);
207    }
208
209    #[test_log::test]
210    #[serial]
211    fn test_worker_thread_id_uniqueness_across_threads() {
212        let id1 = worker_thread_id();
213        let handle = std::thread::spawn(worker_thread_id);
214        let id2 = handle.join().unwrap();
215        // Different threads should have different IDs
216        assert_ne!(id1, id2);
217    }
218
219    #[test_log::test]
220    #[serial]
221    fn test_local_cancellation_isolated_between_threads() {
222        // Reset all states
223        reset_global_simulator_cancellation_token();
224        reset_simulator_cancellation_token();
225
226        // Cancel local simulation on this thread
227        cancel_simulation();
228        assert!(is_simulator_cancelled());
229
230        // Spawn a new thread and check its local state is NOT cancelled
231        let handle = std::thread::spawn(|| {
232            // This thread has its own thread-local token which should NOT be cancelled
233            reset_simulator_cancellation_token();
234            is_simulator_cancelled()
235        });
236
237        let other_thread_cancelled = handle.join().unwrap();
238        // The other thread's local cancellation state should be false
239        // (since we reset it and only cancelled on the main thread)
240        assert!(
241            !other_thread_cancelled,
242            "Local cancellation should not affect other threads"
243        );
244    }
245
246    #[test_log::test]
247    #[serial]
248    fn test_reset_simulator_cancellation_token() {
249        // Reset all states
250        reset_global_simulator_cancellation_token();
251        reset_simulator_cancellation_token();
252
253        // Cancel the token
254        cancel_simulation();
255        assert!(is_simulator_cancelled());
256
257        // Reset should clear cancellation
258        reset_simulator_cancellation_token();
259        assert!(!is_simulator_cancelled());
260    }
261
262    #[test_log::test]
263    #[serial]
264    fn test_cancel_simulation_sets_cancelled_state() {
265        // Reset all states
266        reset_global_simulator_cancellation_token();
267        reset_simulator_cancellation_token();
268
269        assert!(!is_simulator_cancelled());
270
271        cancel_simulation();
272        assert!(is_simulator_cancelled());
273    }
274
275    #[test_log::test]
276    #[serial]
277    fn test_is_simulator_cancelled_respects_global_cancellation() {
278        // Reset all states
279        reset_global_simulator_cancellation_token();
280        reset_simulator_cancellation_token();
281
282        assert!(!is_simulator_cancelled());
283
284        cancel_global_simulation();
285        // Local cancellation should detect global cancellation
286        assert!(is_simulator_cancelled());
287    }
288
289    #[test_log::test]
290    #[serial]
291    fn test_global_cancellation_independent_from_local() {
292        // Reset all states
293        reset_global_simulator_cancellation_token();
294        reset_simulator_cancellation_token();
295
296        cancel_simulation();
297        // Local cancelled but not global directly
298        assert!(!is_global_simulator_cancelled());
299        assert!(is_simulator_cancelled());
300    }
301
302    #[test_log::test]
303    #[serial]
304    fn test_reset_global_simulator_cancellation_token() {
305        // Reset all states
306        reset_global_simulator_cancellation_token();
307        reset_simulator_cancellation_token();
308
309        cancel_global_simulation();
310
311        assert!(is_global_simulator_cancelled());
312
313        reset_global_simulator_cancellation_token();
314        assert!(!is_global_simulator_cancelled());
315    }
316
317    #[test_log::test(switchy_async::test)]
318    #[serial]
319    async fn test_run_until_simulation_cancelled_completes_normally() {
320        // Reset all states
321        reset_global_simulator_cancellation_token();
322        reset_simulator_cancellation_token();
323
324        let result = run_until_simulation_cancelled(async { 42 }).await;
325        assert_eq!(result, Some(42));
326    }
327
328    #[test_log::test(switchy_async::test)]
329    #[serial]
330    async fn test_run_until_simulation_cancelled_with_local_cancellation() {
331        // Reset all states
332        reset_global_simulator_cancellation_token();
333        reset_simulator_cancellation_token();
334
335        let cancel_task = async {
336            cancel_simulation();
337        };
338
339        let work_task = async {
340            // This will never complete
341            std::future::pending::<()>().await;
342            42
343        };
344
345        // Cancel immediately
346        cancel_task.await;
347        let result = run_until_simulation_cancelled(work_task).await;
348        assert_eq!(result, None);
349    }
350
351    #[test_log::test(switchy_async::test)]
352    #[serial]
353    async fn test_run_until_simulation_cancelled_with_global_cancellation() {
354        // Reset all states
355        reset_global_simulator_cancellation_token();
356        reset_simulator_cancellation_token();
357
358        let cancel_task = async {
359            cancel_global_simulation();
360        };
361
362        let work_task = async {
363            // This will never complete
364            std::future::pending::<()>().await;
365            42
366        };
367
368        // Cancel immediately
369        cancel_task.await;
370        let result = run_until_simulation_cancelled(work_task).await;
371        assert_eq!(result, None);
372    }
373
374    #[test_log::test]
375    #[serial]
376    fn test_global_cancellation_affects_other_threads() {
377        // Reset all states
378        reset_global_simulator_cancellation_token();
379        reset_simulator_cancellation_token();
380
381        // Verify not cancelled initially
382        assert!(!is_global_simulator_cancelled());
383
384        // Cancel globally from main thread
385        cancel_global_simulation();
386
387        // Verify another thread sees the global cancellation
388        let handle = std::thread::spawn(|| {
389            // Reset this thread's local token (should not affect global)
390            reset_simulator_cancellation_token();
391            // This should still return true because global is cancelled
392            is_simulator_cancelled()
393        });
394
395        let other_thread_sees_cancellation = handle.join().unwrap();
396        assert!(
397            other_thread_sees_cancellation,
398            "Global cancellation should be visible to all threads"
399        );
400    }
401
402    #[test_log::test]
403    #[serial]
404    fn test_worker_thread_ids_are_monotonically_increasing() {
405        // Spawn multiple threads and collect their IDs
406        let mut handles = Vec::new();
407        for _ in 0..5 {
408            handles.push(std::thread::spawn(worker_thread_id));
409        }
410
411        let mut ids: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
412
413        // Sort to verify all IDs are unique
414        ids.sort_unstable();
415        let original_len = ids.len();
416        ids.dedup();
417        assert_eq!(ids.len(), original_len, "All thread IDs should be unique");
418
419        // All IDs should be >= 1 (IDs start at 1)
420        assert!(ids.iter().all(|&id| id >= 1), "All IDs should be >= 1");
421    }
422
423    #[test_log::test]
424    #[serial]
425    fn test_is_simulator_cancelled_with_both_local_and_global_cancelled() {
426        // Reset all states
427        reset_global_simulator_cancellation_token();
428        reset_simulator_cancellation_token();
429
430        // Cancel both local and global
431        cancel_simulation();
432        cancel_global_simulation();
433
434        // is_simulator_cancelled should return true (tests the OR logic when both are true)
435        assert!(is_simulator_cancelled());
436        assert!(is_global_simulator_cancelled());
437
438        // Reset only global, local should still keep it cancelled
439        reset_global_simulator_cancellation_token();
440        assert!(is_simulator_cancelled());
441        assert!(!is_global_simulator_cancelled());
442
443        // Reset local too, now should be false
444        reset_simulator_cancellation_token();
445        assert!(!is_simulator_cancelled());
446    }
447
448    #[test_log::test]
449    #[serial]
450    fn test_global_cancellation_from_multiple_threads_is_thread_safe() {
451        // Reset all states
452        reset_global_simulator_cancellation_token();
453
454        // Spawn multiple threads that all try to cancel globally
455        let mut handles = Vec::new();
456        for _ in 0..10 {
457            handles.push(std::thread::spawn(|| {
458                cancel_global_simulation();
459                is_global_simulator_cancelled()
460            }));
461        }
462
463        // All threads should see the cancellation
464        for handle in handles {
465            let result = handle.join().unwrap();
466            assert!(result, "All threads should see global cancellation");
467        }
468
469        // Main thread should also see it
470        assert!(is_global_simulator_cancelled());
471    }
472
473    #[test_log::test(switchy_async::test)]
474    #[serial]
475    async fn test_run_until_simulation_cancelled_with_concurrent_cancellation() {
476        // Reset all states
477        reset_global_simulator_cancellation_token();
478        reset_simulator_cancellation_token();
479
480        let work_started = Arc::new(AtomicBool::new(false));
481        let work_started_clone = Arc::clone(&work_started);
482
483        // Create a task that signals when it starts and then waits forever
484        let work_task = async move {
485            work_started_clone.store(true, Ordering::SeqCst);
486            std::future::pending::<()>().await;
487            42
488        };
489
490        // Spawn the cancellation in a way that happens after work starts
491        let result = switchy::unsync::select! {
492            result = run_until_simulation_cancelled(work_task) => result,
493            () = async {
494                // Wait until work has started
495                while !work_started.load(Ordering::SeqCst) {
496                    switchy::unsync::task::yield_now().await;
497                }
498                // Now cancel
499                cancel_simulation();
500            } => None,
501        };
502
503        assert_eq!(result, None, "Task should be cancelled");
504    }
505}