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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
# Native unary translation. Generic HTTP middleware belongs on fallback.
|
|
6
|
+
class Application
|
|
7
|
+
def initialize(server:, fallback: nil)
|
|
8
|
+
raise ArgumentError, "server must be a ConnectRPC::Server" unless server.is_a?(ConnectRPC::Server)
|
|
9
|
+
raise ArgumentError, "fallback must be callable" unless fallback.nil? || fallback.respond_to?(:call)
|
|
10
|
+
|
|
11
|
+
@server = server
|
|
12
|
+
@fallback = fallback
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start_call(request)
|
|
16
|
+
path = request.path&.split("?", 2)&.first
|
|
17
|
+
return if path && @server.cors_response(path: path, http_method: request.method,
|
|
18
|
+
headers: Internal.headers(request))
|
|
19
|
+
|
|
20
|
+
return unless path && @server.handles?(path)
|
|
21
|
+
|
|
22
|
+
@server.start_call(path: path, headers: Internal.headers(request), http_method: request.method)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publication?(request)
|
|
26
|
+
path = request.path&.split("?", 2)&.first
|
|
27
|
+
return false unless path
|
|
28
|
+
|
|
29
|
+
return false unless @server.handles?(path)
|
|
30
|
+
|
|
31
|
+
headers = Internal.headers(request)
|
|
32
|
+
protocol = @server.send(:request_protocol, headers, request.method)
|
|
33
|
+
protocol == :grpc_web ||
|
|
34
|
+
(protocol != :grpc && @server.instance_variable_get(:@methods)[path].is_a?(BidiStreamMethod)) ||
|
|
35
|
+
(request.method == "POST" && protocol == :connect &&
|
|
36
|
+
!Streaming.codec(Protocol.single(headers, "content-type")).nil?) ||
|
|
37
|
+
http1_streaming_route?(request)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def http1_streaming_route?(request)
|
|
41
|
+
return false if request.respond_to?(:stream)
|
|
42
|
+
|
|
43
|
+
path = request.path&.split("?", 2)&.first
|
|
44
|
+
return false unless path && @server.handles?(path)
|
|
45
|
+
|
|
46
|
+
declaration = @server.instance_variable_get(:@methods)[path]
|
|
47
|
+
headers = Internal.headers(request)
|
|
48
|
+
protocol = @server.send(:request_protocol, headers, request.method)
|
|
49
|
+
return false if protocol == :grpc_web
|
|
50
|
+
|
|
51
|
+
(declaration && !declaration.is_a?(Method)) ||
|
|
52
|
+
(request.method == "POST" && protocol == :connect &&
|
|
53
|
+
!Streaming.codec(Protocol.single(headers, "content-type")).nil?)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private :publication?, :http1_streaming_route?
|
|
57
|
+
|
|
58
|
+
def admission_response(request)
|
|
59
|
+
path = request.path&.split("?", 2)&.first
|
|
60
|
+
response = TransportResponse.new(status: 503, headers: {}, body: "")
|
|
61
|
+
if path
|
|
62
|
+
response = @server.cors_response(path: path, http_method: request.method,
|
|
63
|
+
headers: Internal.headers(request), response: response) || response
|
|
64
|
+
end
|
|
65
|
+
::Protocol::HTTP::Response[response.status, Internal.fields(response.headers), nil]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call_with_publication(request, publication:, lifecycle: nil, resource: nil)
|
|
69
|
+
path, = request.path.split("?", 2)
|
|
70
|
+
headers = Internal.headers(request)
|
|
71
|
+
# This source-coupled host shares core's selection before choosing output.
|
|
72
|
+
protocol = @server.send(:request_protocol, headers, request.method)
|
|
73
|
+
h1_connect = !request.respond_to?(:stream) && http1_streaming_route?(request)
|
|
74
|
+
return call(request, lifecycle: lifecycle) if !h1_connect && request.method == "GET" && protocol == :connect
|
|
75
|
+
|
|
76
|
+
web = protocol == :grpc_web
|
|
77
|
+
grpc = protocol == :grpc
|
|
78
|
+
stream = request.stream if request.respond_to?(:stream)
|
|
79
|
+
resource ||= stream&.connection&.resource
|
|
80
|
+
if web && !stream && resource.respond_to?(:web_input)
|
|
81
|
+
h1_input = resource.web_input(request.body, headers: headers)
|
|
82
|
+
end
|
|
83
|
+
early = @server.cors_response(path: path, http_method: request.method, headers: headers)
|
|
84
|
+
h1_input&.close if early
|
|
85
|
+
return ::Protocol::HTTP::Response[early.status, Internal.fields(early.headers), nil] if early
|
|
86
|
+
if h1_connect && @server.instance_variable_get(:@methods)[path].is_a?(BidiStreamMethod)
|
|
87
|
+
return ::Protocol::HTTP::Response[505, {}, nil]
|
|
88
|
+
end
|
|
89
|
+
return call(request, lifecycle: lifecycle) if grpc && !stream
|
|
90
|
+
if h1_connect && request.method == "POST" && request.version != "HTTP/1.1" &&
|
|
91
|
+
Streaming.codec(Protocol.single(headers, "content-type"))
|
|
92
|
+
return ::Protocol::HTTP::Response[505, {}, nil]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless @server.handles?(path) && (web || grpc || Streaming.codec(Protocol.single(headers, "content-type")))
|
|
96
|
+
return call(request, lifecycle: lifecycle)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
body = web ? Internal::GRPCWebBody.new(publication) : Internal::OutputBody.new
|
|
100
|
+
trailers = ::Protocol::HTTP::Headers.new if grpc
|
|
101
|
+
output = if web
|
|
102
|
+
Internal::GRPCWebServerOutput.new(body)
|
|
103
|
+
elsif grpc
|
|
104
|
+
Internal::GRPCServerOutput.new(body, trailers)
|
|
105
|
+
else
|
|
106
|
+
Internal::ServerOutput.new(body)
|
|
107
|
+
end
|
|
108
|
+
begin
|
|
109
|
+
input_type = grpc ? Internal::GRPCInput : Internal::Input
|
|
110
|
+
input = input_type.new(request.body, resource: resource)
|
|
111
|
+
resource.input_retirement.input = input if h1_connect && resource&.input_retirement
|
|
112
|
+
entrypoint = if web
|
|
113
|
+
:call_grpc_web
|
|
114
|
+
else
|
|
115
|
+
(grpc ? :call_grpc : :call_stream)
|
|
116
|
+
end
|
|
117
|
+
options = if web
|
|
118
|
+
{ capabilities: %i[unary server_stream] }
|
|
119
|
+
elsif stream
|
|
120
|
+
{ http2: true }
|
|
121
|
+
else
|
|
122
|
+
{ capabilities: %i[server_stream client_stream] }
|
|
123
|
+
end
|
|
124
|
+
response = @server.public_send(
|
|
125
|
+
entrypoint, path: path, http_method: request.method, headers: headers,
|
|
126
|
+
input: input,
|
|
127
|
+
output: output, **options,
|
|
128
|
+
lifecycle: lifecycle, cancellation: stream&.processing_cancellation,
|
|
129
|
+
publish: lambda { |head|
|
|
130
|
+
fields = ::Protocol::HTTP::Headers[Internal.fields(head.headers)]
|
|
131
|
+
fields.trailer!
|
|
132
|
+
output.headers = fields if grpc
|
|
133
|
+
publication.publish(::Protocol::HTTP::Response[head.status, fields, body])
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if response
|
|
137
|
+
return ::Protocol::HTTP::Response[response.status, Internal.fields(response.headers),
|
|
138
|
+
response.body.empty? ? nil : [response.body]]
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
stream&.detach_cancellation
|
|
142
|
+
# HTTP/1 Finishable retains a reading request until explicit close,
|
|
143
|
+
# even after EOF. The original processor owns this cleanup too.
|
|
144
|
+
if web && !stream
|
|
145
|
+
h1_input ? h1_input.close : request.body&.close
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
body.close_write
|
|
149
|
+
nil
|
|
150
|
+
ensure
|
|
151
|
+
resource.input_retirement&.processor_finished if resource.respond_to?(:input_retirement)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def call(request, lifecycle: nil)
|
|
155
|
+
stream = request.stream if request.respond_to?(:stream)
|
|
156
|
+
stream = nil unless stream.is_a?(Internal::RequestStream)
|
|
157
|
+
path, query = request.path.split("?", 2)
|
|
158
|
+
unless @server.handles?(path)
|
|
159
|
+
return @fallback ? @fallback.call(request) : ::Protocol::HTTP::Response[404, {}, nil]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
response = @server.call(path: path, http_method: request.method, headers: Internal.headers(request),
|
|
163
|
+
input: Internal::Input.new(request.body), query: query || "", lifecycle: lifecycle,
|
|
164
|
+
cancellation: stream&.processing_cancellation)
|
|
165
|
+
body = response.body.empty? ? nil : [response.body]
|
|
166
|
+
::Protocol::HTTP::Response[response.status, Internal.fields(response.headers), body]
|
|
167
|
+
ensure
|
|
168
|
+
stream&.detach_cancellation
|
|
169
|
+
Internal.close_consumed_http1(request)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
module Internal
|
|
6
|
+
# A result is distinct from task finalization and retained child ownership.
|
|
7
|
+
class CompletionJoin
|
|
8
|
+
# Installed only on a selected instance, with independent state per gem.
|
|
9
|
+
module Consume
|
|
10
|
+
def consume
|
|
11
|
+
result = super
|
|
12
|
+
@connectrpc_async_completion&.consumed
|
|
13
|
+
result
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
private_constant :Consume
|
|
17
|
+
|
|
18
|
+
class Observation
|
|
19
|
+
attr_reader :required, :generation, :promise, :violation
|
|
20
|
+
|
|
21
|
+
def initialize(target)
|
|
22
|
+
@target = target
|
|
23
|
+
@fiber = target.fiber
|
|
24
|
+
@scheduler = Fiber.scheduler
|
|
25
|
+
@required = {}.compare_by_identity
|
|
26
|
+
@generation = 0
|
|
27
|
+
@promise = ::Async::Promise.new
|
|
28
|
+
@active = true
|
|
29
|
+
@terminal = false
|
|
30
|
+
@queued = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def terminal? = @terminal
|
|
34
|
+
def fiber_alive? = @fiber&.alive?
|
|
35
|
+
|
|
36
|
+
def retain(children)
|
|
37
|
+
added = false
|
|
38
|
+
children.each do |child|
|
|
39
|
+
next if @required.key?(child)
|
|
40
|
+
|
|
41
|
+
@required[child] = true
|
|
42
|
+
added = true
|
|
43
|
+
end
|
|
44
|
+
advance if added
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Node#consume can expose work before Task#finished? becomes true.
|
|
48
|
+
# Capture identities here, before any deferred check or tree mutation.
|
|
49
|
+
def consumed
|
|
50
|
+
return unless @active
|
|
51
|
+
|
|
52
|
+
retain(CompletionJoin.children(@target))
|
|
53
|
+
return if @terminal || @queued
|
|
54
|
+
|
|
55
|
+
no_work = @required.each_key.none? { |child| CompletionJoin.pending?(child) }
|
|
56
|
+
return unless @target.finished? || (@target.fiber.nil? && no_work)
|
|
57
|
+
|
|
58
|
+
raise "Completion scheduler closed before finalization" if @scheduler.closed?
|
|
59
|
+
|
|
60
|
+
@queued = Check.new(self)
|
|
61
|
+
@scheduler.push(@queued)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
violate(e)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check
|
|
67
|
+
@queued = nil
|
|
68
|
+
retain(CompletionJoin.children(@target))
|
|
69
|
+
unless @target.finished?
|
|
70
|
+
if !fiber_alive? && @required.each_key.none? { |child| CompletionJoin.pending?(child) }
|
|
71
|
+
raise CompletionJoin.residual_violation(@target)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise "Finished task still has its captured live fiber" if fiber_alive?
|
|
78
|
+
|
|
79
|
+
@terminal = true
|
|
80
|
+
# The original fiber is gone, but retained joins can still expose
|
|
81
|
+
# new membership at consume. Keep that hook active until joined.
|
|
82
|
+
@fiber = @scheduler = nil
|
|
83
|
+
advance
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def violate(error)
|
|
87
|
+
return if @violation
|
|
88
|
+
|
|
89
|
+
@violation = error
|
|
90
|
+
release
|
|
91
|
+
advance
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Keep the violation inspectable even if shutdown prevents waking.
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def release
|
|
98
|
+
@active = false
|
|
99
|
+
@fiber = @scheduler = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def advance
|
|
105
|
+
previous = @promise
|
|
106
|
+
@generation += 1
|
|
107
|
+
@promise = ::Async::Promise.new
|
|
108
|
+
previous.resolve(nil)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_constant :Observation
|
|
112
|
+
|
|
113
|
+
# Scheduler callbacks run after finish!'s non-yielding tail. They are
|
|
114
|
+
# neither Tasks nor children, and never execute application work.
|
|
115
|
+
class Check
|
|
116
|
+
def initialize(observation)
|
|
117
|
+
@observation = observation
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def alive? = !@observation.nil?
|
|
121
|
+
|
|
122
|
+
def transfer
|
|
123
|
+
observation = @observation
|
|
124
|
+
return unless observation
|
|
125
|
+
|
|
126
|
+
@observation = nil
|
|
127
|
+
observation.check
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
observation.violate(e)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
private_constant :Check
|
|
133
|
+
|
|
134
|
+
def self.observation(target)
|
|
135
|
+
target.instance_variable_get(:@connectrpc_async_completion)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.children(target)
|
|
139
|
+
target.children&.to_a&.reject { |child| child.transient? || child.finished? } || []
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.pending?(target, seen = {}.compare_by_identity)
|
|
143
|
+
return false if seen.key?(target)
|
|
144
|
+
return true unless target.finished?
|
|
145
|
+
|
|
146
|
+
seen[target] = true
|
|
147
|
+
state = observation(target)
|
|
148
|
+
return false unless state
|
|
149
|
+
|
|
150
|
+
!state.terminal? || state.violation || state.required.each_key.any? { |child| pending?(child, seen) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.scan(target, retained)
|
|
154
|
+
actual = children(target)
|
|
155
|
+
state = observation(target)
|
|
156
|
+
state&.retain(actual)
|
|
157
|
+
actual.each { |child| retained[child] = true }
|
|
158
|
+
state&.required&.each_key { |child| retained[child] = true }
|
|
159
|
+
retained.keys.select { |child| pending?(child) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.residual_violation(target)
|
|
163
|
+
attached = target.children&.to_a&.map do |child|
|
|
164
|
+
{ status: child.status, finished: child.finished?, transient: child.transient? }
|
|
165
|
+
end
|
|
166
|
+
RuntimeError.new("Completion observation violation: unfinished task without a live fiber or required work; " \
|
|
167
|
+
"status=#{target.status}; attached_children=#{attached.inspect}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.call(task, failure: false, on_interrupt: nil)
|
|
171
|
+
interruption = stored_failure = nil
|
|
172
|
+
pending = [[task, false, {}.compare_by_identity]]
|
|
173
|
+
begin
|
|
174
|
+
yield if block_given?
|
|
175
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- finish the join before propagating caller interruption
|
|
176
|
+
interruption = e
|
|
177
|
+
end
|
|
178
|
+
while (entry = pending.last)
|
|
179
|
+
target, known, retained = entry
|
|
180
|
+
raise ArgumentError, "Cannot join the current task" if target.current?
|
|
181
|
+
|
|
182
|
+
begin
|
|
183
|
+
if interruption && on_interrupt
|
|
184
|
+
request = on_interrupt
|
|
185
|
+
on_interrupt = nil
|
|
186
|
+
request.call
|
|
187
|
+
end
|
|
188
|
+
target.wait unless known
|
|
189
|
+
entry[1] = true
|
|
190
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- repeated interruption cannot abandon retirement
|
|
191
|
+
if target.failed? && target.result.equal?(e)
|
|
192
|
+
stored_failure ||= e if (failure && target.equal?(task)) || !e.is_a?(StandardError)
|
|
193
|
+
entry[1] = true
|
|
194
|
+
else
|
|
195
|
+
interruption ||= e
|
|
196
|
+
end
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
work = scan(target, retained)
|
|
201
|
+
state = observation(target)
|
|
202
|
+
state.consumed if state && target.finished? && !state.terminal?
|
|
203
|
+
raise state.violation if state&.violation
|
|
204
|
+
|
|
205
|
+
unless work.empty?
|
|
206
|
+
pending.concat(work.map { |child| [child, false, {}.compare_by_identity] })
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
if target.finished? && (!state || state.terminal?)
|
|
210
|
+
state&.release
|
|
211
|
+
pending.pop
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
unless state
|
|
216
|
+
raise residual_violation(target) unless target.fiber&.alive?
|
|
217
|
+
|
|
218
|
+
state = Observation.new(target)
|
|
219
|
+
target.instance_variable_set(:@connectrpc_async_completion, state)
|
|
220
|
+
target.extend(Consume)
|
|
221
|
+
# Installation, capture and the first rescan have no yielding gap.
|
|
222
|
+
state.consumed
|
|
223
|
+
next
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
generation = state.generation
|
|
227
|
+
signal = state.promise
|
|
228
|
+
next unless scan(target, retained).empty?
|
|
229
|
+
raise state.violation if state.violation
|
|
230
|
+
next if target.finished? && state.terminal?
|
|
231
|
+
raise residual_violation(target) if !target.finished? && !state.fiber_alive?
|
|
232
|
+
next unless generation == state.generation && signal.equal?(state.promise)
|
|
233
|
+
|
|
234
|
+
begin
|
|
235
|
+
signal.wait
|
|
236
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- latch interruption owns no completion credit
|
|
237
|
+
interruption ||= e
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
raise interruption if interruption
|
|
241
|
+
raise stored_failure if stored_failure
|
|
242
|
+
|
|
243
|
+
task.result
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
private_constant :CompletionJoin
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|