hyperprobe-agent 1.2.27.pre.3-java

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.
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HyperProbe
4
+ module Core
5
+ class TokenBucket
6
+ attr_reader :capacity, :tokens
7
+
8
+ def initialize(capacity, refill_rate_per_sec)
9
+ @capacity = capacity.to_f
10
+ @refill_rate = refill_rate_per_sec.to_f / 1000.0 # tokens per millisecond
11
+ @tokens = @capacity
12
+ @last_refill = monotonic_ms
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def try_consume(count = 1)
17
+ @mutex.synchronize do
18
+ refill
19
+ if @tokens >= count
20
+ @tokens -= count
21
+ true
22
+ else
23
+ false
24
+ end
25
+ end
26
+ end
27
+
28
+ def reserve(count)
29
+ @mutex.synchronize do
30
+ refill
31
+ if @tokens >= count
32
+ @tokens -= count
33
+ BandwidthReservation.new(self, count)
34
+ else
35
+ nil
36
+ end
37
+ end
38
+ end
39
+
40
+ def release_tokens(count)
41
+ @mutex.synchronize do
42
+ @tokens = [@capacity, @tokens + count].min
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def refill
49
+ now = monotonic_ms
50
+ delta = now - @last_refill
51
+ amount = delta * @refill_rate
52
+ @tokens = [@capacity, @tokens + amount].min
53
+ @last_refill = now
54
+ end
55
+
56
+ def monotonic_ms
57
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
58
+ end
59
+ end
60
+
61
+ class BandwidthReservation
62
+ attr_reader :tokens
63
+
64
+ def initialize(bucket, tokens)
65
+ @bucket = bucket
66
+ @tokens = tokens
67
+ @committed = false
68
+ @released = false
69
+ @mutex = Mutex.new
70
+ end
71
+
72
+ def commit
73
+ @mutex.synchronize do
74
+ @committed = true
75
+ end
76
+ end
77
+
78
+ def release
79
+ @mutex.synchronize do
80
+ return if @committed || @released
81
+
82
+ @released = true
83
+ @bucket.release_tokens(@tokens)
84
+ end
85
+ end
86
+ end
87
+
88
+ class QuotaManager
89
+ def initialize(hits_per_sec = 10, bytes_per_sec = 200 * 1024)
90
+ @eval_bucket = TokenBucket.new(hits_per_sec, hits_per_sec)
91
+ @bandwidth_bucket = TokenBucket.new(bytes_per_sec, bytes_per_sec)
92
+ end
93
+
94
+ def can_evaluate?
95
+ @eval_bucket.try_consume(1)
96
+ end
97
+
98
+ alias can_evaluate can_evaluate?
99
+
100
+ def can_send?(bytes)
101
+ @bandwidth_bucket.try_consume(bytes)
102
+ end
103
+
104
+ alias can_send can_send?
105
+
106
+ def reserve_bandwidth(bytes)
107
+ @bandwidth_bucket.reserve(bytes)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ module HyperProbe
6
+ module Core
7
+ # This is deliberately a small expression language, not read-only Ruby.
8
+ # Compile the entire tree before executing even a short-circuited branch.
9
+ class SafeASTValidator
10
+ MAX_EXPRESSION_LENGTH = 256
11
+ MAX_NODES = 128
12
+ MAX_DEPTH = 32
13
+ BINARY_OPERATORS = %i[+ - * / % == != < <= > >= && || and or].freeze
14
+ UNARY_OPERATORS = %i[+@ -@ ! not].freeze
15
+ CLASS = Object.instance_method(:class)
16
+ IDENTITY = BasicObject.instance_method(:equal?)
17
+ STRING_SIZE = String.instance_method(:bytesize)
18
+ STRING_SLICE = String.instance_method(:byteslice)
19
+
20
+ class << self
21
+ def validate!(expression)
22
+ parse!(expression)
23
+ nil
24
+ end
25
+
26
+ # Copy through a builtin, never dup/clone/to_str hooks on application data.
27
+ def source(expression, limit = MAX_EXPRESSION_LENGTH)
28
+ unless IDENTITY.bind(CLASS.bind(expression).call).call(String)
29
+ raise SecurityError, 'Expression must be an exact builtin String'
30
+ end
31
+ if STRING_SIZE.bind(expression).call > limit
32
+ raise SecurityError, "Expression is too long (max #{limit} bytes)"
33
+ end
34
+
35
+ copy = STRING_SLICE.bind(expression).call(0, limit + 1)
36
+ raise SecurityError, "Expression is too long (max #{limit} bytes)" if copy.bytesize > limit
37
+ copy
38
+ end
39
+
40
+ def parse!(expression)
41
+ return [:literal, nil] if IDENTITY.bind(expression).call(nil)
42
+
43
+ text = source(expression)
44
+ return [:literal, nil] if text.strip.empty?
45
+
46
+ tree = Ripper.sexp(text)
47
+ raise ArgumentError, 'Invalid expression syntax' unless tree
48
+
49
+ # Only ordinary quoted strings are supported. Decode their contents
50
+ # ourselves; Ripper returns escaped source, not evaluated strings.
51
+ strings = {}
52
+ quote = nil
53
+ Ripper.lex(text).each do |position, type, token, _state|
54
+ case type
55
+ when :on_tstring_beg
56
+ reject!('string syntax') unless ["'", '"'].include?(token)
57
+ quote = token
58
+ when :on_tstring_content
59
+ strings[position] = decode_string(token, quote)
60
+ when :on_tstring_end
61
+ quote = nil
62
+ when :on_embexpr_beg, :on_embvar, :on_heredoc_beg
63
+ reject!('string interpolation or heredoc')
64
+ end
65
+ end
66
+
67
+ compile(tree, strings, [0], 0)
68
+ end
69
+
70
+ # Kept for callers that used to clear the lexical validator's cache.
71
+ # Parsing is bounded and no user-controlled expressions are retained.
72
+ def reset_cache!; end
73
+
74
+ private
75
+
76
+ def reject!(feature)
77
+ raise SecurityError, "Unsupported #{feature}: forbidden in probe expressions"
78
+ end
79
+
80
+ def decode_string(text, quote)
81
+ reject!('string syntax') unless quote
82
+ text.gsub(/\\(.)/m) do
83
+ char = Regexp.last_match(1)
84
+ if quote == "'"
85
+ ["'", '\\'].include?(char) ? char : "\\#{char}"
86
+ else
87
+ escapes = { 'n' => "\n", 'r' => "\r", 't' => "\t", '\\' => '\\', '"' => '"', "'" => "'", '#' => '#' }
88
+ reject!('string escape') unless escapes.key?(char)
89
+ escapes[char]
90
+ end
91
+ end
92
+ end
93
+
94
+ def compile(node, strings, budget, depth)
95
+ budget[0] += 1
96
+ reject!('expression complexity') if budget[0] > MAX_NODES || depth > MAX_DEPTH
97
+ child = ->(value) { compile(value, strings, budget, depth + 1) }
98
+
99
+ case node[0]
100
+ when :program, :paren
101
+ reject!('multiple statements') unless node[1]&.length == 1
102
+ child.call(node[1][0])
103
+ when :vcall, :var_ref
104
+ token = node[1]
105
+ if token[0] == :@ident
106
+ [:local, token[1]]
107
+ elsif token[0] == :@kw && %w[true false nil].include?(token[1])
108
+ [:literal, { 'true' => true, 'false' => false, 'nil' => nil }[token[1]]]
109
+ else
110
+ reject!('variable or constant access')
111
+ end
112
+ when :@int
113
+ [:literal, Integer(node[1])]
114
+ when :@float
115
+ [:literal, Float(node[1])]
116
+ when :string_literal
117
+ content = node[1]
118
+ reject!('string syntax') unless content[0] == :string_content
119
+ value = content.drop(1).map do |part|
120
+ reject!('string interpolation') unless part[0] == :@tstring_content
121
+ strings.fetch(part[2])
122
+ end.join
123
+ [:literal, value]
124
+ when :symbol_literal
125
+ symbol = node[1]
126
+ reject!('symbol syntax') unless symbol[0] == :symbol && %i[@ident @op @kw].include?(symbol[1][0])
127
+ [:literal, symbol[1][1].to_sym]
128
+ when :@label
129
+ [:literal, node[1][0...-1].to_sym]
130
+ when :array
131
+ [:array, (node[1] || []).map { |item| child.call(item) }]
132
+ when :hash
133
+ pairs = node[1]
134
+ reject!('hash syntax') if pairs && pairs[0] != :assoclist_from_args
135
+ [:hash, (pairs ? pairs[1] : []).map do |pair|
136
+ reject!('hash expansion') unless pair[0] == :assoc_new
137
+ [child.call(pair[1]), child.call(pair[2])]
138
+ end]
139
+ when :aref
140
+ args = node[2]
141
+ unless args && args[0] == :args_add_block && args[2] == false && args[1].length == 1
142
+ reject!('subscript arguments')
143
+ end
144
+ [:lookup, child.call(node[1]), child.call(args[1][0])]
145
+ when :call
146
+ unless (node[2][0] == :@period || node[2] == :'.') && node[3][0] == :@ident && %w[length size].include?(node[3][1])
147
+ reject!('method call')
148
+ end
149
+ [:length, child.call(node[1])]
150
+ when :method_add_arg
151
+ unless node[1][0] == :call && node[2] == [:arg_paren, nil]
152
+ reject!('method arguments or function call')
153
+ end
154
+ child.call(node[1])
155
+ when :binary
156
+ reject!('operator') unless BINARY_OPERATORS.include?(node[2])
157
+ [:binary, node[2], child.call(node[1]), child.call(node[3])]
158
+ when :unary
159
+ reject!('unary operator') unless UNARY_OPERATORS.include?(node[1])
160
+ [:unary, node[1], child.call(node[2])]
161
+ when :ifop
162
+ [:conditional, child.call(node[1]), child.call(node[2]), child.call(node[3])]
163
+ else
164
+ reject!("syntax #{node[0]}")
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'java' if RUBY_ENGINE == 'jruby'
5
+
6
+ module HyperProbe
7
+ module Core
8
+ module AgentHealth
9
+ GREEN = :GREEN
10
+ YELLOW = :YELLOW
11
+ RED = :RED
12
+ end
13
+
14
+ class SafetyMonitor
15
+ SAMPLE_INTERVAL_SECONDS = 0.1
16
+ LAG_WINDOW_SIZE = 10
17
+ LAG_YELLOW_BREACHES = 4
18
+ LAG_RED_BREACHES = 7
19
+ SEVERE_LAG_WINDOW_SIZE = 5
20
+ SEVERE_LAG_RED_BREACHES = 3
21
+ SEVERE_LAG_MULTIPLIER = 4.0
22
+ HEAP_SAMPLE_INTERVAL_MS = 5000.0
23
+ HEAP_YELLOW_FRACTION = 0.15
24
+ HEAP_RED_FRACTION = 0.05
25
+
26
+ attr_reader :health, :last_thread_lag_ms, :cumulative_pause_time, :max_lag_ms, :pause_budget_ms
27
+
28
+ def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false,
29
+ heap_runtime: (RUBY_ENGINE == 'jruby' ? Java::JavaLang::Runtime.get_runtime : nil))
30
+ @on_state_change = on_state_change
31
+ @max_lag_ms = max_lag_ms.to_f
32
+ @pause_budget_ms = pause_budget_ms.to_f
33
+ @is_ephemeral_lambda = is_ephemeral_lambda
34
+ @heap_runtime = heap_runtime
35
+ @heap_available_fraction = 1.0
36
+
37
+ @health = AgentHealth::GREEN
38
+ @last_heartbeat = monotonic_ms
39
+ @last_thread_lag_ms = 0.0
40
+ @thread_lag_samples = []
41
+ @cumulative_pause_time = 0.0
42
+ @last_window_reset = monotonic_ms
43
+
44
+ @mutex = Mutex.new
45
+ @notification_mutex = Monitor.new
46
+ @transition_generation = 0
47
+ @running = false
48
+ @thread = nil
49
+ end
50
+
51
+ def start
52
+ @mutex.synchronize do
53
+ return if @running && @thread&.alive?
54
+
55
+ @running = true
56
+ @last_heartbeat = monotonic_ms
57
+ @last_window_reset = monotonic_ms
58
+ @last_heap_sample = @last_window_reset
59
+
60
+ @thread = Thread.new { monitor_loop }
61
+ @thread.name = 'hyperprobe-safety' if @thread.respond_to?(:name=)
62
+ end
63
+ end
64
+
65
+ def after_fork
66
+ @mutex.synchronize do
67
+ @running = false
68
+ @thread = nil
69
+ end
70
+ start
71
+ end
72
+
73
+ def stop
74
+ thread = @mutex.synchronize do
75
+ @running = false
76
+ current = @thread
77
+ @thread = nil
78
+ current
79
+ end
80
+ return if !thread || thread == Thread.current
81
+
82
+ thread.kill
83
+ thread.join(0.1)
84
+ rescue Exception
85
+ # Stop is agent-owned cleanup, including when called from a trap.
86
+ end
87
+
88
+ def report_pause_duration(ms)
89
+ callback_info = nil
90
+ return unless @mutex.try_lock
91
+ begin
92
+ @cumulative_pause_time += ms.to_f
93
+ callback_info = evaluate_health_locked
94
+ # A live monitor delivers transitions off the application thread.
95
+ @pending_notification = callback_info if @running && callback_info
96
+ ensure
97
+ @mutex.unlock
98
+ end
99
+ trigger_callback(callback_info) if callback_info && !@running
100
+ end
101
+
102
+ def record_thread_lag(thread_lag_ms)
103
+ callback_info = nil
104
+ @mutex.synchronize do
105
+ record_thread_lag_locked(thread_lag_ms)
106
+ callback_info = evaluate_health_locked
107
+ end
108
+ trigger_callback(callback_info) if callback_info
109
+ end
110
+
111
+ def get_health
112
+ @mutex.synchronize { @health }
113
+ end
114
+
115
+ def with_health(expected)
116
+ @notification_mutex.synchronize do
117
+ return unless @mutex.synchronize { @health == expected }
118
+
119
+ yield
120
+ end
121
+ rescue Exception
122
+ # Serialize cooldown actions with transition delivery, not state updates.
123
+ end
124
+
125
+ private
126
+
127
+ def monitor_loop
128
+ while @running
129
+ wait_started = monotonic_ms
130
+ sleep SAMPLE_INTERVAL_SECONDS
131
+ break unless @running
132
+
133
+ callback_info = nil
134
+ @mutex.synchronize do
135
+ now = monotonic_ms
136
+ # Expected interval is 100ms; excess time is lag/jitter
137
+ thread_lag_ms = [0.0, now - wait_started - (SAMPLE_INTERVAL_SECONDS * 1000.0)].max
138
+ record_thread_lag_locked(thread_lag_ms)
139
+
140
+ if @heap_runtime && !@is_ephemeral_lambda && now - @last_heap_sample >= HEAP_SAMPLE_INTERVAL_MS
141
+ sample_heap_locked
142
+ @last_heap_sample = now
143
+ end
144
+
145
+ # Reset pause budget window every second
146
+ if now - @last_window_reset > 1000.0
147
+ @cumulative_pause_time = 0.0
148
+ @last_window_reset = now
149
+ end
150
+
151
+ callback_info = evaluate_health_locked || @pending_notification
152
+ @pending_notification = nil
153
+ end
154
+
155
+ trigger_callback(callback_info) if callback_info
156
+ end
157
+ rescue Exception
158
+ @running = false
159
+ end
160
+
161
+ def record_thread_lag_locked(lag_ms)
162
+ clean_lag = [0.0, lag_ms.to_f].max
163
+ @last_thread_lag_ms = clean_lag
164
+ @thread_lag_samples << clean_lag
165
+ @thread_lag_samples.shift while @thread_lag_samples.length > LAG_WINDOW_SIZE
166
+ end
167
+
168
+ def sample_heap_locked
169
+ maximum = @heap_runtime.max_memory.to_f
170
+ return unless maximum.positive?
171
+
172
+ committed = @heap_runtime.total_memory
173
+ free = @heap_runtime.free_memory
174
+ @heap_available_fraction = (free + maximum - committed) / maximum
175
+ rescue Exception
176
+ # Preserve the last heap health and retry at the next sample.
177
+ end
178
+
179
+ def evaluate_health_locked
180
+ prev_health = @health
181
+ reason = nil
182
+
183
+ if @is_ephemeral_lambda
184
+ @health = AgentHealth::GREEN
185
+ return nil if prev_health == AgentHealth::GREEN
186
+
187
+ @transition_generation += 1
188
+ return [@health, 'System stabilized in Lambda mode.', @transition_generation]
189
+ end
190
+
191
+ recent_lags = @thread_lag_samples.dup
192
+ lag_breaches = recent_lags.count { |lag| lag > @max_lag_ms }
193
+ severe_lag_ms = @max_lag_ms * SEVERE_LAG_MULTIPLIER
194
+ severe_window = recent_lags.last(SEVERE_LAG_WINDOW_SIZE)
195
+ severe_lag_breaches = severe_window.count { |lag| lag > severe_lag_ms }
196
+
197
+ if @heap_runtime
198
+ if @heap_available_fraction < HEAP_RED_FRACTION
199
+ @health = AgentHealth::RED
200
+ reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 5% of maximum heap"
201
+ elsif @heap_available_fraction < HEAP_YELLOW_FRACTION
202
+ @health = AgentHealth::YELLOW
203
+ reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 15% of maximum heap"
204
+ else
205
+ @health = AgentHealth::GREEN
206
+ reason = 'JVM heap headroom stabilized.' if prev_health != AgentHealth::GREEN
207
+ end
208
+ elsif severe_lag_breaches >= SEVERE_LAG_RED_BREACHES
209
+ @health = AgentHealth::RED
210
+ reason = "Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
211
+ elsif lag_breaches >= LAG_RED_BREACHES
212
+ @health = AgentHealth::RED
213
+ reason = "Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
214
+ elsif @cumulative_pause_time > @pause_budget_ms
215
+ @health = AgentHealth::RED
216
+ reason = "Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) exceeded limit (#{@pause_budget_ms.round(1)}ms)"
217
+ elsif severe_lag_breaches.positive?
218
+ @health = AgentHealth::YELLOW
219
+ reason = "Moderate impact: Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
220
+ elsif lag_breaches >= LAG_YELLOW_BREACHES
221
+ @health = AgentHealth::YELLOW
222
+ reason = "Moderate impact: Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
223
+ elsif @cumulative_pause_time > (@pause_budget_ms / 2.0)
224
+ @health = AgentHealth::YELLOW
225
+ reason = "Moderate impact: Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) reached 50% of limit (#{@pause_budget_ms.round(1)}ms)"
226
+ else
227
+ @health = AgentHealth::GREEN
228
+ reason = 'System stabilized.' if prev_health != AgentHealth::GREEN
229
+ end
230
+
231
+ return unless @health != prev_health
232
+
233
+ @transition_generation += 1
234
+ [@health, reason, @transition_generation]
235
+ end
236
+
237
+ def trigger_callback(callback_info)
238
+ health, reason, generation = callback_info
239
+ @notification_mutex.synchronize do
240
+ # State transitions release @mutex before acquiring the delivery lock.
241
+ # Recheck here so a delayed notification cannot undo a newer one.
242
+ current = @mutex.synchronize { generation == @transition_generation && health == @health }
243
+ return unless current
244
+
245
+ @on_state_change&.call(health, reason)
246
+ end
247
+ rescue Exception
248
+ # Never crash application thread or safety monitor on callback error
249
+ end
250
+
251
+ def monotonic_ms
252
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
253
+ end
254
+ end
255
+ end
256
+ end