fiber_audit 0.2.1 → 0.3.0
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/ARCHITECTURE.md +26 -24
- data/CHANGELOG.md +23 -0
- data/README.md +42 -18
- data/lib/fiber_audit/cli.rb +16 -4
- data/lib/fiber_audit/operation_vocabulary.rb +2 -1
- data/lib/fiber_audit/runtime/active_operations.rb +19 -5
- data/lib/fiber_audit/runtime/execution_context.rb +55 -43
- data/lib/fiber_audit/runtime/probes/base.rb +24 -3
- 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_observer.rb +54 -18
- data/lib/fiber_audit/runtime/scheduler_snapshot.rb +113 -0
- data/lib/fiber_audit/runtime/watchdog.rb +44 -2
- data/lib/fiber_audit/runtime.rb +1 -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 +90 -10
- data/lib/fiber_audit/static/rules/direct_socket.rb +8 -9
- 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 +18 -20
- data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
- data/lib/fiber_audit/version.rb +1 -1
- metadata +2 -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
|
|
@@ -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,113 @@
|
|
|
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_boolean(scheduler_present, 'scheduler_present'),
|
|
23
|
+
fiber_blocking: normalize_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_boolean(value, field)
|
|
52
|
+
raise RuntimeContractError, "#{field} must be a Boolean" unless [true, false].include?(value)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalize_optional_boolean(value, field)
|
|
58
|
+
return value if value.nil?
|
|
59
|
+
raise RuntimeContractError, "#{field} must be a Boolean or nil" unless [true, false].include?(value)
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Captures immutable scheduler snapshot at the current point in time.
|
|
66
|
+
# This is called at operation start to provide immutable context.
|
|
67
|
+
module SchedulerSnapshotCapture
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
def capture
|
|
71
|
+
scheduler = Fiber.scheduler
|
|
72
|
+
scheduler_present = !scheduler.nil?
|
|
73
|
+
|
|
74
|
+
# Normalize Fiber#blocking? to Boolean
|
|
75
|
+
fiber_blocking = begin
|
|
76
|
+
current_fiber = Fiber.current
|
|
77
|
+
if current_fiber.respond_to?(:blocking?)
|
|
78
|
+
!current_fiber.blocking?.nil?
|
|
79
|
+
else
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Query scheduler capabilities if present
|
|
87
|
+
io_select_supported = nil
|
|
88
|
+
process_wait_supported = nil
|
|
89
|
+
address_resolve_supported = nil
|
|
90
|
+
|
|
91
|
+
if scheduler_present
|
|
92
|
+
io_select_supported = scheduler.respond_to?(:io_select)
|
|
93
|
+
process_wait_supported = scheduler.respond_to?(:process_wait)
|
|
94
|
+
address_resolve_supported = scheduler.respond_to?(:address_resolve)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SchedulerSnapshot.new(
|
|
98
|
+
scheduler_present: scheduler_present,
|
|
99
|
+
fiber_blocking: fiber_blocking,
|
|
100
|
+
scheduler_io_select_supported: io_select_supported,
|
|
101
|
+
scheduler_process_wait_supported: process_wait_supported,
|
|
102
|
+
scheduler_address_resolve_supported: address_resolve_supported
|
|
103
|
+
)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
# Fail open: return a safe default snapshot
|
|
106
|
+
SchedulerSnapshot.new(
|
|
107
|
+
scheduler_present: false,
|
|
108
|
+
fiber_blocking: false
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -15,6 +15,7 @@ module FiberAudit
|
|
|
15
15
|
class Watchdog
|
|
16
16
|
SOURCE = :scheduler_watchdog
|
|
17
17
|
STOP_TIMEOUT_SECONDS = 1
|
|
18
|
+
MAX_OVERLAP_EVENTS = 10
|
|
18
19
|
|
|
19
20
|
Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
|
|
20
21
|
Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
|
|
@@ -296,6 +297,45 @@ module FiberAudit
|
|
|
296
297
|
measurements: { stall_sequence: @stall_sequence, frame_index: index }
|
|
297
298
|
)
|
|
298
299
|
end
|
|
300
|
+
emit_stall_operation_overlap_events(operations, now_ns, snapshot)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def emit_stall_operation_overlap_events(operations, now_ns, _snapshot)
|
|
304
|
+
return if operations.empty?
|
|
305
|
+
|
|
306
|
+
truncated = operations.size > MAX_OVERLAP_EVENTS
|
|
307
|
+
bounded_operations = operations.first(MAX_OVERLAP_EVENTS)
|
|
308
|
+
|
|
309
|
+
bounded_operations.each do |entry|
|
|
310
|
+
overlap_measurements = build_overlap_measurements(entry, truncated, operations.size)
|
|
311
|
+
emit_event(
|
|
312
|
+
kind: :scheduler_stall_operation_overlap,
|
|
313
|
+
monotonic_ns: now_ns,
|
|
314
|
+
operation: entry.operation,
|
|
315
|
+
location: entry.location,
|
|
316
|
+
execution_context: entry.execution_context,
|
|
317
|
+
thread_id: entry.thread_id,
|
|
318
|
+
fiber_id: entry.fiber_id,
|
|
319
|
+
measurements: overlap_measurements
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
rescue StandardError => e
|
|
323
|
+
account_internal_error unless recorder.disabled?
|
|
324
|
+
raise e unless fail_open?
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def build_overlap_measurements(entry, truncated, total_count)
|
|
328
|
+
measurements = {
|
|
329
|
+
stall_sequence: @stall_sequence,
|
|
330
|
+
operation_sequence: entry.sequence,
|
|
331
|
+
operation_started_monotonic_ns: entry.started_monotonic_ns,
|
|
332
|
+
overlap_truncated: truncated,
|
|
333
|
+
overlap_total_count: total_count
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
|
|
337
|
+
|
|
338
|
+
measurements
|
|
299
339
|
end
|
|
300
340
|
|
|
301
341
|
def complete_stall(channel, now_ns:, resumed:)
|
|
@@ -358,7 +398,8 @@ module FiberAudit
|
|
|
358
398
|
)
|
|
359
399
|
end
|
|
360
400
|
|
|
361
|
-
def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil,
|
|
401
|
+
def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, operation: nil,
|
|
402
|
+
execution_context: :unknown, measurements: {})
|
|
362
403
|
recorder.record_control do
|
|
363
404
|
Event.new(
|
|
364
405
|
kind: kind,
|
|
@@ -366,8 +407,9 @@ module FiberAudit
|
|
|
366
407
|
occurred_at: @clock.wall_time,
|
|
367
408
|
monotonic_ns: monotonic_ns,
|
|
368
409
|
duration_ns: duration_ns,
|
|
410
|
+
operation: operation,
|
|
369
411
|
location: location,
|
|
370
|
-
execution_context:
|
|
412
|
+
execution_context: execution_context,
|
|
371
413
|
thread_id: thread_id,
|
|
372
414
|
fiber_id: fiber_id,
|
|
373
415
|
measurements: measurements
|
data/lib/fiber_audit/runtime.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative 'runtime/rails_integration'
|
|
|
22
22
|
require_relative 'runtime/environment'
|
|
23
23
|
require_relative 'runtime/active_operations'
|
|
24
24
|
require_relative 'runtime/heartbeat'
|
|
25
|
+
require_relative 'runtime/scheduler_snapshot'
|
|
25
26
|
require_relative 'runtime/watchdog'
|
|
26
27
|
require_relative 'runtime/scheduler_observer'
|
|
27
28
|
require_relative 'runtime/probes/base'
|
|
@@ -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
|
|
@@ -9,17 +9,90 @@ require_relative '../../operation_vocabulary'
|
|
|
9
9
|
module FiberAudit
|
|
10
10
|
module Static
|
|
11
11
|
module Rules
|
|
12
|
+
# FA1001: Subprocess lifecycle operations that may interfere with
|
|
13
|
+
# the fiber scheduler. Detects subprocess creation, replacement,
|
|
14
|
+
# waiting, detachment, and stream lifecycle operations.
|
|
15
|
+
#
|
|
16
|
+
# Operations are classified into semantic categories:
|
|
17
|
+
# - creation: spawning a new process (info)
|
|
18
|
+
# - replacement: replacing the current process via exec (info)
|
|
19
|
+
# - waiting: blocking waits for subprocess completion (medium)
|
|
20
|
+
# - detach: detaching a subprocess without waiting (info)
|
|
21
|
+
# - stream: subprocess pipe/stream lifecycle via IO.popen (medium)
|
|
22
|
+
#
|
|
23
|
+
# Severity: info for creation/replacement/detach, medium for waits/stream.
|
|
24
|
+
# These are scheduler-cooperation requirements, so execution context does
|
|
25
|
+
# not by itself escalate them to critical.
|
|
12
26
|
class BlockingSubprocess < Base
|
|
13
27
|
id 'FA1001'
|
|
14
|
-
severity :
|
|
28
|
+
severity :medium
|
|
15
29
|
default_confidence :high
|
|
16
|
-
description '
|
|
30
|
+
description 'Subprocess lifecycle operations may interfere with the fiber scheduler'
|
|
17
31
|
|
|
18
32
|
TARGETS = OperationVocabulary::FA1001_TARGETS
|
|
19
33
|
BARE_KERNEL_METHODS = OperationVocabulary::FA1001_KERNEL_METHODS
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
# Per-operation semantic category
|
|
36
|
+
OPERATION_CATEGORY = {
|
|
37
|
+
# Creation (info) - spawning a new process
|
|
38
|
+
'Kernel.spawn' => :creation,
|
|
39
|
+
'Process.spawn' => :creation,
|
|
40
|
+
# Replacement (info) - replacing current process via exec
|
|
41
|
+
'Kernel.exec' => :replacement,
|
|
42
|
+
'Process.exec' => :replacement,
|
|
43
|
+
# Waiting (medium) - blocking waits for subprocess completion
|
|
44
|
+
'Kernel.system' => :waiting,
|
|
45
|
+
'Process.wait' => :waiting,
|
|
46
|
+
'Process.wait2' => :waiting,
|
|
47
|
+
'Process.waitpid' => :waiting,
|
|
48
|
+
'Process.waitpid2' => :waiting,
|
|
49
|
+
'Process.waitall' => :waiting,
|
|
50
|
+
'Process::Status.wait' => :waiting,
|
|
51
|
+
'Open3.capture2' => :waiting,
|
|
52
|
+
'Open3.capture2e' => :waiting,
|
|
53
|
+
'Open3.capture3' => :waiting,
|
|
54
|
+
'Open3.pipeline' => :waiting,
|
|
55
|
+
# Detach (info) - detaching subprocess without waiting
|
|
56
|
+
'Process.detach' => :detach,
|
|
57
|
+
# Stream (medium) - subprocess pipe/stream lifecycle
|
|
58
|
+
'IO.popen' => :stream
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Per-category metadata
|
|
62
|
+
CATEGORY_METADATA = {
|
|
63
|
+
creation: {
|
|
64
|
+
severity: :info,
|
|
65
|
+
title: 'Subprocess creation',
|
|
66
|
+
message: 'Spawning a subprocess may leave background processes that outlive the fiber scheduler session.',
|
|
67
|
+
remediation: 'Track spawned processes or move subprocess creation outside the fiber-scheduled path.'
|
|
68
|
+
},
|
|
69
|
+
replacement: {
|
|
70
|
+
severity: :info,
|
|
71
|
+
title: 'Process replacement',
|
|
72
|
+
message: 'Process replacement via exec replaces the current process image, terminating the fiber scheduler.',
|
|
73
|
+
remediation: 'Avoid exec in fiber-scheduled code; prefer subprocess creation with explicit lifecycle management.'
|
|
74
|
+
},
|
|
75
|
+
waiting: {
|
|
76
|
+
severity: :medium,
|
|
77
|
+
title: 'Subprocess wait',
|
|
78
|
+
message: 'Subprocess waiting requires scheduler process-wait cooperation in a non-blocking Fiber.',
|
|
79
|
+
remediation: 'Verify scheduler process_wait support and runtime progress, ' \
|
|
80
|
+
'or move waits outside the fiber-scheduled path.'
|
|
81
|
+
},
|
|
82
|
+
detach: {
|
|
83
|
+
severity: :info,
|
|
84
|
+
title: 'Subprocess detach',
|
|
85
|
+
message: 'Detaching a subprocess may leave it unmanaged by the fiber scheduler.',
|
|
86
|
+
remediation: 'Avoid detaching subprocesses in fiber-scheduled code; use explicit lifecycle management.'
|
|
87
|
+
},
|
|
88
|
+
stream: {
|
|
89
|
+
severity: :medium,
|
|
90
|
+
title: 'Subprocess pipe stream',
|
|
91
|
+
message: 'Subprocess pipe I/O requires scheduler cooperation while the stream is open.',
|
|
92
|
+
remediation: 'Verify scheduler-aware I/O and runtime progress on the pipe, ' \
|
|
93
|
+
'or move pipe operations outside the fiber-scheduled path.'
|
|
94
|
+
}
|
|
95
|
+
}.freeze
|
|
23
96
|
|
|
24
97
|
def analyze(call_sites:)
|
|
25
98
|
call_sites.filter_map do |site|
|
|
@@ -60,11 +133,14 @@ module FiberAudit
|
|
|
60
133
|
def build_finding(site, match)
|
|
61
134
|
operation = "#{match[:constant]}.#{match[:method]}"
|
|
62
135
|
context = site.execution_context || :unknown
|
|
63
|
-
|
|
136
|
+
category = OPERATION_CATEGORY.fetch(operation, :waiting)
|
|
137
|
+
metadata = CATEGORY_METADATA.fetch(category)
|
|
138
|
+
base_severity = metadata[:severity]
|
|
139
|
+
sev = advisory_severity(base_severity)
|
|
64
140
|
|
|
65
141
|
Finding.new(
|
|
66
142
|
rule_id: self.class.id,
|
|
67
|
-
title:
|
|
143
|
+
title: metadata[:title],
|
|
68
144
|
category: :subprocess,
|
|
69
145
|
severity: sev,
|
|
70
146
|
confidence: match[:confidence],
|
|
@@ -72,15 +148,19 @@ module FiberAudit
|
|
|
72
148
|
symbol: site.enclosing_symbol,
|
|
73
149
|
operation: operation,
|
|
74
150
|
execution_context: context,
|
|
75
|
-
message:
|
|
151
|
+
message: metadata[:message],
|
|
76
152
|
evidence: [
|
|
77
153
|
Evidence.new(
|
|
78
154
|
source: :static,
|
|
79
|
-
message: "Matched #{operation}",
|
|
80
|
-
details: {
|
|
155
|
+
message: "Matched #{operation} (#{category})",
|
|
156
|
+
details: {
|
|
157
|
+
receiver: match[:constant],
|
|
158
|
+
method: match[:method],
|
|
159
|
+
semantic: category
|
|
160
|
+
}
|
|
81
161
|
)
|
|
82
162
|
],
|
|
83
|
-
remediation:
|
|
163
|
+
remediation: metadata[:remediation]
|
|
84
164
|
)
|
|
85
165
|
end
|
|
86
166
|
end
|