bynn-decisionnode 0.4.1

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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ class DecisionNode
7
+ # A frame message's usage: its token counts and its cost, or nil when the
8
+ # message has none.
9
+ module FrameUsage
10
+ private
11
+
12
+ def frame_usage(usage)
13
+ return nil unless usage.is_a?(Hash)
14
+
15
+ Usage.new(usage["input_tokens"], usage["output_tokens"], nil, nil, nil, nil, Cost.parse(usage["cost"]))
16
+ end
17
+ end
18
+ private_constant :FrameUsage
19
+
20
+ # The reply to one session frame.
21
+ class Reply
22
+ include FrameUsage
23
+
24
+ # The `seq` of the frame answered.
25
+ attr_reader :seq
26
+ # Question id to typed Answer, as in a Decision.
27
+ attr_reader :answers
28
+ # The `seq` of each frame this frame superseded, in arrival order, or nil.
29
+ attr_reader :dropped
30
+ # Token counts of this frame and what it cost, a Usage.
31
+ attr_reader :usage
32
+ # The worker's time for this frame, in milliseconds.
33
+ attr_reader :latency_ms
34
+ # The safety check on this frame, as the server sent it (a Hash), or nil.
35
+ attr_reader :safety
36
+ # The message as received.
37
+ attr_reader :raw
38
+
39
+ def initialize(raw)
40
+ @raw = raw
41
+ @seq = raw["seq"]
42
+ @answers = Answer.map(raw["answers"])
43
+ @dropped = raw["dropped"]
44
+ @usage = frame_usage(raw["usage"])
45
+ @latency_ms = raw["latency_ms"]
46
+ @safety = raw["safety"]
47
+ end
48
+
49
+ # The answer to the question with this id.
50
+ def [](id)
51
+ answers.fetch(id.to_s) { raise KeyError, "no answer with the id #{id.to_s.inspect}" }
52
+ end
53
+ end
54
+
55
+ # A frame the session could not answer. A value, never raised: the session
56
+ # stays open and the next frame can be sent.
57
+ class FrameError
58
+ include FrameUsage
59
+
60
+ # The frame's `seq`, or nil when the message could not be read as a frame.
61
+ attr_reader :seq
62
+ attr_reader :error_type, :message, :category
63
+ # The wait the server asks for before the next frame, for `rate_limit_error`
64
+ # and `overloaded_error`.
65
+ attr_reader :retry_after_ms
66
+ # What a refused frame billed (a Usage), and its safety result (a Hash).
67
+ attr_reader :usage, :safety
68
+ attr_reader :raw
69
+
70
+ def initialize(raw)
71
+ @raw = raw
72
+ @seq = raw["seq"]
73
+ @error_type = raw.dig("error", "error_type")
74
+ @message = raw.dig("error", "message")
75
+ @category = raw.dig("error", "category")
76
+ @retry_after_ms = raw["retry_after_ms"]
77
+ @usage = frame_usage(raw["usage"])
78
+ @safety = raw["safety"]
79
+ end
80
+ end
81
+
82
+ # The end of a session's stream, with the close code. It is the last event
83
+ # of Session#replies, and is raised by Session#connect when the server
84
+ # refuses the socket before it opens.
85
+ class SessionClosed < StandardError
86
+ # Codes after which a new session with the same body is the remedy:
87
+ # 1001, 1006 (the connection was lost without a close frame), 1011, 4404,
88
+ # 4408, 4409 and 4410.
89
+ REOPEN_CODES = [1001, 1006, 1011, 4404, 4408, 4409, 4410].freeze
90
+ # The server is at its socket limit; the session is kept, connect again.
91
+ TRY_AGAIN = 1013
92
+
93
+ attr_reader :code, :reason
94
+
95
+ def initialize(code, reason = "")
96
+ @code = code
97
+ @reason = reason.to_s
98
+ super("session closed with #{code}#{@reason.empty? ? '' : " (#{@reason})"}")
99
+ end
100
+
101
+ # True for the codes after which a new session with the same body is the
102
+ # remedy; false otherwise, 1013 included (the session is kept).
103
+ def reopen
104
+ REOPEN_CODES.include?(code)
105
+ end
106
+ alias reopen? reopen
107
+ end
108
+
109
+ # An open session: one state, a fixed set of questions, and a stream of
110
+ # frames answered as they arrive. Made by DecisionNode#open_session.
111
+ #
112
+ # s = dn.open_session(state: { mission: "Survey" }, questions: { abort: DecisionNode.truth("Abort?") })
113
+ # s.connect
114
+ # s.send(frame: { battery: 0.31 })
115
+ # s.replies.each { |event| ... } # Reply, FrameError, then SessionClosed
116
+ # s.end
117
+ #
118
+ # Every field of the open response is available by name (`session_id`,
119
+ # `model`, `stream_url`, `window`, `ttl_seconds`, `expires_at`, `limits`,
120
+ # `questions`, `prefix_tokens`, `usage`, `safety`) or as `session["field"]`.
121
+ # The socket is safe to send on from several threads; read the replies from
122
+ # one.
123
+ class Session
124
+ # The open response as received.
125
+ attr_reader :raw
126
+ # The answers to the session's questions on the state alone, typed.
127
+ attr_reader :answers
128
+
129
+ FIELDS = %w[session_id model stream_url window ttl_seconds expires_at limits questions prefix_tokens usage safety].freeze
130
+
131
+ FIELDS.each do |name|
132
+ define_method(name) { @raw[name] }
133
+ end
134
+
135
+ def initialize(client, raw)
136
+ @client = client
137
+ @raw = raw.is_a?(Hash) ? raw : {}
138
+ @answers = Answer.map(@raw["answers"])
139
+ @seq = 0
140
+ @seq_lock = Mutex.new
141
+ @stream = nil
142
+ @closed = nil
143
+ end
144
+
145
+ def id
146
+ session_id
147
+ end
148
+
149
+ def [](key)
150
+ @raw[key.to_s]
151
+ end
152
+
153
+ def inspect
154
+ "#<#{self.class.name} #{session_id.inspect} model=#{model.inspect}>"
155
+ end
156
+
157
+ # Opens the socket: the key in the Authorization header (never in the URL)
158
+ # and the client's custom headers on the handshake. Returns self. A server
159
+ # that closes the socket before it opens raises SessionClosed; a refusal
160
+ # sent right after it opens is the first event of #replies. Connecting
161
+ # again after a close (for example after 1013) opens a new socket to the
162
+ # same session.
163
+ def connect
164
+ @stream&.close_socket
165
+ @closed = nil
166
+ @stream = @client.__send__(:session_stream, stream_url)
167
+ self
168
+ end
169
+
170
+ # Sends one frame and returns its `seq`; never waits for a reply.
171
+ #
172
+ # frame:: text or any JSON value (the reading).
173
+ # image:: one Image: sent inline under the client's inline threshold,
174
+ # else uploaded first and sent by `upload_id`. A URL image is
175
+ # refused: frames take bytes or an upload.
176
+ # questions:: the ids of the session's questions to ask (default all).
177
+ # reset:: true empties the window before this frame is answered.
178
+ # seq:: the frame's number; by default a counter from 1.
179
+ #
180
+ # A frame that breaks a fixed rule (no frame and no image, an empty frame,
181
+ # an unknown question id, a seq out of range, a URL image, an image the
182
+ # API would refuse) raises BadRequestError; nothing is written.
183
+ def send_frame(frame: nil, image: nil, questions: nil, reset: nil, seq: nil)
184
+ raise ArgumentError, "connect the session before sending" if @stream.nil?
185
+
186
+ # The frame's fixed rules first: a fault raises and nothing is written,
187
+ # and the refused frame takes no seq.
188
+ check_frame(frame: frame, image: image, questions: questions, reset: reset, seq: seq)
189
+ message = { "seq" => seq.nil? ? next_seq : seq }
190
+ message["frame"] = frame unless frame.nil?
191
+ message["image"] = @client.__send__(:frame_image, image) unless image.nil?
192
+ message["questions"] = questions.map(&:to_s) unless questions.nil?
193
+ message["reset"] = reset unless reset.nil?
194
+ @stream.text(@client.__send__(:encode, message))
195
+ message["seq"]
196
+ end
197
+
198
+ # Runs every check #send runs on a frame before writing it, alone: raises
199
+ # the BadRequestError #send would raise (held /v1/models limits included)
200
+ # and returns nil for a frame #send would send. Nothing is written, nothing
201
+ # is uploaded, no seq is used, and the session need not be connected.
202
+ def check_frame(frame: nil, image: nil, questions: nil, reset: nil, seq: nil)
203
+ Checks.frame!(frame: frame, image: image, questions: questions, reset: reset, seq: seq,
204
+ known_questions: @raw["questions"], limits: @client.__send__(:limits_for, model))
205
+ nil
206
+ end
207
+
208
+ # Session#send with keyword arguments sends a frame (see #send_frame);
209
+ # with a method name it is Object#send.
210
+ def send(*args, **kwargs, &block)
211
+ return super unless args.empty? && block.nil?
212
+
213
+ send_frame(**kwargs)
214
+ end
215
+
216
+ # The next event: a Reply, a FrameError, or the SessionClosed that ends
217
+ # the stream (returned again on every later call). nil when nothing
218
+ # arrived within `timeout` seconds (nil waits without limit).
219
+ def next_event(timeout: nil)
220
+ return @closed if @closed
221
+ raise ArgumentError, "connect the session before reading" if @stream.nil?
222
+
223
+ event = @stream.events.pop(timeout: timeout)
224
+ return nil if event.nil?
225
+
226
+ kind, a, b = event
227
+ if kind == :close
228
+ @closed = SessionClosed.new(a, b)
229
+ return @closed
230
+ end
231
+ parse_message(a)
232
+ end
233
+
234
+ # Each event in turn: Reply and FrameError values, then the SessionClosed
235
+ # that ends the stream. Without a block, an Enumerator.
236
+ def replies(timeout: nil)
237
+ return enum_for(:replies, timeout: timeout) unless block_given?
238
+
239
+ loop do
240
+ event = next_event(timeout: timeout)
241
+ return nil if event.nil?
242
+
243
+ yield event
244
+ return nil if event.is_a?(SessionClosed)
245
+ end
246
+ end
247
+
248
+ # Closes the socket with 1000. The session ends on the server.
249
+ def close
250
+ @stream&.close(1000)
251
+ nil
252
+ end
253
+
254
+ # Ends the session (`DELETE /v1/sessions/{id}`); the server then closes the
255
+ # socket with 1000. A session that is already gone (404) counts as ended.
256
+ # Returns the response body, or nil after a 404.
257
+ def end_session
258
+ body, = @client.__send__(:request, "DELETE", "/v1/sessions/#{URI.encode_www_form_component(session_id.to_s)}", nil)
259
+ body
260
+ rescue NotFoundError
261
+ nil
262
+ end
263
+ alias end end_session
264
+
265
+ # Closes the socket without a close frame. For the client's own use.
266
+ def drop
267
+ @stream&.close_socket
268
+ end
269
+
270
+ private
271
+
272
+ def next_seq
273
+ @seq_lock.synchronize { @seq += 1 }
274
+ end
275
+
276
+ def parse_message(text)
277
+ raw = JSON.parse(text, max_nesting: false)
278
+ raise JSON::ParserError, "not an object" unless raw.is_a?(Hash)
279
+
280
+ raw.key?("error") ? FrameError.new(raw) : Reply.new(raw)
281
+ rescue JSON::ParserError
282
+ FrameError.new({ "seq" => nil, "error" => { "error_type" => "invalid_message",
283
+ "message" => "the server sent a message that is not a JSON object" } })
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+ require "uri"
6
+ require "websocket/driver"
7
+
8
+ class DecisionNode
9
+ # The socket of a session: RFC 6455 through websocket-driver over a TCP or
10
+ # TLS socket from the standard library, with one reader thread. Pings are
11
+ # answered at once; a socket that receives nothing, pings included, for
12
+ # IDLE_LIMIT seconds is treated as lost (1006). Used by Session; not used directly.
13
+ #
14
+ # The socket follows the client's transport where a WebSocket can: a `wss`
15
+ # URL goes through the transport's proxy (else the proxy the environment
16
+ # names for Net::HTTP, `no_proxy` honoured) with an HTTP CONNECT tunnel, and
17
+ # TLS takes the transport's `net_http:` trust and client certificate
18
+ # settings (ca_file, ca_path, cert_store, cert, key, ...), with peer and
19
+ # hostname checks always on.
20
+ class SessionStream
21
+ # The server pings every 20 s and drops a socket after 45 s of silence.
22
+ IDLE_LIMIT = 65
23
+ MAX_MESSAGE = 16 * 1024 * 1024
24
+
25
+ attr_reader :url, :events
26
+
27
+ # The `net_http:` settings that apply to the session's TLS context.
28
+ TLS_SETTINGS = %w[ca_file ca_path cert_store cert key extra_chain_cert ssl_version min_version max_version
29
+ ciphers verify_depth].freeze
30
+
31
+ # proxy:: a proxy URI, :env to take the one the environment names, or nil for none.
32
+ # tls:: settings by name (TLS_SETTINGS); others are ignored.
33
+ def initialize(url, headers, timeout, proxy: :env, tls: {})
34
+ @url = url
35
+ @proxy = proxy
36
+ @tls = tls
37
+ @events = Queue.new
38
+ @write_lock = Mutex.new
39
+ @driver_lock = Mutex.new
40
+ @finish_lock = Mutex.new
41
+ @opened = Queue.new
42
+ @done = false
43
+ @io = open_socket(URI.parse(url), timeout)
44
+ @driver = Driver.new(self, max_length: MAX_MESSAGE)
45
+ headers.each { |name, value| @driver.set_header(name, value) }
46
+ @driver.on(:open) { @opened << [:open] }
47
+ @driver.on(:message) { |e| @events << [:message, e.data] }
48
+ @driver.on(:close) { |e| finish(@driver.received_close_code || e.code || 1005, e.reason.to_s) }
49
+ @driver_lock.synchronize { @driver.start }
50
+ @reader = Thread.new { read_loop }
51
+ @reader.report_on_exception = false
52
+ await_open(timeout)
53
+ end
54
+
55
+ # Sends one text message.
56
+ def text(data)
57
+ @driver_lock.synchronize { @driver.text(data) }
58
+ end
59
+
60
+ def close(code)
61
+ @driver_lock.synchronize { @driver.close("", code) }
62
+ end
63
+
64
+ # Called by the driver.
65
+ def write(data)
66
+ @write_lock.synchronize { @io.write(data) }
67
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
68
+ finish(1006, "")
69
+ end
70
+
71
+ def close_socket
72
+ @done = true
73
+ @io.close unless @io.closed?
74
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
75
+ nil
76
+ end
77
+
78
+ private
79
+
80
+ LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
81
+
82
+ def open_socket(uri, timeout)
83
+ host = uri.host.to_s.delete_prefix("[").delete_suffix("]")
84
+ case uri.scheme&.downcase
85
+ when "wss"
86
+ port = uri.port || 443
87
+ proxy = proxy_for(host, port)
88
+ tcp = proxy ? self.class.tunnel(proxy, host, port, timeout) : Socket.tcp(host, port, connect_timeout: timeout)
89
+ tls(tcp, host, timeout)
90
+ when "ws"
91
+ raise ArgumentError, "the stream URL must use wss, got #{uri}" unless LOOPBACK.include?(host.downcase)
92
+
93
+ tcp = Socket.tcp(host, uri.port || 80, connect_timeout: timeout)
94
+ tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
95
+ tcp
96
+ else
97
+ raise ArgumentError, "the stream URL must use wss, got #{uri}"
98
+ end
99
+ rescue Errno::ETIMEDOUT, IO::TimeoutError => e
100
+ raise DecisionNode::TimeoutError, "no connection to the session stream within #{timeout} s (#{e.class})"
101
+ rescue SocketError, SystemCallError => e
102
+ raise DecisionNode::ConnectionError, "cannot connect to the session stream: #{e.message} (#{e.class})"
103
+ end
104
+
105
+ # The proxy for a wss host: the transport's, else the environment's as
106
+ # Net::HTTP finds it (no_proxy honoured), else none.
107
+ def proxy_for(host, port)
108
+ return @proxy unless @proxy == :env
109
+
110
+ URI::HTTP.new("http", nil, host, port, nil, nil, nil, nil, nil).find_proxy
111
+ end
112
+
113
+ def tls(tcp, host, timeout)
114
+ tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
115
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, self.class.tls_context(@tls))
116
+ ssl.hostname = host
117
+ ssl.sync_close = true
118
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
119
+ loop do
120
+ case ssl.connect_nonblock(exception: false)
121
+ when :wait_readable then wait(tcp, :r, deadline)
122
+ when :wait_writable then wait(tcp, :w, deadline)
123
+ else break
124
+ end
125
+ end
126
+ ssl.post_connection_check(host)
127
+ ssl
128
+ rescue OpenSSL::SSL::SSLError => e
129
+ tcp.close
130
+ raise DecisionNode::ConnectionError, "TLS to the session stream failed: #{e.message}"
131
+ end
132
+
133
+ # A TLS context with the given settings, certificate and hostname checks on.
134
+ def self.tls_context(settings)
135
+ params = settings.to_h { |k, v| [k.to_s.delete_suffix("="), v] }.select { |k, _| TLS_SETTINGS.include?(k) }
136
+ context = OpenSSL::SSL::SSLContext.new
137
+ context.set_params(params.transform_keys(&:to_sym).merge(verify_mode: OpenSSL::SSL::VERIFY_PEER, verify_hostname: true))
138
+ context
139
+ end
140
+
141
+ # A TCP socket to host:port through an HTTP proxy's CONNECT tunnel.
142
+ def self.tunnel(proxy, host, port, timeout)
143
+ unless proxy.scheme.to_s.casecmp?("http")
144
+ raise DecisionNode::ConnectionError, "a session socket goes through an http:// proxy only, got #{proxy.scheme}://"
145
+ end
146
+
147
+ tcp = Socket.tcp(proxy.host, proxy.port || 80, connect_timeout: timeout)
148
+ target = host.include?(":") ? "[#{host}]:#{port}" : "#{host}:#{port}"
149
+ head = +"CONNECT #{target} HTTP/1.1\r\nHost: #{target}\r\n"
150
+ if proxy.user
151
+ login = [proxy.user, proxy.password.to_s].map { |part| URI.decode_www_form_component(part) }.join(":")
152
+ head << "Proxy-Authorization: Basic #{[login].pack('m0')}\r\n"
153
+ end
154
+ tcp.write("#{head}\r\n")
155
+ answer = read_head(tcp, Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
156
+ status = answer[%r{\AHTTP/1\.[01] (\d{3})}, 1].to_i
157
+ return tcp if status == 200
158
+
159
+ tcp.close
160
+ raise DecisionNode::ConnectionError, "the proxy refused the tunnel to the session stream with HTTP #{status}"
161
+ rescue IOError, SystemCallError => e
162
+ tcp&.close
163
+ raise DecisionNode::ConnectionError, "the proxy tunnel to the session stream failed: #{e.message} (#{e.class})"
164
+ end
165
+
166
+ # The proxy's answer up to the blank line, read a byte at a time so nothing
167
+ # of the tunnel is taken.
168
+ def self.read_head(io, deadline)
169
+ head = +""
170
+ until head.end_with?("\r\n\r\n")
171
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
172
+ raise DecisionNode::TimeoutError, "the proxy did not answer in time" unless left.positive? && io.wait_readable(left)
173
+
174
+ byte = io.read_nonblock(1, exception: false)
175
+ next if byte == :wait_readable
176
+ raise DecisionNode::ConnectionError, "the proxy closed the connection" if byte.nil?
177
+
178
+ head << byte
179
+ raise DecisionNode::ConnectionError, "the proxy's answer is too long" if head.bytesize > 16_384
180
+ end
181
+ head
182
+ end
183
+
184
+ def wait(io, dir, deadline)
185
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
+ ready = left.positive? && (dir == :r ? io.wait_readable(left) : io.wait_writable(left))
187
+ raise DecisionNode::TimeoutError, "no TLS handshake with the session stream in time" unless ready
188
+ end
189
+
190
+ def await_open(timeout)
191
+ first = @opened.pop(timeout: timeout)
192
+ if first.nil?
193
+ close_socket
194
+ raise DecisionNode::TimeoutError, "the session stream did not open within #{timeout} s"
195
+ end
196
+ return if first.first == :open
197
+
198
+ _, code, reason = first
199
+ status = @driver.status
200
+ if status && status != 101
201
+ raise DecisionNodeError.new("the session stream refused the upgrade with HTTP #{status}", status: status)
202
+ end
203
+ raise SessionClosed.new(code, reason)
204
+ end
205
+
206
+ def read_loop
207
+ until @done
208
+ if !(@io.respond_to?(:pending) && @io.pending.positive?) && !@io.to_io.wait_readable(IDLE_LIMIT)
209
+ finish(1006, "no traffic for #{IDLE_LIMIT} s")
210
+ break
211
+ end
212
+ data = @io.read_nonblock(65_536, exception: false)
213
+ next if data == :wait_readable || data == :wait_writable
214
+
215
+ if data.nil?
216
+ finish(1006, "")
217
+ break
218
+ end
219
+ @driver_lock.synchronize { @driver.parse(data) }
220
+ end
221
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
222
+ finish(1006, "")
223
+ end
224
+
225
+ # Records the close once, closes the socket and wakes #connect.
226
+ def finish(code, reason)
227
+ first = @finish_lock.synchronize do
228
+ next false if @finished
229
+
230
+ @finished = true
231
+ end
232
+ return unless first
233
+
234
+ @events << [:close, code, reason]
235
+ @opened << [:close, code, reason]
236
+ close_socket
237
+ end
238
+
239
+ # The driver, keeping the close code the server sent: websocket-driver
240
+ # replaces codes it does not list (1012 to 1014, 1013 among them) with 1002.
241
+ class Driver < ::WebSocket::Driver::Client
242
+ attr_reader :received_close_code
243
+
244
+ private
245
+
246
+ def emit_frame(buffer)
247
+ if @frame.opcode == OPCODES[:close] && buffer.bytesize >= 2
248
+ payload = ::WebSocket::Mask.mask(buffer.dup, @frame.masking_key)
249
+ @received_close_code = payload.unpack1("S>")
250
+ end
251
+ super
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # The loop behind DecisionNode#supervise: one feed, sessions reopened as the
5
+ # close codes say. Not used directly.
6
+ class Supervisor
7
+ # Codes after which no backoff is needed before the next session: the
8
+ # session went idle (4408) or reached its end of life (4410).
9
+ AT_ONCE = [4408, 4410].freeze
10
+
11
+ def initialize(client, body, handler, max_sessions, drain_timeout)
12
+ @client = client
13
+ @body = body
14
+ @handler = handler
15
+ @max_sessions = max_sessions
16
+ @drain_timeout = drain_timeout
17
+ @session = nil # the session being fed, nil when none is connected
18
+ @kept = nil # a session closed with 1013, to connect to again
19
+ @last = nil # the last SessionClosed seen
20
+ @connects = 0
21
+ @failures = 0
22
+ @answered = 0
23
+ @pending = 0
24
+ end
25
+
26
+ def run(feed)
27
+ feed.each do |reading|
28
+ drain(0)
29
+ until @session
30
+ return @last if stopped? || @connects >= @max_sessions
31
+
32
+ pause_before_connect
33
+ connect
34
+ end
35
+ @session.send_frame(**reading.to_h { |k, v| [k.to_sym, v] })
36
+ @pending += 1
37
+ end
38
+ drain(@drain_timeout)
39
+ if @session
40
+ @session.end_session
41
+ @session.close
42
+ end
43
+ @last
44
+ end
45
+
46
+ private
47
+
48
+ def stopped?
49
+ @last && !@last.reopen && @last.code != SessionClosed::TRY_AGAIN
50
+ end
51
+
52
+ # A capped backoff with jitter, 1 s doubling to 30 s, reset after a
53
+ # session that answered frames; none after 4408 and 4410.
54
+ def pause_before_connect
55
+ return if @last.nil? || AT_ONCE.include?(@last.code)
56
+
57
+ @failures = 0 if @answered.positive?
58
+ base = [DecisionNode::RECONNECT_CAP, DecisionNode::RECONNECT_START * (2**@failures)].min
59
+ @client.__send__(:pause, base * (0.5 + (rand * 0.5)))
60
+ @failures += 1
61
+ end
62
+
63
+ def connect
64
+ session = if @last&.code == SessionClosed::TRY_AGAIN && @kept
65
+ @kept
66
+ else
67
+ @client.open_session(**@body)
68
+ end
69
+ @connects += 1
70
+ @answered = 0
71
+ @pending = 0
72
+ @last = nil
73
+ @kept = nil
74
+ session.connect
75
+ @session = session
76
+ rescue SessionClosed => e
77
+ closed(e, session)
78
+ end
79
+
80
+ def closed(event, session)
81
+ @last = event
82
+ @kept = event.code == SessionClosed::TRY_AGAIN ? session : nil
83
+ @session = nil
84
+ end
85
+
86
+ # Hands every event that arrived to the handler. With a wait, waits up to
87
+ # that many seconds for the outstanding replies.
88
+ def drain(wait)
89
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
90
+ while @session
91
+ timeout = wait.zero? ? 0 : [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.01].max
92
+ event = @session.next_event(timeout: timeout)
93
+ return if event.nil?
94
+
95
+ if event.is_a?(SessionClosed)
96
+ closed(event, @session)
97
+ return
98
+ end
99
+ dropped = event.is_a?(Reply) ? (event.dropped || []).size : 0
100
+ @pending = [@pending - 1 - dropped, 0].max
101
+ @answered += 1 if event.is_a?(Reply)
102
+ @handler.call(event)
103
+ return if wait.positive? && @pending.zero?
104
+ end
105
+ end
106
+ end
107
+ end