fiber_audit 0.2.1 → 0.3.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +7 -8
  3. data/ARCHITECTURE.md +97 -673
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +163 -86
  6. data/lib/fiber_audit/cli.rb +19 -5
  7. data/lib/fiber_audit/configuration.rb +31 -6
  8. data/lib/fiber_audit/operation_semantics.rb +172 -0
  9. data/lib/fiber_audit/operation_vocabulary.rb +2 -1
  10. data/lib/fiber_audit/runtime/active_operations.rb +39 -9
  11. data/lib/fiber_audit/runtime/boot.rb +4 -0
  12. data/lib/fiber_audit/runtime/environment.rb +58 -0
  13. data/lib/fiber_audit/runtime/execution_context.rb +55 -43
  14. data/lib/fiber_audit/runtime/lifecycle.rb +91 -50
  15. data/lib/fiber_audit/runtime/operation_liveness_monitor.rb +282 -0
  16. data/lib/fiber_audit/runtime/operation_liveness_policy.rb +47 -0
  17. data/lib/fiber_audit/runtime/probes/base.rb +27 -4
  18. data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
  19. data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
  20. data/lib/fiber_audit/runtime/scheduler_evidence_classifier.rb +60 -0
  21. data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
  22. data/lib/fiber_audit/runtime/scheduler_snapshot.rb +95 -0
  23. data/lib/fiber_audit/runtime/watchdog.rb +54 -4
  24. data/lib/fiber_audit/runtime.rb +5 -0
  25. data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
  26. data/lib/fiber_audit/static/rules/base.rb +19 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +67 -10
  28. data/lib/fiber_audit/static/rules/direct_socket.rb +54 -19
  29. data/lib/fiber_audit/static/rules/io_select.rb +6 -6
  30. data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
  31. data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
  32. data/lib/fiber_audit/static/rules/thread_current_state.rb +20 -30
  33. data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
  34. data/lib/fiber_audit/version.rb +1 -1
  35. metadata +6 -1
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ module FiberAudit
6
+ # rubocop:disable Metrics/ModuleLength
7
+ module OperationSemantics
8
+ CAPABILITIES = %i[block kernel_sleep io_select process_wait address_resolve].freeze
9
+ CATEGORIES = %i[
10
+ creation replacement waiting detach stream thread_wait synchronization
11
+ nonblocking_try thread_state io_select socket_allocation socket_resolve_connect
12
+ socket_local_connect socket_constructor_unknown http_io unknown
13
+ ].freeze
14
+
15
+ Profile = Data.define(:category, :wait_possible, :inventory_only, :scheduler_capability) do
16
+ def initialize(category:, wait_possible:, inventory_only:, scheduler_capability: nil)
17
+ normalized_category = category.to_sym if category.respond_to?(:to_sym)
18
+ unless CATEGORIES.include?(normalized_category)
19
+ raise RuntimeContractError, "unknown operation semantic category: #{category.inspect}"
20
+ end
21
+
22
+ [[:wait_possible, wait_possible], [:inventory_only, inventory_only]].each do |name, value|
23
+ next if value.nil? || [true, false].include?(value)
24
+
25
+ raise RuntimeContractError, "#{name} must be a Boolean or nil"
26
+ end
27
+
28
+ capability = scheduler_capability&.to_sym
29
+ unless capability.nil? || CAPABILITIES.include?(capability)
30
+ raise RuntimeContractError, "unknown scheduler capability: #{scheduler_capability.inspect}"
31
+ end
32
+
33
+ super(category: normalized_category, wait_possible: wait_possible,
34
+ inventory_only: inventory_only, scheduler_capability: capability)
35
+ end
36
+
37
+ def known? = category != :unknown
38
+ def scheduler_capability_required? = !scheduler_capability.nil?
39
+ end
40
+
41
+ UNKNOWN = Profile.new(category: :unknown, wait_possible: nil, inventory_only: nil).freeze
42
+ SOCKET_SUBCLASS = Profile.new(
43
+ category: :socket_constructor_unknown,
44
+ wait_possible: true,
45
+ inventory_only: false
46
+ ).freeze
47
+
48
+ TABLE = {
49
+ 'Kernel.spawn' => Profile.new(category: :creation, wait_possible: false, inventory_only: true),
50
+ 'Process.spawn' => Profile.new(category: :creation, wait_possible: false, inventory_only: true),
51
+ 'Kernel.exec' => Profile.new(category: :replacement, wait_possible: false, inventory_only: true),
52
+ 'Process.exec' => Profile.new(category: :replacement, wait_possible: false, inventory_only: true),
53
+ 'Kernel.system' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
54
+ scheduler_capability: :process_wait),
55
+ 'Process.wait' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
56
+ scheduler_capability: :process_wait),
57
+ 'Process.wait2' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
58
+ scheduler_capability: :process_wait),
59
+ 'Process.waitpid' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
60
+ scheduler_capability: :process_wait),
61
+ 'Process.waitpid2' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
62
+ scheduler_capability: :process_wait),
63
+ 'Process.waitall' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
64
+ scheduler_capability: :process_wait),
65
+ 'Process::Status.wait' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
66
+ scheduler_capability: :process_wait),
67
+ 'Open3.capture2' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
68
+ scheduler_capability: :process_wait),
69
+ 'Open3.capture2e' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
70
+ scheduler_capability: :process_wait),
71
+ 'Open3.capture3' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
72
+ scheduler_capability: :process_wait),
73
+ 'Open3.pipeline' => Profile.new(category: :waiting, wait_possible: true, inventory_only: false,
74
+ scheduler_capability: :process_wait),
75
+ 'Process.detach' => Profile.new(category: :detach, wait_possible: false, inventory_only: true),
76
+ 'IO.popen' => Profile.new(category: :stream, wait_possible: true, inventory_only: false),
77
+ 'Thread.join' => Profile.new(category: :thread_wait, wait_possible: true, inventory_only: false,
78
+ scheduler_capability: :block),
79
+ 'Thread.value' => Profile.new(category: :thread_wait, wait_possible: true, inventory_only: false,
80
+ scheduler_capability: :block),
81
+ 'Mutex#lock' => Profile.new(category: :synchronization, wait_possible: true, inventory_only: false,
82
+ scheduler_capability: :block),
83
+ 'Mutex#synchronize' => Profile.new(category: :synchronization, wait_possible: true, inventory_only: false,
84
+ scheduler_capability: :block),
85
+ 'Mutex#try_lock' => Profile.new(category: :nonblocking_try, wait_possible: false, inventory_only: false),
86
+ 'ConditionVariable#wait' => Profile.new(category: :synchronization, wait_possible: true,
87
+ inventory_only: false, scheduler_capability: :kernel_sleep),
88
+ 'Monitor#synchronize' => Profile.new(category: :synchronization, wait_possible: true,
89
+ inventory_only: false, scheduler_capability: :block),
90
+ 'MonitorMixin#synchronize' => Profile.new(category: :synchronization, wait_possible: true,
91
+ inventory_only: false, scheduler_capability: :block),
92
+ 'Thread.thread_variable_get' => Profile.new(category: :thread_state, wait_possible: false,
93
+ inventory_only: true),
94
+ 'Thread.thread_variable_set' => Profile.new(category: :thread_state, wait_possible: false,
95
+ inventory_only: true),
96
+ 'IO.select' => Profile.new(category: :io_select, wait_possible: true, inventory_only: false,
97
+ scheduler_capability: :io_select),
98
+ 'Kernel.select' => Profile.new(category: :io_select, wait_possible: true, inventory_only: false,
99
+ scheduler_capability: :io_select),
100
+ 'Socket.new' => Profile.new(category: :socket_allocation, wait_possible: false, inventory_only: true),
101
+ 'IPSocket.new' => Profile.new(category: :socket_allocation, wait_possible: false, inventory_only: true),
102
+ 'UDPSocket.new' => Profile.new(category: :socket_allocation, wait_possible: false, inventory_only: true),
103
+ 'TCPSocket.new' => Profile.new(category: :socket_resolve_connect, wait_possible: true, inventory_only: false,
104
+ scheduler_capability: :address_resolve),
105
+ 'TCPServer.new' => Profile.new(category: :socket_resolve_connect, wait_possible: true, inventory_only: false,
106
+ scheduler_capability: :address_resolve),
107
+ 'UNIXSocket.new' => Profile.new(category: :socket_local_connect, wait_possible: true, inventory_only: false),
108
+ 'UNIXServer.new' => Profile.new(category: :socket_allocation, wait_possible: false, inventory_only: true),
109
+ 'Net::HTTP.get' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
110
+ scheduler_capability: :address_resolve),
111
+ 'Net::HTTP.get_response' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
112
+ scheduler_capability: :address_resolve),
113
+ 'Net::HTTP.start' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
114
+ scheduler_capability: :address_resolve),
115
+ 'Net::HTTP.request' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
116
+ scheduler_capability: :address_resolve),
117
+ 'URI.open' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
118
+ scheduler_capability: :address_resolve),
119
+ 'OpenURI.open_uri' => Profile.new(category: :http_io, wait_possible: true, inventory_only: false,
120
+ scheduler_capability: :address_resolve)
121
+ }.freeze
122
+
123
+ FA1001_CATEGORIES = TABLE.slice(
124
+ 'Kernel.spawn', 'Process.spawn', 'Kernel.exec', 'Process.exec', 'Kernel.system',
125
+ 'Process.wait', 'Process.wait2', 'Process.waitpid', 'Process.waitpid2',
126
+ 'Process.waitall', 'Process::Status.wait', 'Open3.capture2', 'Open3.capture2e',
127
+ 'Open3.capture3', 'Open3.pipeline', 'Process.detach', 'IO.popen'
128
+ ).transform_values(&:category).freeze
129
+
130
+ module_function
131
+
132
+ def resolve(operation)
133
+ TABLE.fetch(normalize_operation(operation), UNKNOWN)
134
+ end
135
+
136
+ def resolve_socket_constructor(operation)
137
+ resolve_with_socket_fallback(operation)
138
+ end
139
+
140
+ # Runtime callers are the controlled targeted-probe registry; the only
141
+ # canonical dynamic `<Class>.new` operations it emits are proven IPSocket
142
+ # subclasses from Probes::Socket.
143
+ def resolve_runtime_operation(operation)
144
+ resolve_with_socket_fallback(operation)
145
+ end
146
+
147
+ def known?(operation) = resolve(operation).known?
148
+
149
+ def normalize_operation(value)
150
+ unless value.is_a?(String) && !value.empty? && value.valid_encoding? && value.bytesize <= 240
151
+ raise RuntimeContractError, 'operation must be a non-empty UTF-8 String of at most 240 bytes'
152
+ end
153
+
154
+ value
155
+ end
156
+ private_class_method :normalize_operation
157
+
158
+ def resolve_with_socket_fallback(operation)
159
+ normalized = normalize_operation(operation)
160
+ TABLE.fetch(normalized) do
161
+ socket_constructor_operation?(normalized) ? SOCKET_SUBCLASS : UNKNOWN
162
+ end
163
+ end
164
+ private_class_method :resolve_with_socket_fallback
165
+
166
+ def socket_constructor_operation?(operation)
167
+ operation.match?(/\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\.new\z/)
168
+ end
169
+ private_class_method :socket_constructor_operation?
170
+ end
171
+ # rubocop:enable Metrics/ModuleLength
172
+ end
@@ -7,7 +7,8 @@ module FiberAudit
7
7
  'Kernel' => %i[system exec spawn].freeze,
