fiber_audit 0.1.0 → 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +41 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +320 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'securerandom'
7
+ require_relative 'policy'
8
+ require_relative 'validation'
9
+ require_relative 'watchdog_policy'
10
+
11
+ module FiberAudit
12
+ module Runtime
13
+ # rubocop:disable Metrics/ModuleLength
14
+ module Environment
15
+ PROTOCOL_VERSION = 1
16
+ ACTIVATION_KEY = 'FIBER_AUDIT_RUNTIME_BOOT'
17
+ SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_SETTINGS'
18
+ FAILURE_MODE_KEY = 'FIBER_AUDIT_RUNTIME_FAILURE_MODE'
19
+ WATCHDOG_SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_WATCHDOG_SETTINGS'
20
+ PROBES_KEY = 'FIBER_AUDIT_RUNTIME_PROBES'
21
+ BOOT_REQUIRE = '-rfiber_audit/runtime/boot'
22
+ MAX_SETTINGS_BYTES = 16_384
23
+ MAX_WATCHDOG_SETTINGS_BYTES = 1_024
24
+ SETTINGS_KEYS = %w[protocol_version launch_id project_root output_directory policy].freeze
25
+ POLICY_KEYS = %w[
26
+ redaction sampling_rate max_events_per_second max_events_per_session
27
+ max_record_bytes max_session_bytes fail_open
28
+ ].freeze
29
+ WATCHDOG_KEYS = %w[
30
+ protocol_version enabled heartbeat_interval_ms stall_threshold_ms max_frames
31
+ ].freeze
32
+
33
+ Settings = Data.define(:protocol_version, :launch_id, :project_root, :output_directory, :policy) do
34
+ def initialize(protocol_version:, launch_id:, project_root:, output_directory:, policy:)
35
+ unless protocol_version == PROTOCOL_VERSION
36
+ raise RuntimeContractError, "runtime activation protocol must be #{PROTOCOL_VERSION}"
37
+ end
38
+ unless launch_id.is_a?(String) && launch_id.match?(Validation::UUID)
39
+ raise RuntimeContractError, 'runtime launch_id must be a canonical lowercase UUID'
40
+ end
41
+ unless policy.is_a?(Policy)
42
+ raise RuntimeContractError, 'runtime activation policy must be a FiberAudit::Runtime::Policy'
43
+ end
44
+
45
+ super(
46
+ protocol_version: protocol_version,
47
+ launch_id: launch_id.dup.freeze,
48
+ project_root: Environment.normalize_directory(project_root, 'project_root'),
49
+ output_directory: Environment.normalize_directory(output_directory, 'output_directory'),
50
+ policy: policy
51
+ )
52
+ end
53
+ end
54
+
55
+ module_function
56
+
57
+ def build(policy:, output_directory:, project_root:, launch_id: SecureRandom.uuid)
58
+ Settings.new(
59
+ protocol_version: PROTOCOL_VERSION,
60
+ launch_id: launch_id,
61
+ project_root: project_root,
62
+ output_directory: output_directory,
63
+ policy: policy
64
+ )
65
+ end
66
+
67
+ def dump(settings)
68
+ require_settings!(settings)
69
+ payload = {
70
+ 'protocol_version' => settings.protocol_version,
71
+ 'launch_id' => settings.launch_id,
72
+ 'project_root' => settings.project_root,
73
+ 'output_directory' => settings.output_directory,
74
+ 'policy' => policy_payload(settings.policy)
75
+ }
76
+ encoded = JSON.generate(payload)
77
+ raise RuntimeSafetyError, 'runtime activation settings are too large' if encoded.bytesize > MAX_SETTINGS_BYTES
78
+
79
+ encoded.freeze
80
+ end
81
+
82
+ def load(environment = ENV)
83
+ return unless activated?(environment)
84
+
85
+ value = environment.fetch(SETTINGS_KEY) do
86
+ raise RuntimeContractError, "missing runtime activation variable: #{SETTINGS_KEY}"
87
+ end
88
+ unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_SETTINGS_BYTES
89
+ raise RuntimeContractError, 'runtime activation settings are invalid'
90
+ end
91
+
92
+ payload = JSON.parse(value)
93
+ require_exact_keys!(payload, SETTINGS_KEYS, 'runtime activation settings')
94
+ policy_values = payload.fetch('policy')
95
+ require_exact_keys!(policy_values, POLICY_KEYS, 'runtime activation policy')
96
+ settings = Settings.new(
97
+ protocol_version: payload.fetch('protocol_version'),
98
+ launch_id: payload.fetch('launch_id'),
99
+ project_root: payload.fetch('project_root'),
100
+ output_directory: payload.fetch('output_directory'),
101
+ policy: Policy.new(**symbolize_policy(policy_values))
102
+ )
103
+ validate_failure_mode!(environment, settings.policy)
104
+ settings
105
+ rescue JSON::ParserError, ArgumentError => e
106
+ raise RuntimeContractError, "invalid runtime activation settings: #{e.message}"
107
+ end
108
+
109
+ def dump_watchdog_policy(policy)
110
+ require_watchdog_policy!(policy)
111
+ payload = {
112
+ 'protocol_version' => PROTOCOL_VERSION,
113
+ 'enabled' => policy.enabled,
114
+ 'heartbeat_interval_ms' => policy.heartbeat_interval_ms,
115
+ 'stall_threshold_ms' => policy.stall_threshold_ms,
116
+ 'max_frames' => policy.max_frames
117
+ }
118
+ encoded = JSON.generate(payload)
119
+ if encoded.bytesize > MAX_WATCHDOG_SETTINGS_BYTES
120
+ raise RuntimeSafetyError, 'runtime watchdog activation settings are too large'
121
+ end
122
+
123
+ encoded.freeze
124
+ end
125
+
126
+ def load_watchdog_policy(environment = ENV)
127
+ value = environment[WATCHDOG_SETTINGS_KEY]
128
+ return WatchdogPolicy::DISABLED if value.nil?
129
+ unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_WATCHDOG_SETTINGS_BYTES
130
+ raise RuntimeContractError, 'runtime watchdog activation settings are invalid'
131
+ end
132
+
133
+ payload = JSON.parse(value)
134
+ require_exact_keys!(payload, WATCHDOG_KEYS, 'runtime watchdog activation settings')
135
+ unless payload.fetch('protocol_version') == PROTOCOL_VERSION
136
+ raise RuntimeContractError, "runtime watchdog activation protocol must be #{PROTOCOL_VERSION}"
137
+ end
138
+
139
+ WatchdogPolicy.new(
140
+ enabled: payload.fetch('enabled'),
141
+ heartbeat_interval_ms: payload.fetch('heartbeat_interval_ms'),
142
+ stall_threshold_ms: payload.fetch('stall_threshold_ms'),
143
+ max_frames: payload.fetch('max_frames')
144
+ )
145
+ rescue JSON::ParserError, ArgumentError => e
146
+ raise RuntimeContractError, "invalid runtime watchdog activation settings: #{e.message}"
147
+ end
148
+
149
+ def activated?(environment = ENV)
150
+ marker = environment[ACTIVATION_KEY]
151
+ return false if marker.nil?
152
+ return true if marker == '1'
153
+
154
+ raise RuntimeContractError, "#{ACTIVATION_KEY} must be 1"
155
+ end
156
+
157
+ def failure_mode(environment = ENV)
158
+ value = environment[FAILURE_MODE_KEY]
159
+ return :open if value.nil? || value == 'open'
160
+ return :closed if value == 'closed'
161
+
162
+ raise RuntimeContractError, "#{FAILURE_MODE_KEY} must be open or closed"
163
+ end
164
+
165
+ def probes_enabled?(environment = ENV)
166
+ value = environment[PROBES_KEY]
167
+ return false if value.nil?
168
+ return true if value == '1'
169
+
170
+ raise RuntimeContractError, "#{PROBES_KEY} must be 1 when present"
171
+ end
172
+
173
+ def child_environment(
174
+ settings:,
175
+ watchdog_policy: nil,
176
+ probes_enabled: false,
177
+ base_environment: ENV,
178
+ library_path: default_library_path
179
+ )
180
+ require_settings!(settings)
181
+ require_watchdog_policy!(watchdog_policy) if watchdog_policy
182
+ raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes_enabled)
183
+ raise RuntimeContractError, 'base_environment must be a Hash-like object' unless base_environment.respond_to?(:[])
184
+
185
+ environment = {
186
+ ACTIVATION_KEY => '1',
187
+ SETTINGS_KEY => dump(settings),
188
+ FAILURE_MODE_KEY => settings.policy.fail_open? ? 'open' : 'closed',
189
+ 'RUBYOPT' => prepend_token(base_environment['RUBYOPT'], BOOT_REQUIRE, separator: ' '),
190
+ 'RUBYLIB' => prepend_token(base_environment['RUBYLIB'], library_path, separator: File::PATH_SEPARATOR)
191
+ }
192
+ environment[WATCHDOG_SETTINGS_KEY] = dump_watchdog_policy(watchdog_policy) if watchdog_policy
193
+ environment[PROBES_KEY] = '1' if probes_enabled
194
+ environment.transform_values(&:freeze).freeze
195
+ end
196
+
197
+ def prepare_output_directory(path)
198
+ normalized = normalize_absolute_path(path, 'output directory')
199
+ if File.exist?(normalized)
200
+ raise RuntimeSafetyError, "runtime output is not a directory: #{normalized}" unless File.directory?(normalized)
201
+
202
+ return normalized
203
+ end
204
+
205
+ FileUtils.mkdir_p(normalized, mode: 0o700)
206
+ File.chmod(0o700, normalized)
207
+ normalized
208
+ rescue SystemCallError => e
209
+ raise RuntimeSafetyError, "cannot prepare runtime output directory: #{e.message}"
210
+ end
211
+
212
+ def normalize_directory(value, field)
213
+ normalized = normalize_absolute_path(value, field)
214
+ raise RuntimeContractError, "#{field} must be an existing directory" unless File.directory?(normalized)
215
+
216
+ normalized.freeze
217
+ end
218
+
219
+ def default_library_path
220
+ File.expand_path('../..', __dir__).freeze
221
+ end
222
+ private_class_method :default_library_path
223
+
224
+ def normalize_absolute_path(value, field)
225
+ text = Validation.string(value, field, max_bytes: 4_096)
226
+ path = Pathname.new(text)
227
+ raise RuntimeContractError, "#{field} must be absolute" unless path.absolute?
228
+
229
+ path.cleanpath.to_s.freeze
230
+ rescue ArgumentError
231
+ raise RuntimeContractError, "#{field} is invalid"
232
+ end
233
+ private_class_method :normalize_absolute_path
234
+
235
+ def prepend_token(existing, token, separator:)
236
+ current = existing.to_s
237
+ parts = current.split(separator)
238
+ return current.dup.freeze if parts.include?(token)
239
+
240
+ current.empty? ? token.dup.freeze : "#{token}#{separator}#{current}".freeze
241
+ end
242
+ private_class_method :prepend_token
243
+
244
+ def require_settings!(value)
245
+ return if value.is_a?(Settings)
246
+
247
+ raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
248
+ end
249
+ private_class_method :require_settings!
250
+
251
+ def require_watchdog_policy!(value)
252
+ return if value.is_a?(WatchdogPolicy)
253
+
254
+ raise RuntimeContractError, 'watchdog_policy must be FiberAudit::Runtime::WatchdogPolicy'
255
+ end
256
+ private_class_method :require_watchdog_policy!
257
+
258
+ def require_exact_keys!(value, expected, path)
259
+ raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
260
+
261
+ unknown = value.keys - expected
262
+ missing = expected - value.keys
263
+ raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
264
+ raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
265
+ end
266
+ private_class_method :require_exact_keys!
267
+
268
+ def policy_payload(policy)
269
+ policy.to_h.transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }.transform_keys(&:to_s)
270
+ end
271
+ private_class_method :policy_payload
272
+
273
+ def symbolize_policy(values)
274
+ values.to_h { |key, value| [key.to_sym, value] }
275
+ end
276
+ private_class_method :symbolize_policy
277
+
278
+ def validate_failure_mode!(environment, policy)
279
+ expected = policy.fail_open? ? :open : :closed
280
+ actual = failure_mode(environment)
281
+ return if actual == expected
282
+
283
+ raise RuntimeContractError, 'runtime activation failure mode does not match policy'
284
+ end
285
+ private_class_method :validate_failure_mode!
286
+ end
287
+ # rubocop:enable Metrics/ModuleLength
288
+ end
289
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../execution_context'
4
+ require_relative 'location'
5
+ require_relative 'validation'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ Event = Data.define(
10
+ :kind,
11
+ :source,
12
+ :occurred_at,
13
+ :monotonic_ns,
14
+ :duration_ns,
15
+ :operation,
16
+ :location,
17
+ :execution_context,
18
+ :thread_id,
19
+ :fiber_id,
20
+ :measurements
21
+ ) do
22
+ def initialize(
23
+ kind:,
24
+ source:,
25
+ occurred_at:,
26
+ monotonic_ns:,
27
+ duration_ns: nil,
28
+ operation: nil,
29
+ location: nil,
30
+ execution_context: :unknown,
31
+ thread_id: nil,
32
+ fiber_id: nil,
33
+ measurements: {}
34
+ )
35
+ super(
36
+ kind: Validation.identifier(kind, 'kind'),
37
+ source: Validation.identifier(source, 'source'),
38
+ occurred_at: Validation.utc_time(occurred_at, 'occurred_at'),
39
+ monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
40
+ duration_ns: Validation.integer(duration_ns, 'duration_ns', allow_nil: true),
41
+ operation: Validation.operation(operation, allow_nil: true),
42
+ location: normalize_location(location),
43
+ execution_context: normalize_context(execution_context),
44
+ thread_id: Validation.integer(thread_id, 'thread_id', allow_nil: true),
45
+ fiber_id: Validation.integer(fiber_id, 'fiber_id', allow_nil: true),
46
+ measurements: normalize_measurements(measurements)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_location(value)
53
+ return value if value.nil? || value.is_a?(Location)
54
+
55
+ raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
56
+ end
57
+
58
+ def normalize_context(value)
59
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
60
+ return normalized if Context::ALL.include?(normalized)
61
+
62
+ raise RuntimeContractError, "execution_context is invalid: #{value.inspect}"
63
+ end
64
+
65
+ def normalize_measurements(value)
66
+ raise RuntimeContractError, 'measurements must be a Hash' unless value.is_a?(Hash)
67
+ if value.size > Event::MAX_MEASUREMENTS
68
+ raise RuntimeContractError, "measurements must contain at most #{Event::MAX_MEASUREMENTS} entries"
69
+ end
70
+
71
+ value.each_with_object({}) do |(key, measurement), normalized|
72
+ name = Validation.identifier(key, 'measurement key').to_s.freeze
73
+ raise RuntimeContractError, "duplicate normalized measurement key: #{name}" if normalized.key?(name)
74
+ unless measurement.nil? || measurement == true || measurement == false ||
75
+ (measurement.is_a?(Numeric) && measurement.finite?)
76
+ raise RuntimeContractError, "measurement #{name} must be a finite number, Boolean, or nil"
77
+ end
78
+
79
+ normalized[name] = measurement
80
+ end.freeze
81
+ end
82
+ end
83
+
84
+ Event.const_set(:MAX_MEASUREMENTS, 32)
85
+ end
86
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../execution_context'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Fiber-local execution context stack.
8
+ # Uses fiber instance variables for isolation without Thread#[] visibility.
9
+ # PID-aware to handle fork correctly.
10
+ module ExecutionContext
11
+ MAX_DEPTH = 32
12
+ IVAR_KEY = :@__fiber_audit_execution_context__
13
+ IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
14
+
15
+ class << self
16
+ def current
17
+ state = current_state
18
+ return Context::UNKNOWN unless state
19
+
20
+ state[:stack].last || Context::UNKNOWN
21
+ end
22
+
23
+ def with(context)
24
+ normalized = validate_context(context)
25
+ state = ensure_state
26
+ return yield if state[:stack].size >= MAX_DEPTH
27
+
28
+ state[:stack].push(normalized)
29
+ begin
30
+ yield
31
+ ensure
32
+ state[:stack].pop
33
+ end
34
+ end
35
+
36
+ def reset!
37
+ fiber = Fiber.current
38
+ fiber.remove_instance_variable(IVAR_KEY) if fiber.instance_variable_defined?(IVAR_KEY)
39
+ fiber.remove_instance_variable(IVAR_PID_KEY) if fiber.instance_variable_defined?(IVAR_PID_KEY)
40
+ end
41
+
42
+ def after_fork!
43
+ reset!
44
+ end
45
+
46
+ private
47
+
48
+ def current_state
49
+ fiber = Fiber.current
50
+ return nil unless fiber.instance_variable_defined?(IVAR_KEY)
51
+
52
+ pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
53
+ return nil unless pid == Process.pid
54
+
55
+ { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid }
56
+ end
57
+
58
+ def ensure_state
59
+ fiber = Fiber.current
60
+ pid = Process.pid
61
+
62
+ if fiber.instance_variable_defined?(IVAR_KEY)
63
+ stored_pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
64
+ return { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid } if stored_pid == pid
65
+
66
+ # PID mismatch - reset
67
+ reset!
68
+ end
69
+
70
+ stack = []
71
+ fiber.instance_variable_set(IVAR_KEY, stack)
72
+ fiber.instance_variable_set(IVAR_PID_KEY, pid)
73
+ { stack: stack, pid: pid }
74
+ end
75
+
76
+ def validate_context(value)
77
+ unless value.is_a?(Symbol) || value.is_a?(String)
78
+ raise RuntimeContractError, 'execution_context must be a Symbol or String'
79
+ end
80
+
81
+ normalized = value.is_a?(Symbol) ? value : value.to_sym
82
+ return normalized if Context::ALL.include?(normalized)
83
+
84
+ raise RuntimeContractError, 'execution_context is invalid'
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'clock'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Scheduler-owned progress fiber observed by the watchdog thread.
8
+ class Heartbeat
9
+ Snapshot = Data.define(:sequence, :last_progress_ns, :thread_id, :fiber_id, :started, :stop_requested)
10
+
11
+ attr_reader :owner_thread
12
+
13
+ def initialize(
14
+ interval_ns:,
15
+ clock: Clock.new,
16
+ owner_thread: Thread.current,
17
+ on_tick: ->(_heartbeat) {},
18
+ on_error: ->(_heartbeat, _error) {}
19
+ )
20
+ raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless clock.is_a?(Clock)
21
+ unless interval_ns.is_a?(Integer) && interval_ns.positive?
22
+ raise RuntimeContractError, 'interval_ns must be a positive Integer'
23
+ end
24
+ raise RuntimeContractError, 'owner_thread must be a Thread' unless owner_thread.is_a?(Thread)
25
+ raise RuntimeContractError, 'on_tick must respond to call' unless on_tick.respond_to?(:call)
26
+ raise RuntimeContractError, 'on_error must respond to call' unless on_error.respond_to?(:call)
27
+
28
+ @clock = clock
29
+ @interval_ns = interval_ns
30
+ @owner_thread = owner_thread
31
+ @on_tick = on_tick
32
+ @on_error = on_error
33
+ @mutex = Mutex.new
34
+ @sequence = 0
35
+ @last_progress_ns = nil
36
+ @fiber_id = nil
37
+ @started = false
38
+ @start_requested = false
39
+ @stop_requested = false
40
+ end
41
+
42
+ def start(schedule: Fiber.method(:schedule), sleeper: Kernel.method(:sleep))
43
+ raise RuntimeContractError, 'schedule must respond to call' unless schedule.respond_to?(:call)
44
+ raise RuntimeContractError, 'sleeper must respond to call' unless sleeper.respond_to?(:call)
45
+
46
+ @mutex.synchronize do
47
+ return self if @start_requested
48
+
49
+ @start_requested = true
50
+ end
51
+ schedule.call { run(sleeper) }
52
+ self
53
+ rescue StandardError
54
+ @mutex.synchronize { @start_requested = false }
55
+ raise
56
+ end
57
+
58
+ def tick
59
+ now_ns = @clock.monotonic_ns
60
+ @mutex.synchronize do
61
+ @sequence += 1
62
+ @last_progress_ns = now_ns
63
+ @fiber_id ||= Fiber.current.object_id
64
+ @started = true
65
+ end
66
+ @on_tick.call(self)
67
+ self
68
+ end
69
+
70
+ def request_stop
71
+ @mutex.synchronize { @stop_requested = true }
72
+ self
73
+ end
74
+
75
+ def snapshot
76
+ @mutex.synchronize do
77
+ Snapshot.new(
78
+ sequence: @sequence,
79
+ last_progress_ns: @last_progress_ns,
80
+ thread_id: owner_thread.object_id,
81
+ fiber_id: @fiber_id,
82
+ started: @started,
83
+ stop_requested: @stop_requested
84
+ )
85
+ end
86
+ end
87
+
88
+ def started?
89
+ @mutex.synchronize { @started }
90
+ end
91
+
92
+ def stop_requested?
93
+ @mutex.synchronize { @stop_requested }
94
+ end
95
+
96
+ private
97
+
98
+ def run(sleeper)
99
+ tick
100
+ loop do
101
+ break if stop_requested?
102
+
103
+ sleeper.call(@interval_ns.fdiv(1_000_000_000))
104
+ break if stop_requested?
105
+
106
+ tick
107
+ end
108
+ rescue StandardError => e
109
+ @on_error.call(self, e)
110
+ end
111
+ end
112
+ end
113
+ end