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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -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
|