8
8
  'Open3' => %i[capture2 capture2e capture3 pipeline].freeze,
9
9
  'IO' => %i[popen].freeze,
10
- 'Process' => %i[waitall detach].freeze
10
+ 'Process' => %i[spawn exec wait wait2 waitpid waitpid2 waitall detach].freeze,
11
+ 'Process::Status' => %i[wait].freeze
11
12
  }.freeze
12
13
  FA1001_KERNEL_METHODS = %i[system exec spawn].freeze
13
14
 
@@ -2,11 +2,12 @@
2
2
 
3
3
  require_relative 'event'
4
4
  require_relative 'location'
5
+ require_relative 'scheduler_snapshot'
5
6
  require_relative 'validation'
6
7
 
7
8
  module FiberAudit
8
9
  module Runtime
9
- # Bounded process-local registry shared by the watchdog and future probes.
10
+ # Bounded process-local registry shared by runtime observers.
10
11
  class ActiveOperations
11
12
  MAX_ENTRIES = 10_000
12
13
  MAX_SNAPSHOT = 100
@@ -19,8 +20,23 @@ module FiberAudit
19
20
  :operation,
20
21
  :location,
21
22
  :execution_context,
22
- :started_monotonic_ns
23
+ :started_monotonic_ns,
24
+ :scheduler_snapshot
23
25
  )
