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.
- checksums.yaml +4 -4
- data/README.md +316 -87
- data/lib/hyperprobe/agent.rb +243 -144
- data/lib/hyperprobe/core/broker.rb +58 -88
- data/lib/hyperprobe/core/evaluator.rb +358 -68
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +33 -12
- data/lib/hyperprobe/core/monitoring_engine.rb +294 -120
- data/lib/hyperprobe/core/safe_ast_validator.rb +140 -61
- data/lib/hyperprobe/core/safety.rb +92 -15
- data/lib/hyperprobe/core/serializer.rb +354 -342
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/lambda.rb +44 -42
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +7 -7
- data/lib/hyperprobe/railtie.rb +6 -0
- data/lib/hyperprobe/version.rb +1 -1
- data/lib/hyperprobe.rb +149 -20
- metadata +25 -9
|
@@ -4,85 +4,164 @@ require 'ripper'
|
|
|
4
4
|
|
|
5
5
|
module HyperProbe
|
|
6
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.
|
|
7
9
|
class SafeASTValidator
|
|
8
10
|
MAX_EXPRESSION_LENGTH = 256
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
eval instance_eval class_eval module_eval
|
|
18
|
-
send __send__ public_send define_method
|
|
19
|
-
const_set const_get instance_variable_set remove_instance_variable
|
|
20
|
-
delete delete! delete_all destroy destroy!
|
|
21
|
-
update update! update_all save save! create create!
|
|
22
|
-
truncate drop clear write require load
|
|
23
|
-
].freeze
|
|
24
|
-
|
|
25
|
-
FORBIDDEN_CONSTANTS = %w[
|
|
26
|
-
File Dir IO FileUtils Socket TCPSocket UDPSocket Net
|
|
27
|
-
Process Kernel ObjectSpace GC System
|
|
28
|
-
].freeze
|
|
29
|
-
|
|
30
|
-
@cache = {}
|
|
31
|
-
@cache_mutex = Mutex.new
|
|
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)
|
|
32
19
|
|
|
33
20
|
class << self
|
|
34
21
|
def validate!(expression)
|
|
35
|
-
|
|
22
|
+
parse!(expression)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
36
25
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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'
|
|
40
30
|
end
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return if @cache[clean_expr]
|
|
31
|
+
if STRING_SIZE.bind(expression).call > limit
|
|
32
|
+
raise SecurityError, "Expression is too long (max #{limit} bytes)"
|
|
44
33
|
end
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
50
39
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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')
|
|
54
64
|
end
|
|
65
|
+
end
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
raise SecurityError, "Assignments and state mutations ('#{text}') are strictly forbidden in probe expressions."
|
|
59
|
-
end
|
|
67
|
+
compile(tree, strings, [0], 0)
|
|
68
|
+
end
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
end
|
|
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
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if has_method_call
|
|
72
|
-
raise SecurityError, "Access to system constant '#{text}' is strictly forbidden in probe expressions."
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def reject!(feature)
|
|
77
|
+
raise SecurityError, "Unsupported #{feature}: forbidden in probe expressions"
|
|
78
|
+
end
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
80
91
|
end
|
|
81
92
|
end
|
|
82
93
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
|
|
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]}")
|
|
86
165
|
end
|
|
87
166
|
end
|
|
88
167
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'java' if RUBY_ENGINE == 'jruby'
|
|
5
|
+
|
|
3
6
|
module HyperProbe
|
|
4
7
|
module Core
|
|
5
8
|
module AgentHealth
|
|
@@ -16,14 +19,20 @@ module HyperProbe
|
|
|
16
19
|
SEVERE_LAG_WINDOW_SIZE = 5
|
|
17
20
|
SEVERE_LAG_RED_BREACHES = 3
|
|
18
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
|
|
19
25
|
|
|
20
26
|
attr_reader :health, :last_thread_lag_ms, :cumulative_pause_time, :max_lag_ms, :pause_budget_ms
|
|
21
27
|
|
|
22
|
-
def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false
|
|
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))
|
|
23
30
|
@on_state_change = on_state_change
|
|
24
31
|
@max_lag_ms = max_lag_ms.to_f
|
|
25
32
|
@pause_budget_ms = pause_budget_ms.to_f
|
|
26
33
|
@is_ephemeral_lambda = is_ephemeral_lambda
|
|
34
|
+
@heap_runtime = heap_runtime
|
|
35
|
+
@heap_available_fraction = 1.0
|
|
27
36
|
|
|
28
37
|
@health = AgentHealth::GREEN
|
|
29
38
|
@last_heartbeat = monotonic_ms
|
|
@@ -33,40 +42,61 @@ module HyperProbe
|
|
|
33
42
|
@last_window_reset = monotonic_ms
|
|
34
43
|
|
|
35
44
|
@mutex = Mutex.new
|
|
36
|
-
@notification_mutex =
|
|
45
|
+
@notification_mutex = Monitor.new
|
|
46
|
+
@transition_generation = 0
|
|
37
47
|
@running = false
|
|
38
48
|
@thread = nil
|
|
39
49
|
end
|
|
40
50
|
|
|
41
51
|
def start
|
|
42
52
|
@mutex.synchronize do
|
|
43
|
-
return if @running
|
|
53
|
+
return if @running && @thread&.alive?
|
|
44
54
|
|
|
45
55
|
@running = true
|
|
46
56
|
@last_heartbeat = monotonic_ms
|
|
47
57
|
@last_window_reset = monotonic_ms
|
|
58
|
+
@last_heap_sample = @last_window_reset
|
|
48
59
|
|
|
49
60
|
@thread = Thread.new { monitor_loop }
|
|
50
61
|
@thread.name = 'hyperprobe-safety' if @thread.respond_to?(:name=)
|
|
51
62
|
end
|
|
52
63
|
end
|
|
53
64
|
|
|
54
|
-
def
|
|
65
|
+
def after_fork
|
|
55
66
|
@mutex.synchronize do
|
|
56
67
|
@running = false
|
|
68
|
+
@thread = nil
|
|
57
69
|
end
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
61
86
|
end
|
|
62
87
|
|
|
63
88
|
def report_pause_duration(ms)
|
|
64
89
|
callback_info = nil
|
|
65
|
-
@mutex.
|
|
90
|
+
return unless @mutex.try_lock
|
|
91
|
+
begin
|
|
66
92
|
@cumulative_pause_time += ms.to_f
|
|
67
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
|
|
68
98
|
end
|
|
69
|
-
trigger_callback(callback_info) if callback_info
|
|
99
|
+
trigger_callback(callback_info) if callback_info && !@running
|
|
70
100
|
end
|
|
71
101
|
|
|
72
102
|
def record_thread_lag(thread_lag_ms)
|
|
@@ -82,6 +112,16 @@ module HyperProbe
|
|
|
82
112
|
@mutex.synchronize { @health }
|
|
83
113
|
end
|
|
84
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
|
+
|
|
85
125
|
private
|
|
86
126
|
|
|
87
127
|
def monitor_loop
|
|
@@ -97,17 +137,25 @@ module HyperProbe
|
|
|
97
137
|
thread_lag_ms = [0.0, now - wait_started - (SAMPLE_INTERVAL_SECONDS * 1000.0)].max
|
|
98
138
|
record_thread_lag_locked(thread_lag_ms)
|
|
99
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
|
+
|
|
100
145
|
# Reset pause budget window every second
|
|
101
146
|
if now - @last_window_reset > 1000.0
|
|
102
147
|
@cumulative_pause_time = 0.0
|
|
103
148
|
@last_window_reset = now
|
|
104
149
|
end
|
|
105
150
|
|
|
106
|
-
callback_info = evaluate_health_locked
|
|
151
|
+
callback_info = evaluate_health_locked || @pending_notification
|
|
152
|
+
@pending_notification = nil
|
|
107
153
|
end
|
|
108
154
|
|
|
109
155
|
trigger_callback(callback_info) if callback_info
|
|
110
156
|
end
|
|
157
|
+
rescue Exception
|
|
158
|
+
@running = false
|
|
111
159
|
end
|
|
112
160
|
|
|
113
161
|
def record_thread_lag_locked(lag_ms)
|
|
@@ -117,6 +165,17 @@ module HyperProbe
|
|
|
117
165
|
@thread_lag_samples.shift while @thread_lag_samples.length > LAG_WINDOW_SIZE
|
|
118
166
|
end
|
|
119
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
|
+
|
|
120
179
|
def evaluate_health_locked
|
|
121
180
|
prev_health = @health
|
|
122
181
|
reason = nil
|
|
@@ -125,7 +184,8 @@ module HyperProbe
|
|
|
125
184
|
@health = AgentHealth::GREEN
|
|
126
185
|
return nil if prev_health == AgentHealth::GREEN
|
|
127
186
|
|
|
128
|
-
|
|
187
|
+
@transition_generation += 1
|
|
188
|
+
return [@health, 'System stabilized in Lambda mode.', @transition_generation]
|
|
129
189
|
end
|
|
130
190
|
|
|
131
191
|
recent_lags = @thread_lag_samples.dup
|
|
@@ -134,7 +194,18 @@ module HyperProbe
|
|
|
134
194
|
severe_window = recent_lags.last(SEVERE_LAG_WINDOW_SIZE)
|
|
135
195
|
severe_lag_breaches = severe_window.count { |lag| lag > severe_lag_ms }
|
|
136
196
|
|
|
137
|
-
if
|
|
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
|
|
138
209
|
@health = AgentHealth::RED
|
|
139
210
|
reason = "Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
|
|
140
211
|
elsif lag_breaches >= LAG_RED_BREACHES
|
|
@@ -159,15 +230,21 @@ module HyperProbe
|
|
|
159
230
|
|
|
160
231
|
return unless @health != prev_health
|
|
161
232
|
|
|
162
|
-
|
|
233
|
+
@transition_generation += 1
|
|
234
|
+
[@health, reason, @transition_generation]
|
|
163
235
|
end
|
|
164
236
|
|
|
165
237
|
def trigger_callback(callback_info)
|
|
166
|
-
health, reason = callback_info
|
|
238
|
+
health, reason, generation = callback_info
|
|
167
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
|
+
|
|
168
245
|
@on_state_change&.call(health, reason)
|
|
169
246
|
end
|
|
170
|
-
rescue
|
|
247
|
+
rescue Exception
|
|
171
248
|
# Never crash application thread or safety monitor on callback error
|
|
172
249
|
end
|
|
173
250
|
|