fiber_audit 0.2.0 → 0.3.0

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.
@@ -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
@@ -125,6 +125,15 @@ module FiberAudit
125
125
  def install_require_hook
126
126
  return if @require_hook_installed
127
127
 
128
+ # Zeitwerk replaces Kernel#require. Load its optional integration before
129
+ # prepending FiberAudit so later Rails autoloads retain Zeitwerk's require
130
+ # semantics. This remains a no-op when Zeitwerk is not installed.
131
+ begin
132
+ require 'zeitwerk'
133
+ rescue LoadError
134
+ nil
135
+ end
136
+
128
137
  # Install narrow guarded require hook for late Rails loading
129
138
  # Independent of probe registry
130
139
  ::Kernel.prepend(RequireHook) unless ::Kernel.ancestors.include?(RequireHook)
@@ -132,33 +141,57 @@ module FiberAudit
132
141
  end
133
142
 
134
143
  def install_controller_hook
135
- return unless defined?(::ActionController::Metal)
144
+ namespace = loaded_constant(Object, :ActionController)
145
+ target = loaded_constant(namespace, :Metal)
146
+ return unless target
136
147
 
137
- ::ActionController::Metal.prepend(ControllerHook) unless ::ActionController::Metal.ancestors.include?(ControllerHook)
148
+ target.prepend(ControllerHook) unless target.ancestors.include?(ControllerHook)
138
149
  end
139
150
 
140
151
  def install_job_hook
141
- return unless defined?(::ActiveJob::Base)
152
+ namespace = loaded_constant(Object, :ActiveJob)
153
+ target = loaded_constant(namespace, :Base)
154
+ return unless target
142
155
 
143
- ::ActiveJob::Base.prepend(JobHook) unless ::ActiveJob::Base.ancestors.include?(JobHook)
156
+ target.prepend(JobHook) unless target.ancestors.include?(JobHook)
144
157
  end
145
158
 
146
159
  def install_cable_hook
147
- return unless defined?(::ActionCable::Channel::Base)
160
+ namespace = loaded_constant(Object, :ActionCable)
161
+ channel = loaded_constant(namespace, :Channel)
162
+ target = loaded_constant(channel, :Base)
163
+ return unless target
164
+
165
+ target.prepend(CableHook) unless target.ancestors.include?(CableHook)
166
+ end
167
+
168
+ def loaded_constant(namespace, name)
169
+ return unless namespace.is_a?(Module)
170
+ return if namespace.autoload?(name)
171
+ return unless namespace.const_defined?(name, false)
148
172
 
149
- ::ActionCable::Channel::Base.prepend(CableHook) unless ::ActionCable::Channel::Base.ancestors.include?(CableHook)
173
+ namespace.const_get(name, false)
174
+ rescue NameError
175
+ nil
150
176
  end
151
177
 
152
178
  def try_install_middleware_hook
153
- return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
179
+ rails = loaded_constant(Object, :Rails)
180
+ return unless rails
154
181
 
155
- stack = ::Rails.application.config.middleware
156
- return if @middleware_stack.equal?(stack)
182
+ # Rails.application may instantiate the application class. Calling it
183
+ # from a require hook while Rails itself is loading can re-enter a
184
+ # partially initialized application, so only inspect an existing instance.
185
+ application = rails.instance_variable_get(:@application)
186
+ return unless application
187
+
188
+ stack = application.config.middleware
189
+ return if @middleware_stack.equal?(stack) || middleware_installed_in?(stack)
157
190
 
158
191
  # Try to insert middleware into Rails stack. A replacement stack (for
159
192
  # example after a Rails reload) is eligible for installation again.
160
193
  begin
161
- stack.use(Middleware)
194
+ stack.insert_before(0, Middleware)
162
195
  @middleware_stack = stack
163
196
  rescue StandardError => e
164
197
  # Stack may be finalized - that's okay, middleware is optional
@@ -166,6 +199,14 @@ module FiberAudit
166
199
  end
167
200
  end
168
201
 
202
+ def middleware_installed_in?(stack)
203
+ return false unless stack.respond_to?(:any?)
204
+
205
+ stack.any? do |entry|
206
+ entry.equal?(Middleware) || (entry.respond_to?(:klass) && entry.klass.equal?(Middleware))
207
+ end
208
+ end
209
+
169
210
  # Require hook for late Rails loading - independent of probe registry
170
211
  # Narrow, guarded, behavior-preserving
171
212
  module RequireHook