26
+ Snapshot = Data.define(:entries, :total_count) do
27
+ def initialize(entries:, total_count:)
28
+ unless entries.is_a?(Array) && entries.all?(Entry)
29
+ raise RuntimeContractError, 'snapshot entries must be ActiveOperations::Entry values'
30
+ end
31
+
32
+ super(
33
+ entries: entries.dup.freeze,
34
+ total_count: Validation.integer(total_count, 'active operation total count')
35
+ )
36
+ end
37
+
38
+ def truncated? = total_count > entries.size
39
+ end
24
40
 
25
41
  def initialize(pid_source: Process.method(:pid), capacity: MAX_ENTRIES, snapshot_limit: MAX_SNAPSHOT)
26
42
  validate_source!(pid_source)
@@ -38,7 +54,8 @@ module FiberAudit
38
54
  location: nil,
39
55
  execution_context: :unknown,
40
56
  thread: Thread.current,
41
- fiber: Fiber.current
57
+ fiber: Fiber.current,
58
+ scheduler_snapshot: nil
42
59
  )
43
60
  ensure_current_process!
44
61
  values = normalize_entry(
@@ -47,7 +64,8 @@ module FiberAudit
47
64
  execution_context: execution_context,
48
65
  monotonic_ns: monotonic_ns,
49
66
  thread: thread,
50
- fiber: fiber
67
+ fiber: fiber,
68
+ scheduler_snapshot: scheduler_snapshot
51
69
  )
