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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +41 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +320 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../active_operations'
4
+ require_relative '../clock'
5
+ require_relative '../event'
6
+ require_relative '../execution_context'
7
+ require_relative '../rails_integration'
8
+ require_relative '../recorder'
9
+ require_relative '../redactor'
10
+
11
+ module FiberAudit
12
+ module Runtime
13
+ module Probes
14
+ # Shared behavior-preserving observation boundary for targeted wrappers.
15
+ # rubocop:disable Metrics/ClassLength
16
+ class Base
17
+ SOURCE = :targeted_probe
18
+ MAX_CALLSITE_FRAMES = 32
19
+ INTERNAL_PATH = File.expand_path('../..', __dir__).freeze
20
+ ORIGINAL_MUTEX_SYNCHRONIZE = Mutex.instance_method(:synchronize)
21
+
22
+ Observation = Data.define(
23
+ :operation,
24
+ :started_monotonic_ns,
25
+ :location,
26
+ :handle,
27
+ :thread_id,
28
+ :fiber_id,
29
+ :measurements,
30
+ :execution_context
31
+ )
32
+
33
+ attr_reader :recorder, :clock, :redactor, :active_operations, :owner_pid, :execution_context_store
34
+
35
+ def initialize(recorder:, clock:, redactor:, active_operations:, execution_context_store: nil,
36
+ pid_source: Process.method(:pid))
37
+ validate_dependencies!(recorder, clock, redactor, active_operations, execution_context_store, pid_source)
38
+ @recorder = recorder
39
+ @clock = clock
40
+ @redactor = redactor
41
+ @active_operations = active_operations
42
+ @execution_context_store = execution_context_store
43
+ @pid_source = pid_source
44
+ @owner_pid = current_pid
45
+ @active = true
46
+ end
47
+
48
+ def observe(operation:, measurements: {}, emit_start: false, measurement_builder: nil)
49
+ return yield unless active_for_current_process?
50
+ return yield if guarded?
51
+
52
+ observation = begin
53
+ prepare_observation(operation, measurements)
54
+ rescue StandardError => e
55
+ instrumentation_failure(e)
56
+ end
57
+ return yield unless observation
58
+
59
+ with_guard { emit_start_observation(observation) } if emit_start
60
+ completed = false
61
+ result = nil
62
+ begin
63
+ result = yield
64
+ completed = true
65
+ result
66
+ ensure
67
+ finalize_observation(observation, completed: completed, result: result, measurement_builder: measurement_builder)
68
+ end
69
+ end
70
+
71
+ def with_guard
72
+ self.class.enter_guard
73
+ yield
74
+ ensure
75
+ self.class.exit_guard
76
+ end
77
+
78
+ def deactivate
79
+ @active = false
80
+ self
81
+ end
82
+
83
+ def active_for_current_process?
84
+ @active && owner_pid == current_pid
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ def fail_open?
90
+ recorder.session.policy.fail_open?
91
+ end
92
+
93
+ def instrumentation_failure(error)
94
+ account_internal_error
95
+ raise error unless fail_open?
96
+
97
+ nil
98
+ end
99
+
100
+ private
101
+
102
+ def validate_dependencies!(candidate_recorder, candidate_clock, candidate_redactor, operations, context_store, pids)
103
+ raise RuntimeContractError, 'recorder must be a Runtime::Recorder' unless candidate_recorder.is_a?(Recorder)
104
+ raise RuntimeContractError, 'clock must be a Runtime::Clock' unless candidate_clock.is_a?(Clock)
105
+ raise RuntimeContractError, 'redactor must be a Runtime::Redactor' unless candidate_redactor.is_a?(Redactor)
106
+ unless operations.is_a?(ActiveOperations)
107
+ raise RuntimeContractError, 'active_operations must be Runtime::ActiveOperations'
108
+ end
109
+ if context_store && !context_store.respond_to?(:current)
110
+ raise RuntimeContractError, 'execution_context_store must respond to current'
111
+ end
112
+ raise RuntimeContractError, 'pid_source must respond to call' unless pids.respond_to?(:call)
113
+ end
114
+
115
+ def prepare_observation(operation, measurements)
116
+ # The block keeps all preparation under one recursion guard.
117
+ # rubocop:disable Metrics/BlockLength
118
+ with_guard do
119
+ canonical_operation = Validation.operation(operation)
120
+ normalized_measurements = normalize_measurements(measurements)
121
+ started_ns = clock.monotonic_ns
122
+ location = project_callsite
123
+ next unless location
124
+
125
+ thread = Thread.current
126
+ fiber = Fiber.current
127
+ captured_context = capture_execution_context
128
+ handle = active_operations.register(
129
+ operation: canonical_operation,
130
+ monotonic_ns: started_ns,
131
+ location: location,
132
+ execution_context: captured_context,
133
+ thread: thread,
134
+ fiber: fiber
135
+ )
136
+ Observation.new(
137
+ operation: canonical_operation,
138
+ started_monotonic_ns: started_ns,
139
+ location: location,
140
+ handle: handle,
141
+ thread_id: thread.object_id,
142
+ fiber_id: fiber.object_id,
143
+ measurements: normalized_measurements,
144
+ execution_context: captured_context
145
+ )
146
+ end
147
+ # rubocop:enable Metrics/BlockLength
148
+ end
149
+
150
+ def capture_execution_context
151
+ return Context::UNKNOWN unless execution_context_store
152
+
153
+ execution_context_store.current
154
+ rescue StandardError
155
+ Context::UNKNOWN
156
+ end
157
+
158
+ def emit_start_observation(observation)
159
+ emit_observation(:operation_started, observation, monotonic_ns: observation.started_monotonic_ns)
160
+ rescue StandardError => e
161
+ with_guard { active_operations.finish(observation.handle) } if observation.handle && !fail_open?
162
+ instrumentation_failure(e)
163
+ end
164
+
165
+ def finalize_observation(observation, completed:, result:, measurement_builder:)
166
+ error = nil
167
+ begin
168
+ if active_for_current_process?
169
+ with_guard do
170
+ ended_ns = clock.monotonic_ns
171
+ if ended_ns < observation.started_monotonic_ns
172
+ raise RuntimeSafetyError, 'probe monotonic clock moved backwards'
173
+ end
174
+
175
+ measurements = completed_measurements(observation, completed, result, measurement_builder)
176
+ emit_observation(
177
+ completed ? :operation_completed : :operation_aborted,
178
+ observation,
179
+ monotonic_ns: ended_ns,
180
+ duration_ns: ended_ns - observation.started_monotonic_ns,
181
+ measurements: measurements
182
+ )
183
+ end
184
+ end
185
+ rescue StandardError => e
186
+ error = e
187
+ ensure
188
+ begin
189
+ with_guard { active_operations.finish(observation.handle) } if observation.handle
190
+ rescue StandardError => e
191
+ error ||= e
192
+ end
193
+ end
194
+
195
+ return unless error
196
+
197
+ account_internal_error
198
+ raise error if completed && !fail_open?
199
+ end
200
+
201
+ def completed_measurements(observation, completed, result, builder)
202
+ values = observation.measurements.dup
203
+ if completed && builder
204
+ generated = builder.call(result)
205
+ raise RuntimeContractError, 'measurement_builder must return a Hash' unless generated.is_a?(Hash)
206
+
207
+ values.merge!(generated)
208
+ end
209
+ values[:operation_sequence] = observation.handle&.sequence
210
+ values
211
+ end
212
+
213
+ def emit_observation(kind, observation, monotonic_ns:, duration_ns: nil, measurements: nil)
214
+ values = measurements || observation.measurements.merge(operation_sequence: observation.handle&.sequence)
215
+ recorder.record do
216
+ Event.new(
217
+ kind: kind,
218
+ source: SOURCE,
219
+ occurred_at: clock.wall_time,
220
+ monotonic_ns: monotonic_ns,
221
+ duration_ns: duration_ns,
222
+ operation: observation.operation,
223
+ location: observation.location,
224
+ execution_context: observation.execution_context,
225
+ thread_id: observation.thread_id,
226
+ fiber_id: observation.fiber_id,
227
+ measurements: values
228
+ )
229
+ end
230
+ end
231
+
232
+ def normalize_measurements(value)
233
+ raise RuntimeContractError, 'probe measurements must be a Hash' unless value.is_a?(Hash)
234
+
235
+ value.dup.freeze
236
+ end
237
+
238
+ def project_callsite
239
+ caller_locations(0, MAX_CALLSITE_FRAMES)&.each do |frame|
240
+ paths = frame_paths(frame)
241
+ next if paths.any? { |path| internal_path?(path) }
242
+
243
+ return paths.filter_map { |path| safe_location(frame, path) }.first
244
+ rescue StandardError
245
+ return nil
246
+ end
247
+ nil
248
+ end
249
+
250
+ def frame_paths(frame)
251
+ candidates = [frame.absolute_path, frame.path].compact.filter_map do |path|
252
+ next unless path.is_a?(String) && !path.start_with?('-', '<')
253
+
254
+ File.absolute_path?(path) ? File.expand_path(path) : File.expand_path(path, redactor.root)
255
+ end
256
+ candidates.uniq
257
+ end
258
+
259
+ def internal_path?(path)
260
+ path == INTERNAL_PATH || path.start_with?("#{INTERNAL_PATH}#{File::SEPARATOR}")
261
+ end
262
+
263
+ def safe_location(frame, path)
264
+ location = redactor.location(path: path, line: frame.lineno, column: nil)
265
+ return if Location::SENTINELS.include?(location.path)
266
+
267
+ location
268
+ end
269
+
270
+ class << self
271
+ def guarded?
272
+ state = guard_state
273
+ key = guard_key
274
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
275
+ state.fetch(:depths).fetch(key, 0).positive?
276
+ end
277
+ end
278
+
279
+ def enter_guard
280
+ state = guard_state
281
+ key = guard_key
282
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
283
+ depths = state.fetch(:depths)
284
+ depths[key] = depths.fetch(key, 0) + 1
285
+ end
286
+ end
287
+
288
+ def exit_guard
289
+ state = guard_state
290
+ key = guard_key
291
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
292
+ depths = state.fetch(:depths)
293
+ depth = depths.fetch(key, 1) - 1
294
+ depth.positive? ? depths[key] = depth : depths.delete(key)
295
+ end
296
+ end
297
+
298
+ private
299
+
300
+ def guard_state
301
+ pid = Process.pid
302
+ return @guard_state if @guard_pid == pid && @guard_state
303
+
304
+ @guard_pid = pid
305
+ @guard_state = { mutex: Mutex.new, depths: {} }
306
+ end
307
+
308
+ def guard_key
309
+ [Thread.current.object_id, Fiber.current.object_id]
310
+ end
311
+ end
312
+
313
+ def guarded?
314
+ self.class.guarded?
315
+ end
316
+
317
+ def account_internal_error
318
+ with_guard { recorder.internal_error! unless recorder.disabled? }
319
+ rescue StandardError
320
+ nil
321
+ end
322
+
323
+ def current_pid
324
+ value = @pid_source.call
325
+ return value if value.is_a?(Integer) && value.positive?
326
+
327
+ raise RuntimeContractError, 'pid_source must return a positive Integer'
328
+ end
329
+ end
330
+ # rubocop:enable Metrics/ClassLength
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ module Probes
6
+ module HTTP
7
+ HTTP_SCHEME = /\Ahttps?:/i
8
+
9
+ module NetHTTPClassHook
10
+ def get(...)
11
+ Registry.observe(operation: 'Net::HTTP.get') { super }
12
+ end
13
+
14
+ def get_response(...)
15
+ Registry.observe(operation: 'Net::HTTP.get_response') { super }
16
+ end
17
+
18
+ def start(...)
19
+ Registry.observe(operation: 'Net::HTTP.start') { super }
20
+ end
21
+ end
22
+
23
+ module NetHTTPClassRequestHook
24
+ def request(...)
25
+ Registry.observe(operation: 'Net::HTTP.request') { super }
26
+ end
27
+ end
28
+
29
+ module NetHTTPInstanceHook
30
+ def start(...)
31
+ Registry.observe(operation: 'Net::HTTP.start') { super }
32
+ end
33
+
34
+ def request(...)
35
+ Registry.observe(operation: 'Net::HTTP.request') { super }
36
+ end
37
+ end
38
+
39
+ module URIHook
40
+ def open(*arguments, **, &)
41
+ return super unless HTTP.http_target?(arguments.first)
42
+
43
+ Registry.observe(operation: 'URI.open') { super }
44
+ end
45
+ end
46
+
47
+ module OpenURIHook
48
+ def open_uri(*arguments, **, &)
49
+ return super unless HTTP.http_target?(arguments.first)
50
+
51
+ Registry.observe(operation: 'OpenURI.open_uri') { super }
52
+ end
53
+ end
54
+
55
+ module_function
56
+
57
+ def install!(registry)
58
+ if defined?(Net::HTTP)
59
+ registry.prepend_once(Net::HTTP.singleton_class, NetHTTPClassHook)
60
+ registry.prepend_once(Net::HTTP, NetHTTPInstanceHook)
61
+ registry.prepend_once(Net::HTTP.singleton_class, NetHTTPClassRequestHook) if Net::HTTP.respond_to?(:request)
62
+ end
63
+ registry.prepend_once(URI.singleton_class, URIHook) if defined?(URI) && URI.respond_to?(:open)
64
+ return unless defined?(OpenURI) && OpenURI.respond_to?(:open_uri)
65
+
66
+ registry.prepend_once(OpenURI.singleton_class, OpenURIHook)
67
+ end
68
+
69
+ def http_target?(value)
70
+ return value.match?(HTTP_SCHEME) if value.instance_of?(String) && value.valid_encoding?
71
+ return false unless defined?(URI::HTTP)
72
+
73
+ value.instance_of?(URI::HTTP) || (defined?(URI::HTTPS) && value.instance_of?(URI::HTTPS))
74
+ rescue StandardError
75
+ false
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ module Probes
6
+ module IOSelect
7
+ module_function
8
+
9
+ def timeout_measurement(arguments)
10
+ { timeout_present: arguments.length >= 4 && !arguments[3].nil? }
11
+ end
12
+
13
+ module IOHook
14
+ def select(*arguments, &)
15
+ Registry.observe(
16
+ operation: 'IO.select',
17
+ measurements: IOSelect.timeout_measurement(arguments)
18
+ ) { super }
19
+ end
20
+ end
21
+
22
+ module KernelInstanceHook
23
+ def select(*arguments, &)
24
+ Registry.observe(
25
+ operation: 'Kernel.select',
26
+ measurements: IOSelect.timeout_measurement(arguments)
27
+ ) { super }
28
+ end
29
+
30
+ private :select
31
+ end
32
+
33
+ module KernelSingletonHook
34
+ def select(*arguments, &)
35
+ Registry.observe(
36
+ operation: 'Kernel.select',
37
+ measurements: IOSelect.timeout_measurement(arguments)
38
+ ) { super }
39
+ end
40
+ end
41
+
42
+ def install!(registry)
43
+ registry.prepend_once(IO.singleton_class, IOHook)
44
+ registry.prepend_once(Kernel, KernelInstanceHook)
45
+ registry.prepend_once(Kernel.singleton_class, KernelSingletonHook)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'http'
5
+ require_relative 'io_select'
6
+ require_relative 'socket'
7
+ require_relative 'subprocess'
8
+ require_relative 'synchronization'
9
+ require_relative 'thread_state'
10
+ require_relative 'thread_wait'
11
+
12
+ module FiberAudit
13
+ module Runtime
14
+ module Probes
15
+ # Process-local owner of idempotent probe installation and dispatch.
16
+ class Registry
17
+ PROBE_INSTALLERS = [
18
+ Subprocess,
19
+ ThreadWait,
20
+ Synchronization,
21
+ ThreadState,
22
+ IOSelect,
23
+ Socket,
24
+ HTTP
25
+ ].freeze
26
+
27
+ module RequireInstanceHook
28
+ def require(...)
29
+ result = super
30
+ Registry.rescan_current!
31
+ result
32
+ end
33
+
34
+ private :require
35
+ end
36
+
37
+ module RequireSingletonHook
38
+ def require(...)
39
+ result = super
40
+ Registry.rescan_current!
41
+ result
42
+ end
43
+ end
44
+
45
+ class << self
46
+ def activate(base:)
47
+ raise RuntimeContractError, 'base must be a Runtime::Probes::Base' unless base.is_a?(Base)
48
+
49
+ registry = new(base: base)
50
+ @current = registry
51
+ registry.install!
52
+ registry
53
+ rescue StandardError
54
+ registry&.deactivate
55
+ @current = nil if @current.equal?(registry)
56
+ raise
57
+ end
58
+
59
+ def observe(operation:, measurements: {}, emit_start: false, measurement_builder: nil, &application)
60
+ raise ArgumentError, 'probe observation requires a block' unless application
61
+
62
+ registry = current_for_observation
63
+ return application.call unless registry
64
+
65
+ registry.base.observe(
66
+ operation: operation,
67
+ measurements: measurements,
68
+ emit_start: emit_start,
69
+ measurement_builder: measurement_builder,
70
+ &application
71
+ )
72
+ end
73
+
74
+ def rescan_current!
75
+ current_for_observation&.scan!
76
+ end
77
+
78
+ def deactivate(registry)
79
+ @current = nil if @current.equal?(registry)
80
+ registry&.deactivate
81
+ end
82
+
83
+ def current
84
+ current_for_observation
85
+ end
86
+
87
+ private
88
+
89
+ def current_for_observation
90
+ registry = @current
91
+ return unless registry
92
+ return registry if registry.active_for_current_process?
93
+ return unless registry.stale_process?
94
+
95
+ FiberAudit::Runtime::Boot.current if defined?(FiberAudit::Runtime::Boot)
96
+ registry = @current
97
+ registry if registry&.active_for_current_process?
98
+ end
99
+ end
100
+
101
+ attr_reader :base, :owner_pid
102
+
103
+ def initialize(base:)
104
+ @base = base
105
+ @owner_pid = Process.pid
106
+ @mutex = Mutex.new
107
+ @active = true
108
+ end
109
+
110
+ def install!
111
+ scan!
112
+ self
113
+ end
114
+
115
+ def scan!
116
+ return self unless active_for_current_process?
117
+
118
+ base.with_guard do
119
+ @mutex.synchronize do
120
+ prepend_once(Kernel, RequireInstanceHook)
121
+ prepend_once(Kernel.singleton_class, RequireSingletonHook)
122
+ PROBE_INSTALLERS.each { |installer| installer.install!(self) }
123
+ end
124
+ end
125
+ self
126
+ rescue StandardError => e
127
+ base.instrumentation_failure(e)
128
+ self
129
+ end
130
+
131
+ def prepend_once(target, hook)
132
+ unless target.is_a?(Module) && hook.is_a?(Module)
133
+ raise RuntimeContractError, 'probe target and hook must be Modules'
134
+ end
135
+
136
+ target.prepend(hook) unless target.ancestors.include?(hook)
137
+ target
138
+ end
139
+
140
+ def deactivate
141
+ @active = false
142
+ base.deactivate
143
+ self
144
+ end
145
+
146
+ def active_for_current_process?
147
+ @active && owner_pid == Process.pid
148
+ end
149
+
150
+ def stale_process?
151
+ owner_pid != Process.pid
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../operation_vocabulary'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ module Probes
8
+ module Socket
9
+ MODULE_NAME = Module.instance_method(:name)
10
+ MODULE_COMPARE = Module.instance_method(:<=)
11
+ CONST_DEFINED = Module.instance_method(:const_defined?)
12
+ CONST_GET = Module.instance_method(:const_get)
13
+
14
+ module ConstructorHook
15
+ def new(...)
16
+ operation = Socket.operation_for(self)
17
+ return super unless operation
18
+
19
+ Registry.observe(operation: operation) { super }
20
+ end
21
+ end
22
+
23
+ module_function
24
+
25
+ def install!(registry)
26
+ return unless defined?(::BasicSocket)
27
+
28
+ registry.prepend_once(::BasicSocket.singleton_class, ConstructorHook)
29
+ end
30
+
31
+ def operation_for(klass)
32
+ name = MODULE_NAME.bind_call(klass)
33
+ return unless safe_constant_name?(name)
34
+ return unless exact_socket?(klass, name) || ip_socket_subclass?(klass)
35
+ return unless resolve_constant(name).equal?(klass)
36
+
37
+ "#{name}.new"
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def exact_socket?(klass, name)
43
+ return false unless OperationVocabulary::FA1006_EXACT.include?(name)
44
+
45
+ resolve_constant(name).equal?(klass)
46
+ end
47
+
48
+ def ip_socket_subclass?(klass)
49
+ return false unless defined?(::IPSocket)
50
+
51
+ MODULE_COMPARE.bind_call(klass, ::IPSocket) == true
52
+ end
53
+
54
+ def safe_constant_name?(name)
55
+ name.is_a?(String) && name.bytesize <= 240 &&
56
+ name.match?(/\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/)
57
+ end
58
+
59
+ def resolve_constant(name)
60
+ namespace = Object
61
+ name.split('::').each do |part|
62
+ unless CONST_DEFINED.bind_call(namespace, part, false)
63
+ namespace = nil
64
+ break
65
+ end
66
+
67
+ namespace = CONST_GET.bind_call(namespace, part, false)
68
+ end
69
+ namespace
70
+ rescue NameError, TypeError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end