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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../errors'
|
|
4
|
+
require_relative '../validation'
|
|
5
|
+
require_relative 'schema'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
module JSONL
|
|
10
|
+
class Writer
|
|
11
|
+
attr_reader :bytes_written, :max_record_bytes
|
|
12
|
+
|
|
13
|
+
def self.open(path:, max_record_bytes:)
|
|
14
|
+
safe_path = Validation.string(path, 'runtime output path', max_bytes: 4_096)
|
|
15
|
+
# The returned Writer deliberately owns this descriptor until #close.
|
|
16
|
+
io = File.open(safe_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) # rubocop:disable Style/FileOpen
|
|
17
|
+
io.binmode
|
|
18
|
+
io.sync = true
|
|
19
|
+
new(io: io, max_record_bytes: max_record_bytes, owns_io: true)
|
|
20
|
+
rescue StandardError
|
|
21
|
+
io&.close
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(io:, max_record_bytes:, owns_io: false)
|
|
26
|
+
raise RuntimeContractError, 'io must respond to write' unless io.respond_to?(:write)
|
|
27
|
+
unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
|
|
28
|
+
raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
|
|
29
|
+
end
|
|
30
|
+
raise RuntimeContractError, 'owns_io must be a Boolean' unless [true, false].include?(owns_io)
|
|
31
|
+
|
|
32
|
+
@io = io
|
|
33
|
+
@max_record_bytes = max_record_bytes
|
|
34
|
+
@owns_io = owns_io
|
|
35
|
+
@bytes_written = 0
|
|
36
|
+
@state = :active
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def prepare(record)
|
|
40
|
+
ensure_active!
|
|
41
|
+
Schema.dump(record, max_record_bytes: max_record_bytes)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(record)
|
|
45
|
+
write_line(prepare(record))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def write_line(line)
|
|
49
|
+
ensure_active!
|
|
50
|
+
validate_line!(line)
|
|
51
|
+
completed = false
|
|
52
|
+
begin
|
|
53
|
+
write_all(line)
|
|
54
|
+
@io.flush if @io.respond_to?(:flush)
|
|
55
|
+
completed = true
|
|
56
|
+
ensure
|
|
57
|
+
@state = :failed unless completed
|
|
58
|
+
end
|
|
59
|
+
line.bytesize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def active?
|
|
63
|
+
@state == :active
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def failed?
|
|
67
|
+
@state == :failed
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def closed?
|
|
71
|
+
@state == :closed
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close
|
|
75
|
+
return if closed?
|
|
76
|
+
|
|
77
|
+
completed = false
|
|
78
|
+
begin
|
|
79
|
+
@io.close if @owns_io && @io.respond_to?(:close) && !@io.closed?
|
|
80
|
+
completed = true
|
|
81
|
+
ensure
|
|
82
|
+
@state = :failed unless completed
|
|
83
|
+
end
|
|
84
|
+
@state = :closed unless failed?
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def write_all(line)
|
|
91
|
+
offset = 0
|
|
92
|
+
while offset < line.bytesize
|
|
93
|
+
written = @io.write(line.byteslice(offset, line.bytesize - offset))
|
|
94
|
+
validate_write_result!(written, line.bytesize - offset)
|
|
95
|
+
offset += written
|
|
96
|
+
@bytes_written += written
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ensure_active!
|
|
101
|
+
raise RuntimeSafetyError, "runtime JSONL writer is #{@state}" unless active?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_line!(line)
|
|
105
|
+
valid = line.is_a?(String) && line.valid_encoding? && line.end_with?("\n") && line.count("\n") == 1
|
|
106
|
+
raise RuntimeContractError, 'runtime JSONL line must be one valid complete line' unless valid
|
|
107
|
+
return unless line.bytesize > max_record_bytes
|
|
108
|
+
|
|
109
|
+
raise RuntimeSafetyError,
|
|
110
|
+
"runtime JSONL record is #{line.bytesize} bytes; limit is #{max_record_bytes}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_write_result!(written, remaining)
|
|
114
|
+
return if written.is_a?(Integer) && written.positive? && written <= remaining
|
|
115
|
+
|
|
116
|
+
@state = :failed
|
|
117
|
+
raise RuntimeSafetyError, "runtime JSONL write returned invalid byte count: #{written.inspect}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'clock'
|
|
5
|
+
require_relative 'active_operations'
|
|
6
|
+
require_relative 'environment'
|
|
7
|
+
require_relative 'execution_context'
|
|
8
|
+
require_relative 'rails_integration'
|
|
9
|
+
require_relative 'jsonl/writer'
|
|
10
|
+
require_relative 'recorder'
|
|
11
|
+
require_relative 'redactor'
|
|
12
|
+
require_relative 'probes/registry'
|
|
13
|
+
require_relative 'scheduler_observer'
|
|
14
|
+
require_relative 'session'
|
|
15
|
+
require_relative 'watchdog'
|
|
16
|
+
require_relative 'watchdog_policy'
|
|
17
|
+
|
|
18
|
+
module FiberAudit
|
|
19
|
+
module Runtime
|
|
20
|
+
# rubocop:disable Metrics/ClassLength
|
|
21
|
+
class Lifecycle
|
|
22
|
+
attr_reader :settings, :owner_pid, :output_path, :recorder,
|
|
23
|
+
:watchdog, :active_operations, :watchdog_policy,
|
|
24
|
+
:redactor, :probe_registry, :execution_context_store,
|
|
25
|
+
:rails_integration
|
|
26
|
+
|
|
27
|
+
def self.start(...)
|
|
28
|
+
new(...)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(
|
|
32
|
+
settings:,
|
|
33
|
+
watchdog_policy: nil,
|
|
34
|
+
probes_enabled: false,
|
|
35
|
+
clock: Clock.new,
|
|
36
|
+
session_id_source: SecureRandom.method(:uuid),
|
|
37
|
+
pid_source: Process.method(:pid),
|
|
38
|
+
writer_factory: JSONL::Writer.method(:open),
|
|
39
|
+
random: Sampler::RANDOM_SOURCE
|
|
40
|
+
)
|
|
41
|
+
validate_dependencies!(
|
|
42
|
+
settings, watchdog_policy, probes_enabled, clock,
|
|
43
|
+
session_id_source, pid_source, writer_factory, random
|
|
44
|
+
)
|
|
45
|
+
@settings = settings
|
|
46
|
+
@watchdog_policy = watchdog_policy
|
|
47
|
+
@probes_enabled = probes_enabled
|
|
48
|
+
@clock = clock
|
|
49
|
+
@session_id_source = session_id_source
|
|
50
|
+
@pid_source = pid_source
|
|
51
|
+
@writer_factory = writer_factory
|
|
52
|
+
@random = random
|
|
53
|
+
@owner_pid = current_pid
|
|
54
|
+
@state = :starting
|
|
55
|
+
@output_path = nil
|
|
56
|
+
@recorder = nil
|
|
57
|
+
@watchdog = nil
|
|
58
|
+
@scheduler_observer = nil
|
|
59
|
+
@active_operations = nil
|
|
60
|
+
@redactor = nil
|
|
61
|
+
@probe_registry = nil
|
|
62
|
+
@execution_context_store = nil
|
|
63
|
+
@rails_integration = nil
|
|
64
|
+
start_process_session!
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
startup_failure!(e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def active?
|
|
70
|
+
@state == :active && recorder&.active?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def disabled?
|
|
74
|
+
@state == :disabled || recorder&.disabled?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def closed?
|
|
78
|
+
@state == :closed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_current_process!
|
|
82
|
+
pid = current_pid
|
|
83
|
+
return self if pid == owner_pid
|
|
84
|
+
|
|
85
|
+
abandon_inherited_runtime!
|
|
86
|
+
@owner_pid = pid
|
|
87
|
+
@output_path = nil
|
|
88
|
+
@recorder = nil
|
|
89
|
+
@watchdog = nil
|
|
90
|
+
@scheduler_observer = nil
|
|
91
|
+
@active_operations = nil
|
|
92
|
+
@redactor = nil
|
|
93
|
+
@probe_registry = nil
|
|
94
|
+
@execution_context_store = nil
|
|
95
|
+
@rails_integration = nil
|
|
96
|
+
@state = :starting
|
|
97
|
+
# Reset fiber-local context after fork
|
|
98
|
+
ExecutionContext.reset!
|
|
99
|
+
start_process_session!
|
|
100
|
+
self
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
process_failure!(e)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def shutdown(exception: nil)
|
|
106
|
+
ensure_current_process!
|
|
107
|
+
return @summary if closed?
|
|
108
|
+
|
|
109
|
+
runtime_error = stop_runtime_observers
|
|
110
|
+
status = runtime_error && exception.nil? ? :degraded : shutdown_status(exception)
|
|
111
|
+
@summary = recorder&.close(status: status)
|
|
112
|
+
@state = :closed
|
|
113
|
+
raise runtime_error if runtime_error && !settings.policy.fail_open?
|
|
114
|
+
|
|
115
|
+
@summary
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@state = :closed
|
|
118
|
+
raise e unless settings.policy.fail_open?
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def validate_dependencies!(candidate_settings, watchdog, probes, candidate_clock, session_ids, pids, writers, random)
|
|
126
|
+
unless candidate_settings.is_a?(Environment::Settings)
|
|
127
|
+
raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
|
|
128
|
+
end
|
|
129
|
+
unless watchdog.nil? || watchdog.is_a?(WatchdogPolicy)
|
|
130
|
+
raise RuntimeContractError, 'watchdog_policy must be a FiberAudit::Runtime::WatchdogPolicy or nil'
|
|
131
|
+
end
|
|
132
|
+
raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes)
|
|
133
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
'session_id_source' => session_ids,
|
|
137
|
+
'pid_source' => pids,
|
|
138
|
+
'writer_factory' => writers,
|
|
139
|
+
'random source' => random
|
|
140
|
+
}.each do |name, source|
|
|
141
|
+
raise RuntimeContractError, "#{name} must respond to call" unless source.respond_to?(:call)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def start_process_session!
|
|
146
|
+
session_id = @session_id_source.call
|
|
147
|
+
started_at = @clock.wall_time
|
|
148
|
+
started_monotonic_ns = @clock.monotonic_ns
|
|
149
|
+
@output_path = build_output_path(session_id)
|
|
150
|
+
session = Session.new(
|
|
151
|
+
id: session_id,
|
|
152
|
+
started_at: started_at,
|
|
153
|
+
started_monotonic_ns: started_monotonic_ns,
|
|
154
|
+
policy: settings.policy
|
|
155
|
+
)
|
|
156
|
+
writer = @writer_factory.call(path: output_path, max_record_bytes: settings.policy.max_record_bytes)
|
|
157
|
+
@recorder = start_recorder(session, writer)
|
|
158
|
+
setup_runtime_observers! if recorder.active?
|
|
159
|
+
@state = recorder.active? ? :active : :disabled
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def start_recorder(session, writer)
|
|
163
|
+
Recorder.start(session: session, writer: writer, clock: @clock, random: @random)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
begin
|
|
166
|
+
writer.close
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
raise
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def setup_runtime_observers!
|
|
174
|
+
@active_operations = ActiveOperations.new(pid_source: @pid_source)
|
|
175
|
+
@redactor = Redactor.new(root: settings.project_root, policy: settings.policy)
|
|
176
|
+
@execution_context_store = ExecutionContext if @probes_enabled
|
|
177
|
+
setup_watchdog! if watchdog_policy
|
|
178
|
+
setup_rails_integration! if @probes_enabled
|
|
179
|
+
setup_probes! if @probes_enabled
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
unless e.instance_variable_defined?(:@fiber_audit_runtime_accounted)
|
|
182
|
+
recorder.internal_error!
|
|
183
|
+
e.instance_variable_set(:@fiber_audit_runtime_accounted, true)
|
|
184
|
+
end
|
|
185
|
+
deactivate_active_components!
|
|
186
|
+
close_failed_startup!(e)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def deactivate_active_components!
|
|
190
|
+
# Deactivate components in reverse order of setup
|
|
191
|
+
begin
|
|
192
|
+
Probes::Registry.deactivate(@probe_registry) if @probe_registry
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
ensure
|
|
196
|
+
@probe_registry = nil
|
|
197
|
+
end
|
|
198
|
+
begin
|
|
199
|
+
RailsIntegration.deactivate(@rails_integration) if @rails_integration
|
|
200
|
+
rescue StandardError
|
|
201
|
+
nil
|
|
202
|
+
ensure
|
|
203
|
+
@rails_integration = nil
|
|
204
|
+
end
|
|
205
|
+
begin
|
|
206
|
+
if @scheduler_observer
|
|
207
|
+
SchedulerObserver.deactivate(@scheduler_observer)
|
|
208
|
+
@watchdog&.stop
|
|
209
|
+
end
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
ensure
|
|
213
|
+
@scheduler_observer = nil
|
|
214
|
+
@watchdog = nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def setup_rails_integration!
|
|
219
|
+
@rails_integration = RailsIntegration.activate(
|
|
220
|
+
context_store: @execution_context_store,
|
|
221
|
+
recorder: recorder
|
|
222
|
+
)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
@rails_integration = nil
|
|
225
|
+
raise unless settings.policy.fail_open?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def setup_watchdog!
|
|
229
|
+
@watchdog = Watchdog.new(
|
|
230
|
+
policy: watchdog_policy,
|
|
231
|
+
recorder: recorder,
|
|
232
|
+
redactor: redactor,
|
|
233
|
+
active_operations: active_operations,
|
|
234
|
+
clock: @clock
|
|
235
|
+
)
|
|
236
|
+
@scheduler_observer = SchedulerObserver.activate(watchdog: watchdog) if watchdog.enabled?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def setup_probes!
|
|
240
|
+
base = Probes::Base.new(
|
|
241
|
+
recorder: recorder,
|
|
242
|
+
clock: @clock,
|
|
243
|
+
redactor: redactor,
|
|
244
|
+
active_operations: active_operations,
|
|
245
|
+
execution_context_store: @execution_context_store,
|
|
246
|
+
pid_source: @pid_source
|
|
247
|
+
)
|
|
248
|
+
@probe_registry = Probes::Registry.activate(base: base)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def close_failed_startup!(error)
|
|
252
|
+
@probe_registry = nil
|
|
253
|
+
return if settings.policy.fail_open?
|
|
254
|
+
|
|
255
|
+
begin
|
|
256
|
+
recorder.close(status: :degraded)
|
|
257
|
+
rescue StandardError
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
raise error
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def build_output_path(session_id)
|
|
264
|
+
unless session_id.is_a?(String) && session_id.match?(Validation::UUID)
|
|
265
|
+
raise RuntimeContractError, 'session ID source must return a canonical lowercase UUID'
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
filename = "fiber-audit-runtime-#{settings.launch_id}-#{owner_pid}-#{session_id}.jsonl"
|
|
269
|
+
File.join(settings.output_directory, filename).freeze
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def abandon_inherited_runtime!
|
|
273
|
+
recorder&.writer&.close
|
|
274
|
+
ensure
|
|
275
|
+
@recorder = nil
|
|
276
|
+
@watchdog = nil
|
|
277
|
+
@scheduler_observer = nil
|
|
278
|
+
@active_operations = nil
|
|
279
|
+
@redactor = nil
|
|
280
|
+
@probe_registry = nil
|
|
281
|
+
@execution_context_store = nil
|
|
282
|
+
@rails_integration = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def stop_runtime_observers
|
|
286
|
+
error = nil
|
|
287
|
+
begin
|
|
288
|
+
RailsIntegration.deactivate(@rails_integration) if @rails_integration
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
error ||= e
|
|
291
|
+
ensure
|
|
292
|
+
@rails_integration = nil
|
|
293
|
+
end
|
|
294
|
+
begin
|
|
295
|
+
Probes::Registry.deactivate(probe_registry) if probe_registry
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
error ||= e
|
|
298
|
+
ensure
|
|
299
|
+
@probe_registry = nil
|
|
300
|
+
end
|
|
301
|
+
begin
|
|
302
|
+
SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
|
|
303
|
+
watchdog&.stop
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
error ||= e
|
|
306
|
+
ensure
|
|
307
|
+
@scheduler_observer = nil
|
|
308
|
+
end
|
|
309
|
+
recorder&.internal_error! if error
|
|
310
|
+
error
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def startup_failure!(error)
|
|
314
|
+
raise error unless defined?(@settings) && settings.policy.fail_open?
|
|
315
|
+
|
|
316
|
+
@state = :disabled
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def process_failure!(error)
|
|
320
|
+
raise error unless settings.policy.fail_open?
|
|
321
|
+
|
|
322
|
+
@state = :disabled
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def current_pid
|
|
327
|
+
value = @pid_source.call
|
|
328
|
+
unless value.is_a?(Integer) && value.positive?
|
|
329
|
+
raise RuntimeContractError, 'pid source must return a positive Integer'
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
value
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def shutdown_status(exception)
|
|
336
|
+
return :completed if exception.nil? || exception.is_a?(SystemExit)
|
|
337
|
+
|
|
338
|
+
:aborted
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
# rubocop:enable Metrics/ClassLength
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy'
|
|
4
|
+
require_relative 'session'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
class Limits
|
|
10
|
+
WINDOW_NS = 1_000_000_000
|
|
11
|
+
MAX_COUNTER = 9_223_372_036_854_775_807
|
|
12
|
+
COUNTERS = SessionSummary::COUNTERS
|
|
13
|
+
DROP_REASONS = %i[
|
|
14
|
+
sampled_out rate_limited session_event_limited session_byte_limited oversize
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :policy, :started_monotonic_ns
|
|
18
|
+
|
|
19
|
+
def initialize(policy:, started_monotonic_ns:)
|
|
20
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
21
|
+
|
|
22
|
+
@policy = policy
|
|
23
|
+
@started_monotonic_ns = Validation.integer(started_monotonic_ns, 'started_monotonic_ns')
|
|
24
|
+
@counters = COUNTERS.to_h { |name| [name, 0] }
|
|
25
|
+
@window_index = 0
|
|
26
|
+
@emitted_in_window = 0
|
|
27
|
+
@last_admission_ns = @started_monotonic_ns
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def observe!
|
|
31
|
+
increment!(:events_observed)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sampled_out!
|
|
35
|
+
increment!(:sampled_out)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def preflight_event(now_ns:)
|
|
39
|
+
now = normalize_admission_time(now_ns)
|
|
40
|
+
advance_window!(now)
|
|
41
|
+
return :session_event_limited unless policy.session_event_allowed?(emitted_events: @counters[:events_emitted])
|
|
42
|
+
return :rate_limited unless policy.rate_allowed?(emitted_in_window: @emitted_in_window)
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def drop!(reason)
|
|
48
|
+
raise RuntimeContractError, "unknown runtime drop reason: #{reason.inspect}" unless DROP_REASONS.include?(reason)
|
|
49
|
+
|
|
50
|
+
increment!(reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emitted!(now_ns:)
|
|
54
|
+
now = normalize_admission_time(now_ns)
|
|
55
|
+
advance_window!(now)
|
|
56
|
+
increment!(:events_emitted)
|
|
57
|
+
increment_window!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def internal_error!(count: 1)
|
|
61
|
+
amount = Validation.integer(count, 'internal error count', minimum: 1)
|
|
62
|
+
increment!(:internal_errors, amount)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def counters
|
|
66
|
+
@counters.dup.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def normalize_admission_time(value)
|
|
72
|
+
now = Validation.integer(value, 'admission monotonic time')
|
|
73
|
+
raise RuntimeContractError, 'admission monotonic time precedes the session' if now < @started_monotonic_ns
|
|
74
|
+
raise RuntimeSafetyError, 'monotonic clock moved backwards' if now < @last_admission_ns
|
|
75
|
+
|
|
76
|
+
@last_admission_ns = now
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def advance_window!(now)
|
|
80
|
+
index = (now - started_monotonic_ns) / WINDOW_NS
|
|
81
|
+
return if index == @window_index
|
|
82
|
+
|
|
83
|
+
@window_index = index
|
|
84
|
+
@emitted_in_window = 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def increment!(name, amount = 1)
|
|
88
|
+
following = @counters.fetch(name) + amount
|
|
89
|
+
raise RuntimeSafetyError, "runtime counter overflow: #{name}" if following > MAX_COUNTER
|
|
90
|
+
|
|
91
|
+
@counters[name] = following
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def increment_window!
|
|
95
|
+
following = @emitted_in_window + 1
|
|
96
|
+
raise RuntimeSafetyError, 'runtime rate counter overflow' if following > MAX_COUNTER
|
|
97
|
+
|
|
98
|
+
@emitted_in_window = following
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'validation'
|
|
5
|
+
|
|
6
|
+
module FiberAudit
|
|
7
|
+
module Runtime
|
|
8
|
+
Location = Data.define(:path, :line, :column) do
|
|
9
|
+
def initialize(path:, line: nil, column: nil)
|
|
10
|
+
super(
|
|
11
|
+
path: normalize_path(path),
|
|
12
|
+
line: Validation.integer(line, 'line', minimum: 1, allow_nil: true),
|
|
13
|
+
column: Validation.integer(column, 'column', allow_nil: true)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def normalize_path(value)
|
|
20
|
+
path = Validation.string(value, 'path', max_bytes: Location::MAX_PATH_BYTES)
|
|
21
|
+
return path if Location::SENTINELS.include?(path)
|
|
22
|
+
|
|
23
|
+
normalized = path.tr('\\', '/')
|
|
24
|
+
raise RuntimeContractError, 'path must be project-relative' if absolute_path?(normalized)
|
|
25
|
+
|
|
26
|
+
clean = Pathname.new(normalized).cleanpath.to_s
|
|
27
|
+
if clean == '.' || clean == '..' || clean.start_with?('../')
|
|
28
|
+
raise RuntimeContractError, 'path must not escape the project root'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
clean.freeze
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
raise RuntimeContractError, 'path is invalid'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def absolute_path?(path)
|
|
37
|
+
path.start_with?('/', '//') || path.match?(%r{\A[A-Za-z]:/})
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Location.const_set(:SENTINELS, %w[[external] [redacted]].freeze)
|
|
42
|
+
Location.const_set(:MAX_PATH_BYTES, 1_024)
|
|
43
|
+
end
|
|
44
|
+
end
|