52
70
 
53
71
  @mutex.synchronize do
@@ -73,13 +91,16 @@ module FiberAudit
73
91
  @mutex.synchronize { @entries.delete(handle) }
74
92
  end
75
93
 
76
- def snapshot(thread_id: nil)
94
+ def snapshot(thread_id: nil) = snapshot_with_metadata(thread_id: thread_id).entries
95
+
96
+ def snapshot_with_metadata(thread_id: nil)
77
97
  ensure_current_process!
78
98
  normalized_thread_id = Validation.integer(thread_id, 'thread_id', allow_nil: true)
79
99
  @mutex.synchronize do
80
100
  entries = @entries.values
81
101
  entries = entries.select { |entry| entry.thread_id == normalized_thread_id } if normalized_thread_id
82
- entries.sort_by(&:sequence).first(@snapshot_limit).freeze
102
+ ordered = entries.sort_by(&:sequence)
103
+ Snapshot.new(entries: ordered.first(@snapshot_limit), total_count: ordered.size)
83
104
  end
84
105
  end
85
106
 
@@ -90,7 +111,7 @@ module FiberAudit
90
111
 
91
112
  private
92
113
 
93
- def normalize_entry(operation:, location:, execution_context:, monotonic_ns:, thread:, fiber:)
114
+ def normalize_entry(operation:, location:, execution_context:, monotonic_ns:, thread:, fiber:, scheduler_snapshot: nil)
94
115
  canonical_operation = Validation.operation(operation)
95
116
  unless location.nil? || location.is_a?(Location)
96
117
  raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
@@ -109,10 +130,17 @@ module FiberAudit
109
130
  operation: canonical_operation,
110
131
  location: location,
111
132
  execution_context: normalized_context,
112
- started_monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns')
133
+ started_monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
134
+ scheduler_snapshot: normalize_scheduler_snapshot(scheduler_snapshot)
113
135
  }
