Skip to content
Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Unreleased

* Failing schedules are now serialized with a move-to-front encoding and printed with a Unicode alphabet that stacks zero-width combining marks. A 420,000-step schedule over 300 tasks that used to print as 8,777 lines now prints as one. Both axes are configurable, via `Config::schedule_encoding` (`ScheduleEncoding`) and `Config::schedule_text_encoding` (`ScheduleTextEncoding`); `ScheduleTextEncoding::Unicode { marks_per_cell: 0 }` gives a still-compact form that does not depend on the terminal treating marks as zero-width. Deserialization detects the format automatically, and the stacking depth is not recorded, so schedules recorded by older versions, or at any depth, still replay.
* `Config::schedule_text_encoding` defaults to `ScheduleTextEncoding::Auto`, which uses the Unicode alphabet when the destination can carry non-ASCII text and hex when it cannot. Schedules written to a file always get Unicode; schedules printed to a terminal get it only if the locale (`LC_ALL`, `LC_CTYPE`, `LANG`) says UTF-8, so a run under `LC_ALL=C` falls back to hex on its own.
* A failure now reports one schedule rather than two. Shuttle used to report from the panic hook and again after the panic had unwound; it now reports once, after the unwind, which also gives the more complete schedule. Reporting from the hook is still what gets a schedule out when a second panic during unwinding aborts the process, so it is available via the new `Config::eager_failure_reports`. With that set, reporting is grow-only: the later, longer schedule supersedes the earlier one, a panic the test catches itself no longer consumes the execution's report, and under `FailurePersistence::Print` the superseding block says so.
* Fix: under `FailurePersistence::File`, a single failure could write two schedule files, the second a longer version of the first. The longer schedule now rewrites the same file, so one failure leaves one file holding the most complete schedule.
* Fix: the panic hook captured the `Config` of the first execution in the process, so later `Runner`s' `FailurePersistence` settings were ignored.
* Fix: replaying a persisted schedule no longer reports "schedule ended early". A schedule recorded at the moment of failure stops there, but the replayed execution keeps scheduling as the panic unwinds; those decisions are now made freely instead of aborting the replay. `ReplayScheduler::set_allow_incomplete` is no longer needed to replay a schedule Shuttle wrote.
* Fix: a panic occurring after a Shuttle test finished, including one raised by the surrounding test harness, was reported as a Shuttle failure and serialized the schedule of the execution that had already completed.

# 0.9.2 (August 6, 2026)

