hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.3

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.
@@ -3,6 +3,7 @@
3
3
  require 'securerandom'
4
4
  require 'uri'
5
5
  require 'thread'
6
+ require 'monitor'
6
7
  require_relative 'version'
7
8
  require_relative 'core/quota'
8
9
  require_relative 'core/safety'
@@ -24,8 +25,17 @@ module HyperProbe
24
25
  @agent_id = SecureRandom.uuid
25
26
  @is_shutdown = false
26
27
  @is_agent_disabled = false
27
- @stop_event = ConditionVariable.new
28
28
  @mutex = Monitor.new # Reentrant mutex in Ruby
29
+ @stop_event = @mutex.new_cond
30
+ @sync_mutex = Mutex.new
31
+ @flush_mutex = Mutex.new
32
+ @shutdown_mutex = Mutex.new
33
+ @probe_generation = 0
34
+ @pending_probe_update = nil
35
+ @finished_probes = {}
36
+ @telemetry_queue = Queue.new
37
+ @active_probes = {}
38
+ @local_hits = {}
29
39
 
30
40
  @log = Core::Logger.get_logger('hyperprobe:agent')
31
41
  @log_broker = Core::Logger.get_logger('hyperprobe:broker')
@@ -34,6 +44,10 @@ module HyperProbe
34
44
 
35
45
  parse_configuration
36
46
 
47
+ if @global_config[:disable_safe_evaluation]
48
+ @log.warn 'Safe evaluation is DISABLED. Probe expressions can execute arbitrary Ruby code, mutate application state, or block execution.'
49
+ end
50
+
37
51
  @quota_manager = Core::QuotaManager.new(@hits_per_sec, @bandwidth_kb_per_sec * 1024)
38
52
  @safety_monitor = Core::SafetyMonitor.new(
39
53
  method(:handle_health_change),
@@ -53,10 +67,8 @@ module HyperProbe
53
67
  enable_keep_alive: @enable_keep_alive
54
68
  )
55
69
 
56
- @telemetry_queue = Queue.new
57
- @active_probes = {}
58
- @local_hits = {}
59
70
  @cooldown_timer = nil
71
+ @cooldown_until = nil
60
72
 
