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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/Cargo.lock +286 -0
- data/Cargo.toml +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +211 -0
- data/ext/rogue_machines_native/core/Cargo.toml +24 -0
- data/ext/rogue_machines_native/core/src/armed.rs +78 -0
- data/ext/rogue_machines_native/core/src/experiment.rs +84 -0
- data/ext/rogue_machines_native/core/src/lib.rs +30 -0
- data/ext/rogue_machines_native/core/src/rogue.rs +135 -0
- data/ext/rogue_machines_native/extconf.rb +3 -0
- data/ext/rogue_machines_native/ffi/Cargo.toml +19 -0
- data/ext/rogue_machines_native/ffi/extconf.rb +21 -0
- data/ext/rogue_machines_native/ffi/src/injection.rs +44 -0
- data/ext/rogue_machines_native/ffi/src/lib.rs +173 -0
- data/ext/rogue_machines_native/ffi/src/registry.rs +288 -0
- data/lib/rogue_machines/version.rb +5 -0
- data/lib/rogue_machines.rb +296 -0
- data/sig/rogue_machines.rbs +146 -0
- metadata +98 -0
|
@@ -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,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
|
+
}
|