114
136
  end
115
137
 
138
+ def normalize_scheduler_snapshot(value)
139
+ return value if value.nil? || value.is_a?(SchedulerSnapshot)
140
+
141
+ raise RuntimeContractError, 'scheduler_snapshot must be a FiberAudit::Runtime::SchedulerSnapshot or nil'
142
+ end
143
+
116
144
  def ensure_current_process!
117
145
  pid = current_pid
118
146
  reset_for_process!(pid) unless pid == @owner_pid
@@ -133,7 +161,9 @@ module FiberAudit
133
161
  end
134
162
 
135
163
  def validate_source!(source)
136
- raise RuntimeContractError, 'pid_source must respond to call' unless source.respond_to?(:call)
164
+ return if source.respond_to?(:call)
165
+
166
+ raise RuntimeContractError, 'pid_source must respond to call'
137
167
  end
138
168
 
139
169
  def validate_limit!(value, name, maximum)
@@ -25,9 +25,13 @@ module FiberAudit
25
25
  watchdog_policy = if environment.key?(Environment::WATCHDOG_SETTINGS_KEY)
26
26
  Environment.load_watchdog_policy(environment)
27
27
  end
28
+ operation_liveness_policy = if environment.key?(Environment::OPERATION_LIVENESS_SETTINGS_KEY)
29
+ Environment.load_operation_liveness_policy(environment)
30
+ end
28
31
  @lifecycle = Lifecycle.start(
29
32
  settings: settings,
30
33
  watchdog_policy: watchdog_policy,
34
+ operation_liveness_policy: operation_liveness_policy,
31
35
  probes_enabled: Environment.probes_enabled?(environment),
32
36
  clock: clock,
33
37
  session_id_source: session_id_source,
@@ -7,6 +7,7 @@ require 'securerandom'
7
7
  require_relative 'policy'
8
8
  require_relative 'validation'
9
9
  require_relative 'watchdog_policy'
10
+ require_relative 'operation_liveness_policy'
10
11
 
11
12
  module FiberAudit
12
13
  module Runtime
@@ -17,10 +18,12 @@ module FiberAudit
17
18
  SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_SETTINGS'
18
19
  FAILURE_MODE_KEY = 'FIBER_AUDIT_RUNTIME_FAILURE_MODE'
19
20
  WATCHDOG_SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_WATCHDOG_SETTINGS'
21
+ OPERATION_LIVENESS_SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_OPERATION_LIVENESS_SETTINGS'
20
22
  PROBES_KEY = 'FIBER_AUDIT_RUNTIME_PROBES'
21
23
  BOOT_REQUIRE = '-rfiber_audit/runtime/boot'
22
24
  MAX_SETTINGS_BYTES = 16_384
23
25
  MAX_WATCHDOG_SETTINGS_BYTES = 1_024
26
+ MAX_OPERATION_LIVENESS_SETTINGS_BYTES = 1_024
24
27
  SETTINGS_KEYS = %w[protocol_version launch_id project_root output_directory policy].freeze
25
28
  POLICY_KEYS = %w[
26
29
  redaction sampling_rate max_events_per_second max_events_per_session
@@ -29,6 +32,9 @@ module FiberAudit
29
32
  WATCHDOG_KEYS = %w[
30
33
  protocol_version enabled heartbeat_interval_ms stall_threshold_ms max_frames
31
34
  ].freeze
35
+ OPERATION_LIVENESS_KEYS = %w[
36
+ protocol_version enabled poll_interval_ms long_active_threshold_ms
37
+ ].freeze
32
38
 
33
39
  Settings = Data.define(:protocol_version, :launch_id, :project_root, :output_directory, :policy) do
34
40
  def initialize(protocol_version:, launch_id:, project_root:, output_directory:, policy:)
@@ -146,6 +152,45 @@ module FiberAudit
146
152
  raise RuntimeContractError, "invalid runtime watchdog activation settings: #{e.message}"
147
153
  end
148
154
 
155
+ def dump_operation_liveness_policy(policy)
156
+ require_operation_liveness_policy!(policy)
157
+ payload = {
158
+ 'protocol_version' => PROTOCOL_VERSION,
159
+ 'enabled' => policy.enabled,
160
+ 'poll_interval_ms' => policy.poll_interval_ms,
161
+ 'long_active_threshold_ms' => policy.long_active_threshold_ms
162
+ }
163
+ encoded = JSON.generate(payload)
164
+ if encoded.bytesize > MAX_OPERATION_LIVENESS_SETTINGS_BYTES
165
+ raise RuntimeSafetyError, 'runtime operation-liveness activation settings are too large'
166
+ end
167
+
168
+ encoded.freeze
169
+ end
170
+
171
+ def load_operation_liveness_policy(environment = ENV)
172
+ value = environment[OPERATION_LIVENESS_SETTINGS_KEY]
173
+ return OperationLivenessPolicy::DISABLED if value.nil?
174
+ unless value.is_a?(String) && value.valid_encoding? &&
175
+ value.bytesize <= MAX_OPERATION_LIVENESS_SETTINGS_BYTES
176
+ raise RuntimeContractError, 'runtime operation-liveness activation settings are invalid'
177
+ end
178
+
179
+ payload = JSON.parse(value)
180
+ require_exact_keys!(payload, OPERATION_LIVENESS_KEYS, 'runtime operation-liveness activation settings')
181
+ unless payload.fetch('protocol_version') == PROTOCOL_VERSION
182
+ raise RuntimeContractError, "runtime operation-liveness activation protocol must be #{PROTOCOL_VERSION}"
183
+ end
184
+
185
+ OperationLivenessPolicy.new(
186
+ enabled: payload.fetch('enabled'),
187
+ poll_interval_ms: payload.fetch('poll_interval_ms'),
188
+ long_active_threshold_ms: payload.fetch('long_active_threshold_ms')
189
+ )
190
+ rescue JSON::ParserError, ArgumentError => e
191
+ raise RuntimeContractError, "invalid runtime operation-liveness activation settings: #{e.message}"
192
+ end
193
+
149
194
  def activated?(environment = ENV)
150
195
  marker = environment[ACTIVATION_KEY]
151
196
  return false if marker.nil?
@@ -173,12 +218,14 @@ module FiberAudit
173
218
  def child_environment(
174
219
  settings:,
175
220
  watchdog_policy: nil,
221
+ operation_liveness_policy: nil,
176
222
  probes_enabled: false,
177
223
  base_environment: ENV,
178
224
  library_path: default_library_path
179
225
  )
180
226
  require_settings!(settings)
181
227
  require_watchdog_policy!(watchdog_policy) if watchdog_policy
228
+ require_operation_liveness_policy!(operation_liveness_policy) if operation_liveness_policy
182
229
  raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes_enabled)
183
230
  raise RuntimeContractError, 'base_environment must be a Hash-like object' unless base_environment.respond_to?(:[])
184
231
 
@@ -190,6 +237,9 @@ module FiberAudit
190
237
  'RUBYLIB' => prepend_token(base_environment['RUBYLIB'], library_path, separator: File::PATH_SEPARATOR)
191
238
  }
