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,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require_relative 'policy'
|
|
8
|
+
require_relative 'validation'
|
|
9
|
+
require_relative 'watchdog_policy'
|
|
10
|
+
|
|
11
|
+
module FiberAudit
|
|
12
|
+
module Runtime
|
|
13
|
+
# rubocop:disable Metrics/ModuleLength
|
|
14
|
+
module Environment
|
|
15
|
+
PROTOCOL_VERSION = 1
|
|
16
|
+
ACTIVATION_KEY = 'FIBER_AUDIT_RUNTIME_BOOT'
|
|
17
|
+
SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_SETTINGS'
|
|
18
|
+
FAILURE_MODE_KEY = 'FIBER_AUDIT_RUNTIME_FAILURE_MODE'
|
|
19
|
+
WATCHDOG_SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_WATCHDOG_SETTINGS'
|
|
20
|
+
PROBES_KEY = 'FIBER_AUDIT_RUNTIME_PROBES'
|
|
21
|
+
BOOT_REQUIRE = '-rfiber_audit/runtime/boot'
|
|
22
|
+
MAX_SETTINGS_BYTES = 16_384
|
|
23
|
+
MAX_WATCHDOG_SETTINGS_BYTES = 1_024
|
|
24
|
+
SETTINGS_KEYS = %w[protocol_version launch_id project_root output_directory policy].freeze
|
|
25
|
+
POLICY_KEYS = %w[
|
|
26
|
+
redaction sampling_rate max_events_per_second max_events_per_session
|
|
27
|
+
max_record_bytes max_session_bytes fail_open
|
|
28
|
+
].freeze
|
|
29
|
+
WATCHDOG_KEYS = %w[
|
|
30
|
+
protocol_version enabled heartbeat_interval_ms stall_threshold_ms max_frames
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
Settings = Data.define(:protocol_version, :launch_id, :project_root, :output_directory, :policy) do
|
|
34
|
+
def initialize(protocol_version:, launch_id:, project_root:, output_directory:, policy:)
|
|
35
|
+
unless protocol_version == PROTOCOL_VERSION
|
|
36
|
+
raise RuntimeContractError, "runtime activation protocol must be #{PROTOCOL_VERSION}"
|
|
37
|
+
end
|
|
38
|
+
unless launch_id.is_a?(String) && launch_id.match?(Validation::UUID)
|
|
39
|
+
raise RuntimeContractError, 'runtime launch_id must be a canonical lowercase UUID'
|
|
40
|
+
end
|
|
41
|
+
unless policy.is_a?(Policy)
|
|
42
|
+
raise RuntimeContractError, 'runtime activation policy must be a FiberAudit::Runtime::Policy'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
super(
|
|
46
|
+
protocol_version: protocol_version,
|
|
47
|
+
launch_id: launch_id.dup.freeze,
|
|
48
|
+
project_root: Environment.normalize_directory(project_root, 'project_root'),
|
|
49
|
+
output_directory: Environment.normalize_directory(output_directory, 'output_directory'),
|
|
50
|
+
policy: policy
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def build(policy:, output_directory:, project_root:, launch_id: SecureRandom.uuid)
|
|
58
|
+
Settings.new(
|
|
59
|
+
protocol_version: PROTOCOL_VERSION,
|
|
60
|
+
launch_id: launch_id,
|
|
61
|
+
project_root: project_root,
|
|
62
|
+
output_directory: output_directory,
|
|
63
|
+
policy: policy
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dump(settings)
|
|
68
|
+
require_settings!(settings)
|
|
69
|
+
payload = {
|
|
70
|
+
'protocol_version' => settings.protocol_version,
|
|
71
|
+
'launch_id' => settings.launch_id,
|
|
72
|
+
'project_root' => settings.project_root,
|
|
73
|
+
'output_directory' => settings.output_directory,
|
|
74
|
+
'policy' => policy_payload(settings.policy)
|
|
75
|
+
}
|
|
76
|
+
encoded = JSON.generate(payload)
|
|
77
|
+
raise RuntimeSafetyError, 'runtime activation settings are too large' if encoded.bytesize > MAX_SETTINGS_BYTES
|
|
78
|
+
|
|
79
|
+
encoded.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def load(environment = ENV)
|
|
83
|
+
return unless activated?(environment)
|
|
84
|
+
|
|
85
|
+
value = environment.fetch(SETTINGS_KEY) do
|
|
86
|
+
raise RuntimeContractError, "missing runtime activation variable: #{SETTINGS_KEY}"
|
|
87
|
+
end
|
|
88
|
+
unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_SETTINGS_BYTES
|
|
89
|
+
raise RuntimeContractError, 'runtime activation settings are invalid'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
payload = JSON.parse(value)
|
|
93
|
+
require_exact_keys!(payload, SETTINGS_KEYS, 'runtime activation settings')
|
|
94
|
+
policy_values = payload.fetch('policy')
|
|
95
|
+
require_exact_keys!(policy_values, POLICY_KEYS, 'runtime activation policy')
|
|
96
|
+
settings = Settings.new(
|
|
97
|
+
protocol_version: payload.fetch('protocol_version'),
|
|
98
|
+
launch_id: payload.fetch('launch_id'),
|
|
99
|
+
project_root: payload.fetch('project_root'),
|
|
100
|
+
output_directory: payload.fetch('output_directory'),
|
|
101
|
+
policy: Policy.new(**symbolize_policy(policy_values))
|
|
102
|
+
)
|
|
103
|
+
validate_failure_mode!(environment, settings.policy)
|
|
104
|
+
settings
|
|
105
|
+
rescue JSON::ParserError, ArgumentError => e
|
|
106
|
+
raise RuntimeContractError, "invalid runtime activation settings: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def dump_watchdog_policy(policy)
|
|
110
|
+
require_watchdog_policy!(policy)
|
|
111
|
+
payload = {
|
|
112
|
+
'protocol_version' => PROTOCOL_VERSION,
|
|
113
|
+
'enabled' => policy.enabled,
|
|
114
|
+
'heartbeat_interval_ms' => policy.heartbeat_interval_ms,
|
|
115
|
+
'stall_threshold_ms' => policy.stall_threshold_ms,
|
|
116
|
+
'max_frames' => policy.max_frames
|
|
117
|
+
}
|
|
118
|
+
encoded = JSON.generate(payload)
|
|
119
|
+
if encoded.bytesize > MAX_WATCHDOG_SETTINGS_BYTES
|
|
120
|
+
raise RuntimeSafetyError, 'runtime watchdog activation settings are too large'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
encoded.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def load_watchdog_policy(environment = ENV)
|
|
127
|
+
value = environment[WATCHDOG_SETTINGS_KEY]
|
|
128
|
+
return WatchdogPolicy::DISABLED if value.nil?
|
|
129
|
+
unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_WATCHDOG_SETTINGS_BYTES
|
|
130
|
+
raise RuntimeContractError, 'runtime watchdog activation settings are invalid'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
payload = JSON.parse(value)
|
|
134
|
+
require_exact_keys!(payload, WATCHDOG_KEYS, 'runtime watchdog activation settings')
|
|
135
|
+
unless payload.fetch('protocol_version') == PROTOCOL_VERSION
|
|
136
|
+
raise RuntimeContractError, "runtime watchdog activation protocol must be #{PROTOCOL_VERSION}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
WatchdogPolicy.new(
|
|
140
|
+
enabled: payload.fetch('enabled'),
|
|
141
|
+
heartbeat_interval_ms: payload.fetch('heartbeat_interval_ms'),
|
|
142
|
+
stall_threshold_ms: payload.fetch('stall_threshold_ms'),
|
|
143
|
+
max_frames: payload.fetch('max_frames')
|
|
144
|
+
)
|
|
145
|
+
rescue JSON::ParserError, ArgumentError => e
|
|
146
|
+
raise RuntimeContractError, "invalid runtime watchdog activation settings: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def activated?(environment = ENV)
|
|
150
|
+
marker = environment[ACTIVATION_KEY]
|
|
151
|
+
return false if marker.nil?
|
|
152
|
+
return true if marker == '1'
|
|
153
|
+
|
|
154
|
+
raise RuntimeContractError, "#{ACTIVATION_KEY} must be 1"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def failure_mode(environment = ENV)
|
|
158
|
+
value = environment[FAILURE_MODE_KEY]
|
|
159
|
+
return :open if value.nil? || value == 'open'
|
|
160
|
+
return :closed if value == 'closed'
|
|
161
|
+
|
|
162
|
+
raise RuntimeContractError, "#{FAILURE_MODE_KEY} must be open or closed"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def probes_enabled?(environment = ENV)
|
|
166
|
+
value = environment[PROBES_KEY]
|
|
167
|
+
return false if value.nil?
|
|
168
|
+
return true if value == '1'
|
|
169
|
+
|
|
170
|
+
raise RuntimeContractError, "#{PROBES_KEY} must be 1 when present"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def child_environment(
|
|
174
|
+
settings:,
|
|
175
|
+
watchdog_policy: nil,
|
|
176
|
+
probes_enabled: false,
|
|
177
|
+
base_environment: ENV,
|
|
178
|
+
library_path: default_library_path
|
|
179
|
+
)
|
|
180
|
+
require_settings!(settings)
|
|
181
|
+
require_watchdog_policy!(watchdog_policy) if watchdog_policy
|
|
182
|
+
raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes_enabled)
|
|
183
|
+
raise RuntimeContractError, 'base_environment must be a Hash-like object' unless base_environment.respond_to?(:[])
|
|
184
|
+
|
|
185
|
+
environment = {
|
|
186
|
+
ACTIVATION_KEY => '1',
|
|
187
|
+
SETTINGS_KEY => dump(settings),
|
|
188
|
+
FAILURE_MODE_KEY => settings.policy.fail_open? ? 'open' : 'closed',
|
|
189
|
+
'RUBYOPT' => prepend_token(base_environment['RUBYOPT'], BOOT_REQUIRE, separator: ' '),
|
|
190
|
+
'RUBYLIB' => prepend_token(base_environment['RUBYLIB'], library_path, separator: File::PATH_SEPARATOR)
|
|
191
|
+
}
|
|
192
|
+
environment[WATCHDOG_SETTINGS_KEY] = dump_watchdog_policy(watchdog_policy) if watchdog_policy
|
|
193
|
+
environment[PROBES_KEY] = '1' if probes_enabled
|
|
194
|
+
environment.transform_values(&:freeze).freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def prepare_output_directory(path)
|
|
198
|
+
normalized = normalize_absolute_path(path, 'output directory')
|
|
199
|
+
if File.exist?(normalized)
|
|
200
|
+
raise RuntimeSafetyError, "runtime output is not a directory: #{normalized}" unless File.directory?(normalized)
|
|
201
|
+
|
|
202
|
+
return normalized
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
FileUtils.mkdir_p(normalized, mode: 0o700)
|
|
206
|
+
File.chmod(0o700, normalized)
|
|
207
|
+
normalized
|
|
208
|
+
rescue SystemCallError => e
|
|
209
|
+
raise RuntimeSafetyError, "cannot prepare runtime output directory: #{e.message}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def normalize_directory(value, field)
|
|
213
|
+
normalized = normalize_absolute_path(value, field)
|
|
214
|
+
raise RuntimeContractError, "#{field} must be an existing directory" unless File.directory?(normalized)
|
|
215
|
+
|
|
216
|
+
normalized.freeze
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def default_library_path
|
|
220
|
+
File.expand_path('../..', __dir__).freeze
|
|
221
|
+
end
|
|
222
|
+
private_class_method :default_library_path
|
|
223
|
+
|
|
224
|
+
def normalize_absolute_path(value, field)
|
|
225
|
+
text = Validation.string(value, field, max_bytes: 4_096)
|
|
226
|
+
path = Pathname.new(text)
|
|
227
|
+
raise RuntimeContractError, "#{field} must be absolute" unless path.absolute?
|
|
228
|
+
|
|
229
|
+
path.cleanpath.to_s.freeze
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
raise RuntimeContractError, "#{field} is invalid"
|
|
232
|
+
end
|
|
233
|
+
private_class_method :normalize_absolute_path
|
|
234
|
+
|
|
235
|
+
def prepend_token(existing, token, separator:)
|
|
236
|
+
current = existing.to_s
|
|
237
|
+
parts = current.split(separator)
|
|
238
|
+
return current.dup.freeze if parts.include?(token)
|
|
239
|
+
|
|
240
|
+
current.empty? ? token.dup.freeze : "#{token}#{separator}#{current}".freeze
|
|
241
|
+
end
|
|
242
|
+
private_class_method :prepend_token
|
|
243
|
+
|
|
244
|
+
def require_settings!(value)
|
|
245
|
+
return if value.is_a?(Settings)
|
|
246
|
+
|
|
247
|
+
raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
|
|
248
|
+
end
|
|
249
|
+
private_class_method :require_settings!
|
|
250
|
+
|
|
251
|
+
def require_watchdog_policy!(value)
|
|
252
|
+
return if value.is_a?(WatchdogPolicy)
|
|
253
|
+
|
|
254
|
+
raise RuntimeContractError, 'watchdog_policy must be FiberAudit::Runtime::WatchdogPolicy'
|
|
255
|
+
end
|
|
256
|
+
private_class_method :require_watchdog_policy!
|
|
257
|
+
|
|
258
|
+
def require_exact_keys!(value, expected, path)
|
|
259
|
+
raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
unknown = value.keys - expected
|
|
262
|
+
missing = expected - value.keys
|
|
263
|
+
raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
|
|
264
|
+
raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
|
|
265
|
+
end
|
|
266
|
+
private_class_method :require_exact_keys!
|
|
267
|
+
|
|
268
|
+
def policy_payload(policy)
|
|
269
|
+
policy.to_h.transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }.transform_keys(&:to_s)
|
|
270
|
+
end
|
|
271
|
+
private_class_method :policy_payload
|
|
272
|
+
|
|
273
|
+
def symbolize_policy(values)
|
|
274
|
+
values.to_h { |key, value| [key.to_sym, value] }
|
|
275
|
+
end
|
|
276
|
+
private_class_method :symbolize_policy
|
|
277
|
+
|
|
278
|
+
def validate_failure_mode!(environment, policy)
|
|
279
|
+
expected = policy.fail_open? ? :open : :closed
|
|
280
|
+
actual = failure_mode(environment)
|
|
281
|
+
return if actual == expected
|
|
282
|
+
|
|
283
|
+
raise RuntimeContractError, 'runtime activation failure mode does not match policy'
|
|
284
|
+
end
|
|
285
|
+
private_class_method :validate_failure_mode!
|
|
286
|
+
end
|
|
287
|
+
# rubocop:enable Metrics/ModuleLength
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../execution_context'
|
|
4
|
+
require_relative 'location'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
Event = Data.define(
|
|
10
|
+
:kind,
|
|
11
|
+
:source,
|
|
12
|
+
:occurred_at,
|
|
13
|
+
:monotonic_ns,
|
|
14
|
+
:duration_ns,
|
|
15
|
+
:operation,
|
|
16
|
+
:location,
|
|
17
|
+
:execution_context,
|
|
18
|
+
:thread_id,
|
|
19
|
+
:fiber_id,
|
|
20
|
+
:measurements
|
|
21
|
+
) do
|
|
22
|
+
def initialize(
|
|
23
|
+
kind:,
|
|
24
|
+
source:,
|
|
25
|
+
occurred_at:,
|
|
26
|
+
monotonic_ns:,
|
|
27
|
+
duration_ns: nil,
|
|
28
|
+
operation: nil,
|
|
29
|
+
location: nil,
|
|
30
|
+
execution_context: :unknown,
|
|
31
|
+
thread_id: nil,
|
|
32
|
+
fiber_id: nil,
|
|
33
|
+
measurements: {}
|
|
34
|
+
)
|
|
35
|
+
super(
|
|
36
|
+
kind: Validation.identifier(kind, 'kind'),
|
|
37
|
+
source: Validation.identifier(source, 'source'),
|
|
38
|
+
occurred_at: Validation.utc_time(occurred_at, 'occurred_at'),
|
|
39
|
+
monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
|
|
40
|
+
duration_ns: Validation.integer(duration_ns, 'duration_ns', allow_nil: true),
|
|
41
|
+
operation: Validation.operation(operation, allow_nil: true),
|
|
42
|
+
location: normalize_location(location),
|
|
43
|
+
execution_context: normalize_context(execution_context),
|
|
44
|
+
thread_id: Validation.integer(thread_id, 'thread_id', allow_nil: true),
|
|
45
|
+
fiber_id: Validation.integer(fiber_id, 'fiber_id', allow_nil: true),
|
|
46
|
+
measurements: normalize_measurements(measurements)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalize_location(value)
|
|
53
|
+
return value if value.nil? || value.is_a?(Location)
|
|
54
|
+
|
|
55
|
+
raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_context(value)
|
|
59
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
|
|
60
|
+
return normalized if Context::ALL.include?(normalized)
|
|
61
|
+
|
|
62
|
+
raise RuntimeContractError, "execution_context is invalid: #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_measurements(value)
|
|
66
|
+
raise RuntimeContractError, 'measurements must be a Hash' unless value.is_a?(Hash)
|
|
67
|
+
if value.size > Event::MAX_MEASUREMENTS
|
|
68
|
+
raise RuntimeContractError, "measurements must contain at most #{Event::MAX_MEASUREMENTS} entries"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
value.each_with_object({}) do |(key, measurement), normalized|
|
|
72
|
+
name = Validation.identifier(key, 'measurement key').to_s.freeze
|
|
73
|
+
raise RuntimeContractError, "duplicate normalized measurement key: #{name}" if normalized.key?(name)
|
|
74
|
+
unless measurement.nil? || measurement == true || measurement == false ||
|
|
75
|
+
(measurement.is_a?(Numeric) && measurement.finite?)
|
|
76
|
+
raise RuntimeContractError, "measurement #{name} must be a finite number, Boolean, or nil"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
normalized[name] = measurement
|
|
80
|
+
end.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Event.const_set(:MAX_MEASUREMENTS, 32)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../execution_context'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Fiber-local execution context stack.
|
|
8
|
+
# Uses fiber instance variables for isolation without Thread#[] visibility.
|
|
9
|
+
# PID-aware to handle fork correctly.
|
|
10
|
+
module ExecutionContext
|
|
11
|
+
MAX_DEPTH = 32
|
|
12
|
+
IVAR_KEY = :@__fiber_audit_execution_context__
|
|
13
|
+
IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def current
|
|
17
|
+
state = current_state
|
|
18
|
+
return Context::UNKNOWN unless state
|
|
19
|
+
|
|
20
|
+
state[:stack].last || Context::UNKNOWN
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with(context)
|
|
24
|
+
normalized = validate_context(context)
|
|
25
|
+
state = ensure_state
|
|
26
|
+
return yield if state[:stack].size >= MAX_DEPTH
|
|
27
|
+
|
|
28
|
+
state[:stack].push(normalized)
|
|
29
|
+
begin
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
state[:stack].pop
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
fiber = Fiber.current
|
|
38
|
+
fiber.remove_instance_variable(IVAR_KEY) if fiber.instance_variable_defined?(IVAR_KEY)
|
|
39
|
+
fiber.remove_instance_variable(IVAR_PID_KEY) if fiber.instance_variable_defined?(IVAR_PID_KEY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def after_fork!
|
|
43
|
+
reset!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def current_state
|
|
49
|
+
fiber = Fiber.current
|
|
50
|
+
return nil unless fiber.instance_variable_defined?(IVAR_KEY)
|
|
51
|
+
|
|
52
|
+
pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
|
|
53
|
+
return nil unless pid == Process.pid
|
|
54
|
+
|
|
55
|
+
{ stack: fiber.instance_variable_get(IVAR_KEY), pid: pid }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_state
|
|
59
|
+
fiber = Fiber.current
|
|
60
|
+
pid = Process.pid
|
|
61
|
+
|
|
62
|
+
if fiber.instance_variable_defined?(IVAR_KEY)
|
|
63
|
+
stored_pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
|
|
64
|
+
return { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid } if stored_pid == pid
|
|
65
|
+
|
|
66
|
+
# PID mismatch - reset
|
|
67
|
+
reset!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
stack = []
|
|
71
|
+
fiber.instance_variable_set(IVAR_KEY, stack)
|
|
72
|
+
fiber.instance_variable_set(IVAR_PID_KEY, pid)
|
|
73
|
+
{ stack: stack, pid: pid }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_context(value)
|
|
77
|
+
unless value.is_a?(Symbol) || value.is_a?(String)
|
|
78
|
+
raise RuntimeContractError, 'execution_context must be a Symbol or String'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
normalized = value.is_a?(Symbol) ? value : value.to_sym
|
|
82
|
+
return normalized if Context::ALL.include?(normalized)
|
|
83
|
+
|
|
84
|
+
raise RuntimeContractError, 'execution_context is invalid'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'clock'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Scheduler-owned progress fiber observed by the watchdog thread.
|
|
8
|
+
class Heartbeat
|
|
9
|
+
Snapshot = Data.define(:sequence, :last_progress_ns, :thread_id, :fiber_id, :started, :stop_requested)
|
|
10
|
+
|
|
11
|
+
attr_reader :owner_thread
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
interval_ns:,
|
|
15
|
+
clock: Clock.new,
|
|
16
|
+
owner_thread: Thread.current,
|
|
17
|
+
on_tick: ->(_heartbeat) {},
|
|
18
|
+
on_error: ->(_heartbeat, _error) {}
|
|
19
|
+
)
|
|
20
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless clock.is_a?(Clock)
|
|
21
|
+
unless interval_ns.is_a?(Integer) && interval_ns.positive?
|
|
22
|
+
raise RuntimeContractError, 'interval_ns must be a positive Integer'
|
|
23
|
+
end
|
|
24
|
+
raise RuntimeContractError, 'owner_thread must be a Thread' unless owner_thread.is_a?(Thread)
|
|
25
|
+
raise RuntimeContractError, 'on_tick must respond to call' unless on_tick.respond_to?(:call)
|
|
26
|
+
raise RuntimeContractError, 'on_error must respond to call' unless on_error.respond_to?(:call)
|
|
27
|
+
|
|
28
|
+
@clock = clock
|
|
29
|
+
@interval_ns = interval_ns
|
|
30
|
+
@owner_thread = owner_thread
|
|
31
|
+
@on_tick = on_tick
|
|
32
|
+
@on_error = on_error
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@sequence = 0
|
|
35
|
+
@last_progress_ns = nil
|
|
36
|
+
@fiber_id = nil
|
|
37
|
+
@started = false
|
|
38
|
+
@start_requested = false
|
|
39
|
+
@stop_requested = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(schedule: Fiber.method(:schedule), sleeper: Kernel.method(:sleep))
|
|
43
|
+
raise RuntimeContractError, 'schedule must respond to call' unless schedule.respond_to?(:call)
|
|
44
|
+
raise RuntimeContractError, 'sleeper must respond to call' unless sleeper.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
return self if @start_requested
|
|
48
|
+
|
|
49
|
+
@start_requested = true
|
|
50
|
+
end
|
|
51
|
+
schedule.call { run(sleeper) }
|
|
52
|
+
self
|
|
53
|
+
rescue StandardError
|
|
54
|
+
@mutex.synchronize { @start_requested = false }
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tick
|
|
59
|
+
now_ns = @clock.monotonic_ns
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
@sequence += 1
|
|
62
|
+
@last_progress_ns = now_ns
|
|
63
|
+
@fiber_id ||= Fiber.current.object_id
|
|
64
|
+
@started = true
|
|
65
|
+
end
|
|
66
|
+
@on_tick.call(self)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request_stop
|
|
71
|
+
@mutex.synchronize { @stop_requested = true }
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def snapshot
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
Snapshot.new(
|
|
78
|
+
sequence: @sequence,
|
|
79
|
+
last_progress_ns: @last_progress_ns,
|
|
80
|
+
thread_id: owner_thread.object_id,
|
|
81
|
+
fiber_id: @fiber_id,
|
|
82
|
+
started: @started,
|
|
83
|
+
stop_requested: @stop_requested
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def started?
|
|
89
|
+
@mutex.synchronize { @started }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stop_requested?
|
|
93
|
+
@mutex.synchronize { @stop_requested }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def run(sleeper)
|
|
99
|
+
tick
|
|
100
|
+
loop do
|
|
101
|
+
break if stop_requested?
|
|
102
|
+
|
|
103
|
+
sleeper.call(@interval_ns.fdiv(1_000_000_000))
|
|
104
|
+
break if stop_requested?
|
|
105
|
+
|
|
106
|
+
tick
|
|
107
|
+
end
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
@on_error.call(self, e)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|