fiber_audit 0.2.1 → 0.3.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +7 -8
  3. data/ARCHITECTURE.md +97 -673
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +163 -86
  6. data/lib/fiber_audit/cli.rb +19 -5
  7. data/lib/fiber_audit/configuration.rb +31 -6
  8. data/lib/fiber_audit/operation_semantics.rb +172 -0
  9. data/lib/fiber_audit/operation_vocabulary.rb +2 -1
  10. data/lib/fiber_audit/runtime/active_operations.rb +39 -9
  11. data/lib/fiber_audit/runtime/boot.rb +4 -0
  12. data/lib/fiber_audit/runtime/environment.rb +58 -0
  13. data/lib/fiber_audit/runtime/execution_context.rb +55 -43
  14. data/lib/fiber_audit/runtime/lifecycle.rb +91 -50
  15. data/lib/fiber_audit/runtime/operation_liveness_monitor.rb +282 -0
  16. data/lib/fiber_audit/runtime/operation_liveness_policy.rb +47 -0
  17. data/lib/fiber_audit/runtime/probes/base.rb +27 -4
  18. data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
  19. data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
  20. data/lib/fiber_audit/runtime/scheduler_evidence_classifier.rb +60 -0
  21. data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
  22. data/lib/fiber_audit/runtime/scheduler_snapshot.rb +95 -0
  23. data/lib/fiber_audit/runtime/watchdog.rb +54 -4
  24. data/lib/fiber_audit/runtime.rb +5 -0
  25. data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
  26. data/lib/fiber_audit/static/rules/base.rb +19 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +67 -10
  28. data/lib/fiber_audit/static/rules/direct_socket.rb +54 -19
  29. data/lib/fiber_audit/static/rules/io_select.rb +6 -6
  30. data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
  31. data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
  32. data/lib/fiber_audit/static/rules/thread_current_state.rb +20 -30
  33. data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
  34. data/lib/fiber_audit/version.rb +1 -1
  35. metadata +6 -1
@@ -6,15 +6,25 @@ module FiberAudit
6
6
  module Subprocess
7
7
  module KernelInstanceHook
8
8
  def system(...)
9
- Registry.observe(operation: 'Kernel.system') { super }
9
+ Registry.observe(
10
+ operation: 'Kernel.system',
11
+ measurements: { waits_for_child: true }
12
+ ) { super }
10
13
  end
11
14
 
12
15
  def exec(...)
13
- Registry.observe(operation: 'Kernel.exec', emit_start: true) { super }
16
+ Registry.observe(
17
+ operation: 'Kernel.exec',
18
+ measurements: { replaces_process: true },
19
+ emit_start: true
20
+ ) { super }
14
21
  end
15
22
 
16
23
  def spawn(...)
17
- Registry.observe(operation: 'Kernel.spawn') { super }
24
+ Registry.observe(
25
+ operation: 'Kernel.spawn',
26
+ measurements: { returns_pid: true, does_not_wait: true }
27
+ ) { super }
18
28
  end
19
29
 
20
30
  private :system, :exec, :spawn
@@ -22,15 +32,25 @@ module FiberAudit
22
32
 
23
33
  module KernelSingletonHook
24
34
  def system(...)
25
- Registry.observe(operation: 'Kernel.system') { super }
35
+ Registry.observe(
36
+ operation: 'Kernel.system',
37
+ measurements: { waits_for_child: true }
38
+ ) { super }
26
39
  end
27
40
 
28
41
  def exec(...)
29
- Registry.observe(operation: 'Kernel.exec', emit_start: true) { super }
42
+ Registry.observe(
43
+ operation: 'Kernel.exec',
44
+ measurements: { replaces_process: true },
45
+ emit_start: true
46
+ ) { super }
30
47
  end
31
48
 
32
49
  def spawn(...)
33
- Registry.observe(operation: 'Kernel.spawn') { super }
50
+ Registry.observe(
51
+ operation: 'Kernel.spawn',
52
+ measurements: { returns_pid: true, does_not_wait: true }
53
+ ) { super }
34
54
  end
35
55
  end
36
56
 
@@ -41,12 +61,70 @@ module FiberAudit
41
61
  end
42
62
 
43
63
  module ProcessHook
