connectrpc-async 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/LICENSE +21 -0
- data/README.md +542 -0
- data/lib/connectrpc/async/application.rb +173 -0
- data/lib/connectrpc/async/completion_join.rb +249 -0
- data/lib/connectrpc/async/connection.rb +711 -0
- data/lib/connectrpc/async/connection_manager.rb +358 -0
- data/lib/connectrpc/async/connection_policy.rb +60 -0
- data/lib/connectrpc/async/host.rb +171 -0
- data/lib/connectrpc/async/http1_connection.rb +144 -0
- data/lib/connectrpc/async/http1_exchange.rb +27 -0
- data/lib/connectrpc/async/http1_stream.rb +76 -0
- data/lib/connectrpc/async/http1_transport.rb +642 -0
- data/lib/connectrpc/async/ingress.rb +100 -0
- data/lib/connectrpc/async/internal.rb +270 -0
- data/lib/connectrpc/async/server_session.rb +457 -0
- data/lib/connectrpc/async/stream_exchange.rb +324 -0
- data/lib/connectrpc/async/tls.rb +124 -0
- data/lib/connectrpc/async/tls_configuration.rb +309 -0
- data/lib/connectrpc/async/transport.rb +488 -0
- data/lib/connectrpc/async/version.rb +7 -0
- data/lib/connectrpc/async.rb +39 -0
- data/sig/connectrpc-async.rbs +165 -0
- data/sig/manifest.yaml +4 -0
- metadata +202 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
# Worker-local source supervision. Published snapshots never escape through
|
|
6
|
+
# status, diagnostics or caller-visible mutable OpenSSL contexts.
|
|
7
|
+
class TLSConfiguration
|
|
8
|
+
class Error < ArgumentError
|
|
9
|
+
attr_reader :category
|
|
10
|
+
|
|
11
|
+
def initialize(category)
|
|
12
|
+
@category = category
|
|
13
|
+
super("TLS configuration failed (#{category})")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
REQUIRED_KEYS = %i[version certificate_chain private_key client_ca].freeze
|
|
18
|
+
private_constant :REQUIRED_KEYS
|
|
19
|
+
|
|
20
|
+
def initialize(tls: nil, tls_provider: nil, refresh_interval: 60, refresh_jitter: 0.1,
|
|
21
|
+
refresh_timeout: 10, on_error: nil, alpn_protocols: ["h2"])
|
|
22
|
+
@owner = ::Async::Task.current?
|
|
23
|
+
raise LifecycleError, "An application-owned Async task is required" unless @owner
|
|
24
|
+
raise ArgumentError, "tls must be a Hash" unless tls.nil? || tls.is_a?(Hash)
|
|
25
|
+
raise ArgumentError, "Choose tls or tls_provider" unless tls.nil? || tls_provider.nil?
|
|
26
|
+
raise ArgumentError, "tls_provider must be callable" unless tls_provider.nil? || tls_provider.respond_to?(:call)
|
|
27
|
+
raise ArgumentError, "on_error must be callable" unless on_error.nil? || on_error.respond_to?(:call)
|
|
28
|
+
|
|
29
|
+
@interval = Internal.seconds(refresh_interval, "refresh_interval")
|
|
30
|
+
@timeout = Internal.seconds(refresh_timeout, "refresh_timeout")
|
|
31
|
+
unless refresh_jitter.is_a?(Numeric) && refresh_jitter.real? && refresh_jitter.finite? &&
|
|
32
|
+
refresh_jitter >= 0 && refresh_jitter < 1
|
|
33
|
+
raise ArgumentError, "refresh_jitter must be finite and in [0,1)"
|
|
34
|
+
end
|
|
35
|
+
unless alpn_protocols.is_a?(Array) && !alpn_protocols.empty? &&
|
|
36
|
+
alpn_protocols.uniq == alpn_protocols && (alpn_protocols - %w[h2 http/1.1]).empty?
|
|
37
|
+
raise ArgumentError, "Unsupported ALPN configuration"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@thread = Thread.current
|
|
41
|
+
@reactor = Fiber.scheduler
|
|
42
|
+
@static = tls
|
|
43
|
+
@provider = tls_provider
|
|
44
|
+
@reporter = on_error
|
|
45
|
+
@jitter = refresh_jitter
|
|
46
|
+
@alpn = alpn_protocols.map { |value| value.dup.freeze }.freeze
|
|
47
|
+
@state = :new
|
|
48
|
+
@timer_progress = ::Async::Condition.new
|
|
49
|
+
@lifetime = @owner.async do
|
|
50
|
+
::Async::Condition.new.wait
|
|
51
|
+
ensure
|
|
52
|
+
shutdown unless @state == :closed
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect = "#<#{self.class} state=#{@state} generation=#{@generation.inspect}>"
|
|
57
|
+
attr_reader :generation
|
|
58
|
+
|
|
59
|
+
def status
|
|
60
|
+
check_affinity!
|
|
61
|
+
now = Internal.now
|
|
62
|
+
{ state: @state, generation: @generation, category: @category,
|
|
63
|
+
source_check_age: @checked_at && (now - @checked_at),
|
|
64
|
+
publication_age: @published_at && (now - @published_at) }.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start
|
|
68
|
+
check_affinity!
|
|
69
|
+
check_recursion!
|
|
70
|
+
raise LifecycleError, "TLS configuration is closed" if %i[closing closed].include?(@state)
|
|
71
|
+
return self if @state == :running
|
|
72
|
+
|
|
73
|
+
@state = :starting
|
|
74
|
+
if @provider
|
|
75
|
+
join_attempt
|
|
76
|
+
elsif @static
|
|
77
|
+
publish(compile(@static), nil)
|
|
78
|
+
end
|
|
79
|
+
raise LifecycleError, "TLS configuration closed during startup" unless @state == :starting
|
|
80
|
+
|
|
81
|
+
@state = :running
|
|
82
|
+
start_timer if @provider && !@timer
|
|
83
|
+
self
|
|
84
|
+
rescue LifecycleError
|
|
85
|
+
raise
|
|
86
|
+
rescue Error
|
|
87
|
+
@state = :new unless %i[closing closed].include?(@state)
|
|
88
|
+
raise
|
|
89
|
+
rescue StandardError
|
|
90
|
+
@state = :new unless %i[closing closed].include?(@state)
|
|
91
|
+
fail_safely(:invalid)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def refresh
|
|
95
|
+
check_affinity!
|
|
96
|
+
check_recursion!
|
|
97
|
+
raise LifecycleError, "A running provider is required" unless @provider && @state == :running
|
|
98
|
+
|
|
99
|
+
join_attempt
|
|
100
|
+
@generation
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reload(certificate_chain:, private_key:, client_ca:)
|
|
104
|
+
check_affinity!
|
|
105
|
+
raise LifecycleError, "Provider owns TLS configuration" if @provider
|
|
106
|
+
raise LifecycleError, "A running TLS configuration is required" unless @state == :running && @snapshot
|
|
107
|
+
|
|
108
|
+
publish(compile(certificate_chain: certificate_chain, private_key: private_key, client_ca: client_ca), nil)
|
|
109
|
+
@generation
|
|
110
|
+
rescue Error
|
|
111
|
+
raise
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
fail_safely(:invalid)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def close
|
|
117
|
+
check_affinity!
|
|
118
|
+
check_recursion!
|
|
119
|
+
shutdown
|
|
120
|
+
self
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Hosting SPI: captures an opaque socket owner synchronously at raw accept.
|
|
124
|
+
# It exposes no parsed credentials or mutable SSLContext.
|
|
125
|
+
def capture(raw)
|
|
126
|
+
check_affinity!
|
|
127
|
+
raise LifecycleError, "TLS admission is closed" unless @state == :running
|
|
128
|
+
|
|
129
|
+
@snapshot&.check_validity!
|
|
130
|
+
Capture.new(raw, @snapshot, @generation)
|
|
131
|
+
end
|
|
132
|
+
private :capture
|
|
133
|
+
|
|
134
|
+
class Capture
|
|
135
|
+
attr_reader :generation
|
|
136
|
+
|
|
137
|
+
def initialize(raw, snapshot, generation)
|
|
138
|
+
@raw = raw
|
|
139
|
+
@snapshot = snapshot
|
|
140
|
+
@generation = generation
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handshake
|
|
144
|
+
@snapshot&.check_validity!
|
|
145
|
+
@socket ||= @snapshot ? @snapshot.socket(@raw) : @raw
|
|
146
|
+
yield @socket if block_given?
|
|
147
|
+
@socket.accept if @snapshot
|
|
148
|
+
@socket
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def authenticated_leaf
|
|
152
|
+
@socket.peer_cert.to_der.freeze if @snapshot&.authenticated?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tls? = !@snapshot.nil?
|
|
156
|
+
def inspect = "#<#{self.class} generation=#{@generation.inspect}>"
|
|
157
|
+
end
|
|
158
|
+
private_constant :Capture
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def check_affinity!
|
|
163
|
+
return if Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@reactor) && ::Async::Task.current?
|
|
164
|
+
|
|
165
|
+
raise LifecycleError, "Use the owning Async reactor and thread"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def check_recursion!
|
|
169
|
+
return unless @callback&.current? || @attempt&.current?
|
|
170
|
+
|
|
171
|
+
raise LifecycleError, "Recursive TLS provider lifecycle call"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def compile(options)
|
|
175
|
+
Internal::TLS.server(**options, alpn_protocols: @alpn)
|
|
176
|
+
rescue StandardError
|
|
177
|
+
raise Error.new(:invalid), cause: nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def publish(snapshot, version)
|
|
181
|
+
@snapshot = snapshot
|
|
182
|
+
@version = version
|
|
183
|
+
@generation = (@generation || 0) + 1
|
|
184
|
+
@published_at = @checked_at = Internal.now
|
|
185
|
+
@category = nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def join_attempt
|
|
189
|
+
attempt = @attempt
|
|
190
|
+
unless attempt
|
|
191
|
+
attempt = ::Async::Task.new(@lifetime, finished: false) { perform_attempt(attempt) }
|
|
192
|
+
@attempt = attempt
|
|
193
|
+
attempt.run
|
|
194
|
+
end
|
|
195
|
+
# Cancellation of a waiter never owns or cancels this shared task.
|
|
196
|
+
category = attempt.wait
|
|
197
|
+
category ||= :closed unless %i[starting running].include?(@state)
|
|
198
|
+
raise Error.new(category), cause: nil if category
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def perform_attempt(attempt)
|
|
202
|
+
@callback = ::Async::Task.new(attempt, finished: false) do
|
|
203
|
+
result = @provider.call
|
|
204
|
+
unless result.is_a?(Hash) && result.size == REQUIRED_KEYS.size &&
|
|
205
|
+
REQUIRED_KEYS.all? { |key| result.key?(key) } &&
|
|
206
|
+
result[:version].is_a?(String) && !result[:version].empty?
|
|
207
|
+
next [:invalid]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
version = result.fetch(:version).dup.freeze
|
|
211
|
+
snapshot = compile(result.except(:version))
|
|
212
|
+
[:ok, version, snapshot]
|
|
213
|
+
rescue Error
|
|
214
|
+
[:invalid]
|
|
215
|
+
rescue StandardError, ScriptError
|
|
216
|
+
[:source]
|
|
217
|
+
end
|
|
218
|
+
@callback.run
|
|
219
|
+
result, version, snapshot = Internal.wait(@callback, Internal.now + @timeout)
|
|
220
|
+
return :closed unless @attempt.equal?(attempt) && %i[starting running].include?(@state)
|
|
221
|
+
|
|
222
|
+
if result != :ok
|
|
223
|
+
category = %i[invalid source].include?(result) ? result : :source
|
|
224
|
+
report(category)
|
|
225
|
+
return category
|
|
226
|
+
end
|
|
227
|
+
if version == @version
|
|
228
|
+
if snapshot.fingerprint != @snapshot.fingerprint
|
|
229
|
+
report(:version_conflict)
|
|
230
|
+
return :version_conflict
|
|
231
|
+
end
|
|
232
|
+
@checked_at = Internal.now
|
|
233
|
+
@category = nil
|
|
234
|
+
else
|
|
235
|
+
publish(snapshot, version)
|
|
236
|
+
end
|
|
237
|
+
nil
|
|
238
|
+
rescue ::Async::TimeoutError
|
|
239
|
+
report(:timeout)
|
|
240
|
+
:timeout
|
|
241
|
+
ensure
|
|
242
|
+
Internal.cancel(@callback)
|
|
243
|
+
Internal.join(@callback)
|
|
244
|
+
@callback = nil
|
|
245
|
+
@attempt = nil if @attempt.equal?(attempt)
|
|
246
|
+
@next_refresh = Internal.now + (@interval * (1 + (((rand * 2) - 1) * @jitter)))
|
|
247
|
+
@timer_progress.signal
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def start_timer
|
|
251
|
+
@timer = @lifetime.async(finished: false) do |task|
|
|
252
|
+
while @state == :running
|
|
253
|
+
# Completion-relative monotonic scheduling; explicit attempts also
|
|
254
|
+
# coalesce with the timer rather than starting another callback.
|
|
255
|
+
remaining = @next_refresh - Internal.now
|
|
256
|
+
if remaining.positive?
|
|
257
|
+
begin
|
|
258
|
+
task.with_timeout([remaining, Internal::MAX_WAIT].min) { @timer_progress.wait }
|
|
259
|
+
rescue ::Async::TimeoutError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
next
|
|
263
|
+
end
|
|
264
|
+
break unless @state == :running
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
refresh
|
|
268
|
+
rescue Error
|
|
269
|
+
nil # perform_attempt has already emitted a safe event.
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def report(category)
|
|
276
|
+
@category = category
|
|
277
|
+
event = { category: category, status: status }.freeze
|
|
278
|
+
if @reporter
|
|
279
|
+
begin
|
|
280
|
+
@reporter.call(event)
|
|
281
|
+
rescue StandardError, ScriptError
|
|
282
|
+
warn "ConnectRPC TLS error reporter failed"
|
|
283
|
+
end
|
|
284
|
+
else
|
|
285
|
+
warn "ConnectRPC TLS refresh failed (#{category})"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def fail_safely(category)
|
|
290
|
+
report(category)
|
|
291
|
+
raise Error.new(category), cause: nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def shutdown
|
|
295
|
+
return if @state == :closed
|
|
296
|
+
|
|
297
|
+
@state = :closing
|
|
298
|
+
Internal.cancel(@timer)
|
|
299
|
+
Internal.cancel(@attempt)
|
|
300
|
+
Internal.cancel(@callback)
|
|
301
|
+
Internal.join(@timer)
|
|
302
|
+
Internal.join(@callback)
|
|
303
|
+
Internal.join(@attempt)
|
|
304
|
+
@state = :closed
|
|
305
|
+
Internal.cancel(@lifetime)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|