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,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