64
+ def spawn(...)
65
+ Registry.observe(
66
+ operation: 'Process.spawn',
67
+ measurements: { returns_pid: true, does_not_wait: true }
68
+ ) { super }
69
+ end
70
+
71
+ def exec(...)
72
+ Registry.observe(
73
+ operation: 'Process.exec',
74
+ measurements: { replaces_process: true },
75
+ emit_start: true
76
+ ) { super }
77
+ end
78
+
79
+ def wait(...)
80
+ Registry.observe(
81
+ operation: 'Process.wait',
82
+ measurements: { waits_for_child: true }
83
+ ) { super }
84
+ end
85
+
86
+ def wait2(...)
87
+ Registry.observe(
88
+ operation: 'Process.wait2',
89
+ measurements: { waits_for_child: true }
90
+ ) { super }
91
+ end
92
+
93
+ def waitpid(...)
94
+ Registry.observe(
95
+ operation: 'Process.waitpid',
96
+ measurements: { waits_for_child: true }
97
+ ) { super }
98
+ end
99
+
100
+ def waitpid2(...)
101
+ Registry.observe(
102
+ operation: 'Process.waitpid2',
103
+ measurements: { waits_for_child: true }
104
+ ) { super }
105
+ end
106
+
44
107
  def waitall(...)
45
- Registry.observe(operation: 'Process.waitall') { super }
108
+ Registry.observe(
109
+ operation: 'Process.waitall',
110
+ measurements: { waits_for_child: true }
111
+ ) { super }
46
112
  end
47
113
 
48
114
  def detach(...)
49
- Registry.observe(operation: 'Process.detach') { super }
115
+ Registry.observe(
116
+ operation: 'Process.detach',
117
+ measurements: { returns_thread: true, does_not_wait: true }
118
+ ) { super }
119
+ end
120
+ end
121
+
122
+ module ProcessStatusHook
123
+ def wait(...)
124
+ Registry.observe(
125
+ operation: 'Process::Status.wait',
126
+ measurements: { waits_for_child: true }
127
+ ) { super }
50
128
  end
51
129
  end
52
130
 
@@ -75,6 +153,9 @@ module FiberAudit
75
153
  registry.prepend_once(Kernel.singleton_class, KernelSingletonHook)
76
154
  registry.prepend_once(IO.singleton_class, IOHook)
77
155
  registry.prepend_once(Process.singleton_class, ProcessHook)
156
+ if defined?(Process::Status) && Process::Status.respond_to?(:wait)
157
+ registry.prepend_once(Process::Status.singleton_class, ProcessStatusHook)
158
+ end
78
159
  registry.prepend_once(Open3.singleton_class, Open3Hook) if defined?(Open3)
79
160
  end
80
161
  end
@@ -12,20 +12,6 @@ module FiberAudit
12
12
  def thread_variable_set(...)
13
13
  Registry.observe(operation: 'Thread.thread_variable_set') { super }
14
14
  end
15
-
16
- def [](...)
17
- return super unless equal?(Thread.current)
18
-
19
- Registry.observe(operation: 'Thread.current.[]') { super }
20
- end
21
-
22
- def []=(...)
23
- if equal?(Thread.current)
24
- Registry.observe(operation: 'Thread.current.[]=') { super }
25
- else
26
- super
27
- end
28
- end
29
15
  end
30
16
 
