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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
module Internal
|
|
6
|
+
# A producer never waits for the consumer. Stream credit bounds payload;
|
|
7
|
+
# coalescing bounds entries independently of the peer's frame sizes.
|
|
8
|
+
class IngressQueue
|
|
9
|
+
CHUNK_SIZE = 16_384
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@chunks = []
|
|
13
|
+
@closed = false
|
|
14
|
+
@changed = ::Async::Condition.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def push(bytes)
|
|
18
|
+
raise ::Protocol::HTTP::Body::Writable::Closed if @closed
|
|
19
|
+
|
|
20
|
+
offset = 0
|
|
21
|
+
while offset < bytes.bytesize
|
|
22
|
+
tail = @chunks.last
|
|
23
|
+
if !tail || tail.bytesize == CHUNK_SIZE
|
|
24
|
+
tail = +"".b
|
|
25
|
+
@chunks << tail
|
|
26
|
+
end
|
|
27
|
+
size = [CHUNK_SIZE - tail.bytesize, bytes.bytesize - offset].min
|
|
28
|
+
tail << bytes.byteslice(offset, size)
|
|
29
|
+
offset += size
|
|
30
|
+
end
|
|
31
|
+
@changed.signal
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pop
|
|
35
|
+
@changed.wait while @chunks.empty? && !@closed
|
|
36
|
+
@chunks.shift
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear = @chunks.clear
|
|
40
|
+
def empty? = @chunks.empty?
|
|
41
|
+
def closed? = @closed
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
@closed = true
|
|
45
|
+
@changed.signal
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Ingress < ::Protocol::HTTP::Body::Writable
|
|
50
|
+
def initialize(stream, length)
|
|
51
|
+
super(length, queue: IngressQueue.new)
|
|
52
|
+
@stream = stream
|
|
53
|
+
@remaining = length
|
|
54
|
+
@retained = 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write(bytes)
|
|
58
|
+
@retained += bytes.bytesize
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read
|
|
63
|
+
chunk = super
|
|
64
|
+
if chunk
|
|
65
|
+
@retained -= chunk.bytesize
|
|
66
|
+
@remaining -= chunk.bytesize if @remaining
|
|
67
|
+
@stream&.reclaim_input(chunk.bytesize)
|
|
68
|
+
elsif @remaining && !@remaining.zero?
|
|
69
|
+
direction = @remaining.positive? ? "short" : "over"
|
|
70
|
+
raise EOFError, "Expected #{length} bytes, #{@remaining} bytes #{direction}!"
|
|
71
|
+
end
|
|
72
|
+
chunk
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def close(error = nil)
|
|
76
|
+
stream = @stream
|
|
77
|
+
@stream = nil
|
|
78
|
+
discarded = @retained
|
|
79
|
+
@retained = 0
|
|
80
|
+
super
|
|
81
|
+
return unless stream
|
|
82
|
+
|
|
83
|
+
stream.finish_input(self, error)
|
|
84
|
+
stream.reclaim_input(discarded) unless error
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A normal producer EOF preserves preceding data. A failed producer
|
|
88
|
+
# releases it immediately, and both paths detach the retired stream.
|
|
89
|
+
def retire(error = nil, discard: false)
|
|
90
|
+
@stream = nil
|
|
91
|
+
@error ||= error
|
|
92
|
+
return unless error || discard
|
|
93
|
+
|
|
94
|
+
@retained = 0
|
|
95
|
+
@queue.clear
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "completion_join"
|
|
4
|
+
|
|
5
|
+
module ConnectRPC
|
|
6
|
+
module Async
|
|
7
|
+
module Internal
|
|
8
|
+
CLEANUP_SECONDS = 0.1
|
|
9
|
+
MAX_WAIT = 86_400
|
|
10
|
+
|
|
11
|
+
# Retained by the host through response output, independent of handler tasks.
|
|
12
|
+
class Exchange
|
|
13
|
+
attr_accessor :decision, :http_status
|
|
14
|
+
|
|
15
|
+
def initialize(lifecycle)
|
|
16
|
+
@lifecycle = lifecycle
|
|
17
|
+
@decision = :processed
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def finish(completed, error = nil)
|
|
21
|
+
@lifecycle.finish(delivery: completed ? :completed : :aborted, error: error,
|
|
22
|
+
decision: @lifecycle.host_rejected? ? :host_rejected : @decision,
|
|
23
|
+
http_status: @http_status)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class WaitTimeout < ::Async::TimeoutError; end
|
|
28
|
+
private_constant :WaitTimeout
|
|
29
|
+
|
|
30
|
+
def self.now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
|
|
32
|
+
def self.positive_integer(value, name)
|
|
33
|
+
raise ArgumentError, "#{name} must be a positive integer" unless value.is_a?(Integer) && value.positive?
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.seconds(value, name, zero: false)
|
|
39
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && (zero ? value >= 0 : value.positive?)
|
|
40
|
+
raise ArgumentError, "#{name} must be finite #{zero ? 'nonnegative' : 'positive'} seconds"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.finite(value, name, requirement)
|
|
47
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && yield(value)
|
|
48
|
+
raise ArgumentError, "#{name} must be a finite number #{requirement}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Monotonic time and scheduler-aware sleep. Specs replace a manager's
|
|
55
|
+
# clock to control backoff timing.
|
|
56
|
+
module MonotonicClock
|
|
57
|
+
def self.now = Internal.now
|
|
58
|
+
|
|
59
|
+
def self.sleep(seconds)
|
|
60
|
+
expires = now + seconds
|
|
61
|
+
while (remaining = expires - now).positive?
|
|
62
|
+
::Kernel.sleep([remaining, MAX_WAIT].min)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Timeouts are injected only into a supervisor waiting on a private task.
|
|
68
|
+
def self.wait(task, expires = nil)
|
|
69
|
+
return task.wait unless expires
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
remaining = expires - now
|
|
73
|
+
raise ::Async::TimeoutError if remaining <= 0
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
return ::Async::Task.current.with_timeout([remaining, MAX_WAIT].min, WaitTimeout) { task.wait }
|
|
77
|
+
rescue WaitTimeout
|
|
78
|
+
# Never mistake a child's timeout for this supervisor's timer, or
|
|
79
|
+
# expose our sentinel to a surrounding wait on this task.
|
|
80
|
+
raise ::Async::TimeoutError if now >= expires
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.join(task)
|
|
86
|
+
task&.wait unless task&.current?
|
|
87
|
+
rescue StandardError
|
|
88
|
+
# The owner/supervisor reports the original failure.
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.join_complete(...) = CompletionJoin.call(...)
|
|
93
|
+
|
|
94
|
+
def self.cancel(task)
|
|
95
|
+
return unless task && !task.current? && !task.finished?
|
|
96
|
+
|
|
97
|
+
task.cancel
|
|
98
|
+
task.cancel if task.cancel_deferred?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.raw_close(raw)
|
|
102
|
+
raw.close if raw && !raw.closed?
|
|
103
|
+
rescue IOError, SystemCallError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
module Affinity
|
|
108
|
+
def initialize_affinity
|
|
109
|
+
@owner = ::Async::Task.current?
|
|
110
|
+
raise LifecycleError, "An application-owned Async task is required" unless @owner
|
|
111
|
+
|
|
112
|
+
@thread = Thread.current
|
|
113
|
+
@reactor = Fiber.scheduler
|
|
114
|
+
@progress = ::Async::Condition.new
|
|
115
|
+
@resources = []
|
|
116
|
+
@state = :new
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def check_affinity!
|
|
120
|
+
return if Thread.current.equal?(@thread) && Fiber.scheduler.equal?(@reactor) && ::Async::Task.current?
|
|
121
|
+
|
|
122
|
+
raise LifecycleError, "Use the owning Async reactor and thread"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def install_owner_guard
|
|
126
|
+
@lifetime = @owner.async do
|
|
127
|
+
::Async::Condition.new.wait
|
|
128
|
+
ensure
|
|
129
|
+
abort_owned unless @state == :closed
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def close(grace: 5)
|
|
134
|
+
check_affinity!
|
|
135
|
+
Internal.seconds(grace, "grace", zero: true)
|
|
136
|
+
return @close_outcome if @state == :closed
|
|
137
|
+
|
|
138
|
+
if @state == :closing
|
|
139
|
+
@progress.wait while @state == :closing
|
|
140
|
+
return @close_outcome
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
@state = :closing
|
|
144
|
+
@closer = @owner.async(finished: false) do
|
|
145
|
+
begin
|
|
146
|
+
close_owned(Internal.now + grace)
|
|
147
|
+
ensure
|
|
148
|
+
abort_owned
|
|
149
|
+
end
|
|
150
|
+
@close_outcome
|
|
151
|
+
end
|
|
152
|
+
@closer.wait
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def finish_closed(outcome)
|
|
156
|
+
@close_outcome = outcome
|
|
157
|
+
@state = :closed
|
|
158
|
+
@progress.signal
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private :initialize_affinity, :check_affinity!, :install_owner_guard, :finish_closed
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Core reads at most n bytes. Keep only the remainder of the current
|
|
165
|
+
# backend chunk, including when a request crosses an exact chunk boundary.
|
|
166
|
+
class Input
|
|
167
|
+
def initialize(body, resource: nil)
|
|
168
|
+
@body = body
|
|
169
|
+
@resource = resource
|
|
170
|
+
@remainder = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def read(length, timeout: nil)
|
|
174
|
+
return read_fragment(length) unless timeout
|
|
175
|
+
|
|
176
|
+
task = ::Async::Task.current.async(finished: false) { read_fragment(length) }
|
|
177
|
+
Internal.wait(task, Internal.now + timeout)
|
|
178
|
+
ensure
|
|
179
|
+
if task && !task.finished?
|
|
180
|
+
cleanup = ::Async::Task.current.async(finished: false) do
|
|
181
|
+
task.cancel
|
|
182
|
+
Internal.join(task)
|
|
183
|
+
end
|
|
184
|
+
begin
|
|
185
|
+
Internal.wait(cleanup, Internal.now + CLEANUP_SECONDS)
|
|
186
|
+
rescue StandardError
|
|
187
|
+
@resource&.abort
|
|
188
|
+
Internal.cancel(cleanup)
|
|
189
|
+
Internal.join(cleanup)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def release_remainder
|
|
195
|
+
size = @remainder&.bytesize || 0
|
|
196
|
+
@remainder = nil
|
|
197
|
+
size
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def read_fragment(length)
|
|
203
|
+
@remainder = @body&.read if @remainder.nil? || @remainder.empty?
|
|
204
|
+
return unless @remainder
|
|
205
|
+
|
|
206
|
+
result = @remainder.byteslice(0, length)
|
|
207
|
+
@remainder = @remainder.byteslice(result.bytesize..)
|
|
208
|
+
result
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# HTTP/1 has no stream object retaining admission through body output.
|
|
213
|
+
# Release only after wire writing and response-finished callbacks close
|
|
214
|
+
# the body, including empty responses and cancellation during callbacks.
|
|
215
|
+
class AdmissionBody < ::Protocol::HTTP::Body::Wrapper
|
|
216
|
+
def initialize(body, &completion)
|
|
217
|
+
super(body || ::Protocol::HTTP::Body::Buffered.wrap([]))
|
|
218
|
+
@completion = completion
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def close(error = nil)
|
|
222
|
+
completion = @completion
|
|
223
|
+
return unless completion
|
|
224
|
+
|
|
225
|
+
@completion = nil
|
|
226
|
+
begin
|
|
227
|
+
super
|
|
228
|
+
ensure
|
|
229
|
+
completion.call
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def self.headers(message)
|
|
235
|
+
# H1 chunked parsing appends trailers to the mutable native headers.
|
|
236
|
+
# Every later processing/observation phase must use the leading carrier.
|
|
237
|
+
if !message.respond_to?(:stream) && (leading = message.instance_variable_get(:@connectrpc_leading_headers))
|
|
238
|
+
return leading
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
result = {}
|
|
242
|
+
message.headers.header.each { |key, value| (result[key.downcase] ||= []) << value.dup.freeze }
|
|
243
|
+
length = message.body&.length
|
|
244
|
+
length ||= message.stream.declared_length if message.respond_to?(:stream)
|
|
245
|
+
length = 0 if length.nil? && message.body.nil?
|
|
246
|
+
result["content-length"] = [length.to_s] if length
|
|
247
|
+
unless message.respond_to?(:stream)
|
|
248
|
+
result.each_value(&:freeze)
|
|
249
|
+
message.instance_variable_set(:@connectrpc_leading_headers, result.freeze)
|
|
250
|
+
end
|
|
251
|
+
result
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def self.fields(headers)
|
|
255
|
+
headers.flat_map { |key, values| values.map { |value| [key, value] } }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.close_consumed_http1(request)
|
|
259
|
+
return if request.respond_to?(:stream) || !request.body.respond_to?(:body)
|
|
260
|
+
|
|
261
|
+
framed = request.body.body
|
|
262
|
+
complete = framed.nil? ||
|
|
263
|
+
(framed.is_a?(::Protocol::HTTP1::Body::Fixed) && framed.remaining.zero?) ||
|
|
264
|
+
(framed.is_a?(::Protocol::HTTP1::Body::Chunked) && !framed.length.nil?)
|
|
265
|
+
request.body&.close if complete
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
private_constant :Internal
|
|
269
|
+
end
|
|
270
|
+
end
|