Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shuttle/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ regex = { version = "1.10.6", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
const-siphasher = "1.0.2"
config = { version = "0.15.18", default-features = false, features = ["toml"] }

[dev-dependencies]
criterion = { version = "0.4.0", features = ["html_reports"] }
Expand Down
62 changes: 62 additions & 0 deletions shuttle/shuttle_config_example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Shuttle Configuration Example
# This file demonstrates available configuration options for Shuttle

# Stack size for each thread (in bytes)
stack_size = 32768

# How to persist failing schedules: "none", "print", or "file"
failure_persistence = "print"

# Optional: Directory path for file persistence (only used when failure_persistence = "file")
# failure_persistence_path = "./shuttle_failures"

# Maximum number of steps before taking action (0 means no limit when max_steps_behavior = "none")
max_steps = 1000000

# What to do when max_steps is reached: "fail", "continue", or "none"
max_steps_behavior = "fail"

# Optional: Maximum time in seconds for a single test iteration
# max_time_secs = 60

# Suppress warning messages
silence_warnings = false

# Record execution steps in tracing spans
record_steps_in_span = false

# Return immediately when a panic occurs (vs continuing to explore other schedules)
immediately_return_on_panic = false

# Enable metrics collection
enable_metrics = false

# Check for uncontrolled nondeterminism
check_uncontrolled_nondeterminism = false

# Scheduler configuration
[scheduler]
# Scheduler type: "random", "pct", "dfs", "replay", "round_robin", or "urw"
# Additional schedulers can be registered using the scheduler registry
type = "random"

# Number of iterations to run
iterations = 100

# Optional: Random seed for reproducible runs
seed = 42

# PCT-specific: Number of priority change points
depth = 3

# DFS-specific: Maximum iterations before stopping
max_iterations = 10000

# DFS-specific: Allow random data generation
allow_random_data = false

# Replay-specific: Path to schedule file
schedule_file = "./schedule.json"

# Replay-specific: Inline schedule string
schedule = "..."
208 changes: 202 additions & 6 deletions shuttle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,14 @@ pub mod scheduler;

mod runtime;

use std::path::Path;
use std::path::PathBuf;

pub use runtime::runner::{PortfolioRunner, Runner};
pub use scheduler::registry::{register_scheduler, SchedulerFactory};

/// Configuration parameters for Shuttle
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct Config {
/// Stack size allocated for each thread
Expand Down Expand Up @@ -265,6 +269,43 @@ impl Config {
immediately_return_on_panic: false,
}
}

/// Create Config for a single Shuttle test from a global config::Config
pub fn from_global_config(settings: &config::Config) -> Self {
Self::try_from_global_config(settings).expect("Failed to load configuration")
}

fn try_from_global_config(global_config: &config::Config) -> Result<Self, config::ConfigError> {
let stack_size = global_config.get_int("stack_size")? as usize;
let failure_persistence = FailurePersistence::variant_from_string(
&global_config.get_string("failure_persistence")?,
global_config
.get_string("failure_persistence_path")
.ok()
.map(PathBuf::from),
);
let max_steps = MaxSteps::variant_from_string(
&global_config.get_string("max_steps_behavior")?,
global_config.get_int("max_steps")? as usize,
);
let max_time = global_config
.get_int("max_time_secs")
.ok()
.map(|s| std::time::Duration::from_secs(s as u64));
let silence_warnings = global_config.get_bool("silence_warnings")?;
let record_steps_in_span = global_config.get_bool("record_steps_in_span")?;
let immediately_return_on_panic = global_config.get_bool("immediately_return_on_panic")?;

Ok(Self {
stack_size,
failure_persistence,
max_steps,
max_time,
silence_warnings,
record_steps_in_span,
immediately_return_on_panic,
})
}
}

