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,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative '../../errors'
|
|
6
|
+
require_relative '../event'
|
|
7
|
+
require_relative '../session'
|
|
8
|
+
|
|
9
|
+
module FiberAudit
|
|
10
|
+
module Runtime
|
|
11
|
+
module JSONL
|
|
12
|
+
# Versioned builders and validators stay together to keep one schema authority.
|
|
13
|
+
# rubocop:disable Metrics/ModuleLength
|
|
14
|
+
module Schema
|
|
15
|
+
SCHEMA_VERSION = '1.0'
|
|
16
|
+
RECORD_TYPES = %w[session_start event session_end].freeze
|
|
17
|
+
ENVELOPE_KEYS = %w[
|
|
18
|
+
schema_version record_type session_id sequence recorded_at monotonic_ns payload
|
|
19
|
+
].freeze
|
|
20
|
+
POLICY_KEYS = %w[
|
|
21
|
+
redaction sampling_rate max_events_per_second max_events_per_session
|
|
22
|
+
max_record_bytes max_session_bytes fail_open
|
|
23
|
+
].freeze
|
|
24
|
+
EVENT_KEYS = %w[
|
|
25
|
+
kind source duration_ns operation location execution_context thread_id fiber_id measurements
|
|
26
|
+
].freeze
|
|
27
|
+
LOCATION_KEYS = %w[path line column].freeze
|
|
28
|
+
END_KEYS = %w[status events_observed events_emitted dropped internal_errors].freeze
|
|
29
|
+
DROPPED_KEYS = %w[
|
|
30
|
+
sampling rate_limit session_event_limit session_byte_limit record_size_limit
|
|
31
|
+
].freeze
|
|
32
|
+
UUID = Validation::UUID
|
|
33
|
+
TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def start_record(session)
|
|
38
|
+
require_type!(session, Session, 'session')
|
|
39
|
+
build_record(
|
|
40
|
+
record_type: 'session_start',
|
|
41
|
+
session_id: session.id,
|
|
42
|
+
sequence: 0,
|
|
43
|
+
recorded_at: session.started_at,
|
|
44
|
+
monotonic_ns: session.started_monotonic_ns,
|
|
45
|
+
payload: {
|
|
46
|
+
'tool_version' => session.tool_version,
|
|
47
|
+
'ruby_version' => session.ruby_version,
|
|
48
|
+
'policy' => policy_payload(session.policy)
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def event_record(session_id:, sequence:, event:)
|
|
54
|
+
require_type!(event, Event, 'event')
|
|
55
|
+
build_record(
|
|
56
|
+
record_type: 'event',
|
|
57
|
+
session_id: session_id,
|
|
58
|
+
sequence: sequence,
|
|
59
|
+
recorded_at: event.occurred_at,
|
|
60
|
+
monotonic_ns: event.monotonic_ns,
|
|
61
|
+
payload: {
|
|
62
|
+
'kind' => event.kind.to_s,
|
|
63
|
+
'source' => event.source.to_s,
|
|
64
|
+
'duration_ns' => event.duration_ns,
|
|
65
|
+
'operation' => event.operation,
|
|
66
|
+
'location' => location_payload(event.location),
|
|
67
|
+
'execution_context' => event.execution_context.to_s,
|
|
68
|
+
'thread_id' => event.thread_id,
|
|
69
|
+
'fiber_id' => event.fiber_id,
|
|
70
|
+
'measurements' => event.measurements
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def end_record(session_id:, sequence:, summary:)
|
|
76
|
+
require_type!(summary, SessionSummary, 'summary')
|
|
77
|
+
build_record(
|
|
78
|
+
record_type: 'session_end',
|
|
79
|
+
session_id: session_id,
|
|
80
|
+
sequence: sequence,
|
|
81
|
+
recorded_at: summary.ended_at,
|
|
82
|
+
monotonic_ns: summary.ended_monotonic_ns,
|
|
83
|
+
payload: {
|
|
84
|
+
'status' => summary.status.to_s,
|
|
85
|
+
'events_observed' => summary.events_observed,
|
|
86
|
+
'events_emitted' => summary.events_emitted,
|
|
87
|
+
'dropped' => {
|
|
88
|
+
'sampling' => summary.sampled_out,
|
|
89
|
+
'rate_limit' => summary.rate_limited,
|
|
90
|
+
'session_event_limit' => summary.session_event_limited,
|
|
91
|
+
'session_byte_limit' => summary.session_byte_limited,
|
|
92
|
+
'record_size_limit' => summary.oversize
|
|
93
|
+
},
|
|
94
|
+
'internal_errors' => summary.internal_errors
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate!(record)
|
|
100
|
+
require_exact_keys!(record, ENVELOPE_KEYS, 'record')
|
|
101
|
+
require_value!(record['schema_version'] == SCHEMA_VERSION, 'schema_version must be 1.0')
|
|
102
|
+
require_value!(RECORD_TYPES.include?(record['record_type']), 'record_type is invalid')
|
|
103
|
+
validate_uuid!(record['session_id'])
|
|
104
|
+
validate_sequence!(record['sequence'], record['record_type'])
|
|
105
|
+
parse_timestamp(record['recorded_at'], 'recorded_at')
|
|
106
|
+
require_non_negative_integer!(record['monotonic_ns'], 'monotonic_ns')
|
|
107
|
+
|
|
108
|
+
case record['record_type']
|
|
109
|
+
when 'session_start' then validate_start_payload!(record['payload'])
|
|
110
|
+
when 'event' then validate_event_payload!(record)
|
|
111
|
+
when 'session_end' then validate_end_payload!(record)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
record
|
|
115
|
+
rescue RuntimeContractError
|
|
116
|
+
raise
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
raise RuntimeContractError, "invalid runtime JSONL record: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def dump(record, max_record_bytes:)
|
|
122
|
+
validate!(record)
|
|
123
|
+
unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
|
|
124
|
+
raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
json = "#{::JSON.generate(record)}\n"
|
|
128
|
+
if json.bytesize > max_record_bytes
|
|
129
|
+
raise RuntimeSafetyError,
|
|
130
|
+
"runtime JSONL record is #{json.bytesize} bytes; limit is #{max_record_bytes}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
json.freeze
|
|
134
|
+
rescue ::JSON::GeneratorError => e
|
|
135
|
+
raise RuntimeContractError, "runtime JSONL record is not JSON-safe: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_record(record_type:, session_id:, sequence:, recorded_at:, monotonic_ns:, payload:)
|
|
139
|
+
record = {
|
|
140
|
+
'schema_version' => SCHEMA_VERSION,
|
|
141
|
+
'record_type' => record_type,
|
|
142
|
+
'session_id' => session_id.is_a?(String) ? session_id.dup : session_id,
|
|
143
|
+
'sequence' => sequence,
|
|
144
|
+
'recorded_at' => format_time(recorded_at),
|
|
145
|
+
'monotonic_ns' => monotonic_ns,
|
|
146
|
+
'payload' => payload
|
|
147
|
+
}
|
|
148
|
+
validate!(record)
|
|
149
|
+
deep_freeze(record)
|
|
150
|
+
end
|
|
151
|
+
private_class_method :build_record
|
|
152
|
+
|
|
153
|
+
def policy_payload(policy)
|
|
154
|
+
{
|
|
155
|
+
'redaction' => policy.redaction.to_s,
|
|
156
|
+
'sampling_rate' => policy.sampling_rate,
|
|
157
|
+
'max_events_per_second' => policy.max_events_per_second,
|
|
158
|
+
'max_events_per_session' => policy.max_events_per_session,
|
|
159
|
+
'max_record_bytes' => policy.max_record_bytes,
|
|
160
|
+
'max_session_bytes' => policy.max_session_bytes,
|
|
161
|
+
'fail_open' => policy.fail_open
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
private_class_method :policy_payload
|
|
165
|
+
|
|
166
|
+
def location_payload(location)
|
|
167
|
+
return unless location
|
|
168
|
+
|
|
169
|
+
{ 'path' => location.path, 'line' => location.line, 'column' => location.column }
|
|
170
|
+
end
|
|
171
|
+
private_class_method :location_payload
|
|
172
|
+
|
|
173
|
+
def validate_start_payload!(payload)
|
|
174
|
+
require_exact_keys!(payload, %w[tool_version ruby_version policy], 'session_start payload')
|
|
175
|
+
Validation.string(payload['tool_version'], 'tool_version', max_bytes: 64)
|
|
176
|
+
Validation.string(payload['ruby_version'], 'ruby_version', max_bytes: 64)
|
|
177
|
+
require_exact_keys!(payload['policy'], POLICY_KEYS, 'policy')
|
|
178
|
+
values = payload['policy']
|
|
179
|
+
Policy.new(
|
|
180
|
+
redaction: values['redaction'],
|
|
181
|
+
sampling_rate: values['sampling_rate'],
|
|
182
|
+
max_events_per_second: values['max_events_per_second'],
|
|
183
|
+
max_events_per_session: values['max_events_per_session'],
|
|
184
|
+
max_record_bytes: values['max_record_bytes'],
|
|
185
|
+
max_session_bytes: values['max_session_bytes'],
|
|
186
|
+
fail_open: values['fail_open']
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
private_class_method :validate_start_payload!
|
|
190
|
+
|
|
191
|
+
def validate_event_payload!(record)
|
|
192
|
+
payload = record['payload']
|
|
193
|
+
require_exact_keys!(payload, EVENT_KEYS, 'event payload')
|
|
194
|
+
location = validate_location_payload!(payload['location'])
|
|
195
|
+
Event.new(
|
|
196
|
+
kind: payload['kind'],
|
|
197
|
+
source: payload['source'],
|
|
198
|
+
occurred_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
|
|
199
|
+
monotonic_ns: record['monotonic_ns'],
|
|
200
|
+
duration_ns: payload['duration_ns'],
|
|
201
|
+
operation: payload['operation'],
|
|
202
|
+
location: location,
|
|
203
|
+
execution_context: payload['execution_context'],
|
|
204
|
+
thread_id: payload['thread_id'],
|
|
205
|
+
fiber_id: payload['fiber_id'],
|
|
206
|
+
measurements: payload['measurements']
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
private_class_method :validate_event_payload!
|
|
210
|
+
|
|
211
|
+
def validate_location_payload!(payload)
|
|
212
|
+
return if payload.nil?
|
|
213
|
+
|
|
214
|
+
require_exact_keys!(payload, LOCATION_KEYS, 'location')
|
|
215
|
+
Location.new(path: payload['path'], line: payload['line'], column: payload['column'])
|
|
216
|
+
end
|
|
217
|
+
private_class_method :validate_location_payload!
|
|
218
|
+
|
|
219
|
+
def validate_end_payload!(record)
|
|
220
|
+
payload = record['payload']
|
|
221
|
+
require_exact_keys!(payload, END_KEYS, 'session_end payload')
|
|
222
|
+
require_exact_keys!(payload['dropped'], DROPPED_KEYS, 'dropped')
|
|
223
|
+
dropped = payload['dropped']
|
|
224
|
+
SessionSummary.new(
|
|
225
|
+
ended_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
|
|
226
|
+
ended_monotonic_ns: record['monotonic_ns'],
|
|
227
|
+
status: payload['status'],
|
|
228
|
+
events_observed: payload['events_observed'],
|
|
229
|
+
events_emitted: payload['events_emitted'],
|
|
230
|
+
sampled_out: dropped['sampling'],
|
|
231
|
+
rate_limited: dropped['rate_limit'],
|
|
232
|
+
session_event_limited: dropped['session_event_limit'],
|
|
233
|
+
session_byte_limited: dropped['session_byte_limit'],
|
|
234
|
+
oversize: dropped['record_size_limit'],
|
|
235
|
+
internal_errors: payload['internal_errors']
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
private_class_method :validate_end_payload!
|
|
239
|
+
|
|
240
|
+
def require_exact_keys!(value, expected, path)
|
|
241
|
+
raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
keys = value.keys
|
|
244
|
+
unknown = keys - expected
|
|
245
|
+
missing = expected - keys
|
|
246
|
+
raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
|
|
247
|
+
raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
|
|
248
|
+
end
|
|
249
|
+
private_class_method :require_exact_keys!
|
|
250
|
+
|
|
251
|
+
def validate_uuid!(value)
|
|
252
|
+
require_value!(value.is_a?(String) && value.match?(UUID), 'session_id must be a canonical lowercase UUID')
|
|
253
|
+
end
|
|
254
|
+
private_class_method :validate_uuid!
|
|
255
|
+
|
|
256
|
+
def validate_sequence!(value, record_type)
|
|
257
|
+
minimum = record_type == 'session_start' ? 0 : 1
|
|
258
|
+
require_non_negative_integer!(value, 'sequence')
|
|
259
|
+
require_value!(value.zero?, 'session_start sequence must be 0') if record_type == 'session_start'
|
|
260
|
+
require_value!(value >= minimum, "#{record_type} sequence must be positive") unless record_type == 'session_start'
|
|
261
|
+
end
|
|
262
|
+
private_class_method :validate_sequence!
|
|
263
|
+
|
|
264
|
+
def parse_timestamp(value, field)
|
|
265
|
+
require_value!(
|
|
266
|
+
value.is_a?(String) && value.match?(TIMESTAMP),
|
|
267
|
+
"#{field} must be UTC RFC3339 with six fractional digits"
|
|
268
|
+
)
|
|
269
|
+
Time.iso8601(value)
|
|
270
|
+
rescue ArgumentError
|
|
271
|
+
raise RuntimeContractError, "#{field} is not a valid timestamp"
|
|
272
|
+
end
|
|
273
|
+
private_class_method :parse_timestamp
|
|
274
|
+
|
|
275
|
+
def format_time(value)
|
|
276
|
+
Validation.utc_time(value, 'recorded_at').iso8601(6)
|
|
277
|
+
end
|
|
278
|
+
private_class_method :format_time
|
|
279
|
+
|
|
280
|
+
def require_non_negative_integer!(value, field)
|
|
281
|
+
require_value!(value.is_a?(Integer) && value >= 0, "#{field} must be a non-negative Integer")
|
|
282
|
+
end
|
|
283
|
+
private_class_method :require_non_negative_integer!
|
|
284
|
+
|
|
285
|
+
def require_type!(value, type, field)
|
|
286
|
+
raise RuntimeContractError, "#{field} must be a #{type}" unless value.is_a?(type)
|
|
287
|
+
end
|
|
288
|
+
private_class_method :require_type!
|
|
289
|
+
|
|
290
|
+
def require_value!(condition, message)
|
|
291
|
+
raise RuntimeContractError, message unless condition
|
|
292
|
+
end
|
|
293
|
+
private_class_method :require_value!
|
|
294
|
+
|
|
295
|
+
def deep_freeze(value)
|
|
296
|
+
case value
|
|
297
|
+
when Hash
|
|
298
|
+
value.each do |key, entry|
|
|
299
|
+
deep_freeze(key)
|
|
300
|
+
deep_freeze(entry)
|
|
301
|
+
end
|
|
302
|
+
when Array
|
|
303
|
+
value.each { |entry| deep_freeze(entry) }
|
|
304
|
+
end
|
|
305
|
+
value.freeze
|
|
306
|
+
end
|
|
307
|
+
private_class_method :deep_freeze
|
|
308
|
+
end
|
|
309
|
+
# rubocop:enable Metrics/ModuleLength
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -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
|