192
239
  environment[WATCHDOG_SETTINGS_KEY] = dump_watchdog_policy(watchdog_policy) if watchdog_policy
240
+ if operation_liveness_policy
241
+ environment[OPERATION_LIVENESS_SETTINGS_KEY] = dump_operation_liveness_policy(operation_liveness_policy)
242
+ end
193
243
  environment[PROBES_KEY] = '1' if probes_enabled
194
244
  environment.transform_values(&:freeze).freeze
195
245
  end
@@ -255,6 +305,14 @@ module FiberAudit
255
305
  end
256
306
  private_class_method :require_watchdog_policy!
257
307
 
308
+ def require_operation_liveness_policy!(value)
309
+ return if value.is_a?(OperationLivenessPolicy)
310
+
311
+ raise RuntimeContractError,
312
+ 'operation_liveness_policy must be FiberAudit::Runtime::OperationLivenessPolicy'
313
+ end
314
+ private_class_method :require_operation_liveness_policy!
315
+
258
316
  def require_exact_keys!(value, expected, path)
259
317
  raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
260
318
 
@@ -4,73 +4,85 @@ require_relative '../execution_context'
4
4
 
5
5
  module FiberAudit
6
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.
7
+ # Fiber-local execution context with propagation to child fibers.
8
+ # Uses Ruby Fiber storage (Fiber[]) for fiber-local state that is
9
+ # inherited by child fibers at creation, enabling automatic context
10
+ # propagation across Fiber boundaries.
11
+ #
12
+ # Frames are immutable (frozen Data objects) forming a linked list.
13
+ # Each +with+ call creates a new frame pointing to the parent frame.
14
+ #
15
+ # Semantics:
16
+ # - Child fibers inherit the parent fiber's current context at creation
17
+ # - Child fiber overrides do not alter parent context
18
+ # - +clear!+ removes context for the current fiber only
19
+ # - +clear!+ inside nested +with+ is not undone by the enclosing ensure
20
+ # (ensure restores only when its own frame is still the active one)
21
+ # - Thread isolation is maintained even though Ruby copies Fiber storage to a
22
+ # newly created Thread's root Fiber
23
+ # - PID mismatch (after fork) is treated as empty context
24
+ # - MAX_DEPTH overflow exposes :unknown rather than stale outer context
10
25
  module ExecutionContext
