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.
- checksums.yaml +4 -4
- data/.fiber-audit.example.yml +7 -8
- data/ARCHITECTURE.md +97 -673
- data/CHANGELOG.md +52 -0
- data/README.md +163 -86
- data/lib/fiber_audit/cli.rb +19 -5
- data/lib/fiber_audit/configuration.rb +31 -6
- data/lib/fiber_audit/operation_semantics.rb +172 -0
- data/lib/fiber_audit/operation_vocabulary.rb +2 -1
- data/lib/fiber_audit/runtime/active_operations.rb +39 -9
- data/lib/fiber_audit/runtime/boot.rb +4 -0
- data/lib/fiber_audit/runtime/environment.rb +58 -0
- data/lib/fiber_audit/runtime/execution_context.rb +55 -43
- data/lib/fiber_audit/runtime/lifecycle.rb +91 -50
- data/lib/fiber_audit/runtime/operation_liveness_monitor.rb +282 -0
- data/lib/fiber_audit/runtime/operation_liveness_policy.rb +47 -0
- data/lib/fiber_audit/runtime/probes/base.rb +27 -4
- data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
- data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
- data/lib/fiber_audit/runtime/scheduler_evidence_classifier.rb +60 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
- data/lib/fiber_audit/runtime/scheduler_snapshot.rb +95 -0
- data/lib/fiber_audit/runtime/watchdog.rb +54 -4
- data/lib/fiber_audit/runtime.rb +5 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
- data/lib/fiber_audit/static/rules/base.rb +19 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +67 -10
- data/lib/fiber_audit/static/rules/direct_socket.rb +54 -19
- data/lib/fiber_audit/static/rules/io_select.rb +6 -6
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
- data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
- data/lib/fiber_audit/static/rules/thread_current_state.rb +20 -30
- data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
- data/lib/fiber_audit/version.rb +1 -1
- 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
|
|
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)
|
|
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
|
-
|
|
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
|
|
8
|
-
# Uses
|
|
9
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
clear!
|
|
44
76
|
end
|
|
45
77
|
|
|
46
78
|
private
|
|
47
79
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
return nil unless
|
|
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
|
-
|
|
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)
|