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,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
module Internal
|
|
6
|
+
# Reactor-owned HTTP/2 client connections for one Transport: stream
|
|
7
|
+
# accounting, one shared attempt at a time, backoff, admission bytes and
|
|
8
|
+
# published state. Attempts run under the transport lifetime, outside any
|
|
9
|
+
# RPC task or lifecycle; waiting calls only observe them.
|
|
10
|
+
class ConnectionManager
|
|
11
|
+
UNAVAILABLE = "HTTP/2 transport admission is closed or full"
|
|
12
|
+
CLOSING = "HTTP/2 transport is closing"
|
|
13
|
+
TRANSIENT = "HTTP/2 connection is in transient failure"
|
|
14
|
+
STOPPED = "HTTP/2 connection setup stopped"
|
|
15
|
+
TERMINAL = %i[closing closed].freeze
|
|
16
|
+
|
|
17
|
+
# One shared attempt. Its waiters read the recorded outcome and error.
|
|
18
|
+
class Attempt
|
|
19
|
+
attr_accessor :task, :resource, :outcome, :error
|
|
20
|
+
attr_reader :started_at
|
|
21
|
+
|
|
22
|
+
def initialize(started_at)
|
|
23
|
+
@started_at = started_at
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def failed? = !@outcome.nil? && @outcome != :ready
|
|
27
|
+
end
|
|
28
|
+
private_constant :Attempt
|
|
29
|
+
|
|
30
|
+
# An acquisition waiter. A fail-fast waiter joins at most one attempt.
|
|
31
|
+
Waiter = Struct.new(:wait_for_ready, :joined)
|
|
32
|
+
private_constant :Waiter
|
|
33
|
+
|
|
34
|
+
attr_reader :state, :pending_bytes
|
|
35
|
+
|
|
36
|
+
def initialize(policy:, observer:, connection_limit:, connect_timeout:, lifetime:, progress:, build:,
|
|
37
|
+
setup:, abort_setup:)
|
|
38
|
+
@policy = policy
|
|
39
|
+
@observer = observer
|
|
40
|
+
@connection_limit = connection_limit
|
|
41
|
+
@connect_timeout = connect_timeout
|
|
42
|
+
@lifetime = lifetime
|
|
43
|
+
@progress = progress
|
|
44
|
+
@build = build
|
|
45
|
+
@setup = setup
|
|
46
|
+
@abort_setup = abort_setup
|
|
47
|
+
@connections = {}.compare_by_identity
|
|
48
|
+
@waiters = {}.compare_by_identity
|
|
49
|
+
@attempt = nil
|
|
50
|
+
@failures = 0
|
|
51
|
+
@timer = nil
|
|
52
|
+
@retry_at = nil
|
|
53
|
+
@pending_bytes = 0
|
|
54
|
+
@state = :idle
|
|
55
|
+
@closing = false
|
|
56
|
+
@dispatching = nil
|
|
57
|
+
@clock = MonotonicClock
|
|
58
|
+
@random = ::Random
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stream usage summed over every accounted connection.
|
|
62
|
+
def usage = @connections.each_value.sum
|
|
63
|
+
|
|
64
|
+
def connections = @connections.dup
|
|
65
|
+
|
|
66
|
+
def check_reentry!
|
|
67
|
+
return unless @dispatching.equal?(Fiber.current)
|
|
68
|
+
|
|
69
|
+
raise LifecycleError, "Transport calls and close are not allowed from a connection observer"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reserves request bytes for an admitted call. A full count, an
|
|
73
|
+
# exhausted byte budget or an oversized call fails immediately.
|
|
74
|
+
def admit(bytes, full:)
|
|
75
|
+
limit = @policy.max_pending_bytes
|
|
76
|
+
if full || (limit && @pending_bytes + bytes > limit)
|
|
77
|
+
emit(:admission_rejected)
|
|
78
|
+
raise Error.new(:unavailable, UNAVAILABLE), cause: nil
|
|
79
|
+
end
|
|
80
|
+
@pending_bytes += bytes
|
|
81
|
+
bytes
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def release_bytes(bytes)
|
|
85
|
+
@pending_bytes -= bytes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def acquire(wait_for_ready: @policy.wait_for_ready)
|
|
89
|
+
waiter = Waiter.new(wait_for_ready, nil)
|
|
90
|
+
@waiters[waiter] = true
|
|
91
|
+
loop do
|
|
92
|
+
raise Error.new(:unavailable, CLOSING), cause: nil if @closing
|
|
93
|
+
|
|
94
|
+
if (connection = available)
|
|
95
|
+
@connections[connection] += 1
|
|
96
|
+
return connection
|
|
97
|
+
end
|
|
98
|
+
joined = waiter.joined
|
|
99
|
+
if joined&.failed?
|
|
100
|
+
# Fail-fast waiters wait for one attempt, then only for capacity.
|
|
101
|
+
raise joined.error unless usable_connection?
|
|
102
|
+
elsif @attempt
|
|
103
|
+
waiter.joined = @attempt unless wait_for_ready
|
|
104
|
+
elsif @retry_at
|
|
105
|
+
raise Error.new(:unavailable, TRANSIENT), cause: nil unless wait_for_ready || usable_connection?
|
|
106
|
+
elsif slot_free?
|
|
107
|
+
attempt = start_attempt
|
|
108
|
+
waiter.joined = attempt unless wait_for_ready
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
@progress.wait
|
|
112
|
+
end
|
|
113
|
+
ensure
|
|
114
|
+
@waiters.delete(waiter)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Exactly-once lease release. A connection that stopped accepting
|
|
118
|
+
# streams retires when its last lease is released.
|
|
119
|
+
def release(connection)
|
|
120
|
+
usage = @connections[connection]
|
|
121
|
+
return unless usage
|
|
122
|
+
raise "Trying to release an unacquired HTTP/2 connection" if usage.zero?
|
|
123
|
+
|
|
124
|
+
usage = @connections[connection] = usage - 1
|
|
125
|
+
if usage.zero? && !usable?(connection)
|
|
126
|
+
retire(connection)
|
|
127
|
+
else
|
|
128
|
+
@progress.signal
|
|
129
|
+
end
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Removes the slot from accounting and wakes waiters before the
|
|
134
|
+
# potentially blocking close joins the connection's retirement.
|
|
135
|
+
def retire(connection)
|
|
136
|
+
return unless @connections.key?(connection)
|
|
137
|
+
|
|
138
|
+
@connections.delete(connection)
|
|
139
|
+
reconcile
|
|
140
|
+
connection.close
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def usable?(connection)
|
|
145
|
+
!connection.resource&.finished? && connection.reusable?
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close
|
|
149
|
+
return if @closing
|
|
150
|
+
|
|
151
|
+
@closing = true
|
|
152
|
+
publish(:closing)
|
|
153
|
+
Internal.cancel(@timer)
|
|
154
|
+
@timer = @retry_at = nil
|
|
155
|
+
Internal.cancel(@attempt&.task)
|
|
156
|
+
@progress.signal
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def finish
|
|
160
|
+
close
|
|
161
|
+
@connections.delete_if { |_, usage| usage.zero? }
|
|
162
|
+
publish(:closed)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def available
|
|
168
|
+
@connections.each do |connection, usage|
|
|
169
|
+
return connection if usage < connection.maximum_concurrent_streams && usable?(connection)
|
|
170
|
+
end
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def usable_connection? = @connections.each_key.any? { |connection| usable?(connection) }
|
|
175
|
+
def slot_free? = @connections.size + (@attempt ? 1 : 0) < @connection_limit
|
|
176
|
+
def demand? = @waiters.each_key.any? { |waiter| waiter.wait_for_ready || !waiter.joined&.failed? }
|
|
177
|
+
|
|
178
|
+
def current_state
|
|
179
|
+
return @state if TERMINAL.include?(@state)
|
|
180
|
+
return :ready if usable_connection?
|
|
181
|
+
return :connecting if @attempt
|
|
182
|
+
return :transient_failure if @retry_at
|
|
183
|
+
|
|
184
|
+
:idle
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Starts a demand-driven attempt when one is allowed and no connection
|
|
188
|
+
# has spare capacity, then republishes. Woken waiters take that capacity.
|
|
189
|
+
def reconcile
|
|
190
|
+
unless @closing
|
|
191
|
+
start_attempt if !@attempt && !@retry_at && slot_free? && demand? && !available
|
|
192
|
+
publish(current_state)
|
|
193
|
+
end
|
|
194
|
+
@progress.signal
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def connection_unusable(resource)
|
|
198
|
+
connection = resource.connection
|
|
199
|
+
return unless connection && @connections.key?(connection)
|
|
200
|
+
|
|
201
|
+
@connections.delete(connection) if @connections[connection].zero?
|
|
202
|
+
reconcile
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Publishes the attempt before running its eager task, so a waiter
|
|
206
|
+
# never misses an attempt that finishes before it waits. The task
|
|
207
|
+
# exists first: close can cancel it while an observer yields, and an
|
|
208
|
+
# interrupted publisher still runs or abandons it.
|
|
209
|
+
def start_attempt
|
|
210
|
+
attempt = Attempt.new(@clock.now)
|
|
211
|
+
@attempt = attempt
|
|
212
|
+
resource = attempt.resource = @build.call
|
|
213
|
+
resource.on_unusable = method(:connection_unusable)
|
|
214
|
+
task = attempt.task = ::Async::Task.new(@lifetime, finished: false) { run_attempt(attempt) }
|
|
215
|
+
begin
|
|
216
|
+
publish(current_state)
|
|
217
|
+
ensure
|
|
218
|
+
task.finished? ? abandon(attempt) : task.run
|
|
219
|
+
end
|
|
220
|
+
attempt
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def run_attempt(attempt)
|
|
224
|
+
resource = attempt.resource
|
|
225
|
+
outcome = ::Async::Promise.new
|
|
226
|
+
setup = ::Async::Task.new(@lifetime, finished: false) do
|
|
227
|
+
@setup.call(resource)
|
|
228
|
+
outcome.resolve(nil)
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
outcome.resolve(e)
|
|
231
|
+
ensure
|
|
232
|
+
outcome.resolve(IOError.new(STOPPED))
|
|
233
|
+
end
|
|
234
|
+
resource.task = setup
|
|
235
|
+
setup.run
|
|
236
|
+
error = wait_setup(outcome, Internal.now + @connect_timeout)
|
|
237
|
+
connection = resource.connection
|
|
238
|
+
# A connection that died before registration never becomes ready.
|
|
239
|
+
error ||= IOError.new("HTTP/2 connection closed before readiness") unless connection && usable?(connection)
|
|
240
|
+
return abandon(attempt) if @closing
|
|
241
|
+
|
|
242
|
+
error ? fail_attempt(attempt, error) : succeed(attempt, connection)
|
|
243
|
+
rescue ::Async::TimeoutError => e
|
|
244
|
+
@closing ? abandon(attempt) : fail_attempt(attempt, e)
|
|
245
|
+
rescue ::Async::Cancel
|
|
246
|
+
abandon(attempt)
|
|
247
|
+
raise
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def wait_setup(outcome, expires)
|
|
251
|
+
loop do
|
|
252
|
+
remaining = expires - Internal.now
|
|
253
|
+
raise ::Async::TimeoutError, "HTTP/2 connection setup timed out" if remaining <= 0
|
|
254
|
+
|
|
255
|
+
begin
|
|
256
|
+
return outcome.wait(timeout: [remaining, MAX_WAIT].min)
|
|
257
|
+
rescue ::Async::TimeoutError
|
|
258
|
+
raise if Internal.now >= expires
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def succeed(attempt, connection)
|
|
264
|
+
@connections[connection] = 0
|
|
265
|
+
@failures = 0
|
|
266
|
+
attempt.outcome = :ready
|
|
267
|
+
@attempt = nil
|
|
268
|
+
publish(current_state)
|
|
269
|
+
emit(:attempt_finished, outcome: :ready, duration: @clock.now - attempt.started_at)
|
|
270
|
+
ensure
|
|
271
|
+
@progress.signal
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def fail_attempt(attempt, error)
|
|
275
|
+
# Raw-first abort; this can wait for the setup task to retire.
|
|
276
|
+
@abort_setup.call(attempt.resource)
|
|
277
|
+
return abandon(attempt) if @closing
|
|
278
|
+
|
|
279
|
+
@failures += 1
|
|
280
|
+
attempt.error = error
|
|
281
|
+
attempt.outcome = classify(error)
|
|
282
|
+
@attempt = nil
|
|
283
|
+
arm_timer if @policy.backoff
|
|
284
|
+
publish(current_state)
|
|
285
|
+
emit(:attempt_finished, outcome: attempt.outcome, duration: @clock.now - attempt.started_at)
|
|
286
|
+
ensure
|
|
287
|
+
@progress.signal
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Close won: no registration, timer or state transition.
|
|
291
|
+
def abandon(attempt)
|
|
292
|
+
@abort_setup.call(attempt.resource)
|
|
293
|
+
ensure
|
|
294
|
+
attempt.error ||= Error.new(:unavailable, CLOSING)
|
|
295
|
+
attempt.outcome ||= :closed
|
|
296
|
+
@attempt = nil if @attempt.equal?(attempt)
|
|
297
|
+
@progress.signal
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def classify(error)
|
|
301
|
+
case error
|
|
302
|
+
when Errno::ECONNREFUSED then :refused
|
|
303
|
+
when ::Async::TimeoutError, Errno::ETIMEDOUT then :timeout
|
|
304
|
+
when OpenSSL::SSL::SSLError then :tls
|
|
305
|
+
when ::Protocol::HTTP2::Error then :protocol
|
|
306
|
+
when EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError then :closed
|
|
307
|
+
else :other
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def arm_timer
|
|
312
|
+
backoff = @policy.backoff
|
|
313
|
+
delay = [backoff.base * (backoff.multiplier.to_f**(@failures - 1)), backoff.max].min *
|
|
314
|
+
(1 - (@random.rand * backoff.jitter))
|
|
315
|
+
@retry_at = @clock.now + delay
|
|
316
|
+
timer = ::Async::Task.new(@lifetime, finished: false) do
|
|
317
|
+
@clock.sleep(delay)
|
|
318
|
+
if @timer.equal?(timer)
|
|
319
|
+
@timer = @retry_at = nil
|
|
320
|
+
reconcile
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
@timer = timer
|
|
324
|
+
timer.run
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def publish(state)
|
|
328
|
+
previous = @state
|
|
329
|
+
return if previous == state
|
|
330
|
+
|
|
331
|
+
@state = state
|
|
332
|
+
emit(:state_changed, previous_state: previous)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Observers run synchronously after bookkeeping and must return quickly.
|
|
336
|
+
def emit(type, previous_state: nil, outcome: nil, duration: nil)
|
|
337
|
+
return unless @observer
|
|
338
|
+
|
|
339
|
+
event = ConnectionEvent.new(
|
|
340
|
+
type: type, state: @state, previous_state: previous_state, outcome: outcome, duration: duration,
|
|
341
|
+
active: @connections.each_key.count { |connection| usable?(connection) },
|
|
342
|
+
draining: @connections.each_key.count { |connection| !usable?(connection) },
|
|
343
|
+
pending_calls: @waiters.size, pending_bytes: @pending_bytes
|
|
344
|
+
)
|
|
345
|
+
dispatching = @dispatching
|
|
346
|
+
@dispatching = Fiber.current
|
|
347
|
+
begin
|
|
348
|
+
@observer.call(event)
|
|
349
|
+
rescue StandardError
|
|
350
|
+
nil
|
|
351
|
+
ensure
|
|
352
|
+
@dispatching = dispatching
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
# Capped, jittered delay between failed connection attempts. Jitter only
|
|
6
|
+
# shortens a delay, so `max` is a hard maximum.
|
|
7
|
+
class Backoff
|
|
8
|
+
attr_reader :base, :multiplier, :jitter, :max
|
|
9
|
+
|
|
10
|
+
def initialize(base: 1.0, multiplier: 1.6, jitter: 0.2, max: 30.0)
|
|
11
|
+
@base = Internal.seconds(base, "base")
|
|
12
|
+
@multiplier = Internal.finite(multiplier, "multiplier", "at least 1") { |value| value >= 1 }
|
|
13
|
+
@jitter = Internal.finite(jitter, "jitter", "in 0...1") { |value| value >= 0 && value < 1 }
|
|
14
|
+
@max = Internal.finite(max, "max", "at least base") { |value| value >= @base }
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ==(other) = other.is_a?(Backoff) && values == other.values
|
|
19
|
+
def eql?(other) = self == other
|
|
20
|
+
def hash = [Backoff, *values].hash
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
def values = [@base, @multiplier, @jitter, @max]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Connection behavior for the HTTP/2 Transport. Every feature can be turned
|
|
28
|
+
# off: `backoff: nil` and `max_pending_bytes: nil`.
|
|
29
|
+
class ConnectionPolicy
|
|
30
|
+
attr_reader :backoff, :wait_for_ready, :max_pending_bytes
|
|
31
|
+
|
|
32
|
+
def initialize(backoff: Backoff.new, wait_for_ready: false, max_pending_bytes: 16 * 1024 * 1024)
|
|
33
|
+
raise ArgumentError, "backoff must be a Backoff or nil" unless backoff.nil? || backoff.is_a?(Backoff)
|
|
34
|
+
raise ArgumentError, "wait_for_ready must be true or false" unless [true, false].include?(wait_for_ready)
|
|
35
|
+
raise ArgumentError, "wait_for_ready requires a backoff" if wait_for_ready && backoff.nil?
|
|
36
|
+
unless max_pending_bytes.nil? || (max_pending_bytes.is_a?(Integer) && max_pending_bytes.positive?)
|
|
37
|
+
raise ArgumentError, "max_pending_bytes must be a positive integer or nil"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@backoff = backoff
|
|
41
|
+
@wait_for_ready = wait_for_ready
|
|
42
|
+
@max_pending_bytes = max_pending_bytes
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ==(other) = other.is_a?(ConnectionPolicy) && values == other.values
|
|
47
|
+
def eql?(other) = self == other
|
|
48
|
+
def hash = [ConnectionPolicy, *values].hash
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
def values = [@backoff, @wait_for_ready, @max_pending_bytes]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A connection observation. It carries counts and classifications only:
|
|
56
|
+
# no addresses, certificates, payloads, URLs or exception text.
|
|
57
|
+
ConnectionEvent = Data.define(:type, :state, :previous_state, :outcome, :duration, :active, :draining,
|
|
58
|
+
:pending_calls, :pending_bytes)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Async
|
|
5
|
+
# Strict HTTP/2 listener composing the same worker session as other hosts.
|
|
6
|
+
class Host
|
|
7
|
+
ACCEPT_RETRY_DELAYS = [0.05, 0.1, 0.2, 0.4, 0.5].freeze
|
|
8
|
+
ACCEPT_RETRY_ERRORS = [Errno::EMFILE, Errno::ENFILE, Errno::ENOBUFS, Errno::ENOMEM,
|
|
9
|
+
Errno::ECONNABORTED, Errno::EINTR].freeze
|
|
10
|
+
private_constant :ACCEPT_RETRY_DELAYS, :ACCEPT_RETRY_ERRORS
|
|
11
|
+
|
|
12
|
+
attr_reader :port, :addresses
|
|
13
|
+
|
|
14
|
+
def initialize(server:, host: "127.0.0.1", port: 0, tls: nil, tls_provider: nil,
|
|
15
|
+
max_connections: 128, max_requests: 128, handshake_timeout: 10, idle_timeout: 60,
|
|
16
|
+
refresh_interval: 60, refresh_jitter: 0.1, refresh_timeout: 10, on_tls_error: nil)
|
|
17
|
+
@host = host
|
|
18
|
+
@requested_port = port
|
|
19
|
+
@addresses = [].freeze
|
|
20
|
+
@owner = ::Async::Task.current?
|
|
21
|
+
@listeners = []
|
|
22
|
+
@completion = ::Async::Promise.new
|
|
23
|
+
application = Application.new(server: server)
|
|
24
|
+
@session = build_session(
|
|
25
|
+
application: application, lifecycle_factory: application.method(:start_call),
|
|
26
|
+
tls: tls, tls_provider: tls_provider,
|
|
27
|
+
max_connections: max_connections, max_requests: max_requests,
|
|
28
|
+
handshake_timeout: handshake_timeout, idle_timeout: idle_timeout,
|
|
29
|
+
refresh_interval: refresh_interval, refresh_jitter: refresh_jitter,
|
|
30
|
+
refresh_timeout: refresh_timeout, on_tls_error: on_tls_error, stop_accepting: method(:stop_accepting)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect = "#<#{self.class} port=#{@port.inspect} session=#{@session.inspect}>"
|
|
35
|
+
|
|
36
|
+
def start
|
|
37
|
+
@session.send(:check_affinity!)
|
|
38
|
+
raise LifecycleError, "Server session cannot start after close" if @closing
|
|
39
|
+
|
|
40
|
+
# Caller-context rejection must not enter startup cleanup or retire a peer.
|
|
41
|
+
@session.send(:preflight_start)
|
|
42
|
+
# An overlapping caller must not rebind, reset @starting or abort the first.
|
|
43
|
+
raise LifecycleError, "Host startup is already in progress" if @starting
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
@starting = true
|
|
47
|
+
@session.start
|
|
48
|
+
return self if @bound
|
|
49
|
+
|
|
50
|
+
@bound = bind
|
|
51
|
+
raise LifecycleError, "Host closed during startup" if @closing
|
|
52
|
+
|
|
53
|
+
@addresses = @bound.sockets.map(&:local_address).freeze
|
|
54
|
+
@port = @addresses.first.ip_port
|
|
55
|
+
@bound.sockets.each do |listener|
|
|
56
|
+
break if @failure || @closing
|
|
57
|
+
|
|
58
|
+
@session.schedule do |task|
|
|
59
|
+
@listeners << task
|
|
60
|
+
accept_loop(listener)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
raise @failure if @failure
|
|
64
|
+
raise LifecycleError, "Host closed during startup" if @closing
|
|
65
|
+
|
|
66
|
+
self
|
|
67
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- retain the first startup cause through obligatory cleanup
|
|
68
|
+
cause = @failure || e
|
|
69
|
+
cleanup = exit_task
|
|
70
|
+
begin
|
|
71
|
+
Internal.join_complete(cleanup, failure: true) { cleanup.run if cleanup.status == :initialized }
|
|
72
|
+
rescue Exception # rubocop:disable Lint/RescueException -- joins retain later caller interruptions until actual finalization
|
|
73
|
+
raise if cleanup.failed?
|
|
74
|
+
end
|
|
75
|
+
raise cause
|
|
76
|
+
ensure
|
|
77
|
+
@starting = false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Ordinary shutdown waits only for listener stop, not session retirement.
|
|
82
|
+
# Terminal failure waits for cooperative abort/join, then raises its cause.
|
|
83
|
+
# Owner interruption is retained through cleanup; the cause still survives.
|
|
84
|
+
def wait
|
|
85
|
+
@session.send(:check_affinity!)
|
|
86
|
+
raise LifecycleError, "Start or close the host before waiting" unless @bound || @closing
|
|
87
|
+
|
|
88
|
+
error = @completion.wait
|
|
89
|
+
if error
|
|
90
|
+
Internal.join_complete(@exit_task, failure: true)
|
|
91
|
+
raise error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def close(**) = @session.close(**)
|
|
98
|
+
def reload_tls(**) = @session.reload_tls(**)
|
|
99
|
+
def refresh_tls = @session.refresh_tls
|
|
100
|
+
def drain_tls(**) = @session.drain_tls(**)
|
|
101
|
+
def tls_generation = @session.tls_generation
|
|
102
|
+
def tls_status = @session.tls_status
|
|
103
|
+
def peer_certificate = @session.peer_certificate
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def build_session(**)
|
|
108
|
+
ServerSession.new(**)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def bind
|
|
112
|
+
::IO::Endpoint.tcp(@host, @requested_port).bound
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def accept_loop(listener)
|
|
116
|
+
failures = 0
|
|
117
|
+
loop do
|
|
118
|
+
begin
|
|
119
|
+
raw, = listener.accept
|
|
120
|
+
rescue *ACCEPT_RETRY_ERRORS
|
|
121
|
+
raise if failures == ACCEPT_RETRY_DELAYS.size
|
|
122
|
+
return if @closing
|
|
123
|
+
|
|
124
|
+
accept_backoff(ACCEPT_RETRY_DELAYS.fetch(failures))
|
|
125
|
+
failures += 1
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
failures = 0
|
|
129
|
+
accepted = @session.accept(raw)
|
|
130
|
+
@session.schedule { accepted.serve } if accepted
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
unless @closing
|
|
134
|
+
@failure ||= e
|
|
135
|
+
exit_task.run unless @starting
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def accept_backoff(delay)
|
|
140
|
+
::Async::Task.current.sleep(delay)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def exit_task
|
|
144
|
+
return @exit_task if @exit_task
|
|
145
|
+
|
|
146
|
+
# Prior shared close owns its peers and grace. Decide before marking our
|
|
147
|
+
# own teardown, so shutdown errors cannot become a new terminal cause.
|
|
148
|
+
force = @failure || !@closing
|
|
149
|
+
@closing = true
|
|
150
|
+
# Store identity before eager execution; callers never own this sequence.
|
|
151
|
+
@exit_task = ::Async::Task.new(@owner, transient: true, finished: false) do
|
|
152
|
+
@session.abort if force
|
|
153
|
+
stop_accepting
|
|
154
|
+
@completion.resolve(@failure)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def join_listeners
|
|
159
|
+
@listeners.each { |listener| Internal.join_complete(listener) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def stop_accepting
|
|
163
|
+
@closing = true
|
|
164
|
+
@bound&.close
|
|
165
|
+
@listeners.each { |listener| Internal.cancel(listener) }
|
|
166
|
+
join_listeners
|
|
167
|
+
@completion.resolve(nil) unless @failure || @starting
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|