mirrors-sdk 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/Gemfile +7 -0
- data/README.md +273 -0
- data/Rakefile +11 -0
- data/lib/mirrors_sdk/agent_context.rb +51 -0
- data/lib/mirrors_sdk/capture.rb +236 -0
- data/lib/mirrors_sdk/circuit.rb +51 -0
- data/lib/mirrors_sdk/client.rb +261 -0
- data/lib/mirrors_sdk/configuration.rb +74 -0
- data/lib/mirrors_sdk/context.rb +281 -0
- data/lib/mirrors_sdk/delivery.rb +74 -0
- data/lib/mirrors_sdk/executor.rb +226 -0
- data/lib/mirrors_sdk/models.rb +104 -0
- data/lib/mirrors_sdk/snapshot.rb +129 -0
- data/lib/mirrors_sdk/transport.rb +223 -0
- data/lib/mirrors_sdk/version.rb +5 -0
- data/lib/mirrors_sdk/wire.rb +238 -0
- data/lib/mirrors_sdk/worker.rb +114 -0
- data/lib/mirrors_sdk.rb +13 -0
- data/mirrors-sdk.gemspec +23 -0
- data/skills/install-mirrors-ruby-sdk/SKILL.md +152 -0
- data/test/capture_test.rb +303 -0
- data/test/configuration_test.rb +64 -0
- data/test/context_test.rb +295 -0
- data/test/executor_test.rb +93 -0
- data/test/fixtures/canonical-events.json +38 -0
- data/test/integration_test.rb +154 -0
- data/test/lifecycle_test.rb +128 -0
- data/test/snapshot_test.rb +88 -0
- data/test/support/runtime_server.rb +101 -0
- data/test/test_helper.rb +30 -0
- data/test/transport_test.rb +182 -0
- data/test/wire_test.rb +128 -0
- metadata +72 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module MirrorsSDK
|
|
6
|
+
class Cancellation
|
|
7
|
+
def initialize
|
|
8
|
+
@lock = Mutex.new
|
|
9
|
+
@reason = nil
|
|
10
|
+
@callbacks = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def canceled?
|
|
14
|
+
@lock.synchronize { !@reason.nil? }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reason
|
|
18
|
+
@lock.synchronize { @reason }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def cancel(reason = "canceled")
|
|
22
|
+
callbacks = @lock.synchronize do
|
|
23
|
+
return false if @reason
|
|
24
|
+
|
|
25
|
+
@reason = reason
|
|
26
|
+
values = @callbacks.values
|
|
27
|
+
@callbacks.clear
|
|
28
|
+
values
|
|
29
|
+
end
|
|
30
|
+
callbacks.each { |callback| safely(callback) }
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def on_cancel(&callback)
|
|
35
|
+
key = Object.new
|
|
36
|
+
canceled = @lock.synchronize do
|
|
37
|
+
@callbacks[key] = callback unless @reason
|
|
38
|
+
!@reason.nil?
|
|
39
|
+
end
|
|
40
|
+
safely(callback) if canceled
|
|
41
|
+
-> { @lock.synchronize { @callbacks.delete(key) } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def safely(callback)
|
|
47
|
+
callback.call
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class CanceledError < StandardError
|
|
54
|
+
attr_reader :reason
|
|
55
|
+
|
|
56
|
+
def initialize(reason = "canceled")
|
|
57
|
+
@reason = reason
|
|
58
|
+
super(reason)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Future
|
|
63
|
+
attr_reader :token
|
|
64
|
+
|
|
65
|
+
def initialize
|
|
66
|
+
@token = Cancellation.new
|
|
67
|
+
@lock = Mutex.new
|
|
68
|
+
@condition = ConditionVariable.new
|
|
69
|
+
@done = false
|
|
70
|
+
@callbacks = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def done?
|
|
74
|
+
@lock.synchronize { @done }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait(timeout = nil)
|
|
78
|
+
deadline = timeout && monotonic + [timeout, 0].max
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
until @done
|
|
81
|
+
remaining = deadline && deadline - monotonic
|
|
82
|
+
return false if remaining && remaining <= 0
|
|
83
|
+
|
|
84
|
+
@condition.wait(@lock, remaining)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def value
|
|
91
|
+
wait
|
|
92
|
+
@lock.synchronize do
|
|
93
|
+
raise @error if @error
|
|
94
|
+
|
|
95
|
+
@value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cancel(reason = "canceled")
|
|
100
|
+
@token.cancel(reason)
|
|
101
|
+
reject(CanceledError.new(reason))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def on_complete(&callback)
|
|
105
|
+
complete = @lock.synchronize do
|
|
106
|
+
@callbacks << callback unless @done
|
|
107
|
+
@done
|
|
108
|
+
end
|
|
109
|
+
notify(callback) if complete
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve(value)
|
|
114
|
+
settle(value, nil)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def reject(error)
|
|
118
|
+
settle(nil, error)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def settle(value, error)
|
|
124
|
+
callbacks = @lock.synchronize do
|
|
125
|
+
return false if @done
|
|
126
|
+
|
|
127
|
+
@value, @error, @done = value, error, true
|
|
128
|
+
@condition.broadcast
|
|
129
|
+
pending = @callbacks
|
|
130
|
+
@callbacks = []
|
|
131
|
+
pending
|
|
132
|
+
end
|
|
133
|
+
callbacks.each { |callback| notify(callback) }
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def notify(callback)
|
|
138
|
+
callback.call(self)
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def monotonic
|
|
144
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Physical workers remain occupied until a canceled operation actually returns.
|
|
149
|
+
# This bounds thread growth even when the operating system stalls DNS resolution.
|
|
150
|
+
class Executor
|
|
151
|
+
def initialize(size:, capacity:, name: "mirrors-sdk")
|
|
152
|
+
raise ArgumentError, "Invalid worker count" unless size.instance_of?(Integer) && size.positive?
|
|
153
|
+
raise ArgumentError, "Invalid capacity" unless capacity.instance_of?(Integer) && capacity >= size
|
|
154
|
+
|
|
155
|
+
@lock = Mutex.new
|
|
156
|
+
@condition = ConditionVariable.new
|
|
157
|
+
@capacity = capacity
|
|
158
|
+
@closed = false
|
|
159
|
+
@queue = []
|
|
160
|
+
@jobs = {}
|
|
161
|
+
@threads = Array.new(size) do |index|
|
|
162
|
+
Thread.new do
|
|
163
|
+
Thread.current.name = "#{name}-#{index}" if Thread.current.respond_to?(:name=)
|
|
164
|
+
work
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
@threads.each { |thread| thread.report_on_exception = false }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def submit(&block)
|
|
171
|
+
raise ArgumentError, "Missing work" unless block
|
|
172
|
+
|
|
173
|
+
@lock.synchronize do
|
|
174
|
+
return nil if @closed || @jobs.length >= @capacity || @threads.any? { |thread| !thread.alive? }
|
|
175
|
+
|
|
176
|
+
future = Future.new
|
|
177
|
+
@jobs[future] = true
|
|
178
|
+
@queue << [future, block]
|
|
179
|
+
@condition.signal
|
|
180
|
+
future
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def shutdown(reason = "closed")
|
|
185
|
+
jobs = @lock.synchronize do
|
|
186
|
+
return if @closed
|
|
187
|
+
|
|
188
|
+
@closed = true
|
|
189
|
+
@queue.each { |future, _block| @jobs.delete(future) }
|
|
190
|
+
queued = @queue.map(&:first)
|
|
191
|
+
@queue.clear
|
|
192
|
+
@condition.broadcast
|
|
193
|
+
@jobs.keys + queued
|
|
194
|
+
end
|
|
195
|
+
jobs.each { |future| future.cancel(reason) }
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def diagnostics
|
|
200
|
+
@lock.synchronize do
|
|
201
|
+
{ pending: @jobs.length, queued: @queue.length, threads: @threads.count(&:alive?), closed: @closed }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def work
|
|
208
|
+
loop do
|
|
209
|
+
job = @lock.synchronize do
|
|
210
|
+
@condition.wait(@lock) while @queue.empty? && !@closed
|
|
211
|
+
@queue.shift unless @closed
|
|
212
|
+
end
|
|
213
|
+
break unless job
|
|
214
|
+
|
|
215
|
+
future, block = job
|
|
216
|
+
begin
|
|
217
|
+
future.resolve(block.call(future.token)) unless future.token.canceled?
|
|
218
|
+
rescue StandardError => error
|
|
219
|
+
future.reject(error)
|
|
220
|
+
ensure
|
|
221
|
+
@lock.synchronize { @jobs.delete(future) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module MirrorsSDK
|
|
7
|
+
# Results are immutable so callers cannot change delivery or freshness bookkeeping.
|
|
8
|
+
class DeliveryReceipt
|
|
9
|
+
attr_reader :status, :reason, :ingestion
|
|
10
|
+
|
|
11
|
+
def initialize(status, reason = nil, ingestion: nil)
|
|
12
|
+
@status, @reason = status.freeze, reason&.freeze
|
|
13
|
+
@ingestion = ingestion&.each_with_object({}) { |(key, value), copy| copy[key.freeze] = value&.freeze }&.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class DeliveryTicket
|
|
19
|
+
attr_reader :id
|
|
20
|
+
|
|
21
|
+
def initialize(identifier = SecureRandom.uuid)
|
|
22
|
+
@id = identifier.freeze
|
|
23
|
+
@mutex, @condition = Mutex.new, ConditionVariable.new
|
|
24
|
+
@receipt = DeliveryReceipt.new("pending")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def receipt
|
|
28
|
+
@mutex.synchronize { @receipt }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Wait explicitly outside the application's model execution path. Seconds, at most 30.
|
|
32
|
+
def wait(timeout = 0)
|
|
33
|
+
timeout = 0 unless (timeout.instance_of?(Integer) || timeout.instance_of?(Float)) && timeout.between?(0, 30)
|
|
34
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
while @receipt.status == "pending"
|
|
37
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
break if remaining <= 0
|
|
39
|
+
|
|
40
|
+
@condition.wait(@mutex, remaining)
|
|
41
|
+
end
|
|
42
|
+
@receipt
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def settle(receipt)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return false unless @receipt.status == "pending"
|
|
49
|
+
|
|
50
|
+
@receipt = receipt
|
|
51
|
+
@condition.broadcast
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private :settle
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class ContextFreshness
|
|
59
|
+
attr_reader :request_id, :trace_id, :decision_id, :mode_version, :issued_at, :expires_at
|
|
60
|
+
|
|
61
|
+
def initialize(request_id:, trace_id:, decision_id:, mode_version:, issued_at:, expires_at:)
|
|
62
|
+
@request_id, @trace_id, @decision_id = request_id.freeze, trace_id.freeze, decision_id.freeze
|
|
63
|
+
@mode_version, @issued_at, @expires_at = mode_version.freeze, issued_at.freeze, expires_at.freeze
|
|
64
|
+
freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class ContextResult
|
|
69
|
+
attr_reader :context, :mode, :delivery, :reason, :elapsed_ms, :freshness
|
|
70
|
+
|
|
71
|
+
def initialize(context: "", mode: "shadow", delivery: "unconfirmed", reason: "unavailable", elapsed_ms: 0, freshness: nil)
|
|
72
|
+
@context, @mode, @delivery, @reason = [context, mode, delivery, reason].map(&:freeze)
|
|
73
|
+
@elapsed_ms, @freshness = elapsed_ms, freshness
|
|
74
|
+
freeze
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class FlushResult
|
|
79
|
+
attr_reader :accepted, :dropped, :unconfirmed, :pending
|
|
80
|
+
|
|
81
|
+
def initialize(accepted: 0, dropped: 0, unconfirmed: 0, pending: 0)
|
|
82
|
+
@accepted, @dropped, @unconfirmed, @pending = accepted, dropped, unconfirmed, pending
|
|
83
|
+
freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def complete
|
|
87
|
+
(@dropped + @unconfirmed + @pending).zero?
|
|
88
|
+
end
|
|
89
|
+
alias complete? complete
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class TurnTimer
|
|
93
|
+
def initialize
|
|
94
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
@mutex = Mutex.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def finish
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@duration ||= [(Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started) * 1000, 0].max
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module MirrorsSDK
|
|
7
|
+
# Copies only ordinary JSON data, without calling application serialization hooks.
|
|
8
|
+
class Snapshot
|
|
9
|
+
CLASS = Object.instance_method(:class)
|
|
10
|
+
OBJECT_ID = BasicObject.instance_method(:__id__)
|
|
11
|
+
ARRAY_EACH = Array.instance_method(:each)
|
|
12
|
+
HASH_EACH = Hash.instance_method(:each_pair)
|
|
13
|
+
ARRAY_LENGTH = Array.instance_method(:length)
|
|
14
|
+
HASH_LENGTH = Hash.instance_method(:length)
|
|
15
|
+
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
16
|
+
STRING_ENCODING = String.instance_method(:encoding)
|
|
17
|
+
SAFE_INTEGER = 9_007_199_254_740_991
|
|
18
|
+
|
|
19
|
+
def self.check_deadline(deadline)
|
|
20
|
+
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
21
|
+
raise Timeout::Error, "Snapshot deadline exceeded"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.copy(value, limit: 1_000_000, deadline: nil)
|
|
26
|
+
new(limit, deadline).copy(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.encode(value, limit: 1_000_000, deadline: nil)
|
|
30
|
+
copied = copy(value, limit: limit, deadline: deadline)
|
|
31
|
+
encoded = JSON.generate(copied)
|
|
32
|
+
check_deadline(deadline)
|
|
33
|
+
raise ArgumentError, "Snapshot encoded byte limit exceeded" if encoded.bytesize > limit
|
|
34
|
+
|
|
35
|
+
encoded
|
|
36
|
+
rescue JSON::GeneratorError, EncodingError => error
|
|
37
|
+
raise ArgumentError, "Invalid JSON encoding: #{error.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(limit, deadline)
|
|
41
|
+
@remaining = limit
|
|
42
|
+
@nodes = 50_000
|
|
43
|
+
@deadline = deadline
|
|
44
|
+
@ancestors = {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def copy(value)
|
|
48
|
+
self.class.check_deadline(@deadline)
|
|
49
|
+
result = visit(value, 0)
|
|
50
|
+
self.class.check_deadline(@deadline)
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def visit(item, depth)
|
|
57
|
+
@nodes -= 1
|
|
58
|
+
self.class.check_deadline(@deadline) if (@nodes % 32).zero?
|
|
59
|
+
spend(1)
|
|
60
|
+
raise ArgumentError, "Snapshot limit exceeded" if @nodes.negative? || depth > 32
|
|
61
|
+
|
|
62
|
+
kind = CLASS.bind(item).call
|
|
63
|
+
return item if [NilClass, TrueClass, FalseClass].include?(kind)
|
|
64
|
+
if kind == Integer
|
|
65
|
+
raise ArgumentError, "JSON integer exceeds the safe integer range" if item.abs > SAFE_INTEGER
|
|
66
|
+
return item
|
|
67
|
+
end
|
|
68
|
+
if kind == Float
|
|
69
|
+
raise ArgumentError, "Nonfinite JSON number" unless item.finite?
|
|
70
|
+
return item
|
|
71
|
+
end
|
|
72
|
+
return copy_string(item) if kind == String
|
|
73
|
+
unless [Hash, Array].include?(kind)
|
|
74
|
+
raise ArgumentError, "Expected ordinary acyclic JSON data"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
identity = OBJECT_ID.bind(item).call
|
|
78
|
+
raise ArgumentError, "Cyclic JSON data" if @ancestors[identity]
|
|
79
|
+
|
|
80
|
+
length = (kind == Hash ? HASH_LENGTH : ARRAY_LENGTH).bind(item).call
|
|
81
|
+
raise ArgumentError, "Snapshot collection limit exceeded" if length > 10_000
|
|
82
|
+
|
|
83
|
+
@ancestors[identity] = true
|
|
84
|
+
begin
|
|
85
|
+
if kind == Array
|
|
86
|
+
result = []
|
|
87
|
+
ARRAY_EACH.bind(item).call { |child| result << visit(child, depth + 1) }
|
|
88
|
+
else
|
|
89
|
+
result = {}
|
|
90
|
+
HASH_EACH.bind(item).call do |key, child|
|
|
91
|
+
raise ArgumentError, "JSON object keys must be strings" unless CLASS.bind(key).call == String
|
|
92
|
+
|
|
93
|
+
result[copy_string(key)] = visit(child, depth + 1)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
result
|
|
97
|
+
ensure
|
|
98
|
+
@ancestors.delete(identity)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def copy_string(value)
|
|
103
|
+
self.class.check_deadline(@deadline)
|
|
104
|
+
source_bytes = STRING_BYTESIZE.bind(value).call
|
|
105
|
+
encoding = STRING_ENCODING.bind(value).call
|
|
106
|
+
# A bounded source allowance accommodates UTF-16/32 before UTF-8 conversion.
|
|
107
|
+
multiplier = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].include?(encoding) ? 1 : 4
|
|
108
|
+
if source_bytes > @remaining * multiplier
|
|
109
|
+
raise ArgumentError, "Snapshot string source limit exceeded"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result = String.new(value)
|
|
113
|
+
self.class.check_deadline(@deadline)
|
|
114
|
+
result = result.encode(Encoding::UTF_8)
|
|
115
|
+
self.class.check_deadline(@deadline)
|
|
116
|
+
raise ArgumentError, "Invalid JSON string encoding" unless result.valid_encoding?
|
|
117
|
+
|
|
118
|
+
spend(result.bytesize)
|
|
119
|
+
result
|
|
120
|
+
rescue EncodingError
|
|
121
|
+
raise ArgumentError, "Invalid JSON string encoding"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def spend(amount)
|
|
125
|
+
@remaining -= amount
|
|
126
|
+
raise ArgumentError, "Snapshot limit exceeded" if @remaining.negative?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "zlib"
|
|
9
|
+
require_relative "circuit"
|
|
10
|
+
require_relative "executor"
|
|
11
|
+
|
|
12
|
+
module MirrorsSDK
|
|
13
|
+
# Error messages are fixed codes and never include credentials or evidence.
|
|
14
|
+
class TransportError < StandardError
|
|
15
|
+
attr_reader :reason, :permanent
|
|
16
|
+
|
|
17
|
+
def initialize(reason, permanent: false)
|
|
18
|
+
@reason, @permanent = reason, permanent
|
|
19
|
+
super(reason)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Transport
|
|
24
|
+
RESPONSE_LIMIT = 65_536
|
|
25
|
+
DeadlineExceeded = Class.new(StandardError)
|
|
26
|
+
private_constant :DeadlineExceeded
|
|
27
|
+
|
|
28
|
+
# JSON's default parser silently accepts duplicate keys.
|
|
29
|
+
class StrictObject < Hash
|
|
30
|
+
def []=(key, value)
|
|
31
|
+
raise JSON::ParserError, "Duplicate key" if key?(key)
|
|
32
|
+
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_constant :StrictObject
|
|
37
|
+
|
|
38
|
+
def initialize(config)
|
|
39
|
+
@config = config
|
|
40
|
+
@base = "#{config.api_url.sub(%r{/+\z}, '')}/v1/agents/#{config.agent_id}/runtime"
|
|
41
|
+
@lock = Mutex.new
|
|
42
|
+
@enabled = config.enabled
|
|
43
|
+
@closed = false
|
|
44
|
+
@active = {}
|
|
45
|
+
@circuits = {}
|
|
46
|
+
%w[trace trace-final mode retrieve-context].each do |name|
|
|
47
|
+
@circuits[name] = Circuit.new(config.failure_threshold, config.cooldown_ms)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def set_enabled(enabled)
|
|
52
|
+
active = @lock.synchronize do
|
|
53
|
+
@enabled = enabled
|
|
54
|
+
enabled ? [] : @active.keys
|
|
55
|
+
end
|
|
56
|
+
active.each { |token| token.cancel("disabled") }
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
active = @lock.synchronize do
|
|
62
|
+
@closed = true
|
|
63
|
+
@enabled = false
|
|
64
|
+
@active.keys
|
|
65
|
+
end
|
|
66
|
+
active.each { |token| token.cancel("closed") }
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request(endpoint, body, deadline:, cancel: nil, final_recovery: false)
|
|
71
|
+
validate_request(endpoint, body)
|
|
72
|
+
raise TransportError.new("timeout") if deadline <= monotonic
|
|
73
|
+
|
|
74
|
+
token = Cancellation.new
|
|
75
|
+
@lock.synchronize do
|
|
76
|
+
raise TransportError.new("disabled") if @closed || !@enabled
|
|
77
|
+
|
|
78
|
+
@active[token] = true
|
|
79
|
+
end
|
|
80
|
+
unsubscribe = cancel && cancel.on_cancel { token.cancel(cancel.reason) }
|
|
81
|
+
check_canceled(token)
|
|
82
|
+
circuit_name = endpoint
|
|
83
|
+
if endpoint == "trace" && (final_recovery || JSON.parse(body)["final"] == true)
|
|
84
|
+
circuit_name = "trace-final"
|
|
85
|
+
end
|
|
86
|
+
finish = @circuits.fetch(circuit_name).acquire
|
|
87
|
+
raise TransportError.new("circuit_open") unless finish
|
|
88
|
+
|
|
89
|
+
remaining = deadline - monotonic
|
|
90
|
+
raise TransportError.new("timeout") if remaining <= 0
|
|
91
|
+
|
|
92
|
+
result = Timeout.timeout(remaining, DeadlineExceeded) do
|
|
93
|
+
value = read_response(endpoint, body, remaining, token)
|
|
94
|
+
unless value.instance_of?(Hash) && (!block_given? || yield(value))
|
|
95
|
+
raise TransportError.new("invalid_response")
|
|
96
|
+
end
|
|
97
|
+
check_canceled(token)
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
finish.call(true)
|
|
101
|
+
result
|
|
102
|
+
rescue CanceledError
|
|
103
|
+
finish.call(nil) if finish
|
|
104
|
+
raise TransportError.new(token.reason || "canceled"), cause: nil
|
|
105
|
+
rescue DeadlineExceeded, Timeout::Error
|
|
106
|
+
finish.call(token && token.canceled? ? nil : false) if finish
|
|
107
|
+
raise TransportError.new(token && token.canceled? ? token.reason : "timeout"), cause: nil
|
|
108
|
+
rescue TransportError => error
|
|
109
|
+
finish.call(token && token.canceled? ? nil : false) if finish
|
|
110
|
+
raise TransportError.new(token.reason), cause: nil if token && token.canceled?
|
|
111
|
+
|
|
112
|
+
raise error, cause: nil
|
|
113
|
+
rescue StandardError
|
|
114
|
+
finish.call(token && token.canceled? ? nil : false) if finish
|
|
115
|
+
raise TransportError.new(token && token.canceled? ? token.reason : "transport"), cause: nil
|
|
116
|
+
ensure
|
|
117
|
+
unsubscribe.call if unsubscribe
|
|
118
|
+
@lock.synchronize { @active.delete(token) } if token
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def validate_request(endpoint, body)
|
|
124
|
+
valid = %w[trace mode retrieve-context].include?(endpoint) && ((endpoint == "mode") == body.nil?)
|
|
125
|
+
valid &&= body.nil? || (body.instance_of?(String) && body.bytesize <= 1_048_576)
|
|
126
|
+
if valid && endpoint == "trace"
|
|
127
|
+
begin
|
|
128
|
+
valid = JSON.parse(body).instance_of?(Hash)
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
valid = false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
raise TransportError.new("invalid_request", permanent: true) unless valid
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def read_response(endpoint, body, timeout, token)
|
|
137
|
+
uri = URI.parse("#{@base}/#{endpoint}")
|
|
138
|
+
# Explicit nil disables environment proxy discovery and credential forwarding.
|
|
139
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
140
|
+
http.use_ssl = uri.scheme == "https"
|
|
141
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
142
|
+
http.open_timeout = timeout
|
|
143
|
+
http.read_timeout = timeout
|
|
144
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
145
|
+
http.max_retries = 0
|
|
146
|
+
request = (body.nil? ? Net::HTTP::Get : Net::HTTP::Post).new(uri.request_uri)
|
|
147
|
+
request["authorization"] = "Bearer #{@config.api_key}"
|
|
148
|
+
request["content-type"] = "application/json"
|
|
149
|
+
request["accept-encoding"] = "identity"
|
|
150
|
+
request["x-mirrors-protocol"] = endpoint == "trace" ? "3" : "1"
|
|
151
|
+
request["idempotency-key"] = Digest::SHA256.hexdigest("#{endpoint}\n#{body}")
|
|
152
|
+
request.body = body unless body.nil?
|
|
153
|
+
unsubscribe = token.on_cancel { http.finish if http.started? }
|
|
154
|
+
check_canceled(token)
|
|
155
|
+
http.start do
|
|
156
|
+
check_canceled(token)
|
|
157
|
+
http.request(request) do |response|
|
|
158
|
+
status = response.code.to_i
|
|
159
|
+
unless (200...300).cover?(status)
|
|
160
|
+
permanent = (400...500).cover?(status) && ![408, 429].include?(status)
|
|
161
|
+
raise TransportError.new(permanent ? "rejected" : "transport", permanent: permanent)
|
|
162
|
+
end
|
|
163
|
+
return parse_body(response, token)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
ensure
|
|
167
|
+
unsubscribe.call if unsubscribe
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_body(response, token)
|
|
171
|
+
encoding = (response["content-encoding"] || "identity").downcase
|
|
172
|
+
unless %w[identity gzip deflate].include?(encoding)
|
|
173
|
+
raise TransportError.new("invalid_response")
|
|
174
|
+
end
|
|
175
|
+
raw = String.new(encoding: Encoding::BINARY)
|
|
176
|
+
response.read_body do |chunk|
|
|
177
|
+
check_canceled(token)
|
|
178
|
+
raise TransportError.new("response_too_large") if raw.bytesize + chunk.bytesize > RESPONSE_LIMIT
|
|
179
|
+
|
|
180
|
+
raw << chunk
|
|
181
|
+
end
|
|
182
|
+
bytes = encoding == "identity" ? raw : inflate(raw, encoding)
|
|
183
|
+
bytes.force_encoding(Encoding::UTF_8)
|
|
184
|
+
raise TransportError.new("invalid_response") unless bytes.valid_encoding?
|
|
185
|
+
|
|
186
|
+
plain(JSON.parse(bytes, object_class: StrictObject, max_nesting: 64, allow_nan: false, create_additions: false))
|
|
187
|
+
rescue JSON::ParserError, Zlib::Error, EncodingError
|
|
188
|
+
raise TransportError.new("invalid_response"), cause: nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def inflate(raw, encoding)
|
|
192
|
+
decoder = Zlib::Inflate.new(encoding == "gzip" ? Zlib::MAX_WBITS + 16 : Zlib::MAX_WBITS)
|
|
193
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
194
|
+
decoder.inflate(raw) do |chunk|
|
|
195
|
+
raise TransportError.new("response_too_large") if output.bytesize + chunk.bytesize > RESPONSE_LIMIT
|
|
196
|
+
|
|
197
|
+
output << chunk
|
|
198
|
+
end
|
|
199
|
+
unless decoder.finished? && decoder.total_in == raw.bytesize
|
|
200
|
+
raise TransportError.new("invalid_response")
|
|
201
|
+
end
|
|
202
|
+
output
|
|
203
|
+
ensure
|
|
204
|
+
decoder.close if decoder
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def plain(value)
|
|
208
|
+
case value
|
|
209
|
+
when Hash then value.each_with_object({}) { |(key, item), output| output[key] = plain(item) }
|
|
210
|
+
when Array then value.map { |item| plain(item) }
|
|
211
|
+
else value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def check_canceled(token)
|
|
216
|
+
raise CanceledError.new(token.reason) if token.canceled?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def monotonic
|
|
220
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|