31
17
  module_function
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../operation_semantics'
4
+ require_relative 'scheduler_snapshot'
5
+
6
+ module FiberAudit
7
+ module Runtime
8
+ module SchedulerEvidenceClassifier
9
+ module_function
10
+
11
+ def measurements(operation:, scheduler_snapshot:)
12
+ unless scheduler_snapshot.nil? || scheduler_snapshot.is_a?(SchedulerSnapshot)
13
+ raise RuntimeContractError, 'scheduler_snapshot must be a SchedulerSnapshot or nil'
14
+ end
15
+
16
+ profile = OperationSemantics.resolve_runtime_operation(operation)
17
+ capability_supported = capability_supported(profile.scheduler_capability, scheduler_snapshot)
18
+ {
19
+ operation_wait_possible: profile.wait_possible,
20
+ operation_inventory_only: profile.inventory_only,
21
+ operation_scheduler_capability_required: profile.known? ? profile.scheduler_capability_required? : nil,
22
+ operation_scheduler_capability_supported: capability_supported,
23
+ operation_scheduler_cooperation_available: cooperation_available(
24
+ profile,
25
+ scheduler_snapshot,
26
+ capability_supported
27
+ )
28
+ }.freeze
29
+ end
30
+
31
+ def capability_supported(capability, snapshot)
32
+ return nil if capability.nil? || snapshot.nil?
33
+
34
+ case capability
35
+ when :block, :kernel_sleep
36
+ snapshot.scheduler_present
37
+ when :io_select
38
+ snapshot.scheduler_io_select_supported
39
+ when :process_wait
40
+ snapshot.scheduler_process_wait_supported
41
+ when :address_resolve
42
+ snapshot.scheduler_address_resolve_supported
43
+ end
44
+ end
45
+ private_class_method :capability_supported
46
+
47
+ def cooperation_available(profile, snapshot, capability_supported)
48
+ return nil unless profile.wait_possible == true
49
+ return nil if snapshot.nil? || snapshot.scheduler_present.nil?
50
+ return false unless snapshot.scheduler_present
51
+ return nil if snapshot.fiber_blocking.nil?
52
+ return false if snapshot.fiber_blocking
53
+ return nil unless profile.scheduler_capability_required?
54
+
55
+ capability_supported
56
+ end
57
+ private_class_method :cooperation_available
58
+ end
59
+ end
60
+ end
@@ -10,14 +10,14 @@ module FiberAudit
10
10
  # Ruby's scheduler API fixes this writer-like method name.
11
11
  # rubocop:disable Naming/AccessorMethodName
12
12
  def set_scheduler(scheduler)
13
- FiberAudit::Runtime::SchedulerObserver.scheduler_replacing(thread: Thread.current) if Fiber.scheduler
13
+ previous = Fiber.scheduler
14
14
  result = super
15
- if scheduler
16
- FiberAudit::Runtime::SchedulerObserver.scheduler_installed(
17
- scheduler: scheduler,
18
- thread: Thread.current
19
- )
20
- end
15
+ current = Fiber.scheduler
16
+ FiberAudit::Runtime::SchedulerObserver.scheduler_changed(
17
+ previous: previous,
18
+ current: current,
19
+ thread: Thread.current
20
+ )
21
21
  result
22
22
  end
23
23
  # rubocop:enable Naming/AccessorMethodName
@@ -25,7 +25,10 @@ module FiberAudit
25
25
 
26
26
  module SchedulerCloseHook
27
27
  def close(...)
28
- FiberAudit::Runtime::SchedulerObserver.scheduler_closing(thread: Thread.current)
28
+ FiberAudit::Runtime::SchedulerObserver.scheduler_closing(
29
+ scheduler: self,
30
+ thread: Thread.current
31
+ )
29
32
  super
30
33
  end
31
34
  end
@@ -41,19 +44,15 @@ module FiberAudit
41
44
  observer
42
45
  end
43
46
 
44
- def scheduler_replacing(thread:)
45
- current_for_process&.scheduler_closing(thread: thread)
46
- end
47
-
48
- def scheduler_installed(scheduler:, thread:)
47
+ def scheduler_changed(previous:, current:, thread:)
49
48
  observer = current_for_process
50
49
  return unless observer
51
50
 
52
- observer.scheduler_installed(scheduler: scheduler, thread: thread)
51
+ observer.scheduler_changed(previous: previous, current: current, thread: thread)
53
52
  end
54
53
 
55
- def scheduler_closing(thread:)
56
- current_for_process&.scheduler_closing(thread: thread)
54
+ def scheduler_closing(thread:, scheduler: nil)
55
+ current_for_process&.scheduler_closing(scheduler: scheduler, thread: thread)
57
56
  end
58
57
 
59
58
  def deactivate(observer)
@@ -82,6 +81,8 @@ module FiberAudit
82
81
  @watchdog = watchdog
83
82
  @owner_pid = Process.pid
84
83
  @active = true
84
+ @mutex = Mutex.new
85
+ @schedulers = {}
85
86
  end
86
87
 
87
88
  def active_for_current_process?
@@ -94,8 +95,17 @@ module FiberAudit
94
95
  self
95
96
  end
96
97
 
98
+ def scheduler_changed(previous:, current:, thread:)
99
+ return self unless active_for_current_process?
100
+
101
+ scheduler_closing(scheduler: previous, thread: thread) if previous && !previous.equal?(current)
102
+ scheduler_installed(scheduler: current, thread: thread) if current
103
+ self
104
+ end
105
+
97
106
  def scheduler_installed(scheduler:, thread:)
