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