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,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ConnectRPC
6
+ module Async
7
+ class Transport
8
+ include Internal::Affinity
9
+
10
+ def initialize(origin:, ca_file: nil, certificate_chain: nil, private_key: nil, connection_limit: 1,
11
+ max_requests: 128, connect_timeout: 10, connection_policy: ConnectionPolicy.new,
12
+ connection_observer: nil)
13
+ initialize_affinity
14
+ @origin = parse_origin(origin)
15
+ @connection_limit = Internal.positive_integer(connection_limit, "connection_limit")
16
+ @max_requests = Internal.positive_integer(max_requests, "max_requests")
17
+ @connect_timeout = Internal.seconds(connect_timeout, "connect_timeout")
18
+ unless connection_policy.is_a?(ConnectionPolicy)
19
+ raise ArgumentError, "connection_policy must be a ConnectionPolicy"
20
+ end
21
+ unless connection_observer.nil? || connection_observer.respond_to?(:call)
22
+ raise ArgumentError, "connection_observer must respond to call"
23
+ end
24
+ if @origin.scheme == "http" && (ca_file || certificate_chain || private_key)
25
+ raise ArgumentError, "TLS options require an HTTPS origin"
26
+ end
27
+
28
+ if @origin.scheme == "https"
29
+ @snapshot = Internal::TLS.client(ca_file: ca_file, certificate_chain: certificate_chain,
30
+ private_key: private_key)
31
+ end
32
+ @calls = {}.compare_by_identity
33
+ @acquiring = {}
34
+ @state = :running
35
+ install_owner_guard
36
+ @manager = Internal::ConnectionManager.new(
37
+ policy: connection_policy, observer: connection_observer, connection_limit: @connection_limit,
38
+ connect_timeout: @connect_timeout, lifetime: @lifetime, progress: @progress,
39
+ build: method(:build_resource), setup: method(:setup_connection), abort_setup: method(:abort_setup)
40
+ )
41
+ end
42
+
43
+ def inspect = "#<#{self.class} state=#{@state} connection_state=#{@manager&.state} origin=#{@origin}>"
44
+
45
+ # A published observation, not a promise that the next call succeeds.
46
+ def connection_state = @manager.state
47
+
48
+ def call(request)
49
+ check_affinity!
50
+ @manager.check_reentry!
51
+ CallCancellation.with_request(request) { |outgoing| perform_call(outgoing) }
52
+ end
53
+
54
+ def streaming_capabilities = %i[server_stream client_stream bidi_stream].freeze
55
+
56
+ def open_stream(request, &)
57
+ raise ArgumentError, "A streaming block is required" unless block_given?
58
+
59
+ check_affinity!
60
+ @manager.check_reentry!
61
+ CallCancellation.with_request(request) { |outgoing| perform_stream(outgoing, &) }
62
+ end
63
+
64
+ def grpc_capabilities = %i[unary server_stream client_stream bidi_stream].freeze
65
+
66
+ def open_grpc_stream(request, &)
67
+ raise ArgumentError, "A gRPC block is required" unless block_given?
68
+
69
+ check_affinity!
70
+ @manager.check_reentry!
71
+ CallCancellation.with_request(request) { |outgoing| perform_stream(outgoing, grpc: true, &) }
72
+ end
73
+
74
+ def close(grace: 5)
75
+ check_affinity!
76
+ @manager.check_reentry!
77
+ super
78
+ end
79
+
80
+ private
81
+
82
+ def perform_stream(request, grpc: false)
83
+ raise ArgumentError, "Request URL must match the transport origin" unless same_origin?(request.url)
84
+ raise Error.new(:unavailable, "HTTP/2 transport admission is closed or full") unless @state == :running
85
+
86
+ operation = { request: request, tasks: {}, grpc: grpc }
87
+ # Streams reserve an admission slot but no request bytes.
88
+ @manager.admit(0, full: @calls.size >= @max_requests)
89
+ @calls[operation] = true
90
+ body = Internal::OutputBody.new
91
+ completed = false
92
+ begin
93
+ stream_operation(operation) { dispatch_stream(operation, body) }
94
+ exchange = grpc ? Internal::GRPCStreamExchange : Internal::StreamExchange
95
+ result = yield exchange.new(self, operation, body)
96
+ completed = true
97
+ result
98
+ ensure
99
+ body.close
100
+ begin
101
+ cleanup(operation)
102
+ ensure
103
+ @calls.delete(operation)
104
+ @progress.signal
105
+ end
106
+ if grpc && completed && operation[:cleanup_error]
107
+ check_request!(request)
108
+ raise Error.new(:unavailable, "HTTP/2 transport cleanup failed"), cause: nil
109
+ end
110
+ end
111
+ end
112
+
113
+ def stream_operation(operation, timeout: nil, &work)
114
+ check_affinity!
115
+ timeout = [timeout || Deadline::MAX_NATIVE_WAIT, Deadline::MAX_NATIVE_WAIT].min if operation[:grpc]
116
+ request = operation.fetch(:request)
117
+ raise Error.new(:unavailable, "HTTP/2 exchange is retired"), cause: nil if operation[:cleaned]
118
+
119
+ check_request!(request)
120
+ event = ::Async::Promise.new
121
+ registration = request.cancellation&.on_cancel { event.resolve([:cancellation, nil]) }
122
+ child = @lifetime.async(finished: false) do
123
+ result = activate(request, &work)
124
+ event.resolve([:result, result])
125
+ rescue StandardError => e
126
+ request.lifecycle&.record_failure(e)
127
+ event.resolve([:error, e])
128
+ ensure
129
+ event.resolve([:stopped, nil])
130
+ end
131
+ operation.fetch(:tasks)[child] = true
132
+ expires = Internal.now + timeout if timeout
133
+ expires = [expires || Float::INFINITY, Internal.now + request.deadline.remaining].min if request.deadline
134
+ tag, result = wait_event(event, expires)
135
+ check_request!(request)
136
+ raise result if tag == :error
137
+ raise IOError, "HTTP/2 operation stopped" unless tag == :result
138
+
139
+ result
140
+ rescue StandardError => e
141
+ request.lifecycle&.record_failure(e)
142
+ check_request!(request)
143
+ if operation[:grpc] && (failure = operation[:response]&.stream&.grpc_error)
144
+ raise failure, cause: nil
145
+ end
146
+ raise if e.is_a?(ConnectRPC::Error)
147
+
148
+ raise Error.new(:unavailable, "HTTP/2 transport failed"), cause: nil
149
+ ensure
150
+ registration&.unregister
151
+ # The call's one cleanup budget owns stopping a still-blocked task.
152
+ if child && !child.finished? && !%i[result error].include?(tag)
153
+ cleanup(operation)
154
+ else
155
+ operation[:tasks]&.delete(child)
156
+ end
157
+ end
158
+
159
+ def dispatch_stream(operation, body)
160
+ request = operation.fetch(:request)
161
+ dispatch(operation) do |connection, task|
162
+ headers = request.headers.dup
163
+ if request.deadline
164
+ key = operation[:grpc] ? "grpc-timeout" : "connect-timeout-ms"
165
+ headers[key] = [operation[:grpc] ? GRPCProtocol.timeout(request.deadline) : request.deadline.timeout_ms]
166
+ end
167
+ GRPCProtocol.check_fields!(headers) if operation[:grpc]
168
+ outgoing = ::Protocol::HTTP::Request["POST", request.url.request_uri, Internal.fields(headers), body,
169
+ scheme: @origin.scheme, authority: @origin.authority]
170
+ response = connection.create_response
171
+ operation[:response] = response
172
+ response.stream.grpc_mark! if operation[:grpc]
173
+ task.defer_cancel { connection.write_request(response, outgoing) }
174
+ end
175
+ end
176
+
177
+ # Acquires a lease and runs the block under its write guard. A connection
178
+ # found unusable before any stream ID is allocated is released and the
179
+ # call returns to acquisition; once dispatch starts it is never resent.
180
+ def dispatch(operation)
181
+ request = operation.fetch(:request)
182
+ task = ::Async::Task.current
183
+ loop do
184
+ connection = acquire(operation, task)
185
+ dispatched = false
186
+ begin
187
+ return connection.synchronize(dispatch: true) do
188
+ check_request!(request)
189
+ raise Internal::UnusableConnection, "Connection is not reusable" unless @manager.usable?(connection)
190
+
191
+ dispatched = true
192
+ yield connection, task
193
+ end
194
+ rescue Internal::UnusableConnection
195
+ raise if dispatched
196
+
197
+ reroute(operation)
198
+ end
199
+ end
200
+ end
201
+
202
+ def acquire(operation, task)
203
+ @acquiring[task] = operation
204
+ begin
205
+ connection = @manager.acquire
206
+ operation[:connection] = connection
207
+ operation[:resource] = connection.resource
208
+ connection
209
+ ensure
210
+ @acquiring.delete(task)
211
+ end
212
+ end
213
+
214
+ # Detaches the unsent lease before any yielding release, so cleanup can
215
+ # never release it again. A failed release fails the call.
216
+ def reroute(operation)
217
+ connection = operation.delete(:connection)
218
+ operation.delete(:resource)
219
+ @manager.release(connection)
220
+ rescue StandardError
221
+ begin
222
+ connection.resource&.abort
223
+ ensure
224
+ @manager.retire(connection)
225
+ end
226
+ raise
227
+ end
228
+
229
+ def perform_call(request)
230
+ raise ArgumentError, "Request URL must match the transport origin" unless same_origin?(request.url)
231
+
232
+ check_request!(request)
233
+ raise Error.new(:unavailable, "HTTP/2 transport admission is closed or full") unless @state == :running
234
+
235
+ event = ::Async::Promise.new
236
+ operation = { request: request }
237
+ # GET carries its encoded message in the request target.
238
+ bytes = request.http_method == "GET" ? request.url.request_uri.bytesize : request.body.bytesize
239
+ operation[:bytes] = @manager.admit(bytes, full: @calls.size >= @max_requests)
240
+ @calls[operation] = true
241
+ registration = request.cancellation&.on_cancel { event.resolve([:cancellation, nil]) }
242
+ begin
243
+ check_request!(request)
244
+ operation[:task] = @lifetime.async(finished: false) do
245
+ result = activate(request) { exchange(operation) }
246
+ event.resolve([:response, result])
247
+ rescue StandardError => e
248
+ request.lifecycle&.record_failure(e)
249
+ event.resolve([:error, e])
250
+ ensure
251
+ event.resolve([:stopped, nil])
252
+ end
253
+ expires = Internal.now + request.deadline.remaining if request.deadline
254
+ tag, result = wait_event(event, expires)
255
+ check_request!(request)
256
+ raise result if tag == :error
257
+ raise Error.new(:unavailable, "HTTP/2 connection closed") unless result
258
+
259
+ result
260
+ rescue ::Async::TimeoutError, StandardError => e
261
+ request.lifecycle&.record_failure(e)
262
+ check_request!(request)
263
+ raise e if e.is_a?(ConnectRPC::Error)
264
+
265
+ raise Error.new(:unavailable, "HTTP/2 transport failed"), cause: nil
266
+ ensure
267
+ registration&.unregister
268
+ begin
269
+ cleanup(operation)
270
+ ensure
271
+ @calls.delete(operation)
272
+ @manager.release_bytes(operation.fetch(:bytes))
273
+ @progress.signal
274
+ end
275
+ end
276
+ end
277
+
278
+ def check_request!(request)
279
+ request.cancellation ? request.cancellation.check! : request.deadline&.check!
280
+ end
281
+
282
+ def wait_event(event, expires)
283
+ loop do
284
+ return event.wait unless expires
285
+
286
+ remaining = expires - Internal.now
287
+ raise ::Async::TimeoutError if remaining <= 0
288
+
289
+ begin
290
+ return event.wait(timeout: [remaining, Internal::MAX_WAIT].min)
291
+ rescue ::Async::TimeoutError
292
+ raise if Internal.now >= expires
293
+ end
294
+ end
295
+ end
296
+
297
+ def parse_origin(value)
298
+ uri = URI(value)
299
+ unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment &&
300
+ uri.path.empty?
301
+ raise ArgumentError, "Origin must contain only an HTTP(S) scheme, host and port"
302
+ end
303
+
304
+ uri.freeze
305
+ end
306
+
307
+ def same_origin?(uri)
308
+ [uri.scheme, uri.hostname&.downcase, uri.port] == [@origin.scheme, @origin.hostname.downcase, @origin.port]
309
+ end
310
+
311
+ def build_resource
312
+ resource = Internal::Resource.new(progress: @progress)
313
+ resource.send(:retirement_parent=, @lifetime)
314
+ @resources.reject! { |entry| entry.send(:retirement_completed?) }
315
+ @resources << resource
316
+ resource
317
+ end
318
+
319
+ def abort_setup(resource)
320
+ raw = resource.raw
321
+ # Ruby 4 close may wait for the blocked SSL handshake. A non-flushing
322
+ # shutdown wakes that setup operation before the existing raw-first abort.
323
+ raw.shutdown(Socket::SHUT_RDWR) if raw && !raw.closed?
324
+ rescue IOError, SystemCallError
325
+ nil
326
+ ensure
327
+ resource.abort
328
+ end
329
+
330
+ def activate(request, &work)
331
+ request.lifecycle ? request.lifecycle.activate(&work) : work.call
332
+ end
333
+
334
+ def setup_connection(resource)
335
+ resource.raw = ::IO::Endpoint.tcp(@origin.hostname, @origin.port).connect
336
+ socket = resource.raw
337
+ if @snapshot
338
+ socket = @snapshot.socket(resource.raw)
339
+ resource.socket = socket
340
+ socket.hostname = @origin.hostname
341
+ socket.connect
342
+ socket.post_connection_check(@origin.hostname)
343
+ raise OpenSSL::SSL::SSLError, "HTTP/2 ALPN is required" unless socket.alpn_protocol == "h2"
344
+ end
345
+ resource.socket = socket
346
+ connection = Internal::ClientConnection.new(::IO::Stream(socket))
347
+ resource.connection = connection
348
+ connection.resource = resource
349
+ connection.send_connection_preface(Internal.settings(@max_requests))
350
+ resource.ready = true
351
+ connection.read_in_background(parent: @lifetime)
352
+ connection
353
+ end
354
+
355
+ def exchange(operation)
356
+ request = operation.fetch(:request)
357
+ check_request!(request)
358
+ connection = dispatch(operation) do |guarded, task|
359
+ headers = request.headers.dup
360
+ headers["connect-timeout-ms"] = [request.deadline.timeout_ms] if request.deadline
361
+ body = request.http_method == "GET" ? nil : [request.body]
362
+ outgoing = ::Protocol::HTTP::Request[request.http_method, request.url.request_uri, Internal.fields(headers),
363
+ body, scheme: @origin.scheme, authority: @origin.authority]
364
+ # Stream ID allocation shares the wire dispatch guard.
365
+ response = guarded.create_response
366
+ operation[:response] = response
367
+ task.defer_cancel { guarded.write_request(response, outgoing) }
368
+ guarded
369
+ end
370
+ response = operation.fetch(:response)
371
+ connection.read_response(response)
372
+ headers = Internal.headers(response)
373
+ bytes = read_body(response, request)
374
+ TransportResponse.new(status: response.status, headers: headers, body: bytes)
375
+ end
376
+
377
+ def read_body(response, request)
378
+ body = response.body
379
+ limit = request.response_limit
380
+ length = body&.length || response.stream.declared_length
381
+ raise Error.new(:resource_exhausted, "Response exceeds byte limit") if length && length > limit
382
+
383
+ bytes = +"".b
384
+ input = Internal::Input.new(body)
385
+ while (chunk = input.read([16_384, limit + 1 - bytes.bytesize].min))
386
+ check_request!(request)
387
+ bytes << chunk
388
+ raise Error.new(:resource_exhausted, "Response exceeds byte limit") if bytes.bytesize > limit
389
+ end
390
+ raise Error.new(:internal, "Content-Length does not match response") if length && bytes.bytesize != length
391
+
392
+ bytes
393
+ end
394
+
395
+ def cleanup(operation)
396
+ return if operation[:cleaned]
397
+
398
+ operation[:cleaned] = true
399
+ expires = Internal.now + Internal::CLEANUP_SECONDS
400
+ cleanup_task = ::Async::Task.current.async(finished: false) do
401
+ # The manager owns connection setup; a call owns only its own tasks.
402
+ tasks = [operation[:task], *operation.fetch(:tasks, {}).keys].compact.uniq
403
+ tasks.each { |task| task.cancel unless task.finished? }
404
+ # Cancel every task before joining in reverse ownership order.
405
+ tasks.reverse_each do |task|
406
+ operation[:grpc] ? (task.wait unless task.current?) : Internal.join(task)
407
+ end
408
+ response = operation[:response]
409
+ if response
410
+ stream = response.stream
411
+ case stream.state
412
+ when :idle then stream.close!
413
+ when :closed then nil
414
+ else stream.send_reset_stream(::Protocol::HTTP2::Error::CANCEL)
415
+ end
416
+ response.close
417
+ end
418
+ end
419
+ begin
420
+ Internal.wait(cleanup_task, expires)
421
+ rescue StandardError => e
422
+ operation[:cleanup_error] ||= e
423
+ begin
424
+ operation[:resource]&.abort
425
+ rescue StandardError
426
+ raise unless operation[:grpc]
427
+ ensure
428
+ Internal.cancel(cleanup_task)
429
+ Internal.join(cleanup_task)
430
+ end
431
+ ensure
432
+ if (connection = operation.delete(:connection))
433
+ begin
434
+ @manager.release(connection)
435
+ rescue StandardError => e
436
+ operation[:cleanup_error] ||= e
437
+ begin
438
+ operation[:resource]&.abort
439
+ # A failed release may precede usage accounting. Retire the
440
+ # aborted connection without retrying release or cleanup.
441
+ @manager.retire(connection)
442
+ rescue StandardError
443
+ raise unless operation[:grpc]
444
+ end
445
+ end
446
+ end
447
+ if operation[:grpc] && (error = operation[:cleanup_error])
448
+ lifecycle = operation.fetch(:request).lifecycle
449
+ lifecycle&.record_failure(error)
450
+ lifecycle&.finish(delivery: :aborted, error: error)
451
+ end
452
+ end
453
+ end
454
+
455
+ def close_owned(expires)
456
+ @manager.close
457
+ waiter = @lifetime.async(finished: false) { @progress.wait until @calls.empty? }
458
+ graceful = true
459
+ begin
460
+ Internal.wait(waiter, expires)
461
+ rescue ::Async::TimeoutError
462
+ graceful = false
463
+ @resources.each(&:abort)
464
+ @calls.each_key { |operation| Internal.cancel(operation[:task]) }
465
+ ensure
466
+ Internal.cancel(waiter)
467
+ end
468
+ drains = @resources.map { |resource| @lifetime.async(finished: false) { resource.drain(@lifetime, expires) } }
469
+ graceful = drains.map(&:wait).all? && graceful
470
+ @manager.finish
471
+ finish_closed(graceful)
472
+ end
473
+
474
+ def abort_owned
475
+ return if @aborting
476
+
477
+ @aborting = true
478
+ @state = :closing unless @state == :closed
479
+ @manager&.close
480
+ @resources.each(&:abort)
481
+ @calls.each_key { |operation| Internal.cancel(operation[:task]) }
482
+ @manager&.finish
483
+ finish_closed(false) unless @state == :closed
484
+ Internal.cancel(@lifetime)
485
+ end
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ VERSION = "0.1.0" # x-release-please-version
6
+ end
7
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connectrpc"
4
+ require_relative "async/version"
5
+ require "async"
6
+ require "async/queue"
7
+ require "async/promise"
8
+ require "async/condition"
9
+ require "async/http/protocol/http2"
10
+ require "async/http/protocol/http"
11
+ require "protocol/http/request"
12
+ require "protocol/http/response"
13
+ require "protocol/http/body/wrapper"
14
+ require "protocol/http/body/buffered"
15
+ require "io/endpoint/host_endpoint"
16
+ require "io/endpoint/bound_endpoint"
17
+ require "openssl"
18
+
19
+ module ConnectRPC
20
+ # Explicit, optional integration. The application owns the Async reactor.
21
+ module Async
22
+ class LifecycleError < StandardError; end
23
+ private_constant :LifecycleError
24
+ end
25
+ end
26
+
27
+ require_relative "async/internal"
28
+ require_relative "async/connection_policy"
29
+ require_relative "async/tls"
30
+ require_relative "async/tls_configuration"
31
+ require_relative "async/ingress"
32
+ require_relative "async/connection"
33
+ require_relative "async/connection_manager"
34
+ require_relative "async/stream_exchange"
35
+ require_relative "async/application"
36
+ require_relative "async/server_session"
37
+ require_relative "async/host"
38
+ require_relative "async/transport"
39
+ require_relative "async/http1_transport"