* Add support for 128-bit atomics (`AtomicI128`/`AtomicU128`) (#299)
Expand Down
177 changes: 177 additions & 0 deletions shuttle-engine/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ pub struct Config {
/// How to persist schedules when a test fails
pub failure_persistence: FailurePersistence,

/// Whether to report a failing schedule from the panic hook, at the moment of the panic, in
/// addition to reporting one after the panic has finished unwinding.
///
/// By default Shuttle reports once, after the unwind. That schedule also covers the scheduling
/// decisions the unwind itself needed, so it is the one that reproduces the failure most
/// faithfully, and reporting only once keeps the output to a single schedule.
///
/// Set this to `true` if a test is at risk of *aborting* rather than unwinding. A panic raised
/// while another panic is unwinding aborts the process immediately, which is a common shape in
/// Rust: a panic poisons a lock, and then a `Drop` handler run by the unwind tries to acquire that
/// same lock. An abort runs no further code, so a report scheduled for after the unwind never
/// happens and the failure is lost. Reporting from the hook gets a schedule out before that can
/// occur.
///
/// The cost is that a failure then reports twice, since the schedule keeps growing during the
/// unwind and the later, longer one supersedes it. Under [`FailurePersistence::File`] the second
/// report rewrites the first one's file, so this costs nothing but a little stderr. Under
/// [`FailurePersistence::Print`] both schedules appear in the output, the second marked as
/// superseding the first.
///
/// This also affects panics a test catches itself, which run the hook just the same. With this
/// off, a swallowed panic reports nothing; with it on, it reports a schedule even if the test goes
/// on to pass.
pub eager_failure_reports: bool,

/// Maximum number of steps a single iteration of a test can take, and how to react when the
/// limit is reached
pub max_steps: MaxSteps,
Expand Down Expand Up @@ -47,6 +72,15 @@ pub struct Config {

/// The config to define how to handle ungraceful shutdowns, ie. when the test panics.
pub ungraceful_shutdown_config: UngracefulShutdownConfig,

/// Which encoding to use when serializing a failing schedule. This only affects schedules
/// Shuttle *writes*; both encodings can always be read back, so changing this does not
/// invalidate schedules you have already saved.
pub schedule_encoding: ScheduleEncoding,

/// Which alphabet to use when rendering a serialized schedule as text. As with
/// [`Config::schedule_encoding`], this only affects schedules Shuttle *writes*.
pub schedule_text_encoding: ScheduleTextEncoding,
}

std::thread_local! {
Expand Down Expand Up @@ -120,11 +154,14 @@ impl Config {
Self {
stack_size: 0xf000,
failure_persistence: FailurePersistence::Print,
eager_failure_reports: false,
max_steps: MaxSteps::FailAfter(1_000_000),
max_time: None,
silence_warnings: false,
record_steps_in_span: false,
ungraceful_shutdown_config: UngracefulShutdownConfig::default(),
schedule_encoding: ScheduleEncoding::default(),
schedule_text_encoding: ScheduleTextEncoding::default(),
}
}
}
Expand Down Expand Up @@ -152,6 +189,146 @@ pub enum FailurePersistence {
File(Option<std::path::PathBuf>),
}

/// Specifies the alphabet used to render a serialized [`Schedule`](crate::scheduler::Schedule) as
/// text.
///
/// This is independent of [`ScheduleEncoding`], which chooses how the schedule's *bytes* are
/// produced; this chooses how those bytes are turned into a printable string. As with
/// `ScheduleEncoding`, deserialization detects the alphabet automatically, so changing this never
/// prevents an existing schedule from being replayed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScheduleTextEncoding {
/// Choose between [`ScheduleTextEncoding::Hex`] and [`ScheduleTextEncoding::Unicode`] based on
/// whether the destination can carry non-ASCII text. This is the default.
///
/// A schedule written to a file always gets the Unicode alphabet, because a file is a byte sink.
/// A schedule printed to a terminal gets it only if the locale says the terminal is expecting
/// UTF-8, which is the mechanism POSIX defines for exactly this question: `LC_ALL`, then
/// `LC_CTYPE`, then `LANG`, first one set wins. So a run under `LC_ALL=C`, or on a system whose
/// locale is not configured, falls back to hex on its own.
///
/// Note what this does *not* detect. Whether the terminal accepts UTF-8 says nothing about
/// whether it renders stacked combining marks as zero-width, and no environment variable answers
/// that. Measuring it would mean writing the text and then querying the cursor position, which
/// needs raw mode on the same terminal, and Shuttle reports schedules from a panic hook, possibly
/// while a panic is still unwinding. So `Auto` decides the alphabet, which is knowable, and
/// leaves the stacking depth to you. If your terminal renders the marks as separate cells, set
/// `Unicode { marks_per_cell: 0 }`, whose cost does not depend on the terminal at all.
Auto,

/// Render as hexadecimal.
///
/// Four bits per character, and pure ASCII, so it survives anything. Use this if a schedule has
/// to pass through tooling that mangles or strips non-ASCII text.
Hex,

/// Render using a dense Unicode alphabet, optionally stacking invisible combining marks to fit
/// more data into each terminal column.
///
/// Each column carries a 14-bit base character plus `marks_per_cell` combining marks of 8 bits
/// each, against hex's 4 bits per column. At the default depth the whole schedule occupies a
/// single cell and prints on one line, however long it is. A checksum is included, so a schedule
/// damaged in transit is reported rather than silently replayed as a different schedule.
///
/// Deeper stacking is denser but relies on the terminal treating every mark as zero-width.
/// Terminals cap how many marks they will attach to one cell, and the cap varies between them.
/// Past that cap a terminal either drops the excess, which damages the schedule and is what the
/// checksum is there to catch, or renders each mark as its own cell, which is harmless but means
/// the output takes one column per character rather than one per cell. If either bothers you,
/// lower this, set it to zero to emit base characters only, or use [`ScheduleTextEncoding::Hex`]
/// or [`FailurePersistence::File`] instead.
Unicode {
/// Number of combining marks to stack on each base character. Zero emits base characters
/// only. Any value beyond the number of marks the payload needs simply puts the remainder of
/// the schedule in the current cell, so a large value means "as few cells as possible".
///
/// The depth is not recorded in the output. The decoder infers each character's width from
/// the character itself, so schedules written at any depth are readable by any version.
marks_per_cell: u32,
},
}

impl ScheduleTextEncoding {
/// Create a new default `ScheduleTextEncoding`.
pub const fn new() -> Self {
Self::Auto
}

/// What [`ScheduleTextEncoding::Auto`] resolves to when non-ASCII output is safe.
///
/// More marks than any schedule has bits, so the whole payload lands in one cell: one column, and
/// therefore one line, regardless of how long the schedule is.
pub const DENSE: Self = Self::Unicode {
marks_per_cell: u32::MAX,
};

/// Resolve [`ScheduleTextEncoding::Auto`] for a destination that either can or cannot carry
/// non-ASCII text. Every other variant is returned unchanged, so this is idempotent and safe to
/// apply more than once.
pub const fn resolve(self, destination_accepts_non_ascii: bool) -> Self {
match self {
Self::Auto if destination_accepts_non_ascii => Self::DENSE,
Self::Auto => Self::Hex,
other => other,
}
}
}

/// Whether stderr, which is where Shuttle prints failing schedules, can carry non-ASCII text.
///
/// If stderr is not a terminal it is a file, a pipe or a captured test log, all of which are byte
/// sinks that take UTF-8 without complaint. If it is a terminal, the locale decides, per POSIX.
///
/// Note that libtest's output capture intercepts `eprintln!` above the file descriptor rather than by
/// replacing it, so this still sees the real terminal under `cargo test`, which is the terminal the
/// captured output is eventually replayed to.
pub fn stderr_accepts_non_ascii() -> bool {
use std::io::IsTerminal;

if !std::io::stderr().is_terminal() {
return true;
}
["LC_ALL", "LC_CTYPE", "LANG"]
.iter()
.filter_map(|variable| std::env::var(variable).ok())
.find(|value| !value.is_empty())
.is_some_and(|value| {
let value = value.to_ascii_lowercase();
value.contains("utf-8") || value.contains("utf8")
})
}

impl Default for ScheduleTextEncoding {
fn default() -> Self {
Self::new()
}
}

/// Specifies how a [`Schedule`](crate::scheduler::Schedule) is encoded when it is serialized.
///
/// Deserialization always auto-detects the encoding from the schedule's leading magic byte, so this
/// setting only affects newly written schedules and never prevents an existing schedule from being
/// replayed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ScheduleEncoding {
/// Encode each step as a fixed-width field, sized to hold the largest
/// [`TaskId`](crate::runtime::task::TaskId) appearing anywhere in the schedule.
///
/// This is simple and fast, but it pays for the largest task ID on *every* step, so it is a
/// poor fit for long schedules over many tasks. Prefer [`ScheduleEncoding::MoveToFront`].
FixedWidth,

/// Encode each step as its rank in a move-to-front list of recently scheduled tasks.
///
/// Schedules typically rotate among a small set of live tasks even when many tasks exist, so
/// ranks are small and cluster near the front of the list. This is the default, and is
/// substantially more compact than [`ScheduleEncoding::FixedWidth`] for long schedules.
#[default]
MoveToFront,
}

