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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +26 -24
- data/CHANGELOG.md +34 -0
- data/README.md +42 -18
- data/lib/fiber_audit/cli.rb +16 -4
- data/lib/fiber_audit/operation_vocabulary.rb +2 -1
- data/lib/fiber_audit/runtime/active_operations.rb +19 -5
- data/lib/fiber_audit/runtime/execution_context.rb +55 -43
- data/lib/fiber_audit/runtime/probes/base.rb +24 -3
- data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
- data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
- data/lib/fiber_audit/runtime/rails_integration.rb +51 -10
- data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
- data/lib/fiber_audit/runtime/scheduler_snapshot.rb +113 -0
- data/lib/fiber_audit/runtime/watchdog.rb +44 -2
- data/lib/fiber_audit/runtime.rb +1 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
- data/lib/fiber_audit/static/rules/base.rb +19 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +90 -10
- data/lib/fiber_audit/static/rules/direct_socket.rb +8 -9
- data/lib/fiber_audit/static/rules/io_select.rb +6 -6
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
- data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
- data/lib/fiber_audit/static/rules/thread_current_state.rb +18 -20
- data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
- data/lib/fiber_audit/version.rb +1 -1
- metadata +2 -1
|
@@ -6,15 +6,25 @@ module FiberAudit
|
|
|
6
6
|
module Subprocess
|
|
7
7
|
module KernelInstanceHook
|
|
8
8
|
def system(...)
|
|
9
|
-
Registry.observe(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
144
|
+
namespace = loaded_constant(Object, :ActionController)
|
|
145
|
+
target = loaded_constant(namespace, :Metal)
|
|
146
|
+
return unless target
|
|
136
147
|
|
|
137
|
-
|
|
148
|
+
target.prepend(ControllerHook) unless target.ancestors.include?(ControllerHook)
|
|
138
149
|
end
|
|
139
150
|
|
|
140
151
|
def install_job_hook
|
|
141
|
-
|
|
152
|
+
namespace = loaded_constant(Object, :ActiveJob)
|
|
153
|
+
target = loaded_constant(namespace, :Base)
|
|
154
|
+
return unless target
|
|
142
155
|
|
|
143
|
-
|
|
156
|
+
target.prepend(JobHook) unless target.ancestors.include?(JobHook)
|
|
144
157
|
end
|
|
145
158
|
|
|
146
159
|
def install_cable_hook
|
|
147
|
-
|
|
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
|
-
|
|
173
|
+
namespace.const_get(name, false)
|
|
174
|
+
rescue NameError
|
|
175
|
+
nil
|
|
150
176
|
end
|
|
151
177
|
|
|
152
178
|
def try_install_middleware_hook
|
|
153
|
-
|
|
179
|
+
rails = loaded_constant(Object, :Rails)
|
|
180
|
+
return unless rails
|
|
154
181
|
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
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
|
-
|
|
13
|
+
previous = Fiber.scheduler
|
|
14
14
|
result = super
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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:
|
|
412
|
+
execution_context: execution_context,
|
|
371
413
|
thread_id: thread_id,
|
|
372
414
|
fiber_id: fiber_id,
|
|
373
415
|
measurements: measurements
|
data/lib/fiber_audit/runtime.rb
CHANGED
|
@@ -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'
|
|
@@ -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
|