98
107
  return self unless active_for_current_process? && watchdog.enabled?
108
+ return self unless track_scheduler(scheduler, thread)
99
109
 
100
110
  install_close_hook!(scheduler)
101
111
  watchdog.scheduler_installed(thread: thread)
@@ -107,13 +117,17 @@ module FiberAudit
107
117
  self
108
118
  end
109
119
 
110
- def scheduler_closing(thread:)
111
- watchdog.scheduler_closing(thread: thread) if active_for_current_process?
120
+ def scheduler_closing(thread:, scheduler: nil)
121
+ return self unless active_for_current_process?
122
+ return self unless untrack_scheduler(scheduler, thread)
123
+
124
+ watchdog.scheduler_closing(thread: thread)
112
125
  self
113
126
  end
114
127
 
115
128
  def deactivate
116
129
  @active = false
130
+ @mutex.synchronize { @schedulers.clear }
117
131
  self
118
132
  end
119
133
 
@@ -123,6 +137,28 @@ module FiberAudit
123
137
  singleton = scheduler.singleton_class
124
138
  singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
125
139
  end
140
+
141
+ def track_scheduler(scheduler, thread)
142
+ @mutex.synchronize do
143
+ key = thread.object_id
144
+ return false if @schedulers[key].equal?(scheduler)
145
+
146
+ @schedulers[key] = scheduler
147
+ true
148
+ end
149
+ end
150
+
151
+ def untrack_scheduler(scheduler, thread)
152
+ @mutex.synchronize do
153
+ key = thread.object_id
154
+ tracked = @schedulers[key]
155
+ return false unless tracked
156
+ return false if scheduler && !tracked.equal?(scheduler)
157
+
158
+ @schedulers.delete(key)
159
+ true
160
+ end
161
+ end
126
162
  end
127
163
  end
128
164
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ # Immutable scheduler snapshot/context fields captured at operation start.
6
+ # All fields are Boolean or nil only, as per JSONL 1.0 schema requirements.
7
+ SchedulerSnapshot = Data.define(
8
+ :scheduler_present,
9
+ :fiber_blocking,
10
+ :scheduler_io_select_supported,
11
+ :scheduler_process_wait_supported,
12
+ :scheduler_address_resolve_supported
13
+ ) do
14
+ def initialize(
15
+ scheduler_present:,
16
+ fiber_blocking:,
17
+ scheduler_io_select_supported: nil,
18
+ scheduler_process_wait_supported: nil,
19
+ scheduler_address_resolve_supported: nil
20
+ )
21
+ super(
22
+ scheduler_present: normalize_optional_boolean(scheduler_present, 'scheduler_present'),
23
+ fiber_blocking: normalize_optional_boolean(fiber_blocking, 'fiber_blocking'),
24
+ scheduler_io_select_supported: normalize_optional_boolean(
25
+ scheduler_io_select_supported,
26
+ 'scheduler_io_select_supported'
27
+ ),
28
+ scheduler_process_wait_supported: normalize_optional_boolean(
29
+ scheduler_process_wait_supported,
30
+ 'scheduler_process_wait_supported'
31
+ ),
32
+ scheduler_address_resolve_supported: normalize_optional_boolean(
33
+ scheduler_address_resolve_supported,
34
+ 'scheduler_address_resolve_supported'
35
+ )
36
+ )
37
+ end
38
+
39
+ def to_measurements
40
+ {
41
+ scheduler_present: scheduler_present,
42
+ fiber_blocking: fiber_blocking,
43
+ scheduler_io_select_supported: scheduler_io_select_supported,
44
+ scheduler_process_wait_supported: scheduler_process_wait_supported,
45
+ scheduler_address_resolve_supported: scheduler_address_resolve_supported
46
+ }.freeze
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_optional_boolean(value, field)
52
+ return value if value.nil?
53
+ raise RuntimeContractError, "#{field} must be a Boolean or nil" unless [true, false].include?(value)
54
+
55
+ value
56
+ end
57
+ end
58
+
59
+ # Captures immutable scheduler snapshot at the current point in time.
60
+ # This is called at operation start to provide immutable context.
61
+ module SchedulerSnapshotCapture
62
+ module_function
63
+
64
+ def capture
65
+ scheduler = Fiber.scheduler
66
+ scheduler_present = !scheduler.nil?
67
+
68
+ current_fiber = Fiber.current
69
+ fiber_blocking = current_fiber.respond_to?(:blocking?) ? current_fiber.blocking? : nil
70
+
71
+ # Query scheduler capabilities if present
72
+ io_select_supported = nil
73
+ process_wait_supported = nil
74
+ address_resolve_supported = nil
75
+
76
+ if scheduler_present
77
+ io_select_supported = scheduler.respond_to?(:io_select)
78
+ process_wait_supported = scheduler.respond_to?(:process_wait)
79
+ address_resolve_supported = scheduler.respond_to?(:address_resolve)
80
+ end
81
+
82
+ SchedulerSnapshot.new(
83
+ scheduler_present: scheduler_present,
84
+ fiber_blocking: fiber_blocking,
85
+ scheduler_io_select_supported: io_select_supported,
86
+ scheduler_process_wait_supported: process_wait_supported,
87
+ scheduler_address_resolve_supported: address_resolve_supported
88
+ )
89
+ rescue StandardError
90
+ # Fail open without inventing scheduler or Fiber state.
91
+ SchedulerSnapshot.new(scheduler_present: nil, fiber_blocking: nil)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -6,6 +6,7 @@ require_relative 'event'
6
6
  require_relative 'heartbeat'