61
73
  @engine = Core::MonitoringEngine.new(
62
74
  @quota_manager,
@@ -69,16 +81,22 @@ module HyperProbe
69
81
  @sync_thread = nil
70
82
  @flush_thread = nil
71
83
  @stats_thread = nil
84
+ @apply_thread = Thread.new { apply_loop }
85
+ @apply_thread.name = 'hyperprobe-apply'
72
86
 
73
87
  if @is_ephemeral_lambda
74
88
  @log.debug 'Running in Ephemeral AWS Lambda Mode.'
75
89
  else
76
90
  @safety_monitor.start
77
91
  start_background_loops
78
- sync_with_broker
79
92
  end
80
93
 
81
94
  @log.info "Agent started for #{@service_id} in #{@environment} (v#{VERSION})"
95
+ rescue SignalException, SystemExit
96
+ shutdown
97
+ raise
98
+ rescue Exception # Agent-owned startup failures must not escape into the host.
99
+ shutdown
82
100
  end
83
101
 
84
102
  def agent_disabled?
@@ -86,65 +104,93 @@ module HyperProbe
86
104
  end
87
105
 
88
106
  def telemetry_queue_length
89
- @telemetry_queue.size
107
+ @telemetry_queue&.size || 0
90
108
  end
91
109
 
92
110
  def force_sync(timeout_sec = nil)
93
- return if @is_shutdown || @is_agent_disabled
111
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
94
112
 
95
113
  sync_with_broker(timeout_sec)
96
114
  end
97
115
 
98
116
  def force_flush(timeout_sec = nil)
99
- return if @is_shutdown || @is_agent_disabled
117
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
100
118
 
101
119
  flush_telemetry(timeout_sec)
102
120
  end
103
121
 
104
122
  def shutdown
105
- @mutex.synchronize do
106
- return if @is_shutdown
123
+ return after_fork if @owner_pid && @owner_pid != Process.pid
124
+ @shutdown_mutex ||= Mutex.new
125
+ return unless @shutdown_mutex.try_lock
107
126
 
108
- @is_shutdown = true
109
- end
127
+ shutdown_locked = true
128
+ return if @is_shutdown
110
129
 
111
- @safety_monitor&.stop
112
- @engine&.close
113
- @cooldown_timer&.exit if @cooldown_timer&.alive?
114
- @broker_client&.shutdown
130
+ @is_shutdown = true
131
+ @is_agent_disabled = true
132
+ threads = [@sync_thread, @flush_thread, @stats_thread, @apply_thread, @cooldown_timer].compact
133
+ threads.each { |thread| thread.kill unless thread == Thread.current }
115
134
 
116
- [@sync_thread, @flush_thread, @stats_thread].compact.each do |t|
117
- t.kill if t.alive? && t != Thread.current
118
- t.join(1.0) rescue nil
135
+ # Cleanup independently: a broken or stuck resource cannot prevent the rest.
136
+ cleanup = [[@engine, :close], [@safety_monitor, :stop], [@broker_client, :shutdown]].each_with_object([]) do |(resource, method), workers|
137
+ next unless resource
138
+
139
+ workers << Thread.new do
140
+ resource.public_send(method)
141
+ rescue Exception
142
+ # Only agent-owned cleanup runs in this thread.
143
+ end
144
+ end
145
+ cleanup << Thread.new do
146
+ @mutex.synchronize do
147
+ until @telemetry_queue.empty?
148
+ settle_reservation(@telemetry_queue.pop(true)[:reservation], :release)
149
+ end
150
+ end
151
+ rescue Exception
152
+ # Reservations belong only to the stopped agent.
153
+ end
154
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
155
+ (threads + cleanup).each do |thread|
156
+ next if thread == Thread.current
157
+
158
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ thread.join(remaining) if remaining.positive?
160
+ rescue SignalException, SystemExit
161
+ raise
162
+ rescue Exception
163
+ # Continue closing the other resources, including from a signal trap.
119
164
  end
165
+ cleanup.each do |thread|
166
+ thread.kill if thread.alive?
167
+ thread.join(0.01)
168
+ rescue SignalException, SystemExit
169
+ raise
170
+ rescue Exception
171
+ # Never wait indefinitely for a broken cleanup thread.
172
+ end
173
+ rescue SignalException, SystemExit
174
+ raise
175
+ rescue Exception
176
+ @is_shutdown = true
177
+ @is_agent_disabled = true
178
+ ensure
179
+ @shutdown_mutex.unlock if shutdown_locked
120
180
  end
121
181
 
122
182
  def after_fork
123
- return if defined?(JRUBY_VERSION)
124
-
125
- @mutex.synchronize do
126
- return if @is_shutdown
127
-
128
- @owner_pid = Process.pid
129
- @agent_id = SecureRandom.uuid
130
-
131
- # Reset queues and client for new process
132
- @telemetry_queue = Queue.new
133
- @broker_client = Core::BrokerClient.new(
134
- broker_url: @broker_url,
135
- service_id: @service_id,
136
- environment: @environment,
137
- commit_sha: @commit_sha,
138
- agent_id: @agent_id,
139
- agent_version: VERSION,
140
- rpc_timeout_sec: @rpc_timeout_sec,
141
- enable_keep_alive: @enable_keep_alive
142
- )
183
+ return if @owner_pid == Process.pid
143
184
 
144
- @safety_monitor.start
145
- start_background_loops
146
- sync_with_broker
147
- end
185
+ # Never call, close, or replace an inherited native gRPC client here.
186
+ @is_shutdown = true
187
+ @is_agent_disabled = true
188
+ @engine&.after_fork
189
+ false
190
+ rescue SignalException, SystemExit
191
+ raise
192
+ rescue Exception
193
+ false
148
194
  end
149
195
 
150
196
  private
@@ -155,8 +201,11 @@ module HyperProbe
155
201
  @broker_url = get_opt(:broker_url, :brokerUrl) || ENV['HYPERPROBE_BROKER_URL']
156
202
  @commit_sha = get_opt(:commit_sha, :commitSha) || ENV['GIT_COMMIT'] || ENV['HYPERPROBE_COMMIT_SHA']
157
203
 
158
- @sync_interval_sec = ((get_opt(:sync_interval_ms, :syncIntervalMs) || parse_env_int('HYPERPROBE_SYNC_INTERVAL_MS', 60_000)).to_f / 1000.0)
159
- @flush_interval_sec = ((get_opt(:flush_interval_ms, :flushIntervalMs) || parse_env_int('HYPERPROBE_FLUSH_INTERVAL_MS', 1000)).to_f / 1000.0)
204
+ raw_sync = get_opt(:sync_interval_ms, :syncIntervalMs) || parse_env_int('HYPERPROBE_SYNC_INTERVAL_MS', 60_000)
205
+ @sync_interval_sec = [raw_sync.to_f / 1000.0, 0.1].max
206
+
207
+ raw_flush = get_opt(:flush_interval_ms, :flushIntervalMs) || parse_env_int('HYPERPROBE_FLUSH_INTERVAL_MS', 1000)
208
+ @flush_interval_sec = [raw_flush.to_f / 1000.0, 0.05].max
160
209
  @max_queue_size = get_opt(:max_queue_size, :maxQueueSize) || parse_env_int('HYPERPROBE_MAX_QUEUE_SIZE', 100)
161
210
  @cooldown_sec = get_opt(:cooldown_sec, :cooldownSec) || parse_env_int('HYPERPROBE_COOLDOWN_SEC', 10)
162
211
 
@@ -168,7 +217,8 @@ module HyperProbe
168
217
 
169
218
  is_aws_lambda = !ENV['AWS_LAMBDA_FUNCTION_NAME'].nil?
170
219
  is_local_emulator = ENV['IS_OFFLINE'] == 'true' || ENV['AWS_SAM_LOCAL'] == 'true'
171
- @is_ephemeral_lambda = (get_opt(:is_lambda, :isLambda) || is_aws_lambda) && !is_local_emulator
220
+ lambda_opt = get_opt(:is_lambda, :isLambda)
221
+ @is_ephemeral_lambda = (lambda_opt.nil? ? is_aws_lambda : lambda_opt) && !is_local_emulator
172
222
  enable_keep_alive_opt = get_opt(:enable_keep_alive, :enableKeepAlive)
173
223
  @enable_keep_alive = enable_keep_alive_opt.nil? ? !@is_ephemeral_lambda : enable_keep_alive_opt
174
224
 
@@ -182,7 +232,9 @@ module HyperProbe
182
232
  max_array_length: get_opt(:max_array_length, :maxArrayLength) || parse_env_int('HYPERPROBE_MAX_ARRAY_LENGTH', 3),
183
233
  stack_frame_depth: get_opt(:stack_frame_depth, :stackFrameDepth) || parse_env_int('HYPERPROBE_STACK_FRAME_DEPTH', 3),
184
234
  max_object_properties: get_opt(:max_object_properties, :maxObjectProperties) || parse_env_int('HYPERPROBE_MAX_OBJECT_PROPERTIES', 50),
185
- max_string_length: get_opt(:max_string_length, :maxStringLength) || parse_env_int('HYPERPROBE_MAX_STRING_LENGTH', 1024)
235
+ max_string_length: get_opt(:max_string_length, :maxStringLength) || parse_env_int('HYPERPROBE_MAX_STRING_LENGTH', 1024),
236
+ capture_closures: get_opt(:capture_closures, :captureClosures) == true,
237
+ disable_safe_evaluation: !Core::Evaluator.safe_evaluation_enabled?(get_opt(:disable_safe_evaluation, :disableSafeEvaluation))
186
238
  }