11
26
  MAX_DEPTH = 32
12
- IVAR_KEY = :@__fiber_audit_execution_context__
13
- IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
27
+ FRAME_KEY = :__fiber_audit_execution_context_frame__
28
+
29
+ # Immutable frame in the context chain. Process and Thread ownership keep
30
+ # inherited storage from crossing fork or Thread boundaries.
31
+ Frame = Data.define(:context, :parent, :depth, :pid, :thread_id)
32
+ private_constant :Frame
14
33
 
15
34
  class << self
16
35
  def current
17
- state = current_state
18
- return Context::UNKNOWN unless state
19
-
20
- state[:stack].last || Context::UNKNOWN
36
+ frame = current_frame
37
+ frame ? frame.context : Context::UNKNOWN
21
38
  end
22
39
 
23
40
  def with(context)
24
41
  normalized = validate_context(context)
25
- state = ensure_state
26
- return yield if state[:stack].size >= MAX_DEPTH
27
-
28
- state[:stack].push(normalized)
42
+ parent = current_frame
43
+
44
+ new_depth = parent ? parent.depth + 1 : 1
45
+ effective = new_depth > MAX_DEPTH ? Context::UNKNOWN : normalized
46
+ frame = Frame.new(
47
+ context: effective,
48
+ parent: parent,
49
+ depth: new_depth,
50
+ pid: Process.pid,
51
+ thread_id: Thread.current.object_id
52
+ )
53
+
54
+ Fiber[FRAME_KEY] = frame
29
55
  begin
30
56
  yield
31
57
  ensure
32
- state[:stack].pop
58
+ # Only restore if our frame is still the active one.
59
+ # If clear! was called (or another with replaced it), skip restore.
60
+ Fiber[FRAME_KEY] = parent if Fiber[FRAME_KEY].equal?(frame)
33
61
  end
34
62
  end
35
63
 
64
+ def clear!
65
+ Fiber[FRAME_KEY] = nil
66
+ end
67
+
68
+ # Compatibility alias for clear!.
36
69
  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)
70
+ clear!
40
71
  end
41
72
 
73
+ # Clear context after fork.
42
74
  def after_fork!
43
- reset!
75
+ clear!
44
76
  end
45
77
 
46
78
  private
47
79
 
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
80
+ def current_frame
81
+ frame = Fiber[FRAME_KEY]
82
+ return nil unless frame&.pid == Process.pid
83
+ return nil unless frame.thread_id == Thread.current.object_id
69
84
 
70
- stack = []
71
- fiber.instance_variable_set(IVAR_KEY, stack)
72
- fiber.instance_variable_set(IVAR_PID_KEY, pid)
73
- { stack: stack, pid: pid }
85
+ frame
74
86
  end
75
87
 
76
88
  def validate_context(value)