@@ -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,113 @@
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_boolean(scheduler_present, 'scheduler_present'),
23
+ fiber_blocking: normalize_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_boolean(value, field)
52
+ raise RuntimeContractError, "#{field} must be a Boolean" unless [true, false].include?(value)
53
+
54
+ value
55
+ end
56
+
57
+ def normalize_optional_boolean(value, field)
58
+ return value if value.nil?
59
+ raise RuntimeContractError, "#{field} must be a Boolean or nil" unless [true, false].include?(value)
60
+
61
+ value
62
+ end
63
+ end
64
+
65
+ # Captures immutable scheduler snapshot at the current point in time.
66
+ # This is called at operation start to provide immutable context.
67
+ module SchedulerSnapshotCapture
68
+ module_function
69
+
70
+ def capture
71
+ scheduler = Fiber.scheduler
72
+ scheduler_present = !scheduler.nil?
73
+
74
+ # Normalize Fiber#blocking? to Boolean
75
+ fiber_blocking = begin
76
+ current_fiber = Fiber.current
77
+ if current_fiber.respond_to?(:blocking?)
78
+ !current_fiber.blocking?.nil?
79
+ else
80
+ false
81
+ end
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ # Query scheduler capabilities if present
87
+ io_select_supported = nil
88
+ process_wait_supported = nil
89
+ address_resolve_supported = nil
90
+
91
+ if scheduler_present
92
+ io_select_supported = scheduler.respond_to?(:io_select)
93
+ process_wait_supported = scheduler.respond_to?(:process_wait)
94
+ address_resolve_supported = scheduler.respond_to?(:address_resolve)
95
+ end
96
+
97
+ SchedulerSnapshot.new(
98
+ scheduler_present: scheduler_present,
99
+ fiber_blocking: fiber_blocking,
100
+ scheduler_io_select_supported: io_select_supported,
101
+ scheduler_process_wait_supported: process_wait_supported,
102
+ scheduler_address_resolve_supported: address_resolve_supported
103
+ )
104
+ rescue StandardError
105
+ # Fail open: return a safe default snapshot
106
+ SchedulerSnapshot.new(
107
+ scheduler_present: false,
108
+ fiber_blocking: false
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
@@ -15,6 +15,7 @@ module FiberAudit
15
15
  class Watchdog
16
16
  SOURCE = :scheduler_watchdog
17
17
  STOP_TIMEOUT_SECONDS = 1
18
+ MAX_OVERLAP_EVENTS = 10
18
19
 
19
20
  Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
20
21
  Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
@@ -296,6 +297,45 @@ module FiberAudit
296
297
  measurements: { stall_sequence: @stall_sequence, frame_index: index }
297
298
  )
298
299
  end
300
+ emit_stall_operation_overlap_events(operations, now_ns, snapshot)
301
+ end
302
+
303
+ def emit_stall_operation_overlap_events(operations, now_ns, _snapshot)
304
+ return if operations.empty?
305
+
306
+ truncated = operations.size > MAX_OVERLAP_EVENTS
307
+ bounded_operations = operations.first(MAX_OVERLAP_EVENTS)
308
+
309
+ bounded_operations.each do |entry|
310
+ overlap_measurements = build_overlap_measurements(entry, truncated, operations.size)
311
+ emit_event(
312
+ kind: :scheduler_stall_operation_overlap,
313
+ monotonic_ns: now_ns,
314
+ operation: entry.operation,
315
+ location: entry.location,
316
+ execution_context: entry.execution_context,
317
+ thread_id: entry.thread_id,
318
+ fiber_id: entry.fiber_id,
319
+ measurements: overlap_measurements
320
+ )
321
+ end
322
+ rescue StandardError => e
323
+ account_internal_error unless recorder.disabled?
324
+ raise e unless fail_open?
325
+ end
326
+
327
+ def build_overlap_measurements(entry, truncated, total_count)
328
+ measurements = {
329
+ stall_sequence: @stall_sequence,
330
+ operation_sequence: entry.sequence,
331
+ operation_started_monotonic_ns: entry.started_monotonic_ns,
332
+ overlap_truncated: truncated,
333
+ overlap_total_count: total_count
334
+ }
335
+
336
+ measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
337
+
338
+ measurements
299
339
  end
300
340
 
301
341
  def complete_stall(channel, now_ns:, resumed:)
@@ -358,7 +398,8 @@ module FiberAudit
358
398
  )
359
399
  end
360
400
 
361
- def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, measurements: {})
401
+ def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, operation: nil,
402
+ execution_context: :unknown, measurements: {})
362
403
  recorder.record_control do
363
404
  Event.new(
364
405
  kind: kind,
@@ -366,8 +407,9 @@ module FiberAudit
366
407
  occurred_at: @clock.wall_time,
367
408
  monotonic_ns: monotonic_ns,
368
409
  duration_ns: duration_ns,
410
+ operation: operation,
369
411
  location: location,
370
- execution_context: :unknown,
412
+ execution_context: execution_context,
371
413
  thread_id: thread_id,
372
414
  fiber_id: fiber_id,
373
415
  measurements: measurements
@@ -22,6 +22,7 @@ require_relative 'runtime/rails_integration'
22
22
  require_relative 'runtime/environment'
23
23
  require_relative 'runtime/active_operations'
24
24
  require_relative 'runtime/heartbeat'
25
+ require_relative 'runtime/scheduler_snapshot'
25
26
  require_relative 'runtime/watchdog'
26
27
  require_relative 'runtime/scheduler_observer'
27
28
  require_relative 'runtime/probes/base'
@@ -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