anzen 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +51 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/README.md +321 -0
- data/Rakefile +23 -0
- data/bin/anzen +168 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/anzen/README_API.md +608 -0
- data/lib/anzen/cli.rb +301 -0
- data/lib/anzen/configuration.rb +254 -0
- data/lib/anzen/exceptions.rb +130 -0
- data/lib/anzen/monitor.rb +85 -0
- data/lib/anzen/monitors/call_stack_depth.rb +147 -0
- data/lib/anzen/monitors/memory.rb +228 -0
- data/lib/anzen/monitors/recursion.rb +193 -0
- data/lib/anzen/registry.rb +169 -0
- data/lib/anzen/version.rb +5 -0
- data/lib/anzen.rb +210 -0
- data/sig/anzen.rbs +4 -0
- metadata +74 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anzen
|
|
4
|
+
# Base interface/contract for all safety monitors
|
|
5
|
+
#
|
|
6
|
+
# All monitors (built-in and custom) must implement this interface.
|
|
7
|
+
# This module defines the required methods that every monitor must provide.
|
|
8
|
+
#
|
|
9
|
+
# @api public
|
|
10
|
+
# @example Implement a custom monitor
|
|
11
|
+
# class MyMonitor
|
|
12
|
+
# include Anzen::Monitor
|
|
13
|
+
#
|
|
14
|
+
# def name
|
|
15
|
+
# 'my_monitor'
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def check!
|
|
19
|
+
# # Your monitoring logic here
|
|
20
|
+
# raise Anzen::ViolationError.new("violation") if problem_detected?
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module Monitor
|
|
24
|
+
# Unique identifier for this monitor
|
|
25
|
+
#
|
|
26
|
+
# @return [String] monitor name (format: [a-z0-9_]+)
|
|
27
|
+
def name
|
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Enable this monitor for checking
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean] true
|
|
34
|
+
def enable
|
|
35
|
+
raise NotImplementedError, "#{self.class} must implement #enable"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Disable this monitor from checking
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] false
|
|
41
|
+
def disable
|
|
42
|
+
raise NotImplementedError, "#{self.class} must implement #disable"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if this monitor is currently enabled
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] true if enabled, false if disabled
|
|
48
|
+
def enabled?
|
|
49
|
+
raise NotImplementedError, "#{self.class} must implement #enabled?"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Execute this monitor's check
|
|
53
|
+
#
|
|
54
|
+
# Reads current state, compares to thresholds, and raises appropriate error if violation detected.
|
|
55
|
+
# Must raise a ViolationError subclass on violation, or CheckFailedError if check itself fails.
|
|
56
|
+
# Must not raise if check passes.
|
|
57
|
+
# Returns nil if check passes (monitor enabled or disabled).
|
|
58
|
+
#
|
|
59
|
+
# @return [nil] if check passes
|
|
60
|
+
# @raise [Anzen::ViolationError] subclass on detection
|
|
61
|
+
# @raise [Anzen::CheckFailedError] on infrastructure failure
|
|
62
|
+
def check!
|
|
63
|
+
raise NotImplementedError, "#{self.class} must implement #check!"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return current status of this monitor
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
|
|
69
|
+
# - name (String): monitor name
|
|
70
|
+
# - enabled (Boolean): whether monitor is enabled
|
|
71
|
+
# - thresholds (Hash): monitor-specific threshold values
|
|
72
|
+
# - last_check (Time): timestamp of last check, or nil if never checked
|
|
73
|
+
# - violations (Integer): count of violations detected
|
|
74
|
+
def status
|
|
75
|
+
raise NotImplementedError, "#{self.class} must implement #status"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return human-readable one-liner for CLI output
|
|
79
|
+
#
|
|
80
|
+
# @return [String] single line describing monitor status
|
|
81
|
+
def to_cli
|
|
82
|
+
raise NotImplementedError, "#{self.class} must implement #to_cli"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anzen
|
|
4
|
+
module Monitors
|
|
5
|
+
# Monitor that detects call stack depth exceeding configured threshold
|
|
6
|
+
#
|
|
7
|
+
# Tracks call stack depth and raises RecursionLimitExceeded when
|
|
8
|
+
# the depth exceeds the configured limit. Supports any recursion pattern
|
|
9
|
+
# (direct, indirect, n-way cycles).
|
|
10
|
+
#
|
|
11
|
+
# Use case: Hard limit on stack depth to prevent OOM/stack overflow
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# monitor = Anzen::Monitors::CallStackDepthMonitor.new(depth_limit: 1000)
|
|
16
|
+
# monitor.enable
|
|
17
|
+
# monitor.check! # Raises RecursionLimitExceeded if depth > 1000
|
|
18
|
+
class CallStackDepthMonitor
|
|
19
|
+
include Anzen::Monitor
|
|
20
|
+
|
|
21
|
+
# @return [Integer] configured depth limit
|
|
22
|
+
attr_reader :depth_limit
|
|
23
|
+
|
|
24
|
+
# @return [Integer] count of violations detected
|
|
25
|
+
attr_reader :violation_count
|
|
26
|
+
|
|
27
|
+
# @return [Time, nil] timestamp of last check
|
|
28
|
+
attr_reader :last_check
|
|
29
|
+
|
|
30
|
+
# Initialize CallStackDepthMonitor
|
|
31
|
+
#
|
|
32
|
+
# @param depth_limit [Integer] maximum allowed call stack depth (must be positive)
|
|
33
|
+
# @raise [ConfigurationError] if depth_limit is not a positive integer
|
|
34
|
+
def initialize(depth_limit: 1000)
|
|
35
|
+
validate_depth_limit(depth_limit)
|
|
36
|
+
@depth_limit = depth_limit
|
|
37
|
+
@enabled = false
|
|
38
|
+
@violation_count = 0
|
|
39
|
+
@last_check = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Monitor name
|
|
43
|
+
#
|
|
44
|
+
# @return [String] "call_stack_depth"
|
|
45
|
+
def name
|
|
46
|
+
'call_stack_depth'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Enable this monitor
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean] true
|
|
52
|
+
def enable
|
|
53
|
+
@enabled = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Disable this monitor
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean] false
|
|
59
|
+
def disable
|
|
60
|
+
@enabled = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if monitor is enabled
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def enabled?
|
|
67
|
+
@enabled
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check current call stack depth
|
|
71
|
+
#
|
|
72
|
+
# Reads the call stack, counts method frames, and raises RecursionLimitExceeded
|
|
73
|
+
# if depth exceeds threshold. Blocks count as method frames.
|
|
74
|
+
# Does nothing if monitor is disabled.
|
|
75
|
+
#
|
|
76
|
+
# @return [nil]
|
|
77
|
+
# @raise [Anzen::RecursionLimitExceeded] if depth exceeds threshold
|
|
78
|
+
# @raise [Anzen::CheckFailedError] if check infrastructure fails
|
|
79
|
+
def check!
|
|
80
|
+
return nil unless @enabled
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
current_depth = calculate_depth
|
|
84
|
+
@last_check = Time.now
|
|
85
|
+
|
|
86
|
+
if current_depth > @depth_limit
|
|
87
|
+
@violation_count += 1
|
|
88
|
+
raise Anzen::RecursionLimitExceeded.new(current_depth, @depth_limit)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
rescue Anzen::RecursionLimitExceeded
|
|
93
|
+
raise
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
raise Anzen::CheckFailedError.new(name, 'Failed to calculate call stack depth', e)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Return current status
|
|
100
|
+
#
|
|
101
|
+
# @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
|
|
102
|
+
def status
|
|
103
|
+
{
|
|
104
|
+
name: name,
|
|
105
|
+
enabled: @enabled,
|
|
106
|
+
thresholds: {
|
|
107
|
+
depth_limit: @depth_limit
|
|
108
|
+
},
|
|
109
|
+
last_check: @last_check,
|
|
110
|
+
violations: @violation_count
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Return human-readable one-liner for CLI
|
|
115
|
+
#
|
|
116
|
+
# @return [String]
|
|
117
|
+
def to_cli
|
|
118
|
+
status_text = @enabled ? 'enabled' : 'disabled'
|
|
119
|
+
"Call stack depth monitor (#{status_text}): limit=#{@depth_limit}, violations=#{@violation_count}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Calculate current call stack depth
|
|
125
|
+
#
|
|
126
|
+
# Counts method frames in the call stack.
|
|
127
|
+
# Uses Kernel.caller to get the call stack.
|
|
128
|
+
#
|
|
129
|
+
# @return [Integer] current depth
|
|
130
|
+
def calculate_depth
|
|
131
|
+
# Kernel.caller returns array of strings like "path/file.rb:123:in `method_name'"
|
|
132
|
+
# Each frame represents a method call (including blocks)
|
|
133
|
+
caller.length
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Validate depth_limit configuration
|
|
137
|
+
#
|
|
138
|
+
# @param depth_limit [Integer, Numeric]
|
|
139
|
+
# @raise [Anzen::ConfigurationError] if invalid
|
|
140
|
+
def validate_depth_limit(depth_limit)
|
|
141
|
+
return if depth_limit.is_a?(Numeric) && depth_limit > 0 && depth_limit == depth_limit.to_i
|
|
142
|
+
|
|
143
|
+
raise Anzen::ConfigurationError, "depth_limit must be a positive integer, got #{depth_limit.inspect}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anzen
|
|
4
|
+
module Monitors
|
|
5
|
+
# Monitor that detects memory consumption exceeding configurable threshold
|
|
6
|
+
#
|
|
7
|
+
# Monitors process RSS (Resident Set Size) memory usage with configurable sampling
|
|
8
|
+
# to minimize overhead. Supports absolute MB limits or percentage of available memory.
|
|
9
|
+
# Raises MemoryLimitExceeded immediately when threshold is exceeded.
|
|
10
|
+
#
|
|
11
|
+
# Use case: Prevent memory leaks and runaway memory consumption in long-running processes
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# monitor = Anzen::Monitors::MemoryMonitor.new(limit_mb: 512)
|
|
16
|
+
# monitor.enable
|
|
17
|
+
# monitor.check! # Raises MemoryLimitExceeded if > 512MB used
|
|
18
|
+
class MemoryMonitor
|
|
19
|
+
include Anzen::Monitor
|
|
20
|
+
|
|
21
|
+
# Default sampling interval in milliseconds
|
|
22
|
+
DEFAULT_SAMPLING_INTERVAL_MS = 100
|
|
23
|
+
|
|
24
|
+
# Default memory limit in MB
|
|
25
|
+
DEFAULT_LIMIT_MB = 512
|
|
26
|
+
|
|
27
|
+
# @return [String] monitor name
|
|
28
|
+
attr_reader :name
|
|
29
|
+
|
|
30
|
+
# @return [Integer] memory limit in MB
|
|
31
|
+
attr_reader :limit_mb
|
|
32
|
+
|
|
33
|
+
# @return [Integer] sampling interval in milliseconds
|
|
34
|
+
attr_reader :sampling_interval_ms
|
|
35
|
+
|
|
36
|
+
# @return [Time, nil] timestamp of last check
|
|
37
|
+
attr_reader :last_check_time
|
|
38
|
+
|
|
39
|
+
# @return [Float] current RSS memory in MB at last check
|
|
40
|
+
attr_reader :current_rss_mb
|
|
41
|
+
|
|
42
|
+
# @return [Integer] count of violations detected
|
|
43
|
+
attr_reader :violation_count
|
|
44
|
+
|
|
45
|
+
# Initialize MemoryMonitor
|
|
46
|
+
#
|
|
47
|
+
# @param limit_mb [Integer] memory limit in MB (default: 512)
|
|
48
|
+
# @param sampling_interval_ms [Integer] milliseconds between checks (default: 100)
|
|
49
|
+
def initialize(limit_mb: DEFAULT_LIMIT_MB, sampling_interval_ms: DEFAULT_SAMPLING_INTERVAL_MS)
|
|
50
|
+
@name = 'memory'
|
|
51
|
+
@enabled = false
|
|
52
|
+
@limit_mb = limit_mb
|
|
53
|
+
@sampling_interval_ms = sampling_interval_ms
|
|
54
|
+
@last_check_time = nil
|
|
55
|
+
@current_rss_mb = 0.0
|
|
56
|
+
@violation_count = 0
|
|
57
|
+
|
|
58
|
+
validate_configuration!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Enable this monitor
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true
|
|
64
|
+
def enable
|
|
65
|
+
@enabled = true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Disable this monitor
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] false
|
|
71
|
+
def disable
|
|
72
|
+
@enabled = false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if monitor is enabled
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def enabled?
|
|
79
|
+
@enabled
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check for memory limit violation
|
|
83
|
+
#
|
|
84
|
+
# Reads current process RSS memory and compares to configured limit.
|
|
85
|
+
# Only performs check if sampling interval has elapsed since last check.
|
|
86
|
+
# Raises MemoryLimitExceeded if memory usage exceeds threshold.
|
|
87
|
+
# Does nothing if monitor is disabled.
|
|
88
|
+
#
|
|
89
|
+
# @return [nil]
|
|
90
|
+
# @raise [Anzen::MemoryLimitExceeded] if memory usage exceeds threshold
|
|
91
|
+
# @raise [Anzen::CheckFailedError] if memory reading fails
|
|
92
|
+
def check!
|
|
93
|
+
return nil unless @enabled
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
if should_check?
|
|
97
|
+
@current_rss_mb = read_process_memory_mb
|
|
98
|
+
@last_check_time = Time.now
|
|
99
|
+
|
|
100
|
+
if @current_rss_mb > @limit_mb
|
|
101
|
+
@violation_count += 1
|
|
102
|
+
raise Anzen::MemoryLimitExceeded.new(@current_rss_mb, @limit_mb)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
rescue Anzen::MemoryLimitExceeded
|
|
108
|
+
raise
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
raise Anzen::CheckFailedError.new(name, 'Failed to read process memory', e)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Return current status
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
|
|
117
|
+
def status
|
|
118
|
+
{
|
|
119
|
+
name: name,
|
|
120
|
+
enabled: @enabled,
|
|
121
|
+
thresholds: {
|
|
122
|
+
limit_mb: @limit_mb,
|
|
123
|
+
sampling_interval_ms: @sampling_interval_ms
|
|
124
|
+
},
|
|
125
|
+
last_check: @last_check_time,
|
|
126
|
+
violations: @violation_count
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Return human-readable one-liner for CLI
|
|
131
|
+
#
|
|
132
|
+
# @return [String]
|
|
133
|
+
def to_cli
|
|
134
|
+
status_text = @enabled ? 'enabled' : 'disabled'
|
|
135
|
+
"Memory monitor (#{status_text}): limit=#{@limit_mb}MB, current=#{@current_rss_mb.round(1)}MB, violations=#{@violation_count}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Validate configuration parameters
|
|
141
|
+
#
|
|
142
|
+
# @raise [Anzen::ConfigurationError] if configuration is invalid
|
|
143
|
+
def validate_configuration!
|
|
144
|
+
unless @limit_mb.is_a?(Integer) && @limit_mb > 0
|
|
145
|
+
raise Anzen::ConfigurationError, "limit_mb must be a positive integer, got: #{@limit_mb.inspect}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return if @sampling_interval_ms.is_a?(Integer) && @sampling_interval_ms >= 0
|
|
149
|
+
|
|
150
|
+
raise Anzen::ConfigurationError,
|
|
151
|
+
"sampling_interval_ms must be a non-negative integer, got: #{@sampling_interval_ms.inspect}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check if enough time has elapsed since last check
|
|
155
|
+
#
|
|
156
|
+
# @return [Boolean] true if should perform check
|
|
157
|
+
def should_check?
|
|
158
|
+
return true if @last_check_time.nil?
|
|
159
|
+
|
|
160
|
+
elapsed_ms = (Time.now - @last_check_time) * 1000
|
|
161
|
+
elapsed_ms >= @sampling_interval_ms
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Read current process memory usage in MB
|
|
165
|
+
#
|
|
166
|
+
# Attempts to read RSS from /proc/[pid]/status (Linux) or falls back to
|
|
167
|
+
# parsing `ps` command output for cross-platform compatibility.
|
|
168
|
+
#
|
|
169
|
+
# @return [Float] memory usage in MB
|
|
170
|
+
# @raise [StandardError] if memory reading fails
|
|
171
|
+
def read_process_memory_mb
|
|
172
|
+
pid = Process.pid
|
|
173
|
+
|
|
174
|
+
# Try Linux /proc filesystem first (most efficient)
|
|
175
|
+
return read_memory_from_proc(pid) if File.exist?("/proc/#{pid}/status")
|
|
176
|
+
|
|
177
|
+
# Fallback to ps command (cross-platform)
|
|
178
|
+
read_memory_from_ps(pid)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Read memory from Linux /proc/[pid]/status
|
|
182
|
+
#
|
|
183
|
+
# @param pid [Integer] process ID
|
|
184
|
+
# @return [Float] memory in MB
|
|
185
|
+
def read_memory_from_proc(pid)
|
|
186
|
+
status_file = "/proc/#{pid}/status"
|
|
187
|
+
content = File.read(status_file)
|
|
188
|
+
|
|
189
|
+
# Find VmRSS line: "VmRSS: 12345 kB"
|
|
190
|
+
vmrss_match = content.match(/^VmRSS:\s+(\d+)\s+kB/)
|
|
191
|
+
raise "Could not find VmRSS in #{status_file}" unless vmrss_match
|
|
192
|
+
|
|
193
|
+
kb = vmrss_match[1].to_i
|
|
194
|
+
kb / 1024.0 # Convert to MB
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Read memory using ps command (fallback for non-Linux systems)
|
|
198
|
+
#
|
|
199
|
+
# @param pid [Integer] process ID
|
|
200
|
+
# @return [Float] memory in MB
|
|
201
|
+
def read_memory_from_ps(pid)
|
|
202
|
+
# Use ps to get RSS in KB, then convert to MB
|
|
203
|
+
# Format: "PID RSS" where RSS is in KB
|
|
204
|
+
output, status = run_ps_command(pid)
|
|
205
|
+
raise "ps command failed: #{status.exitstatus}" unless status.success?
|
|
206
|
+
|
|
207
|
+
lines = output.strip.split("\n")
|
|
208
|
+
raise "ps output incomplete for PID #{pid}" if lines.length < 2
|
|
209
|
+
|
|
210
|
+
# Second line contains the data
|
|
211
|
+
fields = lines[1].strip.split
|
|
212
|
+
raise "ps output format unexpected: #{lines[1]}" if fields.length < 2
|
|
213
|
+
|
|
214
|
+
rss_kb = fields[1].to_i
|
|
215
|
+
rss_kb / 1024.0 # Convert to MB
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Execute ps command (extracted for testability)
|
|
219
|
+
#
|
|
220
|
+
# @param pid [Integer] process ID
|
|
221
|
+
# @return [Array<String, Process::Status>] output and status
|
|
222
|
+
def run_ps_command(pid)
|
|
223
|
+
output = `ps -o pid,rss -p #{pid} 2>/dev/null`
|
|
224
|
+
[output, $?]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anzen
|
|
4
|
+
module Monitors
|
|
5
|
+
# Monitor that detects any recursive method calls (pattern-based)
|
|
6
|
+
#
|
|
7
|
+
# Detects direct recursion (same method repeating) and indirect recursion
|
|
8
|
+
# (method chains forming cycles). Raises RecursionLimitExceeded immediately
|
|
9
|
+
# on first recursion detection, with no depth threshold.
|
|
10
|
+
#
|
|
11
|
+
# Use case: Strict no-recursion enforcement (e.g., signal handlers, async contexts)
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# monitor = Anzen::Monitors::RecursionMonitor.new
|
|
16
|
+
# monitor.enable
|
|
17
|
+
# monitor.check! # Raises RecursionLimitExceeded if any recursion detected
|
|
18
|
+
class RecursionMonitor
|
|
19
|
+
include Anzen::Monitor
|
|
20
|
+
|
|
21
|
+
# Thread-local key for storing call frame tracking
|
|
22
|
+
FRAME_KEY = :anzen_recursion_frames
|
|
23
|
+
|
|
24
|
+
# @return [Integer] count of violations detected
|
|
25
|
+
attr_reader :violation_count
|
|
26
|
+
|
|
27
|
+
# Initialize RecursionMonitor
|
|
28
|
+
#
|
|
29
|
+
# @param depth_limit [Integer] maximum allowed recursion depth (default: 1000)
|
|
30
|
+
def initialize(depth_limit: 1000)
|
|
31
|
+
@enabled = false
|
|
32
|
+
@violation_count = 0
|
|
33
|
+
@depth_limit = depth_limit
|
|
34
|
+
@last_check = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Monitor name
|
|
38
|
+
#
|
|
39
|
+
# @return [String] "recursion"
|
|
40
|
+
def name
|
|
41
|
+
'recursion'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Enable this monitor
|
|
45
|
+
#
|
|
46
|
+
# @return [Boolean] true
|
|
47
|
+
def enable
|
|
48
|
+
@enabled = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Disable this monitor
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean] false
|
|
54
|
+
def disable
|
|
55
|
+
@enabled = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if monitor is enabled
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def enabled?
|
|
62
|
+
@enabled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check for recursion pattern
|
|
66
|
+
#
|
|
67
|
+
# Analyzes the current call stack to detect if any method appears
|
|
68
|
+
# multiple times (direct recursion) or if there's a cycle in the call chain
|
|
69
|
+
# (indirect recursion). Raises RecursionLimitExceeded if recursion is
|
|
70
|
+
# detected and the call stack depth exceeds the configured limit.
|
|
71
|
+
#
|
|
72
|
+
# @return [nil]
|
|
73
|
+
# @raise [Anzen::RecursionLimitExceeded] if recursion detected and depth exceeds limit
|
|
74
|
+
# @raise [Anzen::CheckFailedError] if check infrastructure fails
|
|
75
|
+
def check!
|
|
76
|
+
return nil unless @enabled
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
current_depth = caller.length
|
|
80
|
+
@last_check = Time.now
|
|
81
|
+
|
|
82
|
+
if recursion_detected? && current_depth > @depth_limit
|
|
83
|
+
@violation_count += 1
|
|
84
|
+
raise Anzen::RecursionLimitExceeded.new(current_depth, @depth_limit)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
rescue Anzen::RecursionLimitExceeded
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
raise Anzen::CheckFailedError.new(name, 'Failed to detect recursion pattern', e)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Return current status
|
|
96
|
+
#
|
|
97
|
+
# @return [Hash] status hash with keys: name, enabled, thresholds, last_check, violations
|
|
98
|
+
def status
|
|
99
|
+
{
|
|
100
|
+
name: name,
|
|
101
|
+
enabled: @enabled,
|
|
102
|
+
thresholds: {
|
|
103
|
+
depth_limit: @depth_limit
|
|
104
|
+
},
|
|
105
|
+
last_check: @last_check,
|
|
106
|
+
violations: @violation_count
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Return human-readable one-liner for CLI
|
|
111
|
+
#
|
|
112
|
+
# @return [String]
|
|
113
|
+
def to_cli
|
|
114
|
+
status_text = @enabled ? 'enabled' : 'disabled'
|
|
115
|
+
"Recursion monitor (#{status_text}): violations=#{@violation_count}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Detect if current call stack has recursion pattern
|
|
121
|
+
#
|
|
122
|
+
# Uses call context (file:line:method) for application frames to avoid
|
|
123
|
+
# false positives from framework internals while still detecting cycles.
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean] true if recursion detected
|
|
126
|
+
def recursion_detected?
|
|
127
|
+
contexts = extract_call_contexts
|
|
128
|
+
seen = Set.new
|
|
129
|
+
contexts.each do |ctx|
|
|
130
|
+
return true if seen.include?(ctx)
|
|
131
|
+
|
|
132
|
+
seen.add(ctx)
|
|
133
|
+
end
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extract call contexts (file:line:method) from the filtered call stack
|
|
138
|
+
#
|
|
139
|
+
# Example: "path/file.rb:123:in `method'" => "path/file.rb:123:method"
|
|
140
|
+
#
|
|
141
|
+
# @return [Array<String>]
|
|
142
|
+
def extract_call_contexts
|
|
143
|
+
caller
|
|
144
|
+
.reject { |frame| should_skip_frame?(frame) }
|
|
145
|
+
.map { |frame| extract_call_context(frame) }
|
|
146
|
+
.compact
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Determine if a caller frame should be skipped
|
|
150
|
+
#
|
|
151
|
+
# Skips frames from:
|
|
152
|
+
# - Standard library (stdlib paths)
|
|
153
|
+
# - RSpec and testing framework core
|
|
154
|
+
# - Ruby's internal code
|
|
155
|
+
# - Gems/vendor directories (except application code)
|
|
156
|
+
#
|
|
157
|
+
# Does NOT skip application code in spec/test/integration directories.
|
|
158
|
+
#
|
|
159
|
+
# @param frame [String] caller frame string
|
|
160
|
+
# @return [Boolean] true if frame should be skipped
|
|
161
|
+
def should_skip_frame?(frame)
|
|
162
|
+
# Skip only specific framework/library patterns, not application test code
|
|
163
|
+
skip_patterns = [
|
|
164
|
+
%r{/gems/.*\.rb:}, # Bundler gems
|
|
165
|
+
%r{\.bundle/}, # Bundler paths
|
|
166
|
+
%r{/lib/ruby/\d+\.\d+}, # Standard library
|
|
167
|
+
%r{rspec.*gem.*/lib/}, # RSpec gem code (not test files)
|
|
168
|
+
/<internal:/, # Ruby internals
|
|
169
|
+
/method_missing/ # Dynamic method dispatch
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
skip_patterns.any? { |pattern| frame.match?(pattern) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Extract file:line:method context from a caller frame
|
|
176
|
+
def extract_call_context(frame)
|
|
177
|
+
match = frame.match(/^([^:]+:\d+):in `([^']+)'/)
|
|
178
|
+
return nil unless match
|
|
179
|
+
|
|
180
|
+
file_line = match[1]
|
|
181
|
+
method = match[2]
|
|
182
|
+
"#{file_line}:#{method}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get current call stack depth for error reporting
|
|
186
|
+
#
|
|
187
|
+
# @return [Integer]
|
|
188
|
+
def current_depth
|
|
189
|
+
caller.length
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|