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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MirrorsSDK
4
+ VERSION = "0.1.0"
5
+ end