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
|
@@ -6,15 +6,25 @@ module FiberAudit
|
|
|
6
6
|
module Subprocess
|
|
7
7
|
module KernelInstanceHook
|
|
8
8
|
def system(...)
|
|
9
|
-
Registry.observe(
|
|
9
|
+
Registry.observe(
|
|
10
|
+
operation: 'Kernel.system',
|
|
11
|
+
measurements: { waits_for_child: true }
|
|
12
|
+
) { super }
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def exec(...)
|
|
13
|
-
Registry.observe(
|
|
16
|
+
Registry.observe(
|
|
17
|
+
operation: 'Kernel.exec',
|
|
18
|
+
measurements: { replaces_process: true },
|
|
19
|
+
emit_start: true
|
|
20
|
+
) { super }
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
def spawn(...)
|
|
17
|
-
Registry.observe(
|
|
24
|
+
Registry.observe(
|
|
25
|
+
operation: 'Kernel.spawn',
|
|
26
|
+
measurements: { returns_pid: true, does_not_wait: true }
|
|
27
|
+
) { super }
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
private :system, :exec, :spawn
|
|
@@ -22,15 +32,25 @@ module FiberAudit
|
|
|
22
32
|
|
|
23
33
|
module KernelSingletonHook
|
|
24
34
|
def system(...)
|
|
25
|
-
Registry.observe(
|
|
35
|
+
Registry.observe(
|
|
36
|
+
operation: 'Kernel.system',
|
|
37
|
+
measurements: { waits_for_child: true }
|
|
38
|
+
) { super }
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
def exec(...)
|
|
29
|
-
Registry.observe(
|
|
42
|
+
Registry.observe(
|
|
43
|
+
operation: 'Kernel.exec',
|
|
44
|
+
measurements: { replaces_process: true },
|
|
45
|
+
emit_start: true
|
|
46
|
+
) { super }
|
|
30
47
|
end
|
|
31
48
|
|
|
32
49
|
def spawn(...)
|
|
33
|
-
Registry.observe(
|
|
50
|
+
Registry.observe(
|
|
51
|
+
operation: 'Kernel.spawn',
|
|
52
|
+
measurements: { returns_pid: true, does_not_wait: true }
|
|
53
|
+
) { super }
|
|
34
54
|
end
|
|
35
55
|
end
|
|
36
56
|
|
|
@@ -41,12 +61,70 @@ module FiberAudit
|
|
|
41
61
|
end
|
|
42
62
|
|
|
43
63
|
module ProcessHook
|
|
64
|
+
def spawn(...)
|
|
65
|
+
Registry.observe(
|
|
66
|
+
operation: 'Process.spawn',
|
|
67
|
+
measurements: { returns_pid: true, does_not_wait: true }
|
|
68
|
+
) { super }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def exec(...)
|
|
72
|
+
Registry.observe(
|
|
73
|
+
operation: 'Process.exec',
|
|
74
|
+
measurements: { replaces_process: true },
|
|
75
|
+
emit_start: true
|
|
76
|
+
) { super }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def wait(...)
|
|
80
|
+
Registry.observe(
|
|
81
|
+
operation: 'Process.wait',
|
|
82
|
+
measurements: { waits_for_child: true }
|
|
83
|
+
) { super }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def wait2(...)
|
|
87
|
+
Registry.observe(
|
|
88
|
+
operation: 'Process.wait2',
|
|
89
|
+
measurements: { waits_for_child: true }
|
|
90
|
+
) { super }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def waitpid(...)
|
|
94
|
+
Registry.observe(
|
|
95
|
+
operation: 'Process.waitpid',
|
|
96
|
+
measurements: { waits_for_child: true }
|
|
97
|
+
) { super }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def waitpid2(...)
|
|
101
|
+
Registry.observe(
|
|
102
|
+
operation: 'Process.waitpid2',
|
|
103
|
+
measurements: { waits_for_child: true }
|
|
104
|
+
) { super }
|
|
105
|
+
end
|
|
106
|
+
|
|
44
107
|
def waitall(...)
|
|
45
|
-
Registry.observe(
|
|
108
|
+
Registry.observe(
|
|
109
|
+
operation: 'Process.waitall',
|
|
110
|
+
measurements: { waits_for_child: true }
|
|
111
|
+
) { super }
|
|
46
112
|
end
|
|
47
113
|
|
|
48
114
|
def detach(...)
|
|
49
|
-
Registry.observe(
|
|
115
|
+
Registry.observe(
|
|
116
|
+
operation: 'Process.detach',
|
|
117
|
+
measurements: { returns_thread: true, does_not_wait: true }
|
|
118
|
+
) { super }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
module ProcessStatusHook
|
|
123
|
+
def wait(...)
|
|
124
|
+
Registry.observe(
|
|
125
|
+
operation: 'Process::Status.wait',
|
|
126
|
+
measurements: { waits_for_child: true }
|
|
127
|
+
) { super }
|
|
50
128
|
end
|
|
51
129
|
end
|
|
52
130
|
|
|
@@ -75,6 +153,9 @@ module FiberAudit
|
|
|
75
153
|
registry.prepend_once(Kernel.singleton_class, KernelSingletonHook)
|
|
76
154
|
registry.prepend_once(IO.singleton_class, IOHook)
|
|
77
155
|
registry.prepend_once(Process.singleton_class, ProcessHook)
|
|
156
|
+
if defined?(Process::Status) && Process::Status.respond_to?(:wait)
|
|
157
|
+
registry.prepend_once(Process::Status.singleton_class, ProcessStatusHook)
|
|
158
|
+
end
|
|
78
159
|
registry.prepend_once(Open3.singleton_class, Open3Hook) if defined?(Open3)
|
|
79
160
|
end
|
|
80
161
|
end
|
|
@@ -12,20 +12,6 @@ module FiberAudit
|
|
|
12
12
|
def thread_variable_set(...)
|
|
13
13
|
Registry.observe(operation: 'Thread.thread_variable_set') { super }
|
|
14
14
|
end
|
|
15
|
-
|
|
16
|
-
def [](...)
|
|
17
|
-
return super unless equal?(Thread.current)
|
|
18
|
-
|
|
19
|
-
Registry.observe(operation: 'Thread.current.[]') { super }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def []=(...)
|
|
23
|
-
if equal?(Thread.current)
|
|
24
|
-
Registry.observe(operation: 'Thread.current.[]=') { super }
|
|
25
|
-
else
|
|
26
|
-
super
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
15
|
end
|
|
30
16
|
|
|
31
17
|
module_function
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../operation_semantics'
|
|
4
|
+
require_relative 'scheduler_snapshot'
|
|
5
|
+
|
|
6
|
+
module FiberAudit
|
|
7
|
+
module Runtime
|
|
8
|
+
module SchedulerEvidenceClassifier
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def measurements(operation:, scheduler_snapshot:)
|
|
12
|
+
unless scheduler_snapshot.nil? || scheduler_snapshot.is_a?(SchedulerSnapshot)
|
|
13
|
+
raise RuntimeContractError, 'scheduler_snapshot must be a SchedulerSnapshot or nil'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
profile = OperationSemantics.resolve_runtime_operation(operation)
|
|
17
|
+
capability_supported = capability_supported(profile.scheduler_capability, scheduler_snapshot)
|
|
18
|
+
{
|
|
19
|
+
operation_wait_possible: profile.wait_possible,
|
|
20
|
+
operation_inventory_only: profile.inventory_only,
|
|
21
|
+
operation_scheduler_capability_required: profile.known? ? profile.scheduler_capability_required? : nil,
|
|
22
|
+
operation_scheduler_capability_supported: capability_supported,
|
|
23
|
+
operation_scheduler_cooperation_available: cooperation_available(
|
|
24
|
+
profile,
|
|
25
|
+
scheduler_snapshot,
|
|
26
|
+
capability_supported
|
|
27
|
+
)
|
|
28
|
+
}.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def capability_supported(capability, snapshot)
|
|
32
|
+
return nil if capability.nil? || snapshot.nil?
|
|
33
|
+
|
|
34
|
+
case capability
|
|
35
|
+
when :block, :kernel_sleep
|
|
36
|
+
snapshot.scheduler_present
|
|
37
|
+
when :io_select
|
|
38
|
+
snapshot.scheduler_io_select_supported
|
|
39
|
+
when :process_wait
|
|
40
|
+
snapshot.scheduler_process_wait_supported
|
|
41
|
+
when :address_resolve
|
|
42
|
+
snapshot.scheduler_address_resolve_supported
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
private_class_method :capability_supported
|
|
46
|
+
|
|
47
|
+
def cooperation_available(profile, snapshot, capability_supported)
|
|
48
|
+
return nil unless profile.wait_possible == true
|
|
49
|
+
return nil if snapshot.nil? || snapshot.scheduler_present.nil?
|
|
50
|
+
return false unless snapshot.scheduler_present
|
|
51
|
+
return nil if snapshot.fiber_blocking.nil?
|
|
52
|
+
return false if snapshot.fiber_blocking
|
|
53
|
+
return nil unless profile.scheduler_capability_required?
|
|
54
|
+
|
|
55
|
+
capability_supported
|
|
56
|
+
end
|
|
57
|
+
private_class_method :cooperation_available
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -10,14 +10,14 @@ module FiberAudit
|
|
|
10
10
|
# Ruby's scheduler API fixes this writer-like method name.
|
|
11
11
|
# rubocop:disable Naming/AccessorMethodName
|
|
12
12
|
def set_scheduler(scheduler)
|
|
13
|
-
|
|
13
|
+
previous = Fiber.scheduler
|
|
14
14
|
result = super
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
current = Fiber.scheduler
|
|
16
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_changed(
|
|
17
|
+
previous: previous,
|
|
18
|
+
current: current,
|
|
19
|
+
thread: Thread.current
|
|
20
|
+
)
|
|
21
21
|
result
|
|
22
22
|
end
|
|
23
23
|
# rubocop:enable Naming/AccessorMethodName
|
|
@@ -25,7 +25,10 @@ module FiberAudit
|
|
|
25
25
|
|
|
26
26
|
module SchedulerCloseHook
|
|
27
27
|
def close(...)
|
|
28
|
-
FiberAudit::Runtime::SchedulerObserver.scheduler_closing(
|
|
28
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_closing(
|
|
29
|
+
scheduler: self,
|
|
30
|
+
thread: Thread.current
|
|
31
|
+
)
|
|
29
32
|
super
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -41,19 +44,15 @@ module FiberAudit
|
|
|
41
44
|
observer
|
|
42
45
|
end
|
|
43
46
|
|
|
44
|
-
def
|
|
45
|
-
current_for_process&.scheduler_closing(thread: thread)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def scheduler_installed(scheduler:, thread:)
|
|
47
|
+
def scheduler_changed(previous:, current:, thread:)
|
|
49
48
|
observer = current_for_process
|
|
50
49
|
return unless observer
|
|
51
50
|
|
|
52
|
-
observer.
|
|
51
|
+
observer.scheduler_changed(previous: previous, current: current, thread: thread)
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
def scheduler_closing(thread:)
|
|
56
|
-
current_for_process&.scheduler_closing(thread: thread)
|
|
54
|
+
def scheduler_closing(thread:, scheduler: nil)
|
|
55
|
+
current_for_process&.scheduler_closing(scheduler: scheduler, thread: thread)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def deactivate(observer)
|
|
@@ -82,6 +81,8 @@ module FiberAudit
|
|
|
82
81
|
@watchdog = watchdog
|
|
83
82
|
@owner_pid = Process.pid
|
|
84
83
|
@active = true
|
|
84
|
+
@mutex = Mutex.new
|
|
85
|
+
@schedulers = {}
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
def active_for_current_process?
|
|
@@ -94,8 +95,17 @@ module FiberAudit
|
|
|
94
95
|
self
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
def scheduler_changed(previous:, current:, thread:)
|
|
99
|
+
return self unless active_for_current_process?
|
|
100
|
+
|
|
101
|
+
scheduler_closing(scheduler: previous, thread: thread) if previous && !previous.equal?(current)
|
|
102
|
+
scheduler_installed(scheduler: current, thread: thread) if current
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
97
106
|
def scheduler_installed(scheduler:, thread:)
|
|
98
107
|
return self unless active_for_current_process? && watchdog.enabled?
|
|
108
|
+
return self unless track_scheduler(scheduler, thread)
|
|
99
109
|
|
|
100
110
|
install_close_hook!(scheduler)
|
|
101
111
|
watchdog.scheduler_installed(thread: thread)
|
|
@@ -107,13 +117,17 @@ module FiberAudit
|
|
|
107
117
|
self
|
|
108
118
|
end
|
|
109
119
|
|
|
110
|
-
def scheduler_closing(thread:)
|
|
111
|
-
|
|
120
|
+
def scheduler_closing(thread:, scheduler: nil)
|
|
121
|
+
return self unless active_for_current_process?
|
|
122
|
+
return self unless untrack_scheduler(scheduler, thread)
|
|
123
|
+
|
|
124
|
+
watchdog.scheduler_closing(thread: thread)
|
|
112
125
|
self
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
def deactivate
|
|
116
129
|
@active = false
|
|
130
|
+
@mutex.synchronize { @schedulers.clear }
|
|
117
131
|
self
|
|
118
132
|
end
|
|
119
133
|
|
|
@@ -123,6 +137,28 @@ module FiberAudit
|
|
|
123
137
|
singleton = scheduler.singleton_class
|
|
124
138
|
singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
|
|
125
139
|
end
|
|
140
|
+
|
|
141
|
+
def track_scheduler(scheduler, thread)
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
key = thread.object_id
|
|
144
|
+
return false if @schedulers[key].equal?(scheduler)
|
|
145
|
+
|
|
146
|
+
@schedulers[key] = scheduler
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def untrack_scheduler(scheduler, thread)
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
key = thread.object_id
|
|
154
|
+
tracked = @schedulers[key]
|
|
155
|
+
return false unless tracked
|
|
156
|
+
return false if scheduler && !tracked.equal?(scheduler)
|
|
157
|
+
|
|
158
|
+
@schedulers.delete(key)
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
end
|
|
126
162
|
end
|
|
127
163
|
end
|
|
128
164
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FiberAudit
|
|
4
|
+
module Runtime
|
|
5
|
+
# Immutable scheduler snapshot/context fields captured at operation start.
|
|
6
|
+
# All fields are Boolean or nil only, as per JSONL 1.0 schema requirements.
|
|
7
|
+
SchedulerSnapshot = Data.define(
|
|
8
|
+
:scheduler_present,
|
|
9
|
+
:fiber_blocking,
|
|
10
|
+
:scheduler_io_select_supported,
|
|
11
|
+
:scheduler_process_wait_supported,
|
|
12
|
+
:scheduler_address_resolve_supported
|
|
13
|
+
) do
|
|
14
|
+
def initialize(
|
|
15
|
+
scheduler_present:,
|
|
16
|
+
fiber_blocking:,
|
|
17
|
+
scheduler_io_select_supported: nil,
|
|
18
|
+
scheduler_process_wait_supported: nil,
|
|
19
|
+
scheduler_address_resolve_supported: nil
|
|
20
|
+
)
|
|
21
|
+
super(
|
|
22
|
+
scheduler_present: normalize_optional_boolean(scheduler_present, 'scheduler_present'),
|
|
23
|
+
fiber_blocking: normalize_optional_boolean(fiber_blocking, 'fiber_blocking'),
|
|
24
|
+
scheduler_io_select_supported: normalize_optional_boolean(
|
|
25
|
+
scheduler_io_select_supported,
|
|
26
|
+
'scheduler_io_select_supported'
|
|
27
|
+
),
|
|
28
|
+
scheduler_process_wait_supported: normalize_optional_boolean(
|
|
29
|
+
scheduler_process_wait_supported,
|
|
30
|
+
'scheduler_process_wait_supported'
|
|
31
|
+
),
|
|
32
|
+
scheduler_address_resolve_supported: normalize_optional_boolean(
|
|
33
|
+
scheduler_address_resolve_supported,
|
|
34
|
+
'scheduler_address_resolve_supported'
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_measurements
|
|
40
|
+
{
|
|
41
|
+
scheduler_present: scheduler_present,
|
|
42
|
+
fiber_blocking: fiber_blocking,
|
|
43
|
+
scheduler_io_select_supported: scheduler_io_select_supported,
|
|
44
|
+
scheduler_process_wait_supported: scheduler_process_wait_supported,
|
|
45
|
+
scheduler_address_resolve_supported: scheduler_address_resolve_supported
|
|
46
|
+
}.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def normalize_optional_boolean(value, field)
|
|
52
|
+
return value if value.nil?
|
|
53
|
+
raise RuntimeContractError, "#{field} must be a Boolean or nil" unless [true, false].include?(value)
|
|
54
|
+
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Captures immutable scheduler snapshot at the current point in time.
|
|
60
|
+
# This is called at operation start to provide immutable context.
|
|
61
|
+
module SchedulerSnapshotCapture
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
def capture
|
|
65
|
+
scheduler = Fiber.scheduler
|
|
66
|
+
scheduler_present = !scheduler.nil?
|
|
67
|
+
|
|
68
|
+
current_fiber = Fiber.current
|
|
69
|
+
fiber_blocking = current_fiber.respond_to?(:blocking?) ? current_fiber.blocking? : nil
|
|
70
|
+
|
|
71
|
+
# Query scheduler capabilities if present
|
|
72
|
+
io_select_supported = nil
|
|
73
|
+
process_wait_supported = nil
|
|
74
|
+
address_resolve_supported = nil
|
|
75
|
+
|
|
76
|
+
if scheduler_present
|
|
77
|
+
io_select_supported = scheduler.respond_to?(:io_select)
|
|
78
|
+
process_wait_supported = scheduler.respond_to?(:process_wait)
|
|
79
|
+
address_resolve_supported = scheduler.respond_to?(:address_resolve)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
SchedulerSnapshot.new(
|
|
83
|
+
scheduler_present: scheduler_present,
|
|
84
|
+
fiber_blocking: fiber_blocking,
|
|
85
|
+
scheduler_io_select_supported: io_select_supported,
|
|
86
|
+
scheduler_process_wait_supported: process_wait_supported,
|
|
87
|
+
scheduler_address_resolve_supported: address_resolve_supported
|
|
88
|
+
)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
# Fail open without inventing scheduler or Fiber state.
|
|
91
|
+
SchedulerSnapshot.new(scheduler_present: nil, fiber_blocking: nil)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative 'event'
|
|
|
6
6
|
require_relative 'heartbeat'
|
|
7
7
|
require_relative 'recorder'
|
|
8
8
|
require_relative 'redactor'
|
|
9
|
+
require_relative 'scheduler_evidence_classifier'
|
|
9
10
|
require_relative 'watchdog_policy'
|
|
10
11
|
|
|
11
12
|
module FiberAudit
|
|
@@ -15,6 +16,7 @@ module FiberAudit
|
|
|
15
16
|
class Watchdog
|
|
16
17
|
SOURCE = :scheduler_watchdog
|
|
17
18
|
STOP_TIMEOUT_SECONDS = 1
|
|
19
|
+
MAX_OVERLAP_EVENTS = 10
|
|
18
20
|
|
|
19
21
|
Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
|
|
20
22
|
Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
|
|
@@ -259,6 +261,7 @@ module FiberAudit
|
|
|
259
261
|
start_stall(channel, snapshot, age_ns, now_ns) if policy.stalled?(age_ns: age_ns)
|
|
260
262
|
end
|
|
261
263
|
|
|
264
|
+
# rubocop:disable Metrics/AbcSize
|
|
262
265
|
def start_stall(channel, snapshot, age_ns, now_ns)
|
|
263
266
|
@stall_sequence += 1
|
|
264
267
|
channel.stall = Stall.new(
|
|
@@ -266,7 +269,8 @@ module FiberAudit
|
|
|
266
269
|
progress_sequence: snapshot.sequence,
|
|
267
270
|
began_monotonic_ns: snapshot.last_progress_ns
|
|
268
271
|
)
|
|
269
|
-
|
|
272
|
+
operation_snapshot = active_operations.snapshot_with_metadata(thread_id: snapshot.thread_id)
|
|
273
|
+
operations = operation_snapshot.entries
|
|
270
274
|
frames = safe_frames(channel.thread)
|
|
271
275
|
measurements = {
|
|
272
276
|
stall_sequence: @stall_sequence,
|
|
@@ -275,7 +279,8 @@ module FiberAudit
|
|
|
275
279
|
stall_threshold_ns: policy.stall_threshold_ns,
|
|
276
280
|
heartbeat_interval_ns: policy.heartbeat_interval_ns,
|
|
277
281
|
frame_count: frames.size,
|
|
278
|
-
active_operation_count:
|
|
282
|
+
active_operation_count: operation_snapshot.total_count,
|
|
283
|
+
active_operation_snapshot_truncated: operation_snapshot.truncated?,
|
|
279
284
|
active_operation_first_sequence: operations.first&.sequence,
|
|
280
285
|
active_operation_last_sequence: operations.last&.sequence
|
|
281
286
|
}
|
|
@@ -296,6 +301,49 @@ module FiberAudit
|
|
|
296
301
|
measurements: { stall_sequence: @stall_sequence, frame_index: index }
|
|
297
302
|
)
|
|
298
303
|
end
|
|
304
|
+
emit_stall_operation_overlap_events(operations, operation_snapshot.total_count, now_ns)
|
|
305
|
+
end
|
|
306
|
+
# rubocop:enable Metrics/AbcSize
|
|
307
|
+
|
|
308
|
+
def emit_stall_operation_overlap_events(operations, total_count, now_ns)
|
|
309
|
+
return if operations.empty?
|
|
310
|
+
|
|
311
|
+
bounded_operations = operations.first(MAX_OVERLAP_EVENTS)
|
|
312
|
+
truncated = total_count > bounded_operations.size
|
|
313
|
+
bounded_operations.each do |entry|
|
|
314
|
+
emit_event(
|
|
315
|
+
kind: :scheduler_stall_operation_overlap,
|
|
316
|
+
monotonic_ns: now_ns,
|
|
317
|
+
operation: entry.operation,
|
|
318
|
+
location: entry.location,
|
|
319
|
+
execution_context: entry.execution_context,
|
|
320
|
+
thread_id: entry.thread_id,
|
|
321
|
+
fiber_id: entry.fiber_id,
|
|
322
|
+
measurements: build_overlap_measurements(entry, truncated, total_count)
|
|
323
|
+
)
|
|
324
|
+
end
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
account_internal_error unless recorder.disabled?
|
|
327
|
+
raise e unless fail_open?
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def build_overlap_measurements(entry, truncated, total_count)
|
|
331
|
+
measurements = {
|
|
332
|
+
stall_sequence: @stall_sequence,
|
|
333
|
+
operation_sequence: entry.sequence,
|
|
334
|
+
operation_started_monotonic_ns: entry.started_monotonic_ns,
|
|
335
|
+
overlap_truncated: truncated,
|
|
336
|
+
overlap_total_count: total_count
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
|
|
340
|
+
measurements.merge!(
|
|
341
|
+
SchedulerEvidenceClassifier.measurements(
|
|
342
|
+
operation: entry.operation,
|
|
343
|
+
scheduler_snapshot: entry.scheduler_snapshot
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
measurements
|
|
299
347
|
end
|
|
300
348
|
|
|
301
349
|
def complete_stall(channel, now_ns:, resumed:)
|
|
@@ -358,7 +406,8 @@ module FiberAudit
|
|
|
358
406
|
)
|
|
359
407
|
end
|
|
360
408
|
|
|
361
|
-
def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil,
|
|
409
|
+
def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, operation: nil,
|
|
410
|
+
execution_context: :unknown, measurements: {})
|
|
362
411
|
recorder.record_control do
|
|
363
412
|
Event.new(
|
|
364
413
|
kind: kind,
|
|
@@ -366,8 +415,9 @@ module FiberAudit
|
|
|
366
415
|
occurred_at: @clock.wall_time,
|
|
367
416
|
monotonic_ns: monotonic_ns,
|
|
368
417
|
duration_ns: duration_ns,
|
|
418
|
+
operation: operation,
|
|
369
419
|
location: location,
|
|
370
|
-
execution_context:
|
|
420
|
+
execution_context: execution_context,
|
|
371
421
|
thread_id: thread_id,
|
|
372
422
|
fiber_id: fiber_id,
|
|
373
423
|
measurements: measurements
|
data/lib/fiber_audit/runtime.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
require_relative 'version'
|
|
4
4
|
require_relative 'errors'
|
|
5
5
|
require_relative 'operation_vocabulary'
|
|
6
|
+
require_relative 'operation_semantics'
|
|
6
7
|
require_relative 'execution_context'
|
|
7
8
|
require_relative 'runtime/validation'
|
|
8
9
|
require_relative 'runtime/policy'
|
|
9
10
|
require_relative 'runtime/watchdog_policy'
|
|
11
|
+
require_relative 'runtime/operation_liveness_policy'
|
|
10
12
|
require_relative 'runtime/location'
|
|
11
13
|
require_relative 'runtime/event'
|
|
12
14
|
require_relative 'runtime/session'
|
|
@@ -22,7 +24,10 @@ require_relative 'runtime/rails_integration'
|
|
|
22
24
|
require_relative 'runtime/environment'
|
|
23
25
|
require_relative 'runtime/active_operations'
|
|
24
26
|
require_relative 'runtime/heartbeat'
|
|
27
|
+
require_relative 'runtime/scheduler_snapshot'
|
|
28
|
+
require_relative 'runtime/scheduler_evidence_classifier'
|
|
25
29
|
require_relative 'runtime/watchdog'
|
|
30
|
+
require_relative 'runtime/operation_liveness_monitor'
|
|
26
31
|
require_relative 'runtime/scheduler_observer'
|
|
27
32
|
require_relative 'runtime/probes/base'
|
|
28
33
|
require_relative 'runtime/probes/subprocess'
|
|
@@ -179,6 +179,25 @@ module FiberAudit
|
|
|
179
179
|
def severity_index(severity)
|
|
180
180
|
Severity.index(severity)
|
|
181
181
|
end
|
|
182
|
+
|
|
183
|
+
# Compute the advisory severity for a finding without applying
|
|
184
|
+
# the context ceiling. Advisory rules (FA1002, FA1003, FA1005,
|
|
185
|
+
# FA1006, FA1007) use this to respect configuration overrides
|
|
186
|
+
# but not escalate based on execution context.
|
|
187
|
+
#
|
|
188
|
+
# Resolution order:
|
|
189
|
+
# 1. configuration.severity_override(rule_id) replaces the default
|
|
190
|
+
# 2. No context ceiling is applied
|
|
191
|
+
#
|
|
192
|
+
# @param default_sev [Symbol, String] rule's default severity
|
|
193
|
+
# @return [Symbol] the resolved severity
|
|
194
|
+
def advisory_severity(default_sev)
|
|
195
|
+
# Normalize String to Symbol defensively
|
|
196
|
+
default_sev = default_sev.to_sym if default_sev.is_a?(String)
|
|
197
|
+
|
|
198
|
+
# Configuration override replaces default entirely, no ceiling
|
|
199
|
+
configuration.severity_override(self.class.id) || default_sev
|
|
200
|
+
end
|
|
182
201
|
end
|
|
183
202
|
end
|
|
184
203
|
end
|