187
239
  end
188
240
 
@@ -214,19 +266,21 @@ module HyperProbe
214
266
 
215
267
  def sync_loop
216
268
  until @is_shutdown
217
- sleep @sync_interval_sec
218
- break if @is_shutdown
219
-
220
269
  begin
221
270
  sync_with_broker
222
- rescue StandardError => e
271
+ sleep @sync_interval_sec
272
+ rescue Exception
223
273
  # Silently handle transient sync error
274
+ break
224
275
  end
225
276
  end
226
277
  end
227
278
 
228
279
  def sync_with_broker(timeout_sec = nil)
229
- return if @is_shutdown
280
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
281
+ return false unless @sync_mutex.try_lock
282
+
283
+ sync_locked = true
230
284
 
231
285
  @log_broker.debug "sync_with_broker started #{timeout_sec ? "with timeout #{timeout_sec}" : ''}"
232
286
  response = @broker_client.get_probes(timeout_sec)
@@ -234,181 +288,216 @@ module HyperProbe
234
288
 
235
289
  @log_broker.debug "sync_with_broker: got #{response.probes.length} probes from broker"
236
290
 
237
- # Dynamic global config updates from broker
238
- if response.global_config
239
- gc = response.global_config
240
- @mutex.synchronize do
241
- @global_config[:redact_keys] = gc.redact_keys.to_a unless gc.redact_keys.empty?
242
- @global_config[:redact_values] = gc.redact_values.to_a unless gc.redact_values.empty?
243
- @global_config[:max_object_depth] = gc.max_object_depth if gc.max_object_depth.positive?
244
- @global_config[:max_array_length] = gc.max_array_length if gc.max_array_length.positive?
245
- @global_config[:stack_frame_depth] = gc.stack_frame_depth if gc.stack_frame_depth.positive?
246
- @global_config[:max_object_properties] = gc.max_object_properties if gc.max_object_properties.positive?
247
- @global_config[:max_string_length] = gc.max_string_length if gc.max_string_length.positive?
248
- end
249
- @engine.set_global_config(@global_config)
250
- end
251
-
252
291
  server_probes = response.probes.to_a
