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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ class Supervisor
8
+ SIGNALS = %w[INT TERM HUP QUIT].select { |name| Signal.list.key?(name) }.freeze
9
+
10
+ class SystemAdapter
11
+ def spawn(environment, command, cwd)
12
+ executable = command.fetch(0)
13
+ Process.spawn(
14
+ environment,
15
+ [executable, executable],
16
+ *command.drop(1),
17
+ chdir: cwd,
18
+ pgroup: true
19
+ )
20
+ end
21
+
22
+ def wait2(pid)
23
+ Process.wait2(pid)
24
+ end
25
+
26
+ def trap(signal, handler = nil, &)
27
+ Signal.trap(signal, handler, &)
28
+ end
29
+
30
+ def kill(signal, pid)
31
+ Process.kill(signal, pid)
32
+ end
33
+ end
34
+
35
+ def initialize(command:, environment:, cwd:, adapter: SystemAdapter.new)
36
+ validate_command!(command)
37
+ raise RuntimeContractError, 'child environment must be a Hash' unless environment.is_a?(Hash)
38
+ raise RuntimeContractError, 'runtime cwd must be an existing directory' unless File.directory?(cwd)
39
+
40
+ @command = command.map { |argument| argument.dup.freeze }.freeze
41
+ @environment = environment.transform_values { |value| value&.dup&.freeze }.freeze
42
+ @cwd = File.expand_path(cwd).freeze
43
+ @adapter = adapter
44
+ @child_pid = nil
45
+ end
46
+
47
+ def run
48
+ handlers = {}
49
+ begin
50
+ # Install traps only after spawn so POSIX-spawned children cannot inherit
51
+ # FiberAudit's forwarding handlers and accidentally ignore termination.
52
+ @child_pid = @adapter.spawn(@environment, @command, @cwd)
53
+ install_handlers(handlers)
54
+ _pid, status = wait_for_child
55
+ status_code(status)
56
+ ensure
57
+ restore_handlers(handlers)
58
+ @child_pid = nil
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def validate_command!(command)
65
+ unless command.is_a?(Array) && !command.empty? && command.all?(String)
66
+ raise RuntimeContractError, 'runtime command must be a nonempty Array of Strings'
67
+ end
68
+
69
+ command.each_with_index do |argument, index|
70
+ next if argument.valid_encoding? && !argument.include?("\0") && (index.positive? || !argument.empty?)
71
+
72
+ raise RuntimeContractError, 'runtime command contains an invalid argument'
73
+ end
74
+ end
75
+
76
+ def install_handlers(handlers)
77
+ SIGNALS.each do |signal|
78
+ handlers[signal] = @adapter.trap(signal) { receive_signal(signal) }
79
+ end
80
+ end
81
+
82
+ def restore_handlers(handlers)
83
+ handlers.each { |signal, previous| @adapter.trap(signal, previous) }
84
+ end
85
+
86
+ def receive_signal(signal)
87
+ forward_signal(signal) if @child_pid
88
+ end
89
+
90
+ def forward_signal(signal)
91
+ @adapter.kill(signal, -@child_pid)
92
+ rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
93
+ begin
94
+ @adapter.kill(signal, @child_pid)
95
+ rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
96
+ nil
97
+ end
98
+ end
99
+
100
+ def wait_for_child
101
+ @adapter.wait2(@child_pid)
102
+ rescue Errno::EINTR
103
+ retry
104
+ end
105
+
106
+ def status_code(status)
107
+ return status.exitstatus if status.exited?
108
+ return 128 + status.termsig if status.signaled?
109
+
110
+ raise RuntimeSafetyError, 'wrapped command ended without an exit status'
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ module Validation
8
+ IDENTIFIER = /\A[a-z][a-z0-9_]*\z/
9
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
10
+ CONTROL = /[[:cntrl:]]/
11
+ OPERATION = /\A(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*(?:[.#](?:[a-zA-Z_][a-zA-Z0-9_]*[!?=]?|\[\]=?))\z/
12
+ SPECIAL_OPERATIONS = %w[Thread.current.[] Thread.current.[]=].freeze
13
+
14
+ module_function
15
+
16
+ def identifier(value, field, max_bytes: 64)
17
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
18
+ unless normalized&.match?(IDENTIFIER) && normalized.bytesize <= max_bytes
19
+ raise RuntimeContractError, "#{field} must be a snake-case identifier"
20
+ end
21
+
22
+ normalized.to_sym
23
+ end
24
+
25
+ def string(value, field, max_bytes:, allow_nil: false)
26
+ return if value.nil? && allow_nil
27
+ unless value.is_a?(String) && value.valid_encoding? && !value.empty? &&
28
+ value.bytesize <= max_bytes && !value.match?(CONTROL)
29
+ raise RuntimeContractError, "#{field} must be a non-empty UTF-8 String without control characters"
30
+ end
31
+
32
+ value.dup.freeze
33
+ end
34
+
35
+ def operation(value, allow_nil: false)
36
+ return if value.nil? && allow_nil
37
+
38
+ normalized = string(value, 'operation', max_bytes: 256)
39
+ unless normalized == '[redacted]' || SPECIAL_OPERATIONS.include?(normalized) || normalized.match?(OPERATION)
40
+ raise RuntimeContractError, 'operation must be canonical or redacted'
41
+ end
42
+
43
+ normalized
44
+ end
45
+
46
+ def integer(value, field, minimum: 0, allow_nil: false)
47
+ return if value.nil? && allow_nil
48
+ unless value.is_a?(Integer) && value >= minimum
49
+ raise RuntimeContractError, "#{field} must be an Integer >= #{minimum}"
50
+ end
51
+
52
+ value
53
+ end
54
+
55
+ def finite_number(value, field)
56
+ raise RuntimeContractError, "#{field} must be a finite number" unless value.is_a?(Numeric) && value.finite?
57
+
58
+ value
59
+ end
60
+
61
+ def utc_time(value, field)
62
+ raise RuntimeContractError, "#{field} must be a Time" unless value.is_a?(Time)
63
+
64
+ value.getutc.freeze
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'active_operations'
4
+ require_relative 'clock'
5
+ require_relative 'event'
6
+ require_relative 'heartbeat'
7
+ require_relative 'recorder'
8
+ require_relative 'redactor'
9
+ require_relative 'watchdog_policy'
10
+
11
+ module FiberAudit
12
+ module Runtime
13
+ # Monitors scheduler-owned heartbeat fibers from one dedicated process thread.
14
+ # rubocop:disable Metrics/ClassLength
15
+ class Watchdog
16
+ SOURCE = :scheduler_watchdog
17
+ STOP_TIMEOUT_SECONDS = 1
18
+
19
+ Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
20
+ Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
21
+
22
+ attr_reader :policy, :recorder, :active_operations
23
+
24
+ def initialize(
25
+ policy:,
26
+ recorder:,
27
+ redactor:,
28
+ active_operations:,
29
+ clock: Clock.new,
30
+ thread_factory: ->(&block) { Thread.new(&block) }
31
+ )
32
+ validate_dependencies!(policy, recorder, redactor, active_operations, clock, thread_factory)
33
+ @policy = policy
34
+ @recorder = recorder
35
+ @redactor = redactor
36
+ @active_operations = active_operations
37
+ @clock = clock
38
+ @thread_factory = thread_factory
39
+ @mutex = Mutex.new
40
+ @wait_mutex = Mutex.new
41
+ @condition = ConditionVariable.new
42
+ @channels = {}.compare_by_identity
43
+ @stall_sequence = 0
44
+ @unsupported_seen = false
45
+ @monitor_thread = nil
46
+ @stopping = false
47
+ @stopped = false
48
+ emit_state(policy.enabled? ? :watchdog_absent : :watchdog_disabled)
49
+ end
50
+
51
+ def enabled?
52
+ policy.enabled?
53
+ end
54
+
55
+ def fail_open?
56
+ recorder.session.policy.fail_open?
57
+ end
58
+
59
+ def state
60
+ return :disabled unless enabled?
61
+
62
+ @mutex.synchronize do
63
+ return :active if @channels.values.any?(&:active)
64
+ return :unsupported if @unsupported_seen || @channels.values.any?(&:unsupported)
65
+
66
+ :absent
67
+ end
68
+ end
69
+
70
+ def scheduler_installed(
71
+ thread: Thread.current,
72
+ schedule: Fiber.method(:schedule),
73
+ sleeper: Kernel.method(:sleep)
74
+ )
75
+ return self unless enabled?
76
+ raise RuntimeContractError, 'scheduler thread must be a Thread' unless thread.is_a?(Thread)
77
+
78
+ heartbeat = Heartbeat.new(
79
+ clock: @clock,
80
+ interval_ns: policy.heartbeat_interval_ns,
81
+ owner_thread: thread,
82
+ on_tick: method(:heartbeat_ticked),
83
+ on_error: method(:heartbeat_failed)
84
+ )
85
+ previous = @mutex.synchronize do
86
+ prior = @channels[thread]
87
+ @channels[thread] = Channel.new(
88
+ thread: thread,
89
+ heartbeat: heartbeat,
90
+ active: false,
91
+ unsupported: false,
92
+ stall: nil
93
+ )
94
+ prior
95
+ end
96
+ previous&.heartbeat&.request_stop
97
+ heartbeat.start(schedule: schedule, sleeper: sleeper)
98
+ self
99
+ rescue StandardError => e
100
+ channel_failure(thread, e)
101
+ raise e unless fail_open?
102
+
103
+ self
104
+ end
105
+
106
+ def scheduler_unsupported(thread: Thread.current)
107
+ return self unless enabled?
108
+
109
+ channel_failure(thread, nil)
110
+ self
111
+ end
112
+
113
+ def scheduler_closing(thread: Thread.current)
114
+ return self unless enabled?
115
+
116
+ now_ns = safe_monotonic_ns
117
+ @mutex.synchronize do
118
+ channel = @channels.delete(thread)
119
+ next unless channel
120
+
121
+ channel.heartbeat.request_stop
122
+ complete_stall(channel, now_ns: now_ns, resumed: false) if channel.stall
123
+ unless channel.active || channel.unsupported
124
+ @unsupported_seen = true
125
+ emit_state(:watchdog_unsupported, thread: thread)
126
+ end
127
+ end
128
+ wake_monitor
129
+ self
130
+ rescue StandardError => e
131
+ handle_failure(e)
132
+ raise e unless fail_open?
133
+
134
+ self
135
+ end
136
+
137
+ def poll(now_ns: nil)
138
+ return state unless enabled?
139
+
140
+ observed_now = now_ns.nil? ? @clock.monotonic_ns : Validation.integer(now_ns, 'watchdog poll time')
141
+ @mutex.synchronize do
142
+ @channels.each_value { |channel| poll_channel(channel, observed_now) if channel.active }
143
+ end
144
+ state
145
+ rescue StandardError => e
146
+ handle_failure(e)
147
+ raise e unless fail_open?
148
+
149
+ :unsupported
150
+ end
151
+
152
+ def stop
153
+ return self if @stopped
154
+
155
+ @wait_mutex.synchronize do
156
+ @stopping = true
157
+ @condition.broadcast
158
+ end
159
+ now_ns = safe_monotonic_ns
160
+ @mutex.synchronize do
161
+ @channels.each_value do |channel|
162
+ channel.heartbeat.request_stop
163
+ complete_stall(channel, now_ns: now_ns, resumed: false) if channel.stall
164
+ if !channel.active && !channel.unsupported
165
+ @unsupported_seen = true
166
+ emit_state(:watchdog_unsupported, thread: channel.thread)
167
+ end
168
+ end
169
+ @channels.clear
170
+ end
171
+ stop_monitor_thread
172
+ @stopped = true
173
+ self
174
+ rescue StandardError => e
175
+ handle_failure(e)
176
+ @stopped = true
177
+ raise e unless fail_open?
178
+
179
+ self
180
+ end
181
+
182
+ private
183
+
184
+ def validate_dependencies!(watchdog_policy, candidate_recorder, redactor, operations, clock, threads)
185
+ unless watchdog_policy.is_a?(WatchdogPolicy)
186
+ raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::WatchdogPolicy'
187
+ end
188
+ unless candidate_recorder.is_a?(Recorder)
189
+ raise RuntimeContractError, 'recorder must be a FiberAudit::Runtime::Recorder'
190
+ end
191
+ raise RuntimeContractError, 'redactor must be a FiberAudit::Runtime::Redactor' unless redactor.is_a?(Redactor)
192
+ unless operations.is_a?(ActiveOperations)
193
+ raise RuntimeContractError, 'active_operations must be FiberAudit::Runtime::ActiveOperations'
194
+ end
195
+ raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless clock.is_a?(Clock)
196
+ raise RuntimeContractError, 'thread_factory must respond to call' unless threads.respond_to?(:call)
197
+ end
198
+
199
+ def heartbeat_ticked(heartbeat)
200
+ activate = false
201
+ @mutex.synchronize do
202
+ channel = @channels[heartbeat.owner_thread]
203
+ return unless channel&.heartbeat.equal?(heartbeat)
204
+
205
+ unless channel.active
206
+ channel.active = true
207
+ activate = true
208
+ end
209
+ end
210
+ if activate
211
+ snapshot = heartbeat.snapshot
212
+ emit_state(
213
+ :watchdog_active,
214
+ thread: heartbeat.owner_thread,
215
+ fiber_id: snapshot.fiber_id,
216
+ measurements: policy_measurements
217
+ )
218
+ start_monitor_thread
219
+ end
220
+ wake_monitor
221
+ rescue StandardError => e
222
+ heartbeat_failed(heartbeat, e)
223
+ end
224
+
225
+ def heartbeat_failed(heartbeat, error)
226
+ channel_failure(heartbeat.owner_thread, error)
227
+ raise error unless fail_open?
228
+ end
229
+
230
+ def channel_failure(thread, error)
231
+ already_unsupported = false
232
+ @mutex.synchronize do
233
+ entry = @channels[thread]
234
+ if entry
235
+ already_unsupported = entry.unsupported
236
+ entry.heartbeat.request_stop
237
+ entry.unsupported = true
238
+ entry.active = false
239
+ else
240
+ already_unsupported = @unsupported_seen
241
+ end
242
+ @unsupported_seen = true
243
+ end
244
+ account_internal_error if error
245
+ emit_state(:watchdog_unsupported, thread: thread) unless already_unsupported
246
+ end
247
+
248
+ def poll_channel(channel, now_ns)
249
+ snapshot = channel.heartbeat.snapshot
250
+ return unless snapshot.started && snapshot.last_progress_ns
251
+ raise RuntimeSafetyError, 'watchdog monotonic clock moved backwards' if now_ns < snapshot.last_progress_ns
252
+
253
+ if channel.stall
254
+ complete_stall(channel, now_ns: now_ns, resumed: true) if snapshot.sequence > channel.stall.progress_sequence
255
+ return
256
+ end
257
+
258
+ age_ns = now_ns - snapshot.last_progress_ns
259
+ start_stall(channel, snapshot, age_ns, now_ns) if policy.stalled?(age_ns: age_ns)
260
+ end
261
+
262
+ def start_stall(channel, snapshot, age_ns, now_ns)
263
+ @stall_sequence += 1
264
+ channel.stall = Stall.new(
265
+ sequence: @stall_sequence,
266
+ progress_sequence: snapshot.sequence,
267
+ began_monotonic_ns: snapshot.last_progress_ns
268
+ )
269
+ operations = active_operations.snapshot(thread_id: snapshot.thread_id)
270
+ frames = safe_frames(channel.thread)
271
+ measurements = {
272
+ stall_sequence: @stall_sequence,
273
+ progress_sequence: snapshot.sequence,
274
+ observed_age_ns: age_ns,
275
+ stall_threshold_ns: policy.stall_threshold_ns,
276
+ heartbeat_interval_ns: policy.heartbeat_interval_ns,
277
+ frame_count: frames.size,
278
+ active_operation_count: operations.size,
279
+ active_operation_first_sequence: operations.first&.sequence,
280
+ active_operation_last_sequence: operations.last&.sequence
281
+ }
282
+ emit_event(
283
+ kind: :scheduler_stall_started,
284
+ monotonic_ns: now_ns,
285
+ thread_id: snapshot.thread_id,
286
+ fiber_id: snapshot.fiber_id,
287
+ measurements: measurements
288
+ )
289
+ frames.each_with_index do |location, index|
290
+ emit_event(
291
+ kind: :scheduler_stall_frame,
292
+ monotonic_ns: now_ns,
293
+ location: location,
294
+ thread_id: snapshot.thread_id,
295
+ fiber_id: snapshot.fiber_id,
296
+ measurements: { stall_sequence: @stall_sequence, frame_index: index }
297
+ )
298
+ end
299
+ end
300
+
301
+ def complete_stall(channel, now_ns:, resumed:)
302
+ stall = channel.stall
303
+ return unless stall
304
+
305
+ snapshot = channel.heartbeat.snapshot
306
+ duration = [now_ns - stall.began_monotonic_ns, 0].max
307
+ emit_event(
308
+ kind: :scheduler_stall_completed,
309
+ monotonic_ns: now_ns,
310
+ duration_ns: duration,
311
+ thread_id: snapshot.thread_id,
312
+ fiber_id: snapshot.fiber_id,
313
+ measurements: {
314
+ stall_sequence: stall.sequence,
315
+ progress_sequence: snapshot.sequence,
316
+ resumed: resumed
317
+ }
318
+ )
319
+ channel.stall = nil
320
+ end
321
+
322
+ def safe_frames(thread)
323
+ return [] if policy.max_frames.zero?
324
+
325
+ frames = thread.backtrace_locations(0, policy.max_frames) || []
326
+ frames.first(policy.max_frames).filter_map do |frame|
327
+ location = safe_frame_location(frame)
328
+ location unless location.nil? || Location::SENTINELS.include?(location.path)
329
+ rescue StandardError
330
+ nil
331
+ end.freeze
332
+ rescue StandardError => e
333
+ account_internal_error
334
+ raise e unless fail_open?
335
+
336
+ [].freeze
337
+ end
338
+
339
+ def safe_frame_location(frame)
340
+ path = frame.absolute_path
341
+ unless path
342
+ relative = frame.path
343
+ return unless relative.is_a?(String) && !relative.start_with?('-', '<')
344
+
345
+ path = File.join(@redactor.root, relative)
346
+ end
347
+ @redactor.location(path: path, line: frame.lineno, column: nil)
348
+ end
349
+
350
+ def emit_state(kind, thread: nil, fiber_id: nil, measurements: {})
351
+ state_measurements = measurements.empty? ? policy_measurements : measurements
352
+ emit_event(
353
+ kind: kind,
354
+ monotonic_ns: @clock.monotonic_ns,
355
+ thread_id: thread&.object_id,
356
+ fiber_id: fiber_id,
357
+ measurements: state_measurements
358
+ )
359
+ end
360
+
361
+ def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, measurements: {})
362
+ recorder.record_control do
363
+ Event.new(
364
+ kind: kind,
365
+ source: SOURCE,
366
+ occurred_at: @clock.wall_time,
367
+ monotonic_ns: monotonic_ns,
368
+ duration_ns: duration_ns,
369
+ location: location,
370
+ execution_context: :unknown,
371
+ thread_id: thread_id,
372
+ fiber_id: fiber_id,
373
+ measurements: measurements
374
+ )
375
+ end
376
+ rescue StandardError => e
377
+ account_internal_error unless recorder.disabled?
378
+ raise e unless fail_open?
379
+
380
+ :internal_error
381
+ end
382
+
383
+ def policy_measurements
384
+ {
385
+ heartbeat_interval_ns: policy.heartbeat_interval_ns,
386
+ stall_threshold_ns: policy.stall_threshold_ns,
387
+ max_frames: policy.max_frames
388
+ }
389
+ end
390
+
391
+ def start_monitor_thread
392
+ @wait_mutex.synchronize do
393
+ return if @monitor_thread || @stopping
394
+
395
+ @monitor_thread = @thread_factory.call { monitor_loop }
396
+ raise RuntimeContractError, 'thread_factory must return a Thread' unless @monitor_thread.is_a?(Thread)
397
+
398
+ @monitor_thread.report_on_exception = false
399
+ @monitor_thread.abort_on_exception = !fail_open?
400
+ @monitor_thread.name = 'fiber-audit-watchdog' if @monitor_thread.respond_to?(:name=)
401
+ end
402
+ end
403
+
404
+ def monitor_loop
405
+ loop do
406
+ should_stop = @wait_mutex.synchronize do
407
+ @condition.wait(@wait_mutex, policy.heartbeat_interval_ms.fdiv(1_000)) unless @stopping
408
+ @stopping
409
+ end
410
+ break if should_stop
411
+
412
+ poll
413
+ end
414
+ rescue StandardError => e
415
+ handle_failure(e)
416
+ raise e unless fail_open?
417
+ end
418
+
419
+ def wake_monitor
420
+ @wait_mutex.synchronize { @condition.broadcast }
421
+ end
422
+
423
+ def stop_monitor_thread
424
+ thread = @monitor_thread
425
+ return unless thread && thread != Thread.current
426
+
427
+ return if thread.join(STOP_TIMEOUT_SECONDS)
428
+
429
+ account_internal_error
430
+ thread.kill
431
+ thread.join
432
+ ensure
433
+ @monitor_thread = nil
434
+ end
435
+
436
+ def handle_failure(error)
437
+ first_failure = false
438
+ failed_thread = nil
439
+ unless @mutex.owned?
440
+ @mutex.synchronize do
441
+ first_failure = !@unsupported_seen
442
+ @unsupported_seen = true
443
+ @channels.each_value do |channel|
444
+ failed_thread ||= channel.thread
445
+ channel.heartbeat.request_stop
446
+ channel.active = false
447
+ channel.unsupported = true
448
+ end
449
+ end
450
+ end
451
+ if first_failure
452
+ account_internal_error
453
+ begin
454
+ emit_state(:watchdog_unsupported, thread: failed_thread)
455
+ rescue StandardError
456
+ nil
457
+ end
458
+ end
459
+ error
460
+ end
461
+
462
+ def account_internal_error
463
+ recorder.internal_error!
464
+ rescue StandardError
465
+ nil
466
+ end
467
+
468
+ def safe_monotonic_ns
469
+ @clock.monotonic_ns
470
+ rescue StandardError => e
471
+ account_internal_error
472
+ raise e unless fail_open?
473
+
474
+ recorder.session.started_monotonic_ns
475
+ end
476
+ end
477
+ # rubocop:enable Metrics/ClassLength
478
+ end
479
+ end