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