fiber_audit 0.1.0 → 0.2.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/.fiber-audit.example.yml +19 -0
- data/ARCHITECTURE.md +726 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +201 -0
- data/README.md +66 -8
- data/lib/fiber_audit/cli.rb +87 -2
- data/lib/fiber_audit/configuration.rb +106 -6
- data/lib/fiber_audit/errors.rb +2 -0
- data/lib/fiber_audit/operation_vocabulary.rb +42 -0
- data/lib/fiber_audit/reporters/text.rb +1 -1
- data/lib/fiber_audit/runtime/active_operations.rb +146 -0
- data/lib/fiber_audit/runtime/boot.rb +83 -0
- data/lib/fiber_audit/runtime/clock.rb +35 -0
- data/lib/fiber_audit/runtime/environment.rb +289 -0
- data/lib/fiber_audit/runtime/event.rb +86 -0
- data/lib/fiber_audit/runtime/execution_context.rb +89 -0
- data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
- data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
- data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
- data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
- data/lib/fiber_audit/runtime/limits.rb +102 -0
- data/lib/fiber_audit/runtime/location.rb +44 -0
- data/lib/fiber_audit/runtime/policy.rb +121 -0
- data/lib/fiber_audit/runtime/probes/base.rb +333 -0
- data/lib/fiber_audit/runtime/probes/http.rb +80 -0
- data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
- data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
- data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
- data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
- data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
- data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
- data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
- data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
- data/lib/fiber_audit/runtime/recorder.rb +333 -0
- data/lib/fiber_audit/runtime/redactor.rb +102 -0
- data/lib/fiber_audit/runtime/sampler.rb +26 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
- data/lib/fiber_audit/runtime/session.rb +112 -0
- data/lib/fiber_audit/runtime/supervisor.rb +114 -0
- data/lib/fiber_audit/runtime/validation.rb +68 -0
- data/lib/fiber_audit/runtime/watchdog.rb +479 -0
- data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
- data/lib/fiber_audit/runtime.rb +37 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
- data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
- data/lib/fiber_audit/static/rules/io_select.rb +2 -4
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
- data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
- data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
- data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
- data/lib/fiber_audit/version.rb +1 -1
- data/lib/fiber_audit.rb +1 -0
- metadata +38 -2
|
@@ -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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
WatchdogPolicy = Data.define(
|
|
8
|
+
:enabled,
|
|
9
|
+
:heartbeat_interval_ms,
|
|
10
|
+
:stall_threshold_ms,
|
|
11
|
+
:max_frames
|
|
12
|
+
) do
|
|
13
|
+
def initialize(**values)
|
|
14
|
+
unknown = values.keys - WatchdogPolicy::DEFAULTS.keys
|
|
15
|
+
raise RuntimeContractError, "unknown watchdog policy field: #{unknown.first}" unless unknown.empty?
|
|
16
|
+
|
|
17
|
+
fields = WatchdogPolicy::DEFAULTS.merge(values)
|
|
18
|
+
raise RuntimeContractError, 'enabled must be a Boolean' unless [true, false].include?(fields[:enabled])
|
|
19
|
+
|
|
20
|
+
limits = WatchdogPolicy::LIMITS.to_h do |name, range|
|
|
21
|
+
value = fields.fetch(name)
|
|
22
|
+
unless value.is_a?(Integer) && range.cover?(value)
|
|
23
|
+
raise RuntimeContractError, "#{name} must be an Integer in #{range}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
[name, value]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
super(enabled: fields[:enabled], **limits)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enabled?
|
|
33
|
+
enabled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def heartbeat_interval_ns
|
|
37
|
+
heartbeat_interval_ms * WatchdogPolicy::NANOSECONDS_PER_MILLISECOND
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stall_threshold_ns
|
|
41
|
+
stall_threshold_ms * WatchdogPolicy::NANOSECONDS_PER_MILLISECOND
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stalled?(age_ns:)
|
|
45
|
+
age = Validation.integer(age_ns, 'heartbeat age')
|
|
46
|
+
age > stall_threshold_ns
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
WatchdogPolicy.const_set(:NANOSECONDS_PER_MILLISECOND, 1_000_000)
|
|
51
|
+
WatchdogPolicy.const_set(:DEFAULTS, {
|
|
52
|
+
enabled: true,
|
|
53
|
+
heartbeat_interval_ms: 25,
|
|
54
|
+
stall_threshold_ms: 100,
|
|
55
|
+
max_frames: 20
|
|
56
|
+
}.freeze)
|
|
57
|
+
WatchdogPolicy.const_set(:LIMITS, {
|
|
58
|
+
heartbeat_interval_ms: 1..60_000,
|
|
59
|
+
stall_threshold_ms: 1..600_000,
|
|
60
|
+
max_frames: 0..100
|
|
61
|
+
}.freeze)
|
|
62
|
+
WatchdogPolicy.const_set(:DISABLED, WatchdogPolicy.new(enabled: false))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
require_relative 'operation_vocabulary'
|
|
6
|
+
require_relative 'execution_context'
|
|
7
|
+
require_relative 'runtime/validation'
|
|
8
|
+
require_relative 'runtime/policy'
|
|
9
|
+
require_relative 'runtime/watchdog_policy'
|
|
10
|
+
require_relative 'runtime/location'
|
|
11
|
+
require_relative 'runtime/event'
|
|
12
|
+
require_relative 'runtime/session'
|
|
13
|
+
require_relative 'runtime/redactor'
|
|
14
|
+
require_relative 'runtime/clock'
|
|
15
|
+
require_relative 'runtime/sampler'
|
|
16
|
+
require_relative 'runtime/limits'
|
|
17
|
+
require_relative 'runtime/jsonl/schema'
|
|
18
|
+
require_relative 'runtime/jsonl/writer'
|
|
19
|
+
require_relative 'runtime/recorder'
|
|
20
|
+
require_relative 'runtime/execution_context'
|
|
21
|
+
require_relative 'runtime/rails_integration'
|
|
22
|
+
require_relative 'runtime/environment'
|
|
23
|
+
require_relative 'runtime/active_operations'
|
|
24
|
+
require_relative 'runtime/heartbeat'
|
|
25
|
+
require_relative 'runtime/watchdog'
|
|
26
|
+
require_relative 'runtime/scheduler_observer'
|
|
27
|
+
require_relative 'runtime/probes/base'
|
|
28
|
+
require_relative 'runtime/probes/subprocess'
|
|
29
|
+
require_relative 'runtime/probes/thread_wait'
|
|
30
|
+
require_relative 'runtime/probes/synchronization'
|
|
31
|
+
require_relative 'runtime/probes/thread_state'
|
|
32
|
+
require_relative 'runtime/probes/io_select'
|
|
33
|
+
require_relative 'runtime/probes/socket'
|
|
34
|
+
require_relative 'runtime/probes/http'
|
|
35
|
+
require_relative 'runtime/probes/registry'
|
|
36
|
+
require_relative 'runtime/lifecycle'
|
|
37
|
+
require_relative 'runtime/supervisor'
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../../findings/evidence'
|
|
5
5
|
require_relative '../../correlation/fingerprint'
|
|
6
6
|
require_relative '../../findings/finding'
|
|
7
|
+
require_relative '../../operation_vocabulary'
|
|
7
8
|
|
|
8
9
|
module FiberAudit
|
|
9
10
|
module Static
|
|
@@ -14,14 +15,8 @@ module FiberAudit
|
|
|
14
15
|
default_confidence :high
|
|
15
16
|
description 'Blocking subprocess call in the fiber scheduler path'
|
|
16
17
|
|
|
17
|
-
TARGETS =
|
|
18
|
-
|
|
19
|
-
'Open3' => %i[capture2 capture2e capture3 pipeline].freeze,
|
|
20
|
-
'IO' => %i[popen].freeze,
|
|
21
|
-
'Process' => %i[waitall detach].freeze
|
|
22
|
-
}.freeze
|
|
23
|
-
|
|
24
|
-
BARE_KERNEL_METHODS = %i[system exec spawn].freeze
|
|
18
|
+
TARGETS = OperationVocabulary::FA1001_TARGETS
|
|
19
|
+
BARE_KERNEL_METHODS = OperationVocabulary::FA1001_KERNEL_METHODS
|
|
25
20
|
|
|
26
21
|
MESSAGE = 'Subprocess operation may block the thread running the fiber scheduler.'
|
|
27
22
|
REMEDIATION = 'Move long-running subprocess work outside the request path, or verify scheduler behaviour under load.'
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../../findings/evidence'
|
|
5
5
|
require_relative '../../correlation/fingerprint'
|
|
6
6
|
require_relative '../../findings/finding'
|
|
7
|
+
require_relative '../../operation_vocabulary'
|
|
7
8
|
|
|
8
9
|
module FiberAudit
|
|
9
10
|
module Static
|
|
@@ -19,9 +20,7 @@ module FiberAudit
|
|
|
19
20
|
TITLE = 'Direct socket creation'
|
|
20
21
|
CATEGORY = :network
|
|
21
22
|
|
|
22
|
-
EXACT =
|
|
23
|
-
TCPSocket TCPServer UDPSocket UNIXSocket UNIXServer Socket IPSocket
|
|
24
|
-
].freeze
|
|
23
|
+
EXACT = OperationVocabulary::FA1006_EXACT
|
|
25
24
|
|
|
26
25
|
MESSAGE = 'Direct socket use may bypass scheduler-aware networking ' \
|
|
27
26
|
'and block the scheduler thread.'
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../../findings/evidence'
|
|
5
5
|
require_relative '../../correlation/fingerprint'
|
|
6
6
|
require_relative '../../findings/finding'
|
|
7
|
+
require_relative '../../operation_vocabulary'
|
|
7
8
|
|
|
8
9
|
module FiberAudit
|
|
9
10
|
module Static
|
|
@@ -22,10 +23,7 @@ module FiberAudit
|
|
|
22
23
|
MESSAGE = 'IO.select may bypass scheduler-aware I/O and block the thread running the fiber scheduler.'
|
|
23
24
|
REMEDIATION = 'Use scheduler-aware I/O APIs or allow the active Fiber scheduler to manage readiness.'
|
|
24
25
|
|
|
25
|
-
TARGETS =
|
|
26
|
-
'IO' => :select,
|
|
27
|
-
'Kernel' => :select
|
|
28
|
-
}.freeze
|
|
26
|
+
TARGETS = OperationVocabulary::FA1005_TARGETS
|
|
29
27
|
|
|
30
28
|
class << self
|
|
31
29
|
def title = TITLE
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../../findings/evidence'
|
|
5
5
|
require_relative '../../correlation/fingerprint'
|
|
6
6
|
require_relative '../../findings/finding'
|
|
7
|
+
require_relative '../../operation_vocabulary'
|
|
7
8
|
|
|
8
9
|
module FiberAudit
|
|
9
10
|
module Static
|
|
@@ -22,11 +23,8 @@ module FiberAudit
|
|
|
22
23
|
MESSAGE = 'Synchronous HTTP activity in a request-like context may block the thread running the fiber scheduler.'
|
|
23
24
|
REMEDIATION = 'Use a scheduler-aware HTTP client, or move outbound HTTP work outside the request path.'
|
|
24
25
|
|
|
25
|
-
NET_HTTP_METHODS =
|
|
26
|
-
URI_METHODS =
|
|
27
|
-
'URI' => :open,
|
|
28
|
-
'OpenURI' => :open_uri
|
|
29
|
-
}.freeze
|
|
26
|
+
NET_HTTP_METHODS = OperationVocabulary::FA1007_NET_HTTP_METHODS
|
|
27
|
+
URI_METHODS = OperationVocabulary::FA1007_URI_METHODS
|
|
30
28
|
EMIT_CONTEXTS = %i[request middleware websocket callback].freeze
|
|
31
29
|
|
|
32
30
|
def analyze(call_sites:)
|
|
@@ -4,6 +4,7 @@ require_relative 'base'
|
|
|
4
4
|
require_relative '../../findings/evidence'
|
|
5
5
|
require_relative '../../correlation/fingerprint'
|
|
6
6
|
require_relative '../../findings/finding'
|
|
7
|
+
require_relative '../../operation_vocabulary'
|
|
7
8
|
|
|
8
9
|
module FiberAudit
|
|
9
10
|
module Static
|
|
@@ -16,12 +17,7 @@ module FiberAudit
|
|
|
16
17
|
|
|
17
18
|
RULE_TITLE = 'Thread synchronization'
|
|
18
19
|
RULE_CATEGORY = :synchronization
|
|
19
|
-
TARGETS =
|
|
20
|
-
'Mutex' => %i[lock synchronize try_lock],
|
|
21
|
-
'ConditionVariable' => %i[wait],
|
|
22
|
-
'Monitor' => %i[synchronize],
|
|
23
|
-
'MonitorMixin' => %i[synchronize]
|
|
24
|
-
}.freeze
|
|
20
|
+
TARGETS = OperationVocabulary::FA1003_TARGETS
|
|
25
21
|
TRY_LOCK_MSG = 'Mutex.try_lock is non-blocking but may indicate ' \
|
|
26
22
|
'thread-oriented synchronization in fiber-scheduled code.'
|
|
27
23
|
NORMAL_MSG = 'Synchronization operation may block the thread running the fiber scheduler.'
|