smplkit 3.0.134 → 3.0.136
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 +4 -4
- data/lib/smplkit/client.rb +27 -27
- data/lib/smplkit/config/client.rb +44 -41
- data/lib/smplkit/event_stream.rb +483 -0
- data/lib/smplkit/flags/client.rb +45 -42
- data/lib/smplkit/jobs/client.rb +2 -2
- data/lib/smplkit/jobs/models.rb +2 -2
- data/lib/smplkit/logging/client.rb +69 -59
- data/lib/smplkit/transport.rb +1 -1
- data/lib/smplkit/version.rb +2 -2
- data/lib/smplkit.rb +1 -1
- metadata +2 -22
- data/lib/smplkit/ws.rb +0 -268
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/http/client"
|
|
6
|
+
require "async/http/endpoint"
|
|
7
|
+
|
|
8
|
+
module Smplkit
|
|
9
|
+
# Manages the single live-updates event stream to the app service.
|
|
10
|
+
#
|
|
11
|
+
# A single +EventStream+ instance is shared across all product modules
|
|
12
|
+
# (config, flags, logging) within one +Smplkit::Client+. Product modules
|
|
13
|
+
# register listeners for specific event names; the shared stream dispatches
|
|
14
|
+
# incoming events to the appropriate listeners. Modules also register
|
|
15
|
+
# refetch callbacks, invoked after every successful *re*connect so their
|
|
16
|
+
# caches recover anything the server published while the stream was down.
|
|
17
|
+
#
|
|
18
|
+
# The stream runs on a dedicated SDK-owned thread that hosts the +Async+
|
|
19
|
+
# reactor and the underlying +async-http+ I/O. Public methods are
|
|
20
|
+
# thread-safe and non-blocking.
|
|
21
|
+
#
|
|
22
|
+
# Wire protocol — Server-Sent Events (SSE) over plain HTTPS:
|
|
23
|
+
#
|
|
24
|
+
# - +GET <app_base_url>/api/v1/events+ with +Accept: text/event-stream+
|
|
25
|
+
# and +Authorization: Bearer <api_key>+.
|
|
26
|
+
# - A 200 response with a +text/event-stream+ content type is a
|
|
27
|
+
# successful connect; auth failure is a plain HTTP 401.
|
|
28
|
+
# - Each SSE frame carries the event name in the +event:+ field and a
|
|
29
|
+
# JSON object in +data:+ (+{"id": "<key>"}+ for single-resource
|
|
30
|
+
# events, +{}+ for bulk refreshes and the initial +connected+ event).
|
|
31
|
+
# - The server emits a +: keepalive+ comment frame every 30 seconds when
|
|
32
|
+
# idle; any received bytes count as liveness. Reads that stall past
|
|
33
|
+
# +READ_TIMEOUT+ seconds tear the connection down for a reconnect.
|
|
34
|
+
# - The server's +retry:+ field seeds the reconnect backoff base.
|
|
35
|
+
#
|
|
36
|
+
# On disconnect the reactor reconnects with exponential backoff (base
|
|
37
|
+
# delay doubling up to +MAX_BACKOFF+ seconds), resetting to the base on
|
|
38
|
+
# every successful connect. +stop+ flips +@closed+ and waits; the reader
|
|
39
|
+
# re-checks the flag at least every +POLL_INTERVAL+, tears its own
|
|
40
|
+
# connection down in-reactor, and the daemon thread terminates.
|
|
41
|
+
class EventStream
|
|
42
|
+
# Seconds without any bytes from the server (events or keepalive
|
|
43
|
+
# comments) before the connection is considered dead — two missed
|
|
44
|
+
# 30-second server keepalives.
|
|
45
|
+
READ_TIMEOUT = 45
|
|
46
|
+
|
|
47
|
+
# How long a single blocking read may park the reactor before it wakes
|
|
48
|
+
# to re-check +@closed+. Liveness is tracked as a deadline across
|
|
49
|
+
# polls (see +read_loop+), so this changes nothing on the wire — it
|
|
50
|
+
# exists so +stop+ never has to interrupt the reactor from a foreign
|
|
51
|
+
# thread: a cross-thread close cannot wake a fiber blocked in
|
|
52
|
+
# +body.read+, which left teardown hanging until the next keepalive.
|
|
53
|
+
POLL_INTERVAL = 1.0
|
|
54
|
+
|
|
55
|
+
# Ceiling for the exponential reconnect backoff, in seconds.
|
|
56
|
+
MAX_BACKOFF = 60
|
|
57
|
+
|
|
58
|
+
# Initial reconnect backoff base, in seconds, used until the server
|
|
59
|
+
# supplies its own via the SSE +retry:+ field.
|
|
60
|
+
DEFAULT_RETRY = 1.0
|
|
61
|
+
|
|
62
|
+
# Sent on the stream request — the platform WAF rejects requests that
|
|
63
|
+
# carry no User-Agent. There is no caller-supplied header surface on the
|
|
64
|
+
# event stream, so the SDK default always applies.
|
|
65
|
+
USER_AGENT = Smplkit.user_agent.freeze
|
|
66
|
+
|
|
67
|
+
# Incremental parser for a +text/event-stream+ byte stream.
|
|
68
|
+
#
|
|
69
|
+
# Feed it raw chunks as they arrive; it returns the events completed by
|
|
70
|
+
# each chunk. Implements the SSE wire format: +\n+, +\r\n+, and +\r+
|
|
71
|
+
# line terminators (including a CRLF split across chunks), a leading
|
|
72
|
+
# UTF-8 BOM, comment lines (leading +:+), field values split across
|
|
73
|
+
# chunk boundaries, multiple +data:+ lines joined with +\n+, and the
|
|
74
|
+
# numeric +retry:+ field. Unknown fields are ignored.
|
|
75
|
+
class Parser
|
|
76
|
+
# One complete SSE event: +name+ is the event name (from the +event:+
|
|
77
|
+
# field, defaulting to +"message"+), +data+ the joined data payload.
|
|
78
|
+
Event = Struct.new(:name, :data, keyword_init: true)
|
|
79
|
+
|
|
80
|
+
UTF8_BOM = String.new("\xEF\xBB\xBF", encoding: Encoding::BINARY).freeze
|
|
81
|
+
|
|
82
|
+
# Milliseconds from the most recent valid +retry:+ field, or +nil+.
|
|
83
|
+
attr_reader :retry_ms
|
|
84
|
+
|
|
85
|
+
def initialize
|
|
86
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
87
|
+
@bom_pending = true
|
|
88
|
+
@event_type = ""
|
|
89
|
+
@data_lines = []
|
|
90
|
+
@retry_ms = nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Consume one chunk of the stream. Returns the (possibly empty) array
|
|
94
|
+
# of +Event+s completed by this chunk.
|
|
95
|
+
def feed(chunk)
|
|
96
|
+
@buffer << chunk.dup.force_encoding(Encoding::BINARY)
|
|
97
|
+
strip_bom if @bom_pending
|
|
98
|
+
events = []
|
|
99
|
+
while (line = next_line)
|
|
100
|
+
handle_line(line, events)
|
|
101
|
+
end
|
|
102
|
+
events
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Drop a UTF-8 BOM at the very start of the stream. The BOM is three
|
|
108
|
+
# bytes and may itself arrive split across chunks, so stay pending
|
|
109
|
+
# while the buffer is still a strict prefix of it.
|
|
110
|
+
def strip_bom
|
|
111
|
+
if @buffer.bytesize >= UTF8_BOM.bytesize
|
|
112
|
+
@buffer = @buffer.byteslice(UTF8_BOM.bytesize..) if @buffer.start_with?(UTF8_BOM)
|
|
113
|
+
@bom_pending = false
|
|
114
|
+
elsif !UTF8_BOM.start_with?(@buffer)
|
|
115
|
+
@bom_pending = false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Extract the next complete line (terminator: +\n+, +\r\n+, or bare
|
|
120
|
+
# +\r+), or +nil+ if the buffer holds none. A CR as the final buffered
|
|
121
|
+
# byte is held back — it may be the first half of a CRLF whose LF is
|
|
122
|
+
# still in flight. Line splitting happens on the raw bytes (0x0A/0x0D
|
|
123
|
+
# never appear inside a UTF-8 multi-byte sequence); the completed line
|
|
124
|
+
# is re-tagged UTF-8.
|
|
125
|
+
def next_line
|
|
126
|
+
cr = @buffer.index("\r")
|
|
127
|
+
lf = @buffer.index("\n")
|
|
128
|
+
return nil if cr.nil? && lf.nil?
|
|
129
|
+
|
|
130
|
+
if cr && (lf.nil? || cr < lf)
|
|
131
|
+
return nil if cr == @buffer.bytesize - 1
|
|
132
|
+
|
|
133
|
+
line = @buffer.byteslice(0, cr)
|
|
134
|
+
skip = @buffer.getbyte(cr + 1) == 0x0A ? 2 : 1
|
|
135
|
+
@buffer = @buffer.byteslice(cr + skip, @buffer.bytesize - cr - skip)
|
|
136
|
+
else
|
|
137
|
+
line = @buffer.byteslice(0, lf)
|
|
138
|
+
@buffer = @buffer.byteslice(lf + 1, @buffer.bytesize - lf - 1)
|
|
139
|
+
end
|
|
140
|
+
line.force_encoding(Encoding::UTF_8)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A blank line dispatches the accumulated event; a leading +:+ marks a
|
|
144
|
+
# comment (the server's keepalive frame) — ignored here, since mere
|
|
145
|
+
# receipt of its bytes already counted as liveness at the read layer.
|
|
146
|
+
def handle_line(line, events)
|
|
147
|
+
if line.empty?
|
|
148
|
+
flush_pending(events)
|
|
149
|
+
elsif !line.start_with?(":")
|
|
150
|
+
apply_field(*split_field(line))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Split +"field: value"+ on the first colon, stripping at most one
|
|
155
|
+
# leading space from the value. A line with no colon is a field with
|
|
156
|
+
# an empty value.
|
|
157
|
+
def split_field(line)
|
|
158
|
+
sep = line.index(":")
|
|
159
|
+
return [line, ""] if sep.nil?
|
|
160
|
+
|
|
161
|
+
value = line[(sep + 1)..]
|
|
162
|
+
value = value[1..] if value.start_with?(" ")
|
|
163
|
+
[line[0...sep], value]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# +id:+ is deliberately ignored along with unknown fields — the SDK
|
|
167
|
+
# never resumes with +Last-Event-ID+.
|
|
168
|
+
def apply_field(field, value)
|
|
169
|
+
case field
|
|
170
|
+
when "event"
|
|
171
|
+
@event_type = value
|
|
172
|
+
when "data"
|
|
173
|
+
@data_lines << value
|
|
174
|
+
when "retry"
|
|
175
|
+
@retry_ms = Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Blank line: emit the accumulated event. Per the SSE spec an event
|
|
180
|
+
# with an empty data buffer is discarded (the event type still
|
|
181
|
+
# resets); multiple +data:+ lines join with +\n+.
|
|
182
|
+
def flush_pending(events)
|
|
183
|
+
unless @data_lines.empty?
|
|
184
|
+
name = @event_type.empty? ? "message" : @event_type
|
|
185
|
+
events << Event.new(name: name, data: @data_lines.join("\n"))
|
|
186
|
+
end
|
|
187
|
+
@event_type = ""
|
|
188
|
+
@data_lines = []
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def initialize(app_base_url:, api_key:, metrics: nil)
|
|
193
|
+
@app_base_url = app_base_url
|
|
194
|
+
@api_key = api_key
|
|
195
|
+
@metrics = metrics
|
|
196
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
197
|
+
@refetch_callbacks = []
|
|
198
|
+
@listeners_lock = Mutex.new
|
|
199
|
+
@connection_status = "disconnected"
|
|
200
|
+
@closed = false
|
|
201
|
+
@stream_thread = nil
|
|
202
|
+
@client = nil
|
|
203
|
+
@response = nil
|
|
204
|
+
@connection_lock = Mutex.new
|
|
205
|
+
@retry_base = DEFAULT_RETRY
|
|
206
|
+
@attempt = 0
|
|
207
|
+
@ever_connected = false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ----- Listener registration ------------------------------------
|
|
211
|
+
|
|
212
|
+
def on(event_name, &callback)
|
|
213
|
+
@listeners_lock.synchronize { @listeners[event_name] << callback }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def off(event_name, callback)
|
|
217
|
+
@listeners_lock.synchronize { @listeners[event_name].delete(callback) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Register a refetch callback, invoked (with no arguments) after every
|
|
221
|
+
# successful *re*connect — never on the initial connect. Product modules
|
|
222
|
+
# use this to run their bulk-refresh path so caches recover events
|
|
223
|
+
# missed while the stream was down.
|
|
224
|
+
def on_reconnect(callback = nil, &block)
|
|
225
|
+
cb = callback || block
|
|
226
|
+
@listeners_lock.synchronize { @refetch_callbacks << cb }
|
|
227
|
+
cb
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def off_reconnect(callback)
|
|
231
|
+
@listeners_lock.synchronize { @refetch_callbacks.delete(callback) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Dispatch +payload+ to every listener registered for +event_name+.
|
|
235
|
+
# Event names nothing subscribed to dispatch to zero listeners — unknown
|
|
236
|
+
# events are ignored by construction. Listener exceptions are caught and
|
|
237
|
+
# logged; one bad listener never blocks the rest.
|
|
238
|
+
def dispatch(event_name, payload)
|
|
239
|
+
callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
|
|
240
|
+
callbacks.each do |cb|
|
|
241
|
+
cb.call(payload)
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
Smplkit.debug("events", "listener for #{event_name} raised: #{e.class}: #{e.message}")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# ----- Connection status ----------------------------------------
|
|
248
|
+
|
|
249
|
+
attr_reader :connection_status
|
|
250
|
+
|
|
251
|
+
# ----- Lifecycle ------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def start
|
|
254
|
+
return if @stream_thread&.alive?
|
|
255
|
+
|
|
256
|
+
Smplkit.debug("events", "starting shared event stream background thread")
|
|
257
|
+
@closed = false
|
|
258
|
+
@connection_status = "connecting"
|
|
259
|
+
@stream_thread = Thread.new { run_reactor }
|
|
260
|
+
@stream_thread.name = "smplkit-events" if @stream_thread.respond_to?(:name=)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def stop
|
|
264
|
+
Smplkit.debug("events", "stopping shared event stream")
|
|
265
|
+
@closed = true
|
|
266
|
+
thread = @stream_thread
|
|
267
|
+
@stream_thread = nil
|
|
268
|
+
if thread
|
|
269
|
+
# The reactor re-checks +@closed+ at least every POLL_INTERVAL and
|
|
270
|
+
# closes its own connection in-reactor on the way out — closing it
|
|
271
|
+
# from this thread instead would race the reactor and cannot wake
|
|
272
|
+
# a blocked read.
|
|
273
|
+
thread.join(POLL_INTERVAL + 1.0)
|
|
274
|
+
if thread.alive?
|
|
275
|
+
# Last resort: a connect attempt wedged before the read loop.
|
|
276
|
+
thread.kill
|
|
277
|
+
close_active_connection
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
# Set authoritatively after the thread is dead so a racing connect
|
|
281
|
+
# call (which also sets "connecting") cannot clobber this value.
|
|
282
|
+
@connection_status = "disconnected"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# ----- URL builder ----------------------------------------------
|
|
286
|
+
|
|
287
|
+
def build_events_url
|
|
288
|
+
url = @app_base_url.dup
|
|
289
|
+
url = "https://#{url}" unless url.start_with?("https://", "http://")
|
|
290
|
+
"#{url.chomp("/")}/api/v1/events"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# ----- Inbound event handling (extracted for tests) -------------
|
|
294
|
+
|
|
295
|
+
# Process one parsed SSE event the way the live read loop does: parse
|
|
296
|
+
# the JSON payload and dispatch it to the listeners registered for the
|
|
297
|
+
# event name.
|
|
298
|
+
#
|
|
299
|
+
# Returns +:dispatched+ or +:unparseable+ for the caller to log/observe;
|
|
300
|
+
# the live read loop ignores the return value.
|
|
301
|
+
def handle_event(event_name, data)
|
|
302
|
+
payload =
|
|
303
|
+
begin
|
|
304
|
+
JSON.parse(data)
|
|
305
|
+
rescue JSON::ParserError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
unless payload.is_a?(Hash)
|
|
309
|
+
Smplkit.debug("events", "ignoring #{event_name.inspect} event with non-object payload")
|
|
310
|
+
return :unparseable
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
dispatch(event_name, payload)
|
|
314
|
+
:dispatched
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private
|
|
318
|
+
|
|
319
|
+
def run_reactor
|
|
320
|
+
Sync do |task|
|
|
321
|
+
stream_main(task)
|
|
322
|
+
end
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
Smplkit.debug("events", "event stream thread exited unexpectedly: #{e.class}: #{e.message}")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Connect/read/reconnect forever until +stop+. Every pass either ends
|
|
328
|
+
# with a clean server EOF or an exception (connect failure, read error,
|
|
329
|
+
# liveness timeout) — both funnel into the same backoff + retry.
|
|
330
|
+
def stream_main(task)
|
|
331
|
+
until @closed
|
|
332
|
+
begin
|
|
333
|
+
connect_and_stream(task)
|
|
334
|
+
rescue StandardError => e
|
|
335
|
+
return if @closed
|
|
336
|
+
|
|
337
|
+
Smplkit.debug("events", "stream error (url: #{build_events_url}): #{e.class}: #{e.message}")
|
|
338
|
+
end
|
|
339
|
+
return if @closed
|
|
340
|
+
|
|
341
|
+
@connection_status = "reconnecting"
|
|
342
|
+
delay = next_backoff_delay
|
|
343
|
+
Smplkit.debug("events", "reconnecting in #{delay}s")
|
|
344
|
+
task.sleep(delay)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# One connection lifetime: open the request, verify the SSE handshake,
|
|
349
|
+
# then read frames until EOF/error. The response and client are always
|
|
350
|
+
# torn down on the way out.
|
|
351
|
+
def connect_and_stream(task)
|
|
352
|
+
@connection_status = "connecting"
|
|
353
|
+
Smplkit.debug("events", "connecting to #{build_events_url}")
|
|
354
|
+
endpoint = Async::HTTP::Endpoint.parse(build_events_url)
|
|
355
|
+
client = Async::HTTP::Client.new(endpoint)
|
|
356
|
+
@connection_lock.synchronize { @client = client }
|
|
357
|
+
response = nil
|
|
358
|
+
begin
|
|
359
|
+
response = client.get(endpoint.path, request_headers)
|
|
360
|
+
@connection_lock.synchronize { @response = response }
|
|
361
|
+
verify_response!(response)
|
|
362
|
+
mark_connected
|
|
363
|
+
read_loop(task, response.body)
|
|
364
|
+
ensure
|
|
365
|
+
mark_disconnected
|
|
366
|
+
close_quietly(response)
|
|
367
|
+
close_quietly(client)
|
|
368
|
+
@connection_lock.synchronize do
|
|
369
|
+
@response = nil
|
|
370
|
+
@client = nil
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def request_headers
|
|
376
|
+
[
|
|
377
|
+
["accept", "text/event-stream"],
|
|
378
|
+
["authorization", "Bearer #{@api_key}"],
|
|
379
|
+
["user-agent", USER_AGENT]
|
|
380
|
+
]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# A successful connect is exactly: HTTP 200 with a text/event-stream
|
|
384
|
+
# content type. Anything else (401 on bad auth, proxies serving HTML,
|
|
385
|
+
# ...) tears down and backs off.
|
|
386
|
+
def verify_response!(response)
|
|
387
|
+
status = response.status
|
|
388
|
+
content_type = response.headers["content-type"].to_s
|
|
389
|
+
return if status == 200 && content_type.start_with?("text/event-stream")
|
|
390
|
+
|
|
391
|
+
raise ConnectionError, "event stream connect failed: HTTP #{status} " \
|
|
392
|
+
"(content-type: #{content_type.inspect})"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def mark_connected
|
|
396
|
+
reconnected = @ever_connected
|
|
397
|
+
@ever_connected = true
|
|
398
|
+
@attempt = 0
|
|
399
|
+
@connection_status = "connected"
|
|
400
|
+
@metrics&.record_gauge("platform.event_connections", 1, unit: "connections")
|
|
401
|
+
Smplkit.debug("events", reconnected ? "event stream reconnected" : "event stream connected")
|
|
402
|
+
run_refetch_callbacks if reconnected
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Leaving the connected state (only): flip the gauge and status. A
|
|
406
|
+
# connect attempt that never completed the handshake records nothing.
|
|
407
|
+
def mark_disconnected
|
|
408
|
+
return unless @connection_status == "connected"
|
|
409
|
+
|
|
410
|
+
@connection_status = "reconnecting"
|
|
411
|
+
@metrics&.record_gauge("platform.event_connections", 0, unit: "connections")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def run_refetch_callbacks
|
|
415
|
+
callbacks = @listeners_lock.synchronize { @refetch_callbacks.dup }
|
|
416
|
+
callbacks.each do |cb|
|
|
417
|
+
cb.call
|
|
418
|
+
rescue StandardError => e
|
|
419
|
+
Smplkit.debug("events", "refetch callback raised: #{e.class}: #{e.message}")
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Read chunks until EOF, feeding the SSE parser and dispatching the
|
|
424
|
+
# events it completes. Reads park for at most POLL_INTERVAL at a time
|
|
425
|
+
# so +stop+ is honored promptly; liveness is a rolling deadline — any
|
|
426
|
+
# received bytes, including keepalive comment frames, push it out by
|
|
427
|
+
# READ_TIMEOUT. A deadline breach raises into the reconnect path.
|
|
428
|
+
def read_loop(task, body)
|
|
429
|
+
parser = Parser.new
|
|
430
|
+
deadline = monotonic_now + READ_TIMEOUT
|
|
431
|
+
until @closed
|
|
432
|
+
begin
|
|
433
|
+
chunk = task.with_timeout(POLL_INTERVAL) { body.read }
|
|
434
|
+
rescue Async::TimeoutError
|
|
435
|
+
raise Async::TimeoutError, "no data for #{READ_TIMEOUT}s" if monotonic_now >= deadline
|
|
436
|
+
|
|
437
|
+
next
|
|
438
|
+
end
|
|
439
|
+
break if chunk.nil?
|
|
440
|
+
|
|
441
|
+
deadline = monotonic_now + READ_TIMEOUT
|
|
442
|
+
process_chunk(parser, chunk)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Seam for specs; the liveness deadline math needs a controllable clock.
|
|
447
|
+
def monotonic_now
|
|
448
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def process_chunk(parser, chunk)
|
|
452
|
+
parser.feed(chunk).each { |event| handle_event(event.name, event.data) }
|
|
453
|
+
@retry_base = parser.retry_ms / 1000.0 unless parser.retry_ms.nil?
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Exponential backoff: base, 2x, 4x, ... capped at MAX_BACKOFF. The
|
|
457
|
+
# base comes from the server's +retry:+ field (DEFAULT_RETRY until one
|
|
458
|
+
# arrives); +mark_connected+ resets the exponent on every successful
|
|
459
|
+
# connect. No jitter.
|
|
460
|
+
def next_backoff_delay
|
|
461
|
+
delay = [@retry_base * (2**@attempt), MAX_BACKOFF].min
|
|
462
|
+
@attempt += 1 if delay < MAX_BACKOFF
|
|
463
|
+
delay
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def close_active_connection
|
|
467
|
+
response, client = @connection_lock.synchronize do
|
|
468
|
+
pair = [@response, @client]
|
|
469
|
+
@response = nil
|
|
470
|
+
@client = nil
|
|
471
|
+
pair
|
|
472
|
+
end
|
|
473
|
+
close_quietly(response)
|
|
474
|
+
close_quietly(client)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def close_quietly(resource)
|
|
478
|
+
resource&.close
|
|
479
|
+
rescue StandardError => e
|
|
480
|
+
Smplkit.debug("events", "close raised: #{e.class}: #{e.message}")
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|