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.
@@ -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