7
7
  require_relative 'recorder'
8
8
  require_relative 'redactor'
9
+ require_relative 'scheduler_evidence_classifier'
9
10
  require_relative 'watchdog_policy'
10
11
 
11
12
  module FiberAudit
@@ -15,6 +16,7 @@ module FiberAudit
15
16
  class Watchdog
16
17
  SOURCE = :scheduler_watchdog
17
18
  STOP_TIMEOUT_SECONDS = 1
19
+ MAX_OVERLAP_EVENTS = 10
18
20
 
19
21
  Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
20
22
  Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
@@ -259,6 +261,7 @@ module FiberAudit
259
261
  start_stall(channel, snapshot, age_ns, now_ns) if policy.stalled?(age_ns: age_ns)
260
262
  end
261
263
 
264
+ # rubocop:disable Metrics/AbcSize
262
265
  def start_stall(channel, snapshot, age_ns, now_ns)
263
266
  @stall_sequence += 1
264
267
  channel.stall = Stall.new(
@@ -266,7 +269,8 @@ module FiberAudit
266
269
  progress_sequence: snapshot.sequence,
267
270
  began_monotonic_ns: snapshot.last_progress_ns
268
271
  )
269
- operations = active_operations.snapshot(thread_id: snapshot.thread_id)
272
+ operation_snapshot = active_operations.snapshot_with_metadata(thread_id: snapshot.thread_id)
273
+ operations = operation_snapshot.entries
270
274
  frames = safe_frames(channel.thread)
271
275
  measurements = {
272
276
  stall_sequence: @stall_sequence,
@@ -275,7 +279,8 @@ module FiberAudit
275
279
  stall_threshold_ns: policy.stall_threshold_ns,
276
280
  heartbeat_interval_ns: policy.heartbeat_interval_ns,
277
281
  frame_count: frames.size,
278
- active_operation_count: operations.size,
282
+ active_operation_count: operation_snapshot.total_count,
283
+ active_operation_snapshot_truncated: operation_snapshot.truncated?,
279
284
  active_operation_first_sequence: operations.first&.sequence,
280
285
  active_operation_last_sequence: operations.last&.sequence
281
286
  }
@@ -296,6 +301,49 @@ module FiberAudit
296
301
  measurements: { stall_sequence: @stall_sequence, frame_index: index }
297
302
  )
298
303
  end
