rogue_machines 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ //! What a fault is, who gets it, and when it stops.
2
+
3
+ use crate::PER_MILLION;
4
+
5
+ /// What the caller does instead of the real call.
6
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
7
+ pub enum Fault {
8
+ /// Fail.
9
+ Raise,
10
+ /// Take this many nanoseconds longer, then make the real call.
11
+ Delay { ns: u64 },
12
+ /// Succeed, with a substitute value.
13
+ Garble,
14
+ }
15
+
16
+ /// The share of calls a fault reaches, in millionths.
17
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18
+ pub struct Fraction(u32);
19
+
20
+ impl Fraction {
21
+ /// Reaches nothing.
22
+ pub const NONE: Self = Self(0);
23
+
24
+ /// Reaches every call.
25
+ pub const ALL: Self = Self(PER_MILLION);
26
+
27
+ /// Rejects a share above [`PER_MILLION`].
28
+ #[must_use]
29
+ pub const fn new(millionths: u32) -> Option<Self> {
30
+ if millionths > PER_MILLION {
31
+ None
32
+ } else {
33
+ Some(Self(millionths))
34
+ }
35
+ }
36
+
37
+ #[must_use]
38
+ pub const fn millionths(self) -> u32 {
39
+ self.0
40
+ }
41
+
42
+ /// Whether `draw`, which is below [`PER_MILLION`], falls in this share.
43
+ #[must_use]
44
+ pub const fn covers(self, draw: u32) -> bool {
45
+ draw < self.0
46
+ }
47
+ }
48
+
49
+ /// An armed fault.
50
+ ///
51
+ /// The expiry is what keeps an experiment from outliving the person who armed
52
+ /// it, so there is no way to build one without a reading it expires at.
53
+ ///
54
+ /// An expiry is only meaningful against the clock it was computed on. This
55
+ /// does not try to infer that a reading came from a different clock: with
56
+ /// several threads taking their own readings, a reading older than the arming
57
+ /// is ordinary interleaving rather than evidence of anything, and treating it
58
+ /// as evidence retires experiments that were just armed.
59
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60
+ pub struct Experiment {
61
+ pub fault: Fault,
62
+ pub fraction: Fraction,
63
+ /// The first reading at which this is no longer armed.
64
+ pub expires_at_ns: u64,
65
+ }
66
+
67
+ impl Experiment {
68
+ #[must_use]
69
+ pub const fn new(fault: Fault, fraction: Fraction, expires_at_ns: u64) -> Self {
70
+ Self {
71
+ fault,
72
+ fraction,
73
+ expires_at_ns,
74
+ }
75
+ }
76
+
77
+ #[must_use]
78
+ pub const fn spent(&self, now_ns: u64) -> bool {
79
+ now_ns >= self.expires_at_ns
80
+ }
81
+ }
82
+
83
+ #[cfg(test)]
84
+ mod tests;
@@ -0,0 +1,30 @@
1
+ #![cfg_attr(not(feature = "std"), no_std)]
2
+
3
+ //! Fault injection: making a dependency fail, stall or lie on purpose.
4
+ //!
5
+ //! A [`Rogue`] holds the experiments a process is running and answers one
6
+ //! question per call: inject a fault, or pass.
7
+ //!
8
+ //! Nothing here performs a fault. The caller raises, sleeps and substitutes,
9
+ //! and supplies both the clock and the draw. Disarmed is the default and the
10
+ //! fast path: with nothing armed a decision is one relaxed load.
11
+
12
+ /// Compiles and runs the README's examples as doctests.
13
+ #[cfg(doctest)]
14
+ #[doc = include_str!("../README.md")]
15
+ struct ReadmeDoctests;
16
+
17
+ mod armed;
18
+ pub mod experiment;
19
+ pub mod rogue;
20
+
21
+ pub(crate) use armed::Armed;
22
+ pub use armed::Slot;
23
+ pub use experiment::{Experiment, Fault, Fraction};
24
+ pub use rogue::{Decision, Rogue};
25
+
26
+ /// Slots a [`Rogue`] holds, which is the width of the mask tracking them.
27
+ pub const EXPERIMENTS: usize = u32::BITS as usize;
28
+
29
+ /// Millionths in a whole, the unit a [`Fraction`] is given in.
30
+ pub const PER_MILLION: u32 = 1_000_000;
@@ -0,0 +1,135 @@
1
+ //! The experiments a process is running.
2
+
3
+ use crate::{Armed, EXPERIMENTS, Experiment, Fault, PER_MILLION, Slot};
4
+
5
+ /// What to do with one call.
6
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
7
+ pub enum Decision {
8
+ /// Make the real call.
9
+ Pass,
10
+ /// Do this instead.
11
+ Inject(Fault),
12
+ }
13
+
14
+ /// A slot table and the mask over it.
15
+ ///
16
+ /// ```
17
+ /// use rogue_machines::{Decision, Experiment, Fault, Fraction, Rogue, Slot};
18
+ ///
19
+ /// const SECOND: u64 = 1_000_000_000;
20
+ /// let Some(slot) = Slot::new(0) else { unreachable!() };
21
+ /// let mut rogue = Rogue::new();
22
+ ///
23
+ /// // Disarmed, which is where every process starts.
24
+ /// assert_eq!(rogue.decide(slot, 0, 0), Decision::Pass);
25
+ ///
26
+ /// rogue.arm(slot, Experiment::new(Fault::Raise, Fraction::ALL, 5 * SECOND));
27
+ /// assert_eq!(rogue.decide(slot, SECOND, 0), Decision::Inject(Fault::Raise));
28
+ ///
29
+ /// // Past its expiry it disarms itself, and stays disarmed.
30
+ /// assert_eq!(rogue.decide(slot, 5 * SECOND, 0), Decision::Pass);
31
+ /// assert!(!rogue.any_armed());
32
+ /// ```
33
+ #[derive(Debug)]
34
+ pub struct Rogue {
35
+ armed: Armed,
36
+ experiments: [Option<Experiment>; EXPERIMENTS],
37
+ }
38
+
39
+ impl Default for Rogue {
40
+ fn default() -> Self {
41
+ Self::new()
42
+ }
43
+ }
44
+
45
+ impl Rogue {
46
+ #[must_use]
47
+ pub const fn new() -> Self {
48
+ Self {
49
+ armed: Armed::new(),
50
+ experiments: [None; EXPERIMENTS],
51
+ }
52
+ }
53
+
54
+ /// Arms `slot`, replacing whatever it held.
55
+ pub fn arm(&mut self, slot: Slot, experiment: Experiment) {
56
+ self.experiments[slot.index()] = Some(experiment);
57
+ self.armed.hold(slot);
58
+ }
59
+
60
+ pub fn disarm(&mut self, slot: Slot) {
61
+ self.experiments[slot.index()] = None;
62
+ self.armed.release(slot);
63
+ }
64
+
65
+ pub fn disarm_all(&mut self) {
66
+ self.experiments = [None; EXPERIMENTS];
67
+ self.armed.release_all();
68
+ }
69
+
70
+ /// Whether any experiment is armed as of the last [`sweep`](Self::sweep),
71
+ /// which is the only read a call runs when none is.
72
+ #[must_use]
73
+ pub fn any_armed(&self) -> bool {
74
+ self.armed.any()
75
+ }
76
+
77
+ /// How many experiments are armed as of the last [`sweep`](Self::sweep).
78
+ #[must_use]
79
+ pub fn armed_count(&self) -> u32 {
80
+ self.armed.count()
81
+ }
82
+
83
+ /// Releases every slot whose experiment has expired by `now_ns`.
84
+ ///
85
+ /// [`any_armed`](Self::any_armed) is a bare mask read, so without this an
86
+ /// experiment that expired on a slot nothing decides on again would hold
87
+ /// the gate open for the life of the process. Both readers below sweep
88
+ /// first, so the next call of either restores the disarmed fast path.
89
+ pub fn sweep(&self, now_ns: u64) {
90
+ if !self.armed.any() {
91
+ return;
92
+ }
93
+ for index in 0..EXPERIMENTS {
94
+ let Some(slot) = Slot::new(index) else {
95
+ continue;
96
+ };
97
+ if self.armed.holds(slot) && self.spent(slot, now_ns) {
98
+ self.armed.release(slot);
99
+ }
100
+ }
101
+ }
102
+
103
+ /// The experiment arming `slot` at `now_ns`.
104
+ #[must_use]
105
+ pub fn active(&self, slot: Slot, now_ns: u64) -> Option<&Experiment> {
106
+ self.sweep(now_ns);
107
+ if !self.armed.holds(slot) {
108
+ return None;
109
+ }
110
+ self.experiments[slot.index()].as_ref()
111
+ }
112
+
113
+ /// What to do with one call: retires anything expired by `now_ns`, then
114
+ /// injects if `slot` is armed and `draw`, which is below
115
+ /// [`PER_MILLION`](crate::PER_MILLION), falls in the share it covers.
116
+ #[must_use]
117
+ pub fn decide(&self, slot: Slot, now_ns: u64, draw: u32) -> Decision {
118
+ debug_assert!(draw < PER_MILLION, "a draw must be below PER_MILLION");
119
+ match self.active(slot, now_ns) {
120
+ Some(experiment) if experiment.fraction.covers(draw) => {
121
+ Decision::Inject(experiment.fault)
122
+ }
123
+ _ => Decision::Pass,
124
+ }
125
+ }
126
+
127
+ /// Whether `slot` holds nothing, or holds something already expired. An
128
+ /// empty slot counts as spent so a bit can never outlive its experiment.
129
+ fn spent(&self, slot: Slot, now_ns: u64) -> bool {
130
+ self.experiments[slot.index()].is_none_or(|experiment| experiment.spent(now_ns))
131
+ }
132
+ }
133
+
134
+ #[cfg(test)]
135
+ mod tests;
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "rogue_machines_native"
3
+ version = "0.1.1"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ authors = ["Abdelkader Boudih <oss@seuros.com>"]
7
+ license = "MIT"
8
+ description = "Ruby bindings for the rogue-machines fault injector"
9
+ publish = false
10
+
11
+ [lib]
12
+ crate-type = ["cdylib"]
13
+
14
+ [lints]
15
+ workspace = true
16
+
17
+ [dependencies]
18
+ rogue-machines = { workspace = true, features = ["std"] }
19
+ magnus = { version = ">= 0.9.3", default-features = false, features = ["io"] }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'pathname'
5
+ require 'rb_sys/mkmf'
6
+
7
+ create_rust_makefile('rogue_machines_native/rogue_machines_native') do |r|
8
+ r.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
9
+ r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
10
+ end
11
+
12
+ # rake-compiler builds from tmp/, where the generated relative manifest path no
13
+ # longer resolves.
14
+ makefile = File.join(Dir.pwd, 'Makefile')
15
+ File.write(
16
+ makefile,
17
+ File.read(makefile).gsub(
18
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
19
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
20
+ )
21
+ )
@@ -0,0 +1,44 @@
1
+ //! The frozen `Data` object a decision hands back when it injects.
2
+
3
+ use std::sync::OnceLock;
4
+
5
+ use magnus::value::Opaque;
6
+ use magnus::{Error, RClass, RModule, RStruct, Ruby, Value, prelude::*};
7
+ use rogue_machines::Fault;
8
+
9
+ use crate::error;
10
+
11
+ static INJECTION: OnceLock<Opaque<RClass>> = OnceLock::new();
12
+
13
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
14
+ let injection = ruby.define_data(None, ("fault", "delay_ns"))?;
15
+ module.const_set("Injection", injection)?;
16
+ INJECTION
17
+ .set(injection.into())
18
+ .map_err(|_| error(ruby, "Injection was defined twice".to_owned()))
19
+ }
20
+
21
+ /// The fault to perform, and how long to wait when it is a delay.
22
+ pub fn injection(ruby: &Ruby, fault: Fault) -> Result<Value, Error> {
23
+ let class = INJECTION
24
+ .get()
25
+ .map(|class| ruby.get_inner(*class))
26
+ .ok_or_else(|| error(ruby, "Injection was not defined".to_owned()))?;
27
+ let instance = RStruct::try_convert(class.obj_alloc()?)?;
28
+ let (name, delay_ns) = match fault {
29
+ Fault::Raise => ("raise", 0),
30
+ Fault::Delay { ns } => ("delay", ns),
31
+ Fault::Garble => ("garble", 0),
32
+ };
33
+ for (index, member) in [
34
+ ruby.into_value(ruby.to_symbol(name)),
35
+ ruby.into_value(delay_ns),
36
+ ]
37
+ .into_iter()
38
+ .enumerate()
39
+ {
40
+ instance.aset(index, member)?;
41
+ }
42
+ instance.freeze();
43
+ Ok(instance.as_value())
44
+ }
@@ -0,0 +1,173 @@
1
+ //! Ruby bindings for the `rogue-machines` fault injector.
2
+ //!
3
+ //! The core decides; this layer names the experiments and converts seconds to
4
+ //! the nanoseconds the core counts in. The clock and the draw come from the
5
+ //! caller, so `RogueMachines.clock` and `RogueMachines.random` are swappable
6
+ //! and an experiment replays exactly.
7
+
8
+ #![allow(clippy::needless_pass_by_value)]
9
+
10
+ mod injection;
11
+ mod registry;
12
+
13
+ use std::sync::OnceLock;
14
+
15
+ use magnus::value::Opaque;
16
+ use magnus::{Error, ExceptionClass, Integer, RModule, Ruby, Value, prelude::*};
17
+ use rogue_machines::{Fraction, PER_MILLION};
18
+
19
+ static ERROR: OnceLock<Opaque<ExceptionClass>> = OnceLock::new();
20
+ static INJECTED: OnceLock<Opaque<ExceptionClass>> = OnceLock::new();
21
+
22
+ /// Nanoseconds in a second.
23
+ pub(crate) const NS_PER_SECOND: u32 = 1_000_000_000;
24
+
25
+ /// Longest an experiment may run for. An experiment is something a person is
26
+ /// waiting on, so a day is already generous; past that it is configuration,
27
+ /// and it must be re-armed deliberately.
28
+ pub(crate) const LONGEST_EXPERIMENT: f64 = 24.0 * 60.0 * 60.0;
29
+
30
+ /// Longest a single delay may wait. Bounds how long a stall can hold a caller
31
+ /// that has no deadline of its own.
32
+ pub(crate) const LONGEST_DELAY: f64 = 5.0 * 60.0;
33
+
34
+ /// Seconds as nanoseconds, rejecting a duration no experiment should run for.
35
+ ///
36
+ /// An unbounded duration is not merely untidy: `as u64` saturates rather than
37
+ /// wrapping, so without `longest` a large `delay` becomes a stall of centuries.
38
+ #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
39
+ pub(crate) fn duration(ruby: &Ruby, name: &str, seconds: f64, longest: f64) -> Result<u64, Error> {
40
+ if !seconds.is_finite() || seconds <= 0.0 || seconds > longest {
41
+ return Err(Error::new(
42
+ ruby.exception_arg_error(),
43
+ format!("{name} must be above 0 and at most {longest} seconds, got {seconds}"),
44
+ ));
45
+ }
46
+ let nanoseconds = seconds * f64::from(NS_PER_SECOND);
47
+ if nanoseconds < 1.0 {
48
+ return Err(Error::new(
49
+ ruby.exception_arg_error(),
50
+ format!("{name} of {seconds} is below the one nanosecond resolution"),
51
+ ));
52
+ }
53
+ Ok(nanoseconds.round() as u64)
54
+ }
55
+
56
+ /// A reading in nanoseconds.
57
+ ///
58
+ /// A Float is refused rather than truncated, and so is a value outside the
59
+ /// range: magnus would reject that with its own `RangeError`, which would
60
+ /// leak out of a protected call site as something the gem never documents.
61
+ pub(crate) fn reading(ruby: &Ruby, name: &str, value: Value) -> Result<u64, Error> {
62
+ whole(ruby, name, value)?.to_u64().map_err(|_| {
63
+ Error::new(
64
+ ruby.exception_arg_error(),
65
+ format!(
66
+ "{name} must be a non-negative Integer of at most {}",
67
+ u64::MAX
68
+ ),
69
+ )
70
+ })
71
+ }
72
+
73
+ /// A draw below [`PER_MILLION`].
74
+ ///
75
+ /// A truncated draw becomes 0, which every share covers, so a caller whose
76
+ /// `rand` returns a Float would inject on every call instead of none.
77
+ pub(crate) fn draw(ruby: &Ruby, value: Value) -> Result<u32, Error> {
78
+ let drawn = whole(ruby, "draw", value)?.to_u32().map_err(|_| {
79
+ Error::new(
80
+ ruby.exception_arg_error(),
81
+ format!("draw must be a non-negative Integer below {PER_MILLION}"),
82
+ )
83
+ })?;
84
+ if drawn >= PER_MILLION {
85
+ return Err(Error::new(
86
+ ruby.exception_arg_error(),
87
+ format!("draw must be below {PER_MILLION}, got {drawn}"),
88
+ ));
89
+ }
90
+ Ok(drawn)
91
+ }
92
+
93
+ /// A generation tag.
94
+ pub(crate) fn counted(ruby: &Ruby, name: &str, value: Value) -> Result<u32, Error> {
95
+ whole(ruby, name, value)?.to_u32().map_err(|_| {
96
+ Error::new(
97
+ ruby.exception_arg_error(),
98
+ format!(
99
+ "{name} must be a non-negative Integer of at most {}",
100
+ u32::MAX
101
+ ),
102
+ )
103
+ })
104
+ }
105
+
106
+ fn whole(ruby: &Ruby, name: &str, value: Value) -> Result<Integer, Error> {
107
+ Integer::from_value(value).ok_or_else(|| {
108
+ Error::new(
109
+ ruby.exception_arg_error(),
110
+ format!("{name} must be an Integer"),
111
+ )
112
+ })
113
+ }
114
+
115
+ /// A share of calls, rejecting one outside a whole or too small to express.
116
+ #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
117
+ pub(crate) fn share(ruby: &Ruby, given: f64) -> Result<Fraction, Error> {
118
+ if !given.is_finite() || !(0.0..=1.0).contains(&given) {
119
+ return Err(Error::new(
120
+ ruby.exception_arg_error(),
121
+ format!("share must be between 0 and 1, got {given}"),
122
+ ));
123
+ }
124
+ let millionths = given * f64::from(PER_MILLION);
125
+ if given > 0.0 && millionths < 1.0 {
126
+ return Err(Error::new(
127
+ ruby.exception_arg_error(),
128
+ format!("share of {given} is below the one millionth resolution"),
129
+ ));
130
+ }
131
+ let millionths = millionths.round() as u32;
132
+ Fraction::new(millionths).ok_or_else(|| {
133
+ Error::new(
134
+ ruby.exception_arg_error(),
135
+ format!("share of {millionths} millionths is not a whole"),
136
+ )
137
+ })
138
+ }
139
+
140
+ pub(crate) fn error(ruby: &Ruby, message: String) -> Error {
141
+ ERROR.get().map_or_else(
142
+ || {
143
+ Error::new(
144
+ ruby.exception_runtime_error(),
145
+ "RogueMachines was not initialised",
146
+ )
147
+ },
148
+ |class| Error::new(ruby.get_inner(*class), message),
149
+ )
150
+ }
151
+
152
+ fn define_errors(ruby: &Ruby, module: RModule) -> Result<(), Error> {
153
+ let base = module.define_error("Error", ruby.exception_standard_error())?;
154
+ ERROR
155
+ .set(base.into())
156
+ .map_err(|_| error(ruby, "Error was defined twice".to_owned()))?;
157
+
158
+ let injected = module.define_error("Injected", base)?;
159
+ INJECTED
160
+ .set(injected.into())
161
+ .map_err(|_| error(ruby, "Injected was defined twice".to_owned()))
162
+ }
163
+
164
+ #[magnus::init]
165
+ fn init(ruby: &Ruby) -> Result<(), Error> {
166
+ let module = ruby.define_module("RogueMachines")?;
167
+ module.const_set("NS_PER_SECOND", NS_PER_SECOND)?;
168
+ module.const_set("PER_MILLION", PER_MILLION)?;
169
+ module.const_set("EXPERIMENTS", rogue_machines::EXPERIMENTS)?;
170
+ define_errors(ruby, module)?;
171
+ injection::define(ruby, module)?;
172
+ registry::define(ruby, module)
173
+ }