impl Default for Config {
Expand All @@ -290,6 +331,25 @@ pub enum FailurePersistence {
File(Option<std::path::PathBuf>),
}

impl FailurePersistence {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These things might be easier with https://docs.rs/strum/latest/strum/

fn variant_to_string(&self) -> &str {
match self {
FailurePersistence::None => "none",
FailurePersistence::Print => "print",
FailurePersistence::File(_) => "file",
}
}

fn variant_from_string(s: &str, path: Option<std::path::PathBuf>) -> Self {
match s {
"none" => FailurePersistence::None,
"file" => FailurePersistence::File(path),
"print" => FailurePersistence::Print,
_ => panic!("Unexpected failure_persistence: {s}"),
}
}
}

/// Specifies an upper bound on the number of steps a single iteration of a Shuttle test can take,
/// and how to react when the bound is reached.
///
Expand Down Expand Up @@ -319,19 +379,155 @@ pub enum MaxSteps {
ContinueAfter(usize),
}

/// Run the given function once under a round-robin concurrency scheduler.
// TODO consider removing this -- round robin scheduling is never what you want.
#[doc(hidden)]
impl MaxSteps {
fn variant_to_string(&self) -> &str {
match self {
MaxSteps::None => "none",
MaxSteps::FailAfter(_) => "fail",
MaxSteps::ContinueAfter(_) => "continue",
}
}

fn variant_from_string(s: &str, value: usize) -> Self {
match s {
"none" => MaxSteps::None,
"fail" => MaxSteps::FailAfter(value),
"continue" => MaxSteps::ContinueAfter(value),
_ => panic!("Unexpected max_steps_behavior: {s}"),
}
}
}

fn should_check_uncontrolled_nondeterminism(global_config: &config::Config) -> bool {
global_config.get_bool("check_uncontrolled_nondeterminism").unwrap()
}

fn should_enable_metrics(global_config: &config::Config) -> bool {
global_config.get_bool("enable_metrics").unwrap()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These things should be impled on &self where self is the config and live somewhere else


/// Run the given function once under the globally configured Shuttle scheduler.
///
/// # Configuration Sources
///
/// Configuration is loaded in the following order (later sources override earlier ones):
/// 1. Default values from [`Config::default`]
/// 2. TOML file (path determined by `SHUTTLE_CONFIG_FILE` env var or `shuttle.toml` by default)
/// 3. Environment variables with the `SHUTTLE.` prefix
///
/// # Environment Variables
///
/// Any configuration option can be overridden via environment variables using the pattern
/// `SHUTTLE.<KEY>`. Nested fields (such as scheduler-specific configuration) are also delineated
/// by `.`s. For example:
/// - `SHUTTLE.STACK_SIZE=65536` sets the stack size
/// - `SHUTTLE.SCHEDULER.TYPE=random` sets the scheduler type
/// - `SHUTTLE.SCHEDULER.ITERATIONS=200` sets the number of iterations
///
/// # Custom Schedulers
///
/// Custom scheduler implementations can be registered with [`register_scheduler`] to make them
/// available via configuration. This allows custom schedulers to be used with `check` without
/// modifying test code:
///
/// ```no_run
/// use shuttle::scheduler::{Schedule, Scheduler, Task, TaskId};
/// use shuttle::register_scheduler;
///
/// struct MyScheduler;
/// impl Scheduler for MyScheduler {
/// fn new_execution(&mut self) -> Option<Schedule> { Some(Schedule::new(0)) }
/// fn next_task(&mut self, runnable: &[&Task], _: Option<TaskId>, _: bool) -> Option<TaskId> {
/// runnable.first().map(|t| t.id())
/// }
/// fn next_u64(&mut self) -> u64 { 0 }
/// }
///
/// register_scheduler("my_scheduler", |_config| Box::new(MyScheduler));
/// ```
///
/// # Example
///
/// ```no_run
/// use shuttle::sync::{Arc, Mutex};
/// use shuttle::thread;
///
/// // Set the config file path via environment variable
/// std::env::set_var("SHUTTLE_CONFIG_FILE", "shuttle_config_example.toml");
///
/// shuttle::check(|| {
/// let lock = Arc::new(Mutex::new(0u64));
/// let lock2 = lock.clone();
///
/// thread::spawn(move || {
/// *lock.lock().unwrap() = 1;
/// });
///
/// let _ = *lock2.lock().unwrap();
/// });
/// ```
pub fn check<F>(f: F)
where
F: Fn() + Send + Sync + 'static,
{
use crate::scheduler::RoundRobinScheduler;
let global_config = load_global_config();
let config = Config::from_global_config(&global_config);
let scheduler_type = global_config
.get_string("scheduler.type")
.expect("No scheduler type found in global config!");
let mut scheduler = scheduler::registry::create_scheduler(&scheduler_type, &global_config);
if should_enable_metrics(&global_config) {
scheduler = Box::new(scheduler::metrics::MetricsScheduler::new(scheduler));
}
if should_check_uncontrolled_nondeterminism(&global_config) {
scheduler = Box::new(scheduler::UncontrolledNondeterminismCheckScheduler::new(scheduler));
}

let runner = Runner::new(RoundRobinScheduler::new(1), Default::default());
let runner = Runner::new(scheduler, config);
runner.run(f);
}

/// Load configuration from TOML files and environment variables
#[doc(hidden)]
pub fn load_global_config() -> config::Config {
load_global_config_from(None::<&str>)
}

/// Load configuration from TOML files and environment variables with an optional file path
#[doc(hidden)]
pub fn load_global_config_from<P: AsRef<Path>>(config_path: Option<P>) -> config::Config {
try_load_global_config_from(config_path).expect("Failed to load configuration")
}

fn try_load_global_config_from<P: AsRef<Path>>(config_path: Option<P>) -> Result<config::Config, config::ConfigError> {
let defaults = Config::default();

let max_steps_value = match defaults.max_steps {
MaxSteps::None => 0_i128,
MaxSteps::FailAfter(n) | MaxSteps::ContinueAfter(n) => n as i128,
};
let max_steps_behavior = defaults.max_steps.variant_to_string();

let config_path = config_path
.map(|p| p.as_ref().to_path_buf())
.or_else(|| std::env::var("SHUTTLE_CONFIG_FILE").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("shuttle"));

config::Config::builder()
.set_default("stack_size", defaults.stack_size as i128)?
.set_default("failure_persistence", defaults.failure_persistence.variant_to_string())?
.set_default("max_steps", max_steps_value)?
.set_default("max_steps_behavior", max_steps_behavior)?
.set_default("silence_warnings", defaults.silence_warnings)?
.set_default("record_steps_in_span", defaults.record_steps_in_span)?
.set_default("immediately_return_on_panic", defaults.immediately_return_on_panic)?
.set_default("enable_metrics", false)?
.set_default("check_uncontrolled_nondeterminism", false)?
.add_source(config::File::from(config_path.as_path()).required(false))
.add_source(config::Environment::with_prefix("SHUTTLE").separator("."))
.build()
}

/// Run the given function under a *uniformly* random scheduler for some number of iterations.
/// Each iteration will run a (potentially) different randomized schedule.
pub fn check_urw<F>(f: F, iterations: usize)
Expand Down
7 changes: 7 additions & 0 deletions shuttle/src/scheduler/dfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ impl DfsScheduler {
}
}

/// Construct a new DfsScheduler from configuration.
pub fn from_config(config: &config::Config) -> Self {
let max_iterations = config.get_int("scheduler.max_iterations").ok().map(|i| i as usize);
let allow_random_data = config.get_bool("scheduler.allow_random_data").unwrap_or(false);
Self::new(max_iterations, allow_random_data)
}

/// Check if there are any scheduling points at or below the `index`th level that have remaining
/// schedulable tasks to explore.
// TODO probably should memoize this -- at each iteration, just need to know the largest i
Expand Down
2 changes: 2 additions & 0 deletions shuttle/src/scheduler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ mod data;
mod dfs;
mod pct;
mod random;
/// Global registry for custom schedulers
pub mod registry;
mod replay;
mod round_robin;
mod uncontrolled_nondeterminism;
Expand Down
11 changes: 11 additions & 0 deletions shuttle/src/scheduler/pct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ impl PctScheduler {
Self::new_from_seed(OsRng.next_u64(), max_depth, max_iterations)
}

/// Construct a new PctScheduler from configuration.
pub fn from_config(config: &config::Config) -> Self {
let depth = config.get_int("scheduler.depth").unwrap_or(3) as usize;
let iterations = config.get_int("scheduler.iterations").unwrap_or(100) as usize;
if let Ok(seed) = config.get_int("scheduler.seed") {
Self::new_from_seed(seed as u64, depth, iterations)
} else {
Self::new(depth, iterations)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these shouldn't exist and that constructing a scheduler from a config should be done via the regular scheduler creation apis and live with the config


/// Construct a new PCTScheduler with a given seed.
///
/// If the `SHUTTLE_RANDOM_SEED` environment variable is set, then that seed will be used instead.
Expand Down
10 changes: 10 additions & 0 deletions shuttle/src/scheduler/random.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ impl RandomScheduler {
Self::new_from_seed(OsRng.next_u64(), max_iterations)
}

/// Construct a new RandomScheduler from configuration.
pub fn from_config(config: &config::Config) -> Self {
let iterations = config.get_int("scheduler.iterations").unwrap_or(100) as usize;
if let Ok(seed) = config.get_int("scheduler.seed") {
Self::new_from_seed(seed as u64, iterations)
} else {
Self::new(iterations)
}
}

/// Construct a new RandomScheduler with a given seed.
///
/// Two RandomSchedulers initialized with the same seed will make the same scheduling decisions
Expand Down
Loading
Loading