304
+ emit_stall_operation_overlap_events(operations, operation_snapshot.total_count, now_ns)
305
+ end
306
+ # rubocop:enable Metrics/AbcSize
307
+
308
+ def emit_stall_operation_overlap_events(operations, total_count, now_ns)
309
+ return if operations.empty?
310
+
311
+ bounded_operations = operations.first(MAX_OVERLAP_EVENTS)
312
+ truncated = total_count > bounded_operations.size
313
+ bounded_operations.each do |entry|
314
+ emit_event(
315
+ kind: :scheduler_stall_operation_overlap,
316
+ monotonic_ns: now_ns,
317
+ operation: entry.operation,
318
+ location: entry.location,
319
+ execution_context: entry.execution_context,
320
+ thread_id: entry.thread_id,
321
+ fiber_id: entry.fiber_id,
322
+ measurements: build_overlap_measurements(entry, truncated, total_count)
323
+ )
324
+ end
325
+ rescue StandardError => e
326
+ account_internal_error unless recorder.disabled?
327
+ raise e unless fail_open?
328
+ end
329
+
330
+ def build_overlap_measurements(entry, truncated, total_count)
331
+ measurements = {
332
+ stall_sequence: @stall_sequence,
333
+ operation_sequence: entry.sequence,
334
+ operation_started_monotonic_ns: entry.started_monotonic_ns,
335
+ overlap_truncated: truncated,
336
+ overlap_total_count: total_count
337
+ }
338
+
339
+ measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
340
+ measurements.merge!(
341
+ SchedulerEvidenceClassifier.measurements(
342
+ operation: entry.operation,
343
+ scheduler_snapshot: entry.scheduler_snapshot
344
+ )
345
+ )
346
+ measurements
299
347
  end
300
348
 
301
349
  def complete_stall(channel, now_ns:, resumed:)
@@ -358,7 +406,8 @@ module FiberAudit
358
406
  )
359
407
  end
360
408
 
361
- def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, measurements: {})
409
+ def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, operation: nil,
410
+ execution_context: :unknown, measurements: {})
362
411
  recorder.record_control do
363
412
  Event.new(
364
413
  kind: kind,
@@ -366,8 +415,9 @@ module FiberAudit
366
415
  occurred_at: @clock.wall_time,
367
416
  monotonic_ns: monotonic_ns,
368
417
  duration_ns: duration_ns,
418
+ operation: operation,
369
419
  location: location,
370
- execution_context: :unknown,
420
+ execution_context: execution_context,
371
421
  thread_id: thread_id,
372
422
  fiber_id: fiber_id,
373
423
  measurements: measurements
@@ -3,10 +3,12 @@
3
3
  require_relative 'version'
4
4
  require_relative 'errors'
5
5
  require_relative 'operation_vocabulary'
6
+ require_relative 'operation_semantics'
6
7
  require_relative 'execution_context'
7
8
  require_relative 'runtime/validation'
8
9
  require_relative 'runtime/policy'
9
10
  require_relative 'runtime/watchdog_policy'
11
+ require_relative 'runtime/operation_liveness_policy'
10
12
  require_relative 'runtime/location'
11
13
  require_relative 'runtime/event'
12
14
  require_relative 'runtime/session'
@@ -22,7 +24,10 @@ require_relative 'runtime/rails_integration'
22
24
  require_relative 'runtime/environment'
23
25
  require_relative 'runtime/active_operations'
24
26
  require_relative 'runtime/heartbeat'
27
+ require_relative 'runtime/scheduler_snapshot'
28
+ require_relative 'runtime/scheduler_evidence_classifier'
25
29
  require_relative 'runtime/watchdog'
30
+ require_relative 'runtime/operation_liveness_monitor'
26
31
  require_relative 'runtime/scheduler_observer'
27
32
  require_relative 'runtime/probes/base'
28
33
  require_relative 'runtime/probes/subprocess'
@@ -20,6 +20,7 @@ module FiberAudit
20
20
  Open3
21
21
  IO
22
22
  Process
23
+ Process::Status
23
24
  Thread
24
25
  Mutex
25
26
  ConditionVariable
@@ -179,6 +179,25 @@ module FiberAudit
179
179
  def severity_index(severity)
180
180
  Severity.index(severity)
181
181
  end
182
+
183
+ # Compute the advisory severity for a finding without applying
184
+ # the context ceiling. Advisory rules (FA1002, FA1003, FA1005,
185
+ # FA1006, FA1007) use this to respect configuration overrides
186
+ # but not escalate based on execution context.
187
+ #
188
+ # Resolution order:
189
+ # 1. configuration.severity_override(rule_id) replaces the default
190
+ # 2. No context ceiling is applied
191
+ #
192
+ # @param default_sev [Symbol, String] rule's default severity
193
+ # @return [Symbol] the resolved severity
194
+ def advisory_severity(default_sev)
195
+ # Normalize String to Symbol defensively
196
+ default_sev = default_sev.to_sym if default_sev.is_a?(String)
197
+
198
+ # Configuration override replaces default entirely, no ceiling
199
+ configuration.severity_override(self.class.id) || default_sev
200
+ end
182
201
  end
183
202
  end
184
203
  end