/// 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
4 changes: 2 additions & 2 deletions shuttle-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ pub mod sync_types;
pub mod thread_support;

pub use config::{
Config, ContinuationFunctionBehavior, FailurePersistence, MaxSteps, UngracefulShutdownConfig,
UNGRACEFUL_SHUTDOWN_CONFIG,
Config, ContinuationFunctionBehavior, FailurePersistence, MaxSteps, ScheduleEncoding, ScheduleTextEncoding,
UngracefulShutdownConfig, UNGRACEFUL_SHUTDOWN_CONFIG,
};
pub use runtime::runner::{PortfolioRunner, Runner};
pub use sync_types::{ResourceSignature, ResourceType};
Expand Down
7 changes: 5 additions & 2 deletions shuttle-engine/src/runtime/execution.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::runtime::failure::{init_panic_hook, persist_failure};
use crate::runtime::failure::{begin_execution, init_panic_hook, persist_failure};
use crate::runtime::storage::{StorageKey, StorageMap};
use crate::runtime::task::clock::VectorClock;
use crate::runtime::task::labels::Labels;
Expand Down Expand Up @@ -146,7 +146,10 @@ impl Execution {
{
let state = RefCell::new(ExecutionState::new(config.clone(), Rc::clone(&self.scheduler)));

init_panic_hook(config.clone());
init_panic_hook();
// Held for the rest of this function, including while a failing execution unwinds, so that the
// panic hook only reports schedules for panics that actually happened inside the execution.
let _execution = begin_execution(config);
CurrentSchedule::init(self.initial_schedule.clone());
UNGRACEFUL_SHUTDOWN_CONFIG.set(config.ungraceful_shutdown_config);

Expand Down
Loading
Loading