rogue_machines 0.1.1-arm64-darwin

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 642a94554e3fc861c63351c2a3eb7c638660e18e4391126e003242d8b8548737
4
+ data.tar.gz: 83eee7560dd5c148a2c5d238afa7a9c18f4aa8ceda22b84edd64eedbaa173345
5
+ SHA512:
6
+ metadata.gz: 7c35ef2d9b24ef523bec6e3418ec3e4e144ff6cdfa20fa43cdf47aeffe1d8711490ecddaca1d50091c076244f14835fd473de9b1f0de68ab3e1df67f408308f3
7
+ data.tar.gz: 0513c4ad37a65ab265a1508719dfe0fc692f02fb2ba50ad64af9dd7ffdb92a1e49152b6a700aa2082735470d755fd3633e89084e136772f98cce3558871baedd
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1](https://github.com/seuros/rogue_machines/compare/rogue_machines-v0.1.0...rogue_machines/v0.1.1) (2026-11-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * fault injection, disarmed by default, with an expiry it cannot outlive ([e6cfe89](https://github.com/seuros/rogue_machines/commit/e6cfe8999947dbfb048eaf15a13cb2d4dbb03943))
9
+ * pass a rubygems OTP through the environment ([4f60306](https://github.com/seuros/rogue_machines/commit/4f60306bf35aa8fc7336432c0cdd13b61004796b))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * collect the built gems whatever depth the download puts them at ([a470d97](https://github.com/seuros/rogue_machines/commit/a470d97586dad75365425ff5fdde68a982863262))
15
+ * the release tag carries the gem name, and the gate uses the aggregate output ([1c980cd](https://github.com/seuros/rogue_machines/commit/1c980cd0a36bdce2913e1a74252f90987183f0f7))
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # RogueMachines 💥
2
+
3
+ > **Controlled sabotage for the fleet.** The ship that breaks your systems on
4
+ > purpose, so the void doesn't get to do it first.
5
+
6
+ Every other vessel in this fleet exists to survive a failure. None of them can
7
+ produce one. A breaker that has never opened, a retry budget that has never
8
+ been spent and a deadline that has never been hit are three untested claims
9
+ flying in formation. **AMS Gray** arms the failure that tests them.
10
+
11
+ ---
12
+
13
+ ## 🚀 Launch Sequence
14
+
15
+ ```bash
16
+ # Add to your ship's Gemfile
17
+ gem 'rogue_machines'
18
+ ```
19
+
20
+ Requires **Ruby 4.0+** and a Rust toolchain, or a platform gem.
21
+
22
+ ---
23
+
24
+ ## 🎯 Mission Brief
25
+
26
+ ```ruby
27
+ require 'rogue_machines'
28
+
29
+ RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
30
+
31
+ RogueMachines.run(:payments) do
32
+ Payments.charge(order)
33
+ end
34
+ ```
35
+
36
+ One call in a hundred now fails for the next five minutes, and the breaker
37
+ wrapped around it finally gets to prove it works.
38
+
39
+ ---
40
+
41
+ ## 🛡️ Safety Interlocks
42
+
43
+ **Weapons are cold until you arm them.** A process that has armed nothing
44
+ injects nothing, and `run` says so by validating the name and reading one word
45
+ through one native call.
46
+
47
+ ```ruby
48
+ RogueMachines.armed? # => false
49
+ RogueMachines.run(:payments) { Payments.charge(order) } # the real call
50
+ ```
51
+
52
+ It allocates nothing: zero objects per call for a Symbol, a String or a frozen
53
+ String name. The overhead is about 160 ns, most of it the name check rather
54
+ than the native read. That is why this ships as a runtime dependency rather
55
+ than a test-only one. The interlock sits on every call to every protected
56
+ dependency, so it has to be cheap enough to leave in.
57
+
58
+ Nothing is armed by loading the gem, by an environment variable or by a config
59
+ file. Arming is an explicit call naming a target and a share of traffic.
60
+
61
+ ---
62
+
63
+ ## ⏱️ Every Experiment Self-Destructs
64
+
65
+ `expires_in:` is **required**. There is no way to arm an experiment that
66
+ outlives the officer who armed it, and no constructor in the Rust core that
67
+ takes a fault without a reading it expires at.
68
+
69
+ ```ruby
70
+ RogueMachines.arm(:payments, fault: :raise, share: 1.0, expires_in: 60)
71
+ RogueMachines.armed # => [:payments]
72
+
73
+ # sixty seconds later, with nobody touching anything
74
+ RogueMachines.armed # => []
75
+ RogueMachines.armed? # => false
76
+ ```
77
+
78
+ An expiry is **observed, not scheduled**: nothing retires an experiment until
79
+ something takes a reading. The first `run`, `armed` or `armed?` at or after the
80
+ expiry retires it, and once retired a clock that steps backwards cannot bring
81
+ it back.
82
+
83
+ An experiment lasts at most twenty-four hours. Past that it is configuration,
84
+ not an experiment, and it must be armed again deliberately.
85
+
86
+ ---
87
+
88
+ ## 💥 The Armory
89
+
90
+ ```ruby
91
+ # Fail.
92
+ RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
93
+
94
+ # Be slow, then make the real call.
95
+ RogueMachines.arm(:search, fault: :delay, delay: 2, share: 0.5, expires_in: 300)
96
+
97
+ # Succeed, with a substitute.
98
+ RogueMachines.arm(:catalogue, fault: :garble, garbled: [], share: 0.1, expires_in: 300)
99
+ ```
100
+
101
+ A `:raise` raises `RogueMachines::Injected`. A `:garble` returns `garbled:`
102
+ without running the block, which defaults to `nil` so a forgotten substitute
103
+ cannot quietly return the real value.
104
+
105
+ **There is no weapon that holds a caller forever.** A dependency that times out
106
+ is a `:delay` longer than the caller's own budget, which leaves the caller's
107
+ timeout in charge of when it gives up. The delay is spent against the ambient
108
+ `deadline_machines` budget, so a stall inside a deadline trips that deadline
109
+ instead of sleeping past it. Because a deadline is opt-in per call site, a
110
+ delay is also capped at five minutes. That is not a substitute for a deadline;
111
+ it bounds what a mistyped argument can do to a caller that has none.
112
+
113
+ ---
114
+
115
+ ## 🎲 Targeting
116
+
117
+ `share:` is a fraction of calls between 0 and 1, and the draw deciding each
118
+ call comes from `RogueMachines.random`. Replace it to replay an exact run:
119
+
120
+ ```ruby
121
+ RogueMachines.random = Random.new(1234)
122
+ ```
123
+
124
+ A share is half open at its own boundary and expressed in millionths, so 0.0
125
+ reaches nothing and 1.0 reaches everything. It is a threshold from zero rather
126
+ than an interval: 0.1 and 0.9 both cover a low draw, so two experiments cannot
127
+ divide one stream of calls between them. A share below one millionth is
128
+ refused rather than armed as a silent no-op.
129
+
130
+ ---
131
+
132
+ ## 🕰️ The Ship's Chronometer
133
+
134
+ `RogueMachines.clock` delegates to `DeadlineMachines.clock`, so a test can
135
+ expire an experiment without sleeping:
136
+
137
+ ```ruby
138
+ RogueMachines.clock = DeadlineMachines::TestClock.new
139
+ RogueMachines.arm(:payments, fault: :raise, share: 1.0, expires_in: 60)
140
+ RogueMachines.clock.advance(60)
141
+ RogueMachines.armed? # => false
142
+ ```
143
+
144
+ This is the one place `RogueMachines` consumes a primitive from the fleet
145
+ rather than tripping it.
146
+
147
+ **Install the chronometer through `RogueMachines.clock=`.** Doing so disarms
148
+ everything first, because every armed expiry was computed against the outgoing
149
+ clock and an expiry is only meaningful against the clock it came from.
150
+
151
+ It is safe to do under load. A reading is taken before any lock, so that a
152
+ caller's clock never runs inside one, which means a call can be part way
153
+ through when the clock is replaced. Each reading therefore carries the
154
+ generation it belongs to, and a call arriving with a superseded one is refused
155
+ rather than acted on: arming raises and nothing is armed, a decision makes the
156
+ real call, and a query reports nothing. A clock being replaced faster than a
157
+ reading can be taken is answered the same way rather than retried forever, and
158
+ a clock returning something that is not a non-negative Integer of nanoseconds
159
+ raises an error naming the clock.
160
+
161
+ Replacing `DeadlineMachines.clock` underneath is reflected, since the
162
+ delegation is not memoised, but it cannot disarm anything. A clock whose epoch
163
+ is lower than the old one then makes an armed experiment look as though its
164
+ expiry is still far away. Set `RogueMachines.clock` and the question does not
165
+ arise.
166
+
167
+ A reading is also taken before the lock when arming, so the expiry of a new
168
+ experiment is measured from slightly before it is armed. With a real monotonic
169
+ clock that is microseconds. Nothing infers that a reading came from a
170
+ different clock: with several threads taking their own readings, a reading
171
+ older than the arming is ordinary interleaving, and treating it as evidence of
172
+ a clock change retires experiments that were just armed.
173
+
174
+ ---
175
+
176
+ ## 🔩 The Native Extension Is Required
177
+
178
+ There is no pure-Ruby fallback and no switch to turn one on. `require
179
+ 'rogue_machines'` loads the extension and fails if it is not there. The gem is
180
+ a binding to the `rogue-machines` crate in `ext/rogue_machines_native/core`,
181
+ which has zero dependencies and builds `no_std` for wasm and for a
182
+ microcontroller. The binding asks for its `std` feature, so the crate as this
183
+ gem consumes it is not `no_std`.
184
+
185
+ ---
186
+
187
+ ## 🚫 Rules of Engagement
188
+
189
+ - **No breaker, limiter, retry or budget.** It trips those. `breaker_machines`,
190
+ `throttle_machines`, `chrono_machines` and `shed_machines` own them.
191
+ - **No clock of its own.** Every reading is nanoseconds the caller passes in,
192
+ which is why the core builds for bare metal.
193
+ - **No randomness of its own.** The draw is the caller's.
194
+ - **No capacity beyond 32 experiments**, because the armed set is one 32-bit
195
+ word and the interlock on the hot path is a single load of it.
196
+
197
+ ---
198
+
199
+ ## 📡 Credits
200
+
201
+ **AMS Gray**, NCC-0401, *Gray*-class raider.
202
+
203
+ Named for Jim Gray's *Why Do Computers Stop and What Can Be Done About It?*
204
+ (1985), which argued that the way to find out whether a system tolerates
205
+ faults is to give it some.
206
+
207
+ ---
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RogueMachines
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'deadline_machines'
4
+
5
+ require_relative 'rogue_machines/version'
6
+
7
+ # Rogue, Injection, Error and Injected are defined by the native extension;
8
+ # there is no pure-Ruby implementation to fall back to.
9
+ require 'rogue_machines_native/rogue_machines_native'
10
+
11
+ # Fault injection: making a dependency fail, stall or lie on purpose.
12
+ #
13
+ # Disarmed is the default. `run` costs one native call that reads one word
14
+ # until something is armed.
15
+ module RogueMachines
16
+ # Attempts at one consistent reading before giving up on a clock that is
17
+ # being replaced faster than it can be read.
18
+ READINGS = 100
19
+
20
+ @registry = Rogue.new
21
+ @substitutes = {}.freeze
22
+ @lock = Mutex.new
23
+
24
+ class << self
25
+ attr_writer :random
26
+
27
+ # Monotonic nanoseconds. Delegates to deadline_machines rather than
28
+ # memoising it, so replacing that gem's clock moves every expiry with it.
29
+ def clock
30
+ @clock || DeadlineMachines.clock
31
+ end
32
+
33
+ # Installing a clock disarms everything first. Every armed expiry was
34
+ # computed against the outgoing clock, and an expiry measured against a
35
+ # clock it did not come from is not an expiry.
36
+ #
37
+ # It also starts a generation, so a reading already taken against the
38
+ # outgoing clock is refused rather than compared against expiries it has
39
+ # no relation to. `sweep` is destructive, so such a reading would retire
40
+ # experiments armed after the swap.
41
+ def clock=(clock)
42
+ @lock.synchronize do
43
+ # The native call goes last here, against the rule the other two-store
44
+ # writes follow, because this order is observable: `reading` is
45
+ # lock-free, and bumping the generation first would pair an outgoing
46
+ # reading with the incoming generation, which every consumer accepts.
47
+ Thread.handle_interrupt(Object => :never) do
48
+ @substitutes = {}.freeze
49
+ @clock = clock
50
+ registry.invalidate
51
+ end
52
+ end
53
+ end
54
+
55
+ def random
56
+ @random ||= Random.new
57
+ end
58
+
59
+ def registry
60
+ @registry
61
+ end
62
+
63
+ # Arms an experiment under `name`, replacing any already there.
64
+ #
65
+ # RogueMachines.arm(:payments, fault: :raise, share: 0.01, expires_in: 300)
66
+ # RogueMachines.arm(:slow, fault: :delay, delay: 2, share: 1.0, expires_in: 60)
67
+ # RogueMachines.arm(:lies, fault: :garble, garbled: [], share: 0.5, expires_in: 60)
68
+ #
69
+ # `expires_in` is required and counts from now, so no experiment outlives
70
+ # the person who armed it.
71
+ def arm(name, fault:, share:, expires_in:, delay: nil, garbled: nil)
72
+ key = named(name)
73
+
74
+ # The reading is taken before the lock so that a caller's clock never
75
+ # runs inside it, which means the expiry is measured from slightly
76
+ # before the arming. See "The clock is deadline_machines'" in the README.
77
+ pair = reading
78
+ unless pair
79
+ raise Error, "the clock was replaced #{READINGS} times while taking one reading; " \
80
+ 'nothing is armed'
81
+ end
82
+
83
+ now, age = pair
84
+ options = { fault: fault.to_s, share: Float(share), expires_in: Float(expires_in),
85
+ now_ns: now, generation: age }
86
+ options[:delay] = Float(delay) if delay
87
+
88
+ @lock.synchronize do
89
+ # Two stores. An interrupt landing between them would leave an armed
90
+ # garble with nothing to return for the rest of its life, and nothing
91
+ # repairs that, because publishing only ever removes keys.
92
+ Thread.handle_interrupt(Object => :never) do
93
+ registry.arm(key, **options)
94
+ payload = options[:fault] == 'garble' ? garbled : nil
95
+ publish(substitutes.merge(key => payload), registry.armed(now, age))
96
+ end
97
+ end
98
+ name
99
+ end
100
+
101
+ # Reports whether `name` was still running, which an experiment that
102
+ # expired on its own is not.
103
+ #
104
+ # The substitute goes first: a call landing between the two sees a garble
105
+ # with nothing to return rather than the real value it was armed against.
106
+ def disarm(name)
107
+ key = named(name)
108
+ pair = reading
109
+ return false unless pair
110
+
111
+ now, age = pair
112
+ @lock.synchronize do
113
+ # A reading from a replaced clock must not drop a substitute belonging
114
+ # to the generation that replaced it.
115
+ break false unless registry.generation == age
116
+
117
+ # The native call validates its reading and can raise, so it goes
118
+ # first: nothing is mutated unless it succeeds. Ordering no longer
119
+ # matters to a reader, since a decision and its payload are read
120
+ # under this same lock.
121
+ Thread.handle_interrupt(Object => :never) do
122
+ was_running = registry.disarm(key, now, age)
123
+ @substitutes = substitutes.except(key).freeze
124
+ was_running
125
+ end
126
+ end
127
+ end
128
+
129
+ def disarm_all
130
+ @lock.synchronize do
131
+ Thread.handle_interrupt(Object => :never) do
132
+ registry.disarm_all
133
+ @substitutes = {}.freeze
134
+ end
135
+ end
136
+ end
137
+
138
+ # With no name, whether anything at all is armed. Asked through the name
139
+ # list rather than the gate `run` reads, because the gate only drops an
140
+ # expired experiment when something next decides.
141
+ def armed?(name = nil)
142
+ names = running
143
+ return names.any? if name.nil?
144
+
145
+ names.include?(named(name))
146
+ end
147
+
148
+ # The experiments still running, which excludes any that has expired.
149
+ def armed
150
+ running.map(&:to_sym)
151
+ end
152
+
153
+ # Runs the block, or the fault armed under `name` instead.
154
+ def run(name)
155
+ check(name)
156
+ unless registry.any_armed?
157
+ release unless substitutes.empty?
158
+ return yield
159
+ end
160
+
161
+ key = named(name)
162
+ pair = reading
163
+ return yield unless pair
164
+
165
+ now, age = pair
166
+ drawn = random.rand(PER_MILLION)
167
+
168
+ # The decision and the payload live in different stores, so reading them
169
+ # one after the other is wrong in both orders: a name published between
170
+ # the two is in the decision and not the table, and one retired between
171
+ # them is in the table and not the decision. They are read together.
172
+ # Nothing caller-supplied runs inside the lock, since the clock and the
173
+ # draw are already taken.
174
+ injection, payload = @lock.synchronize do
175
+ found = registry.decide(key, now, drawn, age)
176
+ [found, found && substitutes[key]]
177
+ end
178
+ reconcile(now, age)
179
+ return yield unless injection
180
+
181
+ case injection.fault
182
+ when :raise then raise Injected, "#{name} is armed to raise"
183
+ when :garble then payload
184
+ when :delay
185
+ stall(injection.delay_ns)
186
+ yield
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def substitutes
193
+ @substitutes
194
+ end
195
+
196
+ # A reading and the generation it belongs to.
197
+ #
198
+ # The generation is read on both sides of the reading. Reading it only
199
+ # afterwards would adopt the new one and hand a stale reading to a
200
+ # destructive sweep; reading it only before would pair an outgoing
201
+ # reading with the incoming generation, which every consumer accepts. The
202
+ # pair cannot be sampled in one step, so a change means retry.
203
+ def reading
204
+ READINGS.times do
205
+ age = registry.generation
206
+ now = clock.now_ns
207
+ unless now.is_a?(Integer) && !now.negative?
208
+ raise Error, "#{clock.class} returned #{now.inspect}; a reading must be a " \
209
+ 'non-negative Integer of nanoseconds'
210
+ end
211
+ return [now, age] if registry.generation == age
212
+ end
213
+ nil
214
+ end
215
+
216
+ # Rejects a name the native table could not key on, without converting it,
217
+ # so a call site answers the same way whether or not anything is armed.
218
+ def check(name)
219
+ case name
220
+ when Symbol then legible(name.name)
221
+ when String then legible(name)
222
+ else raise ArgumentError, "an experiment name must be a Symbol or a String, got #{name.class}"
223
+ end
224
+ end
225
+
226
+ def legible(name)
227
+ raise ArgumentError, 'an experiment needs a name' if name.empty?
228
+ return if name.ascii_only?
229
+ return if name.encoding == Encoding::UTF_8 && name.valid_encoding?
230
+ raise ArgumentError, "an experiment name must be valid UTF-8, got #{name.encoding}" \
231
+ unless name.encoding == Encoding::UTF_8
232
+
233
+ raise ArgumentError, 'an experiment name holds bytes that are not valid UTF-8'
234
+ end
235
+
236
+ # The checked name, as something the native table can key on. Not `to_s`:
237
+ # a String subclass can override it, which armed one name and reported
238
+ # another.
239
+ def named(name)
240
+ check(name)
241
+ name.is_a?(Symbol) ? name.name : name
242
+ end
243
+
244
+ # The names still running, releasing the substitutes of everything that is
245
+ # not.
246
+ def running
247
+ pair = reading
248
+ return [] unless pair
249
+
250
+ now, age = pair
251
+ @lock.synchronize do
252
+ # A reading from a replaced clock lists nothing, and must not be used
253
+ # to prune: publishing against an empty list would wipe the table.
254
+ break [] unless registry.generation == age
255
+
256
+ names = registry.armed(now, age)
257
+ publish(substitutes, names)
258
+ names
259
+ end
260
+ end
261
+
262
+ # Releases the substitutes of anything no longer running, so one armed
263
+ # experiment holding the gate open cannot keep another's substitute, and
264
+ # the caller's object, alive until the next `arm`.
265
+ def reconcile(now_ns, age)
266
+ return if substitutes.size <= registry.armed_count(now_ns, age)
267
+
268
+ @lock.synchronize do
269
+ publish(substitutes, registry.armed(now_ns, age)) if registry.generation == age
270
+ end
271
+ end
272
+
273
+ # Only if nothing is armed once the lock is held. The gate `run` reads is
274
+ # sampled before it, so an `arm` landing in between would otherwise have
275
+ # its just-published substitute discarded, leaving an armed garble with
276
+ # nothing to return for the rest of its life.
277
+ def release
278
+ @lock.synchronize { @substitutes = {}.freeze unless registry.any_armed? }
279
+ end
280
+
281
+ # Replaces the table with the entries `names` still covers, in one
282
+ # assignment.
283
+ def publish(table, names)
284
+ @substitutes = table.slice(*names).freeze
285
+ end
286
+
287
+ # Spends the delay against the caller's deadline rather than past it, so a
288
+ # slow dependency trips the timeout that was already there.
289
+ def stall(delay_ns)
290
+ DeadlineMachines.check!
291
+ seconds = DeadlineMachines.clamp_sleep(delay_ns.fdiv(NS_PER_SECOND))
292
+ sleep(seconds) if seconds.positive?
293
+ DeadlineMachines.check!
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,146 @@
1
+ # Fault injection: making a dependency fail, stall or lie on purpose.
2
+ module RogueMachines
3
+ VERSION: String
4
+
5
+ # Nanoseconds in a second.
6
+ NS_PER_SECOND: Integer
7
+
8
+ # Millionths in a whole, the unit a share is held in.
9
+ PER_MILLION: Integer
10
+
11
+ # Experiments a process can arm at once, the width of the armed mask.
12
+ EXPERIMENTS: Integer
13
+
14
+ # Attempts at one consistent reading before giving up on a clock being
15
+ # replaced faster than it can be read.
16
+ READINGS: Integer
17
+
18
+ class Error < StandardError
19
+ end
20
+
21
+ # Raised by a `:raise` fault in place of the real call.
22
+ class Injected < Error
23
+ end
24
+
25
+ # The fault to perform for one call.
26
+ class Injection < ::Data
27
+ # `:raise`, `:delay` or `:garble`.
28
+ attr_reader fault: Symbol
29
+
30
+ # How long a `:delay` waits, and zero for the others.
31
+ attr_reader delay_ns: Integer
32
+ end
33
+
34
+ # Anything that reports monotonic nanoseconds.
35
+ interface _Clock
36
+ def now_ns: () -> Integer
37
+ end
38
+
39
+ # Anything that draws below a bound, which `Random` satisfies.
40
+ interface _Random
41
+ def rand: (Integer bound) -> Integer
42
+ end
43
+
44
+ # The experiments a process is running, by name. Reach it through the module.
45
+ class Rogue
46
+ def self.new: () -> instance
47
+
48
+ # Whether any experiment is armed as of the last sweep, which is the whole
49
+ # of the decision a disarmed process takes. A sweep happens on every
50
+ # `decide` and every `armed`, so this trails an expiry by one call.
51
+ def any_armed?: () -> bool
52
+
53
+ # Sweeps expired experiments at `now_ns`, then counts what is left.
54
+ # Reports 0 without sweeping for a superseded generation. Every seam
55
+ # taking a reading refuses a Float rather than truncating it.
56
+ def armed_count: (Integer now_ns, Integer generation) -> Integer
57
+
58
+ # Arms `name`, replacing any experiment already there. Raises
59
+ # `ArgumentError` for an unknown fault, a share outside a whole or below
60
+ # one millionth, a duration that is non-positive, below a nanosecond or
61
+ # longer than the cap, a `now_ns` that is not an Integer or is outside the
62
+ # range, or a `:delay`
63
+ # with no `delay:`. Raises `Error` when every slot is taken. Nothing is
64
+ # armed and no slot is taken when it raises.
65
+ def arm: (String name, fault: String, share: Float, expires_in: Float,
66
+ now_ns: Integer, generation: Integer, ?delay: Float) -> void
67
+
68
+ # Reports whether `name` was still running, which an experiment that
69
+ # expired on its own is not.
70
+ def disarm: (String name, Integer now_ns, Integer generation) -> bool
71
+
72
+ def disarm_all: () -> void
73
+
74
+ # Which clock the armed expiries were computed against. Every reading is
75
+ # only meaningful for one of these, and a call carrying another one is
76
+ # refused rather than acted on, since sweeping is destructive.
77
+ def generation: () -> Integer
78
+
79
+ # Disarms everything and starts a generation, retiring readings already
80
+ # taken against the outgoing clock. Returns the new generation.
81
+ def invalidate: () -> Integer
82
+
83
+ # The names still running at `now_ns`, in no particular order, and none
84
+ # for a superseded generation.
85
+ def armed: (Integer now_ns, Integer generation) -> Array[String]
86
+
87
+ # The fault to perform, or `nil` to make the real call. Raises
88
+ # `ArgumentError` for a `draw` or `now_ns` that is not an Integer, is
89
+ # outside its range, or is a `draw` at or above `PER_MILLION`. A Float is
90
+ # refused rather than truncated, because a truncated draw becomes 0,
91
+ # which every share covers.
92
+ def decide: (String name, Integer now_ns, Integer draw, Integer generation) -> Injection?
93
+
94
+ def inspect: () -> String
95
+ end
96
+
97
+ # Delegates to `DeadlineMachines.clock` when unset, rather than memoising
98
+ # it, so replacing that clock moves every expiry with it.
99
+ def self.clock: () -> _Clock
100
+
101
+ # Disarms everything first: an armed expiry was computed against the
102
+ # outgoing clock and is not meaningful against another one.
103
+ def self.clock=: (_Clock?) -> void
104
+ def self.random: () -> _Random
105
+ def self.random=: (_Random?) -> void
106
+ # The registry itself. Reach for the module methods instead: arming
107
+ # through this bypasses the substitute a `:garble` returns, and freezing
108
+ # it does not prevent arming, since the native side holds its own state.
109
+ def self.registry: () -> Rogue
110
+
111
+ # Arms an experiment under `name`. `expires_in` counts from now and is
112
+ # required, so no experiment outlives the person who armed it.
113
+ #
114
+ # Raises `ArgumentError` for a blank name, a name that is neither ASCII nor
115
+ # valid UTF-8, or any argument the registry refuses. Raises `Error` when
116
+ # every slot is taken, when the clock was replaced faster than a reading
117
+ # could be taken, and when the clock returned something that is not a
118
+ # non-negative Integer of nanoseconds.
119
+ #
120
+ # `share`, `expires_in` and `delay` go through `Float()`, so a String that
121
+ # parses as a number is accepted, one that does not raises `ArgumentError`,
122
+ # and a value with no numeric conversion at all raises `TypeError`.
123
+ def self.arm: (Symbol | String name, fault: Symbol | String, share: Numeric | String,
124
+ expires_in: Numeric | String, ?delay: (Numeric | String)?,
125
+ ?garbled: untyped) -> (Symbol | String)
126
+
127
+ def self.disarm: (Symbol | String name) -> bool
128
+ def self.disarm_all: () -> void
129
+
130
+ # With no name, whether anything at all is armed.
131
+ def self.armed?: (?(Symbol | String)?) -> bool
132
+
133
+ # The experiments still running, which excludes any that has expired.
134
+ def self.armed: () -> Array[Symbol]
135
+
136
+ # Runs the block, or the fault armed under `name` instead. Not the block's
137
+ # own type: a `:garble` returns the substitute it was armed with, which is
138
+ # whatever the caller passed as `garbled:`. Makes the real call when the
139
+ # clock was replaced faster than a reading could be taken.
140
+ #
141
+ # Raises `Injected` for a `:raise` fault, which is the point of the gem,
142
+ # `DeadlineMachines::Error` when a `:delay` spends the ambient budget,
143
+ # `ArgumentError` for a name it cannot key on or a draw the random gave it,
144
+ # and `Error` for a clock returning an unusable reading.
145
+ def self.run: (Symbol | String name) { () -> untyped } -> untyped
146
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rogue_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: arm64-darwin
6
+ authors:
7
+ - Abdelkader Boudih
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: deadline_machines
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.0
27
+ description: 'Fault injection for chaos engineering: arms a named experiment that
28
+ makes a dependency fail, stall or lie for a share of calls, so the breakers, retries
29
+ and budgets around it can be shown to work. Bound to Ruby from the zero-dependency
30
+ rogue-machines crate. Disarmed by default, and every experiment carries an expiry
31
+ it cannot outlive.'
32
+ email:
33
+ - oss@seuros.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - CHANGELOG.md
39
+ - LICENSE.txt
40
+ - README.md
41
+ - ext/rogue_machines_native/extconf.rb
42
+ - lib/rogue_machines.rb
43
+ - lib/rogue_machines/version.rb
44
+ - lib/rogue_machines_native/rogue_machines_native.bundle
45
+ - sig/rogue_machines.rbs
46
+ homepage: https://github.com/seuros/rogue_machines
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ source_code_uri: https://github.com/seuros/rogue_machines
51
+ changelog_uri: https://github.com/seuros/rogue_machines/blob/master/CHANGELOG.md
52
+ bug_tracker_uri: https://github.com/seuros/rogue_machines/issues
53
+ rubygems_mfa_required: 'true'
54
+ cargo_crate_name: rogue_machines_native
55
+ cargo_manifest_path: ext/rogue_machines_native/ffi/Cargo.toml
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '4.0'
65
+ - - "<"
66
+ - !ruby/object:Gem::Version
67
+ version: 4.1.dev
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.5.23
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Fault injection and chaos engineering for Ruby
78
+ test_files: []