253
292
  server_probe_ids = server_probes.map(&:id)
254
-
255
- to_apply = []
256
293
  now_ms = (Time.now.to_f * 1000).to_i
294
+ config = nil
295
+ snapshot = @mutex.synchronize do
296
+ return false if @is_shutdown
257
297
 
258
- @mutex.synchronize do
298
+ if response.global_config
299
+ gc = response.global_config
300
+ @global_config[:redact_keys] = gc.redact_keys.to_a unless gc.redact_keys.empty?
301
+ @global_config[:redact_values] = gc.redact_values.to_a unless gc.redact_values.empty?
302
+ %i[max_object_depth max_array_length stack_frame_depth max_object_properties max_string_length capture_closures].each do |field|
303
+ @global_config[field] = gc.public_send(field) if gc.public_send("has_#{field}?")
304
+ end
305
+ config = @global_config.dup
306
+ end
259
307
  # Clean stale local probes
260
308
  @active_probes.delete_if { |pid, _| !server_probe_ids.include?(pid) }
261
309
  @local_hits.delete_if { |pid, _| !server_probe_ids.include?(pid) }
310
+ @finished_probes.delete_if { |pid, _| !server_probe_ids.include?(pid) }
262
311
 
263
312
  server_probes.each do |probe|
264
313
  hits = @local_hits[probe.id] || 0
265
314
  is_expired = probe.expiry_time.positive? && probe.expiry_time <= now_ms
266
315
 
267
- if hits < probe.hit_limit && !is_expired
268
- to_apply << probe
316
+ if hits < probe.hit_limit && !is_expired && !@finished_probes[probe.id]
269
317
  @active_probes[probe.id] = probe
270
318
  else
271
319
  @active_probes.delete(probe.id)
272
320
  end
273
321
  end
322
+ probe_snapshot_locked
274
323
  end
275
324
 
276
- @log_broker.debug "Found #{to_apply.length} active probes to apply locally"
277
- @engine.set_probes(to_apply)
325
+ @engine.set_global_config(config) if config
326
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
278
327
  true
279
- rescue StandardError => e
280
- @log_broker.error("Failed to sync with broker: #{e.message}", e)
328
+ rescue SignalException, SystemExit
329
+ raise
330
+ rescue Exception
281
331
  false
332
+ ensure
333
+ @sync_mutex.unlock if sync_locked
282
334
  end
283
335
 
284
336
  def flush_loop
285
337
  until @is_shutdown
286
- sleep @flush_interval_sec
287
- break if @is_shutdown
288
-
289
338
  begin
339
+ sleep @flush_interval_sec
340
+ break if @is_shutdown
341
+
290
342
  flush_telemetry
291
- rescue StandardError => e
343
+ rescue Exception
292
344
  # Silently handle flush loop error
293
345
  end
294
346
  end
295
347
  end
296
348
 
297
349
  def flush_telemetry(timeout_sec = nil)
298
- return if @is_shutdown || @telemetry_queue.empty?
350
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
351
+ return false unless @flush_mutex.try_lock
352
+
353
+ flush_locked = true
299
354
 
300
355
  batch = []
301
- until @telemetry_queue.empty?
302
- begin
303
- batch << @telemetry_queue.pop(true)
304
- rescue ThreadError
305
- break
306
- end
356
+ @mutex.synchronize do
357
+ batch << @telemetry_queue.pop(true) until @telemetry_queue.empty?
307
358
  end
308
359
 
309
360
  return if batch.empty?
310
361
 
311
- @log_broker.info "Flushing #{batch.length} telemetry events to broker..."
312
-
313
362
  begin
314
363
  events = batch.map { |item| item[:event] }
315
364
  finished_probe_ids = @broker_client.report_telemetry(events, timeout_sec)
