fix: C allocator contract, shadertoy overloads/MSL stride, minidumps
Closes the remaining library rows of the final completeness audit (docs/rewrite/notes/final-Z3.md): - libghostty-vt: OSC parser storage goes through the caller-supplied C allocator via a ByteStorage abstraction; ghostty_osc_new reports GHOSTTY_OUT_OF_MEMORY and failed grows invalidate the OSC like Zig. Tested with a counting/failing/limiting C allocator. - shadertoy: sampler parameter rewriting is keyed by full signature so overloads work; the Metal uniform block keeps std140 strides for iChannelTime[4] (MSL offsets checked against the upload layout). - tripwire.zig ported (named trip points, hit counts, verification) and used to port the previously skipped allocation-failure tests that have a fallible Rust path; the rest are documented. - crash: fatal signals capture siginfo + registers and a forked helper writes a minidump (minidump-writer) as an envelope attachment, like sentry-native/Breakpad; tests parse it with the minidump crate. - kitty LoadingImage::debug_dump (debug builds) and the Darwin Instruments signpost around benchmark runs. Test Plan: - cargo test -p ghostty-vt-capi --test c_allocator - cargo test -p ghostty-crash --test minidump
This commit is contained in:
1 parent
5568f99bae
commit
8af6ab68d3
22 files changed
+2079
-57
No files matched your search
@@ -353,6 +353,42 @@ impl LoadingImage {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file name `debug_dump` writes (Zig `@tagName` spellings).
|
||||
#[cfg(debug_assertions)]
|
||||
#[must_use]
|
||||
pub fn debug_dump_filename(&self) -> String {
|
||||
let format = match self.image.format {
|
||||
TransmissionFormat::Rgb => "rgb",
|
||||
TransmissionFormat::Rgba => "rgba",
|
||||
TransmissionFormat::Png => "png",
|
||||
TransmissionFormat::GrayAlpha => "gray_alpha",
|
||||
TransmissionFormat::Gray => "gray",
|
||||
};
|
||||
let compression = match self.image.compression {
|
||||
TransmissionCompression::None => "none",
|
||||
TransmissionCompression::ZlibDeflate => "zlib_deflate",
|
||||
};
|
||||
format!(
|
||||
"image-{format}-{compression}-{}x{}-{}.data",
|
||||
self.image.width, self.image.height, self.image.id
|
||||
)
|
||||
}
|
||||
|
||||
/// Debug function to write the data to a file in `dir`. This is useful
|
||||
/// for capturing some test data for unit tests. Only exists in debug
|
||||
/// builds (Zig: `@compileError("debugDump in non-debug")`).
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn debug_dump_to(&self, dir: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::write(dir.join(self.debug_dump_filename()), &self.data)
|
||||
}
|
||||
|
||||
/// Same as [`Self::debug_dump_to`] using the current working directory,
|
||||
/// like Zig's `debugDump`.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn debug_dump(&self) -> std::io::Result<()> {
|
||||
self.debug_dump_to(std::path::Path::new("."))
|
||||
}
|
||||
|
||||
/// Adds a chunk of data to the image. Use this if the image
|
||||
/// is coming in chunks (the "m" parameter in the protocol).
|
||||
pub fn add_data(&mut self, data: &[u8]) -> Result<(), Error> {
|
||||
@@ -623,6 +659,37 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn loading_image_debug_dump_writes_data() {
|
||||
let mut li = LoadingImage {
|
||||
image: Image {
|
||||
id: 7,
|
||||
width: 3,
|
||||
height: 2,
|
||||
format: TransmissionFormat::GrayAlpha,
|
||||
compression: TransmissionCompression::ZlibDeflate,
|
||||
..Image::default()
|
||||
},
|
||||
data: vec![1, 2, 3, 4],
|
||||
display: None,
|
||||
quiet: Quiet::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
"image-gray_alpha-zlib_deflate-3x2-7.data",
|
||||
li.debug_dump_filename()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("ghostty-dump-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
li.debug_dump_to(&dir).unwrap();
|
||||
let got = std::fs::read(dir.join(li.debug_dump_filename())).unwrap();
|
||||
assert_eq!(vec![1, 2, 3, 4], got);
|
||||
li.image.format = TransmissionFormat::Png;
|
||||
li.image.compression = TransmissionCompression::None;
|
||||
assert_eq!("image-png-none-3x2-7.data", li.debug_dump_filename());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
const RGB_NONE_20X15: &[u8] =
|
||||
include_bytes!("testdata/image-rgb-none-20x15-2147483647-raw.data");
|
||||
const RGB_ZLIB_128X96: &[u8] =
|
||||
|
||||
@@ -22,3 +22,4 @@ pub mod style;
|
||||
pub mod tabstops;
|
||||
pub mod terminal;
|
||||
pub mod tmux;
|
||||
pub mod tripwire;
|
||||
@@ -196,6 +196,29 @@ pub struct PageList {
|
||||
pin_serial: u64,
|
||||
pause_checks: usize,
|
||||
}
|
||||
/// Failure points of [`PageList::init`] (Zig: `init_tw`). The Zig
|
||||
/// `init_memory_pool` point has no counterpart: Rust has no up-front memory
|
||||
/// pool, the slabs start empty and grow with the fallible reserves below.
|
||||
/// `viewport_pin` and `viewport_pin_track` are two allocations in Zig (pool
|
||||
/// create + set insert) that are one reserve in `track_pin` here; both points
|
||||
/// are checked before it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum InitPoint {
|
||||
InitPages,
|
||||
ViewportPin,
|
||||
ViewportPinTrack,
|
||||
}
|
||||
crate::tripwire_module!(init_tw, InitPoint, Error);
|
||||
|
||||
/// Failure points of the page creation done by `init` (Zig: `initPages_tw`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum InitPagesPoint {
|
||||
Node,
|
||||
BufStd,
|
||||
BufNonStd,
|
||||
}
|
||||
crate::tripwire_module!(init_pages_tw, InitPagesPoint, Error);
|
||||
|
||||
fn std_size() -> usize {
|
||||
Page::layout(page::STD_CAPACITY).total_size
|
||||
}
|
||||
@@ -242,12 +265,15 @@ impl PageList {
|
||||
pin_serial: 0,
|
||||
pause_checks: 0,
|
||||
};
|
||||
init_tw::check(InitPoint::InitPages)?;
|
||||
while s.total_rows < usize::from(rows) {
|
||||
s.grow()?;
|
||||
}
|
||||
let pin = s
|
||||
.get_top_left(Tag::Screen)
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
init_tw::check(InitPoint::ViewportPin)?;
|
||||
init_tw::check(InitPoint::ViewportPinTrack)?;
|
||||
s.viewport_pin = Some(s.track_pin(pin)?);
|
||||
Ok(s)
|
||||
}
|
||||
@@ -269,6 +295,7 @@ impl PageList {
|
||||
if Page::layout(cap).total_size > u32::MAX as usize {
|
||||
return Err(Error::OutOfSpace);
|
||||
}
|
||||
init_pages_tw::check(InitPagesPoint::Node)?;
|
||||
self.nodes.try_reserve(1).map_err(|_| Error::OutOfMemory)?;
|
||||
let mut data = if let Some(mut p) = self.clean_pool.pop().filter(|p| p.capacity == cap) {
|
||||
p.size = page::Size {
|
||||
@@ -281,6 +308,11 @@ impl PageList {
|
||||
p.reinit()?;
|
||||
p
|
||||
} else {
|
||||
if Page::layout(cap).total_size <= std_size() {
|
||||
init_pages_tw::check(InitPagesPoint::BufStd)?;
|
||||
} else {
|
||||
init_pages_tw::check(InitPagesPoint::BufNonStd)?;
|
||||
}
|
||||
Page::init(cap)?
|
||||
};
|
||||
data.size.rows = 0;
|
||||
@@ -2972,6 +3004,55 @@ mod tests {
|
||||
s.clear_dirty();
|
||||
}
|
||||
#[test]
|
||||
fn page_list_init_error() {
|
||||
use crate::tripwire::ResetMode;
|
||||
// Test every failure point in `init` and ensure we exit early
|
||||
// without panicking or leaving the tripwire armed.
|
||||
for tag in [
|
||||
InitPoint::InitPages,
|
||||
InitPoint::ViewportPin,
|
||||
InitPoint::ViewportPinTrack,
|
||||
] {
|
||||
init_tw::error_always(tag, Error::OutOfMemory);
|
||||
assert_eq!(
|
||||
Err(Error::OutOfMemory),
|
||||
PageList::init(80, 24, None).map(drop)
|
||||
);
|
||||
init_tw::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
// init creates pages transitively, so check each failure there too.
|
||||
let big = page::STD_CAPACITY.max_cols().unwrap() + 1;
|
||||
for tag in [
|
||||
InitPagesPoint::Node,
|
||||
InitPagesPoint::BufStd,
|
||||
InitPagesPoint::BufNonStd,
|
||||
] {
|
||||
init_pages_tw::error_always(tag, Error::OutOfMemory);
|
||||
let cols = if tag == InitPagesPoint::BufStd {
|
||||
80
|
||||
} else {
|
||||
big
|
||||
};
|
||||
assert_eq!(
|
||||
Err(Error::OutOfMemory),
|
||||
PageList::init(cols, 24, None).map(drop)
|
||||
);
|
||||
init_pages_tw::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
// Non-standard pages: fail on the second one.
|
||||
init_pages_tw::error_after(InitPagesPoint::BufNonStd, Error::OutOfMemory, 1);
|
||||
assert_eq!(
|
||||
Err(Error::OutOfMemory),
|
||||
PageList::init(big, page::STD_CAPACITY.rows + 1, None).map(drop)
|
||||
);
|
||||
init_pages_tw::end(ResetMode::Reset).unwrap();
|
||||
|
||||
// And with nothing armed init still works.
|
||||
PageList::init(80, 24, None).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn page_list_init_rows_across_two_pages() {
|
||||
let mut cols = 50;
|
||||
while initial_capacity(cols).rows >= 100 {
|
||||
|
||||
@@ -30,6 +30,14 @@ use crate::{
|
||||
size::CellCountInt,
|
||||
};
|
||||
|
||||
/// Failure points of [`ScreenSearch::reload_active`] (Zig: `reloadActive_tw`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReloadActivePoint {
|
||||
HistoryAppendNew,
|
||||
HistoryAppendExisting,
|
||||
}
|
||||
crate::tripwire_module!(reload_active_tw, ReloadActivePoint, Error);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScreenSearch {
|
||||
/// Identity of the screen being searched (its address). Only used to
|
||||
@@ -568,6 +576,7 @@ impl ScreenSearch {
|
||||
if hl.chunks[0].node == history_node {
|
||||
continue;
|
||||
}
|
||||
reload_active_tw::check(ReloadActivePoint::HistoryAppendNew)?;
|
||||
results.push(hl);
|
||||
}
|
||||
|
||||
@@ -584,6 +593,7 @@ impl ScreenSearch {
|
||||
// Matches! Reverse our list then append all the remaining
|
||||
// history items that didn't start on our original node.
|
||||
results.reverse();
|
||||
reload_active_tw::check(ReloadActivePoint::HistoryAppendExisting)?;
|
||||
results.append(&mut self.history_results);
|
||||
self.history_results = results;
|
||||
|
||||
@@ -1086,6 +1096,75 @@ mod tests {
|
||||
search.deinit(t.screen_mut());
|
||||
}
|
||||
|
||||
/// Shared setup of the two `reload_active partial history cleanup` tests:
|
||||
/// several "Fizz" matches in history, then more that move into history on
|
||||
/// the next reload.
|
||||
fn reload_failure_setup() -> (Terminal, ScreenSearch) {
|
||||
let mut t = terminal_scrollback(10, 2, usize::MAX);
|
||||
feed(&mut t, b"Fizz\r\nFizz\r\n");
|
||||
while total_pages(&t) < 3 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
for _ in 0..t.screen().pages.rows {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"Fizz.");
|
||||
|
||||
// Complete initial search
|
||||
let mut search = ScreenSearch::new(t.screen_mut(), b"Fizz").unwrap();
|
||||
search.search_all(t.screen_mut()).unwrap();
|
||||
|
||||
// Trigger reload by adding more content that changes the
|
||||
// active/history boundary.
|
||||
feed(&mut t, b"\r\nFizz\r\nFizz\r\n");
|
||||
while total_pages(&t) < 4 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
for _ in 0..t.screen().pages.rows {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
(t, search)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_active_partial_history_cleanup_on_append_slice_error() {
|
||||
use crate::tripwire::ResetMode;
|
||||
let (mut t, mut search) = reload_failure_setup();
|
||||
let before = search.history_results.len();
|
||||
|
||||
// Fail at the final append (after the loop completes). Nothing may be
|
||||
// half-applied to the existing history results.
|
||||
reload_active_tw::error_always(
|
||||
ReloadActivePoint::HistoryAppendExisting,
|
||||
Error::OutOfMemory,
|
||||
);
|
||||
// reload_active is called by select(), which triggers the error path.
|
||||
assert_eq!(
|
||||
Err(Error::OutOfMemory),
|
||||
search.select(t.screen_mut(), Select::Next)
|
||||
);
|
||||
reload_active_tw::end(ResetMode::Reset).unwrap();
|
||||
assert_eq!(before, search.history_results.len());
|
||||
search.deinit(t.screen_mut());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_active_partial_history_cleanup_on_loop_append_error() {
|
||||
use crate::tripwire::ResetMode;
|
||||
let (mut t, mut search) = reload_failure_setup();
|
||||
let before = search.history_results.len();
|
||||
|
||||
// Fail after the first loop append succeeded.
|
||||
reload_active_tw::error_after(ReloadActivePoint::HistoryAppendNew, Error::OutOfMemory, 1);
|
||||
assert_eq!(
|
||||
Err(Error::OutOfMemory),
|
||||
search.select(t.screen_mut(), Select::Next)
|
||||
);
|
||||
reload_active_tw::end(ResetMode::Reset).unwrap();
|
||||
assert_eq!(before, search.history_results.len());
|
||||
search.deinit(t.screen_mut());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_into_history() {
|
||||
let mut t = terminal_scrollback(10, 2, usize::MAX);
|
||||
|
||||
@@ -59,6 +59,13 @@ pub struct Tabstops {
|
||||
dynamic_stops: Vec<Unit>,
|
||||
}
|
||||
|
||||
/// Failure points of [`Tabstops::resize`] (Zig: `resize_tw`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResizePoint {
|
||||
DynamicAlloc,
|
||||
}
|
||||
crate::tripwire_module!(resize_tw, ResizePoint, TryReserveError);
|
||||
|
||||
impl Default for Tabstops {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -141,6 +148,7 @@ impl Tabstops {
|
||||
}
|
||||
|
||||
// Allocate first so that failure leaves our state untouched.
|
||||
resize_tw::check(ResizePoint::DynamicAlloc)?;
|
||||
let mut new: Vec<Unit> = Vec::new();
|
||||
new.try_reserve_exact(size)?;
|
||||
new.resize(size, 0);
|
||||
@@ -175,6 +183,7 @@ impl Tabstops {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tripwire::ResetMode;
|
||||
|
||||
#[test]
|
||||
fn tabstops_basic() {
|
||||
@@ -238,7 +247,21 @@ mod tests {
|
||||
assert_eq!(9, count);
|
||||
}
|
||||
|
||||
// "Tabstops: resize alloc failure preserves state" is skipped: it uses
|
||||
// Zig tripwire allocation failure injection. The Rust resize allocates
|
||||
// before mutating, so a failure leaves `cols` unchanged by construction.
|
||||
#[test]
|
||||
fn tabstops_resize_alloc_failure_preserves_state() {
|
||||
// If resize() fails during allocation, the original cols value is
|
||||
// preserved (not corrupted).
|
||||
let mut t = Tabstops::new(80, 8).unwrap();
|
||||
let original_cols = t.cols;
|
||||
|
||||
// Trigger allocation failure when resizing beyond prealloc
|
||||
let oom = Vec::<u8>::new().try_reserve(usize::MAX).unwrap_err();
|
||||
resize_tw::error_always(ResizePoint::DynamicAlloc, oom.clone());
|
||||
let result = t.resize(PREALLOC_COLUMNS * 2);
|
||||
assert_eq!(Err(oom), result);
|
||||
resize_tw::end(ResetMode::Reset).unwrap();
|
||||
|
||||
// cols should be unchanged after failed resize
|
||||
assert_eq!(original_cols, t.cols);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Failure injection for testing error handling paths (port of
|
||||
//! `tripwire.zig`).
|
||||
//!
|
||||
//! Improper cleanup on error paths is one of the highest sources of bugs.
|
||||
//! Many of those points are hard to exercise in unit tests and rare to
|
||||
//! encounter in production. This module injects errors at named points
|
||||
//! during unit tests so that every error path can be exercised.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Declare a module per fallible function with [`tripwire_module!`]. The
|
||||
//! point enum is hand-curated; the error type is the function's own error.
|
||||
//! Sprinkle `tw::check(Point::X)?` before every operation that you want to be
|
||||
//! able to fail, then in a unit test configure expected failures with
|
||||
//! [`error_always`](TripwireState::error_always) /
|
||||
//! [`error_after`](TripwireState::error_after) and finish with
|
||||
//! `tw::end(ResetMode::Reset)` to verify the expectations were met.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tripwire_module!(resize_tw, ResizePoint, TryReserveError);
|
||||
//!
|
||||
//! fn resize() -> Result<(), E> {
|
||||
//! resize_tw::check(ResizePoint::DynamicAlloc)?;
|
||||
//! // ...
|
||||
//! }
|
||||
//!
|
||||
//! #[test]
|
||||
//! fn resize_fails() {
|
||||
//! resize_tw::error_always(ResizePoint::DynamicAlloc, e);
|
||||
//! assert!(resize().is_err());
|
||||
//! resize_tw::end(ResetMode::Reset).unwrap();
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Outside of unit tests (`cfg(test)` of the crate that expands the macro)
|
||||
//! `check` is a no-op that the optimizer removes.
|
||||
//!
|
||||
//! State is per module *and per thread*. Rust runs tests on separate threads
|
||||
//! in parallel, so this gives each test the same isolation Zig gets from
|
||||
//! running tests sequentially.
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Whether to reset expectations in [`TripwireState::end`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResetMode {
|
||||
Reset,
|
||||
Retain,
|
||||
}
|
||||
|
||||
/// Returned from `end` if a configured point was never tripped.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("untripped tripwire")]
|
||||
pub struct UntrippedError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Tripwire<E> {
|
||||
/// Error to return when tripped.
|
||||
err: E,
|
||||
/// The number of times this tripwire has been reached. This is NOT the
|
||||
/// number of times it has tripped, since we may have `min`.
|
||||
reached: usize,
|
||||
/// The minimum number of times this must be reached before tripping.
|
||||
/// After this point it trips every time. This is a "before" check, so if
|
||||
/// this is 1 it trips the second time it is reached.
|
||||
min: usize,
|
||||
/// True if this has been tripped at least once.
|
||||
tripped: bool,
|
||||
}
|
||||
|
||||
/// The configured tripwires of one module.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TripwireState<P, E> {
|
||||
wires: Vec<(P, Tripwire<E>)>,
|
||||
}
|
||||
|
||||
impl<P, E> Default for TripwireState<P, E> {
|
||||
fn default() -> Self {
|
||||
Self { wires: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Copy + PartialEq + Debug, E: Clone> TripwireState<P, E> {
|
||||
/// Check for a failure at the given failure point. Place directly before
|
||||
/// the operation that may fail.
|
||||
pub fn check(&mut self, point: P) -> Result<(), E> {
|
||||
let Some((_, w)) = self.wires.iter_mut().find(|(p, _)| *p == point) else {
|
||||
return Ok(());
|
||||
};
|
||||
w.reached += 1;
|
||||
if w.reached <= w.min {
|
||||
return Ok(());
|
||||
}
|
||||
w.tripped = true;
|
||||
Err(w.err.clone())
|
||||
}
|
||||
|
||||
/// Mark a failure point to always trip with the given error.
|
||||
pub fn error_always(&mut self, point: P, err: E) {
|
||||
self.error_after(point, err, 0);
|
||||
}
|
||||
|
||||
/// Mark a failure point to trip with the given error after it has been
|
||||
/// reached at least `min` times. Zero is equivalent to `error_always`.
|
||||
pub fn error_after(&mut self, point: P, err: E, min: usize) {
|
||||
let wire = Tripwire {
|
||||
err,
|
||||
reached: 0,
|
||||
min,
|
||||
tripped: false,
|
||||
};
|
||||
if let Some(slot) = self.wires.iter_mut().find(|(p, _)| *p == point) {
|
||||
slot.1 = wire;
|
||||
} else {
|
||||
self.wires.push((point, wire));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the tripwire session. Errors if there were untripped
|
||||
/// expectations. Expectations are reset (per `mode`) even on error.
|
||||
pub fn end(&mut self, mode: ResetMode) -> Result<(), UntrippedError> {
|
||||
let mut untripped = false;
|
||||
for (p, w) in &self.wires {
|
||||
if !w.tripped {
|
||||
log::warn!("untripped point={p:?}");
|
||||
untripped = true;
|
||||
}
|
||||
}
|
||||
if mode == ResetMode::Reset {
|
||||
self.reset();
|
||||
}
|
||||
if untripped {
|
||||
Err(UntrippedError)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unset all the tripwires. You should usually call `end` instead.
|
||||
pub fn reset(&mut self) {
|
||||
self.wires.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare a tripwire module `$name` with failure points `$point` (a `Copy +
|
||||
/// PartialEq + Debug` enum) and error type `$err` (`Clone`). Generates
|
||||
/// `$name::{check, error_always, error_after, end, reset}`.
|
||||
#[macro_export]
|
||||
macro_rules! tripwire_module {
|
||||
($vis:vis $name:ident, $point:ty, $err:ty) => {
|
||||
#[allow(dead_code)]
|
||||
$vis mod $name {
|
||||
use super::*;
|
||||
use $crate::tripwire::{ResetMode, TripwireState, UntrippedError};
|
||||
|
||||
::std::thread_local! {
|
||||
static STATE: ::std::cell::RefCell<TripwireState<$point, $err>> =
|
||||
::std::cell::RefCell::new(TripwireState::default());
|
||||
}
|
||||
|
||||
/// Check for a failure at `point`. A no-op outside of tests.
|
||||
#[inline(always)]
|
||||
pub fn check(point: $point) -> ::std::result::Result<(), $err> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
STATE.with(|s| s.borrow_mut().check(point))
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = point;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_always(point: $point, err: $err) {
|
||||
STATE.with(|s| s.borrow_mut().error_always(point, err));
|
||||
}
|
||||
|
||||
pub fn error_after(point: $point, err: $err, min: usize) {
|
||||
STATE.with(|s| s.borrow_mut().error_after(point, err, min));
|
||||
}
|
||||
|
||||
pub fn end(mode: ResetMode) -> ::std::result::Result<(), UntrippedError> {
|
||||
STATE.with(|s| s.borrow_mut().end(mode))
|
||||
}
|
||||
|
||||
pub fn reset() {
|
||||
STATE.with(|s| s.borrow_mut().reset());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Io {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum E {
|
||||
Oom,
|
||||
}
|
||||
crate::tripwire_module!(io, Io, E);
|
||||
|
||||
#[test]
|
||||
fn tripwire_basic() {
|
||||
// Reset should work
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
// By default, it's pass-through
|
||||
io::check(Io::Read).unwrap();
|
||||
// Always trip
|
||||
io::error_always(Io::Read, E::Oom);
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Read));
|
||||
// Happens again
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Read));
|
||||
// Other points are unaffected
|
||||
io::check(Io::Write).unwrap();
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tripwire_error_after() {
|
||||
// Trip after 2 calls (on the 3rd call)
|
||||
io::error_after(Io::Read, E::Oom, 2);
|
||||
io::check(Io::Read).unwrap();
|
||||
io::check(Io::Read).unwrap();
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Read));
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Read));
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tripwire_error_after_untripped_if_min_not_reached() {
|
||||
io::error_after(Io::Read, E::Oom, 2);
|
||||
io::check(Io::Read).unwrap();
|
||||
assert_eq!(Err(UntrippedError), io::end(ResetMode::Reset));
|
||||
// Expectations are reset even when end() fails.
|
||||
io::check(Io::Read).unwrap();
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tripwire_untripped_never_reached_and_retain() {
|
||||
io::error_always(Io::Write, E::Oom);
|
||||
assert_eq!(Err(UntrippedError), io::end(ResetMode::Retain));
|
||||
// Retained: still armed.
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Write));
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tripwire_state_is_per_thread() {
|
||||
io::error_always(Io::Read, E::Oom);
|
||||
std::thread::spawn(|| io::check(Io::Read).unwrap())
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(Err(E::Oom), io::check(Io::Read));
|
||||
io::end(ResetMode::Reset).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user