316
-
317
- @log_broker.info "Successfully flushed #{batch.length} telemetry events to broker"
365
+ delivered = true
318
366
 
319
367
  # Commit bandwidth reservations
320
368
  batch.each do |item|
321
- item[:reservation]&.commit
369
+ settle_reservation(item[:reservation], :commit)
322
370
  end
323
371
 
324
372
  # Handle globally finished probes
325
373
  if finished_probe_ids && !finished_probe_ids.empty?
326
- changed = false
327
- @mutex.synchronize do
374
+ snapshot = @mutex.synchronize do
375
+ return if @is_shutdown
376
+
328
377
  finished_probe_ids.each do |pid|
329
- if @active_probes.key?(pid)
330
- @active_probes.delete(pid)
331
- changed = true
332
- end
378
+ @finished_probes[pid] = true
379
+ @active_probes.delete(pid)
333
380
  end
381
+ probe_snapshot_locked
334
382
  end
335
- @engine.set_probes(@active_probes.values) if changed
383
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
336
384
  end
337
- rescue StandardError => e
338
- @log_broker.error("Failed to flush telemetry: #{e.message}", e)
385
+ rescue SignalException, SystemExit
386
+ raise
387
+ rescue Exception
388
+ return false if delivered
389
+
339
390
  # On flush failure, re-queue events up to max capacity
340
391
  batch.each do |item|
341
- item[:reservation]&.release
342
- if @telemetry_queue.size < @max_queue_size
343
- @telemetry_queue.push(item)
392
+ retained = @mutex.synchronize do
393
+ if !@is_shutdown && @telemetry_queue.size < @max_queue_size
394
+ @telemetry_queue.push(item)
395
+ true
396
+ end
344
397
  end
398
+ settle_reservation(item[:reservation], :release) unless retained
345
399
  end
346
400
  end
401
+ rescue SignalException, SystemExit
402
+ raise
403
+ rescue Exception
404
+ false
405
+ ensure
406
+ if @is_shutdown && batch && !delivered
407
+ batch.each { |item| settle_reservation(item[:reservation], :release) }
408
+ end
409
+ @flush_mutex.unlock if flush_locked
347
410
  end
348
411
 
349
412
  def stats_loop
350
413
  until @is_shutdown
351
- sleep 5.0
352
- break if @is_shutdown
353
-
354
414
  begin
415
+ sleep 5.0
416
+ break if @is_shutdown
417
+
355
418
  stats = @engine.get_stats
356
419
  if stats[:hits].positive? || stats[:skips].positive?
357
420
  @log_stats.info "Probes Hit: #{stats[:hits]}, Probes Skipped: #{stats[:skips]}"
358
421
  end
359
- rescue StandardError
422
+ rescue Exception
360
423
  # Ignore stats error
361
424
  end
362
425
  end
363
426
  end
364
427
 
365
428
  def handle_capture(event)
429
+ return if @owner_pid != Process.pid || @is_shutdown || @is_agent_disabled
430
+ return unless @mutex.try_enter
431
+
432
+ locked = true
366
433
  return if @is_shutdown || @is_agent_disabled
367
434
 
368
435
  probe_id = event[:probe_id]
369
- @log.debug "Captured event for probe #{probe_id}"
370
- probe = nil
371
- hits = 0
372
-
373
- @mutex.synchronize do
374
- probe = @active_probes[probe_id]
375
- return unless probe
376
-
377
- hits = (@local_hits[probe_id] || 0) + 1
378
- @local_hits[probe_id] = hits
436
+ probe = @active_probes[probe_id]
437
+ return unless probe
438
+
439
+ raw_limit = probe.respond_to?(:hit_limit) ? probe.hit_limit : probe[:hit_limit]
440
+ hit_limit = raw_limit.to_i
441
+ hits = @local_hits[probe_id] || 0
442
+ return if hit_limit.positive? && hits >= hit_limit
443
+
444
+ # Attempted captures consume the safety fuse even when delivery is dropped.
445
+ @local_hits[probe_id] = hits + 1
446
+ if hit_limit.positive? && hits + 1 >= hit_limit
447
+ @active_probes.delete(probe_id)
448
+ @pending_probe_update = probe_snapshot_locked
449
+ @stop_event.broadcast
379
450
  end
380
451
 
381
- # Reserve bandwidth
452
+ return if @telemetry_queue.size >= @max_queue_size
453
+
382
454
  event_size = @broker_client.estimate_event_size(event)
383
455
  reservation = @quota_manager.reserve_bandwidth(event_size)
384
- unless reservation
385
- @log.debug "Bandwidth quota exceeded; dropping event for probe #{probe_id}"
386
- return
387
- end
456
+ return unless reservation
457
+
458
+ @telemetry_queue.push(event: event, reservation: reservation)
459
+ admitted = true
460
+ rescue SignalException, SystemExit
461
+ raise
462
+ rescue Exception
463
+ # This callback is agent code running on an application thread.
464
+ nil
465
+ ensure
466
+ @mutex.exit if locked
467
+ settle_reservation(reservation, :release) if reservation && !admitted
468
+ end
388
469
 
389
- # Admission to telemetry queue
390
- item = { event: event, reservation: reservation }
391
- if @telemetry_queue.size < @max_queue_size
392
- @telemetry_queue.push(item)
393
- else
394
- @log.debug "Telemetry queue full. Dropping event for probe #{probe_id}"
395
- reservation.release
396
- end
470
+ def settle_reservation(reservation, action)
471
+ reservation&.public_send(action)
472
+ rescue Exception
473
+ nil
474
+ end
397
475
 
398
- # Enforce hitLimit fuse
399
- hit_limit = probe.respond_to?(:hit_limit) ? probe.hit_limit : probe[:hit_limit]
400
- if hits >= hit_limit
401
- remaining_probes = nil
402
- @mutex.synchronize do
403
- @active_probes.delete(probe_id)
404
- remaining_probes = @active_probes.values
476
+ def probe_snapshot_locked
477
+ @probe_generation += 1
478
+ [@active_probes.values, @probe_generation]
479
+ end
480
+
481
+ def apply_loop
482
+ loop do
483
+ snapshot = @mutex.synchronize do
484
+ @stop_event.wait until @is_shutdown || @pending_probe_update
485
+ break if @is_shutdown
486
+
487
+ update = @pending_probe_update
488
+ @pending_probe_update = nil
489
+ update
405
490
  end
406
- @engine.set_probes(remaining_probes)
491
+ break unless snapshot
492
+
493
+ @engine.set_probes(snapshot[0], generation: snapshot[1])
407
494
  end
495
+ rescue Exception
496
+ shutdown
408
497
  end
409
498
 
410
499
  def handle_health_change(health, reason = nil)
411
- return if @is_shutdown
500
+ return if @owner_pid != Process.pid || @is_shutdown
412
501
 
413
502
  case health
414
503
  when Core::AgentHealth::RED
@@ -417,22 +506,32 @@ module HyperProbe
417
506
 
418
507
  @cooldown_timer&.exit if @cooldown_timer&.alive?
419
508
  cooldown_duration = @cooldown_sec
509
+ @cooldown_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + cooldown_duration
420
510
  @cooldown_timer = Thread.new do
421
511
  @log_safety.info "Cooldown period started (#{cooldown_duration}s)..."
422
512
  sleep cooldown_duration
423
- if @safety_monitor.get_health == Core::AgentHealth::GREEN
424
- @log_safety.info "Cooldown period ended. Resuming instrumentation..."
425
- @engine.resume
513
+ @safety_monitor.with_health(Core::AgentHealth::GREEN) do
514
+ if !@is_shutdown && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @cooldown_until
515
+ @log_safety.info "Cooldown period ended. Resuming instrumentation..."
516
+ @engine.resume
517
+ end
426
518
  end
519
+ rescue Exception
520
+ # Agent-owned cooldown work must never abort the application.
427
521
  end
522
+ @cooldown_timer.kill if @is_shutdown
428
523
  when Core::AgentHealth::YELLOW
429
524
  @log_safety.warn "Safety Warning: YELLOW Status (Moderate Overhead). #{reason || ''}"
430
525
  when Core::AgentHealth::GREEN
431
526
  @log_safety.info "Status back to GREEN. #{reason || ''}"
432
- if @engine.is_suspended && (!@cooldown_timer || !@cooldown_timer.alive?)
527
+ # A timer may be alive briefly after its final health check. Use the
528
+ # deadline, not thread liveness, so a concurrent GREEN is never lost.
529
+ if @engine.is_suspended && (!@cooldown_until || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @cooldown_until)
433
530
  @engine.resume
434
531
  end
435
532
  end
533
+ rescue Exception
534
+ @is_agent_disabled = true
436
535
  end
437
536
  end
438
537
  end