bsdkrun 0.1.0 → 0.2.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 +4 -4
- data/README.md +67 -4
- data/lib/bsdkrun/args.rb +46 -0
- data/lib/bsdkrun/client.rb +543 -0
- data/lib/bsdkrun/errors.rb +25 -0
- data/lib/bsdkrun/shell_session.rb +139 -0
- data/lib/bsdkrun/types.rb +94 -2
- data/lib/bsdkrun/version.rb +1 -1
- data/lib/bsdkrun/websocket_frame.rb +116 -0
- data/lib/bsdkrun/ws_client.rb +315 -0
- data/lib/bsdkrun.rb +4 -0
- metadata +5 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Bsdkrun
|
|
6
|
+
# A live interactive session opened by {Bsdkrun::Client#shell}.
|
|
7
|
+
#
|
|
8
|
+
# Output arrives on the {Bsdkrun::WsClient}'s background reader thread, so
|
|
9
|
+
# {#on_output} / {#on_exit} callbacks run on that thread, not the caller's —
|
|
10
|
+
# the same tradeoff +web/src/lib/graphql.ts+ makes with its +onNext+
|
|
11
|
+
# handlers, just single-threaded there because JS has no threads to worry
|
|
12
|
+
# about.
|
|
13
|
+
class ShellSession
|
|
14
|
+
# @return [String] the session id (+openShell+'s return value).
|
|
15
|
+
attr_reader :id
|
|
16
|
+
|
|
17
|
+
# @!visibility private
|
|
18
|
+
#
|
|
19
|
+
# Constructed by {Bsdkrun::Client#shell} — not meant to be built directly.
|
|
20
|
+
# +unsubscribe+ is assigned right after construction, before the
|
|
21
|
+
# +shellOutput+ subscription can possibly deliver anything, so callbacks
|
|
22
|
+
# closing over +self+ never observe it unset.
|
|
23
|
+
def initialize(client:, id:)
|
|
24
|
+
@client = client
|
|
25
|
+
@id = id
|
|
26
|
+
@unsubscribe = nil
|
|
27
|
+
@callback_mutex = Mutex.new
|
|
28
|
+
@on_output = nil
|
|
29
|
+
@on_exit = nil
|
|
30
|
+
# The shellOutput subscription starts synchronously inside
|
|
31
|
+
# Client#shell, on the WsClient's background reader thread — a real,
|
|
32
|
+
# genuinely concurrent thread here (unlike JS's single-threaded event
|
|
33
|
+
# loop), so output/exit can arrive before the caller gets around to
|
|
34
|
+
# calling #on_output/#on_exit. The daemon buffers from the moment the
|
|
35
|
+
# session opens specifically so nothing is lost in that window (see
|
|
36
|
+
# daemon/README.md); buffer here too so a late registration still sees
|
|
37
|
+
# everything, exactly like write-side of this same race in the other
|
|
38
|
+
# SDKs' shell()/ShellSession.
|
|
39
|
+
@buffered_output = []
|
|
40
|
+
@exit_delivered = false
|
|
41
|
+
@buffered_exit = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @!visibility private
|
|
45
|
+
def unsubscribe=(proc)
|
|
46
|
+
@unsubscribe = proc
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Register a callback for output chunks (already base64-decoded). Any
|
|
50
|
+
# output that arrived before this was called is replayed immediately.
|
|
51
|
+
# @yieldparam bytes [String] binary-safe.
|
|
52
|
+
# @return [void]
|
|
53
|
+
def on_output(&block)
|
|
54
|
+
buffered = @callback_mutex.synchronize do
|
|
55
|
+
@on_output = block
|
|
56
|
+
buf = @buffered_output
|
|
57
|
+
@buffered_output = []
|
|
58
|
+
buf
|
|
59
|
+
end
|
|
60
|
+
buffered.each { |bytes| block.call(bytes) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Register a callback for the session's exit code. Fires once — right
|
|
64
|
+
# away if the session had already exited before this was called.
|
|
65
|
+
# @yieldparam exit_code [Integer, nil] nil if the session ended without
|
|
66
|
+
# ever reporting one (e.g. the connection dropped).
|
|
67
|
+
# @return [void]
|
|
68
|
+
def on_exit(&block)
|
|
69
|
+
already_exited, code = @callback_mutex.synchronize do
|
|
70
|
+
@on_exit = block
|
|
71
|
+
[@exit_delivered, @buffered_exit]
|
|
72
|
+
end
|
|
73
|
+
block.call(code) if already_exited
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Send keystrokes / input bytes.
|
|
77
|
+
# @param data [String]
|
|
78
|
+
# @return [void]
|
|
79
|
+
def write(data)
|
|
80
|
+
@client.request(
|
|
81
|
+
"mutation($s:String!,$d:String!){ sendShellInput(sessionId:$s, dataBase64:$d) }",
|
|
82
|
+
{ s: @id, d: Base64.strict_encode64(data.to_s.b) }
|
|
83
|
+
)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resize the pseudo-terminal.
|
|
88
|
+
# @param rows [Integer]
|
|
89
|
+
# @param cols [Integer]
|
|
90
|
+
# @return [void]
|
|
91
|
+
def resize(rows, cols)
|
|
92
|
+
@client.request(
|
|
93
|
+
"mutation($s:String!,$r:Int!,$c:Int!){ resizeShell(sessionId:$s, rows:$r, cols:$c) }",
|
|
94
|
+
{ s: @id, r: rows, c: cols }
|
|
95
|
+
)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Unsubscribe and close the session. Idempotent on the wire (+closeShell+
|
|
100
|
+
# is safe to call on an already-closed session) — a request failure here
|
|
101
|
+
# (already gone, machine removed, etc.) is swallowed rather than raised,
|
|
102
|
+
# matching the other SDKs' `close`/`closeShell` handling.
|
|
103
|
+
# @return [void]
|
|
104
|
+
def close
|
|
105
|
+
@unsubscribe&.call
|
|
106
|
+
begin
|
|
107
|
+
@client.request("mutation($s:String!){ closeShell(sessionId:$s) }", { s: @id })
|
|
108
|
+
rescue GraphQLError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @!visibility private — invoked from the {WsClient} reader thread.
|
|
115
|
+
def deliver_output(bytes)
|
|
116
|
+
cb = @callback_mutex.synchronize do
|
|
117
|
+
cb = @on_output
|
|
118
|
+
@buffered_output << bytes if cb.nil?
|
|
119
|
+
cb
|
|
120
|
+
end
|
|
121
|
+
cb&.call(bytes)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @!visibility private — invoked from the {WsClient} reader thread. Only
|
|
125
|
+
# the first call has any effect, whether that arrives as a real exit code
|
|
126
|
+
# or (from {Bsdkrun::Client#shell}'s `on_error`) as +nil+ for a dropped
|
|
127
|
+
# connection.
|
|
128
|
+
def deliver_exit(exit_code)
|
|
129
|
+
cb = @callback_mutex.synchronize do
|
|
130
|
+
next nil if @exit_delivered
|
|
131
|
+
|
|
132
|
+
@exit_delivered = true
|
|
133
|
+
@buffered_exit = exit_code
|
|
134
|
+
@on_exit
|
|
135
|
+
end
|
|
136
|
+
cb&.call(exit_code)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/bsdkrun/types.rb
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Bsdkrun
|
|
4
|
+
# A host->guest TCP port forward, as reported by +bsdkrun ps --json+.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [r] bind
|
|
7
|
+
# @return [String] host interface, e.g. "127.0.0.1" or "0.0.0.0".
|
|
8
|
+
# @!attribute [r] host
|
|
9
|
+
# @return [Integer] host port.
|
|
10
|
+
# @!attribute [r] guest
|
|
11
|
+
# @return [Integer] guest port.
|
|
12
|
+
PortForward = Data.define(:bind, :host, :guest) do
|
|
13
|
+
# @param row [Hash]
|
|
14
|
+
# @return [PortForward]
|
|
15
|
+
def self.from_row(row)
|
|
16
|
+
new(
|
|
17
|
+
bind: row["bind"].to_s,
|
|
18
|
+
host: row["host"].to_i,
|
|
19
|
+
guest: row["guest"].to_i
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
4
24
|
# A machine as reported by +bsdkrun ps --json+.
|
|
5
25
|
#
|
|
6
26
|
# @!attribute [r] id
|
|
@@ -9,10 +29,12 @@ module Bsdkrun
|
|
|
9
29
|
# @return [String, nil] DNS name on a network, or nil if unnamed.
|
|
10
30
|
# @!attribute [r] status
|
|
11
31
|
# @return [String] "running" or "exited" (derived from +running+).
|
|
32
|
+
# @!attribute [r] ports
|
|
33
|
+
# @return [Array<PortForward>] host<->guest TCP port forwards.
|
|
12
34
|
SandboxInfo = Data.define(
|
|
13
35
|
:id, :name, :image, :kind, :command, :status, :running, :exit_code,
|
|
14
36
|
:pid, :detached, :cpus, :mem, :volume, :state_dir, :network, :net_ip,
|
|
15
|
-
:created_at, :finished_at
|
|
37
|
+
:created_at, :finished_at, :ports
|
|
16
38
|
) do
|
|
17
39
|
# Map a +ps --json+ row (String keys) to a typed instance.
|
|
18
40
|
# @param row [Hash]
|
|
@@ -37,13 +59,46 @@ module Bsdkrun
|
|
|
37
59
|
network: row["network"],
|
|
38
60
|
net_ip: row["net_ip"],
|
|
39
61
|
created_at: row["created_at"].to_i,
|
|
40
|
-
finished_at: to_i_or_nil(row["finished_at"])
|
|
62
|
+
finished_at: to_i_or_nil(row["finished_at"]),
|
|
63
|
+
ports: (row["ports"] || []).map { |p| PortForward.from_row(p) }
|
|
41
64
|
)
|
|
42
65
|
end
|
|
43
66
|
|
|
44
67
|
def self.to_i_or_nil(value)
|
|
45
68
|
value.nil? ? nil : value.to_i
|
|
46
69
|
end
|
|
70
|
+
|
|
71
|
+
# Map a GraphQL +Machine+ object (String keys, camelCase — it comes from
|
|
72
|
+
# +JSON.parse+ on the daemon's response, not the CLI) to a typed instance.
|
|
73
|
+
# Sibling to {from_row}; same fields, different source and casing.
|
|
74
|
+
#
|
|
75
|
+
# @param m [Hash] a +MACHINE_FIELDS+-shaped hash, e.g. from
|
|
76
|
+
# {Bsdkrun::Client#list} / {Bsdkrun::Client#get}.
|
|
77
|
+
# @return [SandboxInfo]
|
|
78
|
+
def self.from_graphql(m)
|
|
79
|
+
running = !!m["running"]
|
|
80
|
+
new(
|
|
81
|
+
id: m["id"].to_s,
|
|
82
|
+
name: m["name"],
|
|
83
|
+
image: m["image"].to_s,
|
|
84
|
+
kind: m["kind"].to_s,
|
|
85
|
+
command: (m["command"] || "").to_s,
|
|
86
|
+
status: m["status"].to_s,
|
|
87
|
+
running: running,
|
|
88
|
+
exit_code: to_i_or_nil(m["exitCode"]),
|
|
89
|
+
pid: to_i_or_nil(m["pid"]),
|
|
90
|
+
detached: !!m["detached"],
|
|
91
|
+
cpus: m["cpus"].to_i,
|
|
92
|
+
mem: m["mem"].to_i,
|
|
93
|
+
volume: m["volume"],
|
|
94
|
+
state_dir: m["stateDir"].to_s,
|
|
95
|
+
network: m["network"],
|
|
96
|
+
net_ip: m["netIp"],
|
|
97
|
+
created_at: m["createdAt"].to_i,
|
|
98
|
+
finished_at: to_i_or_nil(m["finishedAt"]),
|
|
99
|
+
ports: (m["ports"] || []).map { |p| PortForward.from_row(p) }
|
|
100
|
+
)
|
|
101
|
+
end
|
|
47
102
|
end
|
|
48
103
|
|
|
49
104
|
# An image as reported by +bsdkrun images --json+.
|
|
@@ -141,4 +196,41 @@ module Bsdkrun
|
|
|
141
196
|
self
|
|
142
197
|
end
|
|
143
198
|
end
|
|
199
|
+
|
|
200
|
+
# The outcome of a {Bsdkrun::Client} lifecycle mutation (+stopMachine+,
|
|
201
|
+
# +startMachine+, +removeMachines+, +updateMachine+, +commitMachine+, ...).
|
|
202
|
+
# A non-zero +exit_code+ is a value to inspect, not necessarily a failure —
|
|
203
|
+
# mirrors +daemon/src/graphql.rs+'s +CommandResult+.
|
|
204
|
+
#
|
|
205
|
+
# @!attribute [r] exit_code
|
|
206
|
+
# @return [Integer]
|
|
207
|
+
# @!attribute [r] stdout
|
|
208
|
+
# @return [String]
|
|
209
|
+
# @!attribute [r] stderr
|
|
210
|
+
# @return [String]
|
|
211
|
+
CommandResult = Data.define(:exit_code, :stdout, :stderr)
|
|
212
|
+
|
|
213
|
+
# A {Bsdkrun::Client#exec} result: the guest command's exit status and its
|
|
214
|
+
# combined (stdout+stderr, in arrival order) output, decoded from the
|
|
215
|
+
# +shellOutput+ subscription's base64 frames.
|
|
216
|
+
#
|
|
217
|
+
# @!attribute [r] exit_code
|
|
218
|
+
# @return [Integer]
|
|
219
|
+
# @!attribute [r] output
|
|
220
|
+
# @return [String] binary-safe combined output.
|
|
221
|
+
ExecResult = Data.define(:exit_code, :output)
|
|
222
|
+
|
|
223
|
+
# An open interactive shell session, as reported by +openShell+ / the
|
|
224
|
+
# +shellSessions+ query. Mirrors +daemon/src/graphql.rs+'s +ShellSessionInfo+.
|
|
225
|
+
#
|
|
226
|
+
# @!attribute [r] id
|
|
227
|
+
# @return [String]
|
|
228
|
+
# @!attribute [r] machine_id
|
|
229
|
+
# @return [String]
|
|
230
|
+
# @!attribute [r] finished
|
|
231
|
+
# @return [Boolean]
|
|
232
|
+
# @!attribute [r] truncated
|
|
233
|
+
# @return [Boolean] whether buffered output was dropped to stay under the
|
|
234
|
+
# session buffer cap.
|
|
235
|
+
ShellSessionInfo = Data.define(:id, :machine_id, :finished, :truncated)
|
|
144
236
|
end
|
data/lib/bsdkrun/version.rb
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bsdkrun
|
|
4
|
+
# RFC 6455 §5.2 frame encode/decode, hand-rolled because the Ruby standard
|
|
5
|
+
# library has no WebSocket client.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately narrow: only what {WsClient} needs to speak
|
|
8
|
+
# +graphql-transport-ws+, which exchanges small JSON text frames. Fragmented
|
|
9
|
+
# messages (a logical message split across CONTINUATION frames) are
|
|
10
|
+
# unsupported — {read} returns whatever single frame arrives, and a caller
|
|
11
|
+
# that gets a non-FIN frame is expected to treat it as an error. In
|
|
12
|
+
# practice every graphql-ws message this SDK deals with is small enough
|
|
13
|
+
# that no server has ever been observed to fragment one.
|
|
14
|
+
module WebSocketFrame
|
|
15
|
+
OPCODES = {
|
|
16
|
+
continuation: 0x0,
|
|
17
|
+
text: 0x1,
|
|
18
|
+
binary: 0x2,
|
|
19
|
+
close: 0x8,
|
|
20
|
+
ping: 0x9,
|
|
21
|
+
pong: 0xA
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Build a complete frame.
|
|
27
|
+
#
|
|
28
|
+
# @param opcode [Symbol] a key of {OPCODES}.
|
|
29
|
+
# @param payload [String] the frame body (binary-safe).
|
|
30
|
+
# @param mask [Boolean] +true+ for client->server frames (RFC 6455
|
|
31
|
+
# requires it — the mask key is randomly generated per frame); +false+
|
|
32
|
+
# for server->client frames, which MUST NOT be masked.
|
|
33
|
+
# @return [String] the raw bytes to write to the socket.
|
|
34
|
+
def build(opcode:, payload: "".b, mask: true)
|
|
35
|
+
payload = payload.to_s.dup.force_encoding(Encoding::BINARY)
|
|
36
|
+
code = OPCODES.fetch(opcode) { raise ArgumentError, "unknown opcode #{opcode.inspect}" }
|
|
37
|
+
|
|
38
|
+
frame = +"".b
|
|
39
|
+
frame << [0x80 | code].pack("C") # FIN=1, RSV1-3=0
|
|
40
|
+
frame << length_bytes(payload.bytesize, mask)
|
|
41
|
+
|
|
42
|
+
if mask
|
|
43
|
+
key = Random.bytes(4)
|
|
44
|
+
frame << key
|
|
45
|
+
frame << apply_mask(payload, key)
|
|
46
|
+
else
|
|
47
|
+
frame << payload
|
|
48
|
+
end
|
|
49
|
+
frame
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Read exactly one frame from +io+ (anything responding to +#read(n)+ —
|
|
53
|
+
# a +TCPSocket+, an +OpenSSL::SSL::SSLSocket+, or a +StringIO+ in tests).
|
|
54
|
+
#
|
|
55
|
+
# @param io [#read]
|
|
56
|
+
# @return [Array(Boolean, Integer, String)] +[fin, opcode, payload]+ —
|
|
57
|
+
# +payload+ is already unmasked.
|
|
58
|
+
# @raise [EOFError] if the socket closes mid-frame.
|
|
59
|
+
def read(io)
|
|
60
|
+
b0, b1 = read_exact(io, 2).unpack("C2")
|
|
61
|
+
fin = (b0 & 0x80) != 0
|
|
62
|
+
opcode = b0 & 0x0F
|
|
63
|
+
masked = (b1 & 0x80) != 0
|
|
64
|
+
len = b1 & 0x7F
|
|
65
|
+
|
|
66
|
+
len = read_exact(io, 2).unpack1("n") if len == 126
|
|
67
|
+
len = read_exact(io, 8).unpack1("Q>") if len == 127
|
|
68
|
+
|
|
69
|
+
mask_key = masked ? read_exact(io, 4) : nil
|
|
70
|
+
payload = len.positive? ? read_exact(io, len) : "".b
|
|
71
|
+
payload = apply_mask(payload, mask_key) if masked
|
|
72
|
+
|
|
73
|
+
[fin, opcode, payload]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# XOR-mask (or, symmetrically, unmask) +payload+ against a 4-byte +key+.
|
|
77
|
+
# @param payload [String]
|
|
78
|
+
# @param key [String] exactly 4 bytes.
|
|
79
|
+
# @return [String]
|
|
80
|
+
def apply_mask(payload, key)
|
|
81
|
+
out = payload.b
|
|
82
|
+
key_bytes = key.bytes
|
|
83
|
+
bytes = out.bytes
|
|
84
|
+
bytes.each_index { |i| bytes[i] ^= key_bytes[i % 4] }
|
|
85
|
+
bytes.pack("C*")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @!visibility private
|
|
89
|
+
def length_bytes(len, mask)
|
|
90
|
+
mask_bit = mask ? 0x80 : 0x00
|
|
91
|
+
if len < 126
|
|
92
|
+
[mask_bit | len].pack("C")
|
|
93
|
+
elsif len < 65_536
|
|
94
|
+
[mask_bit | 126].pack("C") + [len].pack("n")
|
|
95
|
+
else
|
|
96
|
+
[mask_bit | 127].pack("C") + [len].pack("Q>")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @!visibility private
|
|
101
|
+
#
|
|
102
|
+
# +io.read(n)+ already blocks until +n+ bytes are available or EOF, but a
|
|
103
|
+
# partial read (fewer than +n+ bytes, non-nil) is technically possible
|
|
104
|
+
# per the IO contract, so this loops to be sure.
|
|
105
|
+
def read_exact(io, n)
|
|
106
|
+
buf = "".b
|
|
107
|
+
while buf.bytesize < n
|
|
108
|
+
chunk = io.read(n - buf.bytesize)
|
|
109
|
+
raise EOFError, "socket closed mid-frame" if chunk.nil?
|
|
110
|
+
|
|
111
|
+
buf << chunk
|
|
112
|
+
end
|
|
113
|
+
buf
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
require "digest/sha1"
|
|
8
|
+
require "base64"
|
|
9
|
+
|
|
10
|
+
require_relative "websocket_frame"
|
|
11
|
+
require_relative "errors"
|
|
12
|
+
|
|
13
|
+
module Bsdkrun
|
|
14
|
+
# A hand-rolled +graphql-transport-ws+ client (the graphql-ws project's
|
|
15
|
+
# protocol; the negotiated subprotocol string is literally
|
|
16
|
+
# +"graphql-transport-ws"+), because the Ruby standard library has no
|
|
17
|
+
# WebSocket client.
|
|
18
|
+
#
|
|
19
|
+
# One socket per instance, opened lazily on the first {#subscribe} and
|
|
20
|
+
# closed once the last subscription ends. A background reader +Thread+
|
|
21
|
+
# pumps frames off the socket and dispatches to each subscription's
|
|
22
|
+
# callbacks — this SDK is otherwise synchronous (see
|
|
23
|
+
# {Bsdkrun::Sandbox}, which shells out via +Open3+), so the public surface
|
|
24
|
+
# here stays callback-based and lets callers like {Bsdkrun::Client#exec}
|
|
25
|
+
# block on a +Queue+ instead of needing their own event loop.
|
|
26
|
+
#
|
|
27
|
+
# Two mutexes, deliberately not one: +@state_mutex+ guards the small bits
|
|
28
|
+
# of bookkeeping (+@subs+, +@pending+, +@acked+) so the reader thread and
|
|
29
|
+
# callers never race on them, while +@write_mutex+ only serializes actual
|
|
30
|
+
# socket writes. Holding one lock across a blocking socket write while the
|
|
31
|
+
# other is needed just for a hash lookup would let a slow write stall
|
|
32
|
+
# unrelated bookkeeping.
|
|
33
|
+
class WsClient
|
|
34
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
35
|
+
private_constant :GUID
|
|
36
|
+
|
|
37
|
+
# @param ws_url [String] e.g. "ws://host:50052/graphql/ws".
|
|
38
|
+
# @param token [String]
|
|
39
|
+
def initialize(ws_url:, token:)
|
|
40
|
+
@ws_url = ws_url
|
|
41
|
+
@token = token
|
|
42
|
+
|
|
43
|
+
@connect_mutex = Mutex.new
|
|
44
|
+
@write_mutex = Mutex.new
|
|
45
|
+
@state_mutex = Mutex.new
|
|
46
|
+
|
|
47
|
+
@socket = nil
|
|
48
|
+
@reader_thread = nil
|
|
49
|
+
@acked = false
|
|
50
|
+
@subs = {}
|
|
51
|
+
@pending = []
|
|
52
|
+
@next_id = 0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Start a subscription.
|
|
56
|
+
#
|
|
57
|
+
# Connects (and sends +connection_init+) on the first call. If
|
|
58
|
+
# +connection_ack+ has not arrived yet, the +subscribe+ message is queued
|
|
59
|
+
# and flushed once it does — starting a subscription before the ack
|
|
60
|
+
# would be sent to a daemon not yet ready to accept operations.
|
|
61
|
+
#
|
|
62
|
+
# @param query [String]
|
|
63
|
+
# @param variables [Hash]
|
|
64
|
+
# @param on_next [#call] invoked with the +data+ hash for each +next+.
|
|
65
|
+
# @param on_error [#call, nil] invoked with an {Error} exception once.
|
|
66
|
+
# @param on_complete [#call, nil] invoked with no args once, on a clean end.
|
|
67
|
+
# @return [Proc] call to unsubscribe (sends +complete+, then closes the
|
|
68
|
+
# socket if this was the last subscription).
|
|
69
|
+
def subscribe(query, variables, on_next:, on_error: nil, on_complete: nil)
|
|
70
|
+
ensure_connected
|
|
71
|
+
|
|
72
|
+
id = (@state_mutex.synchronize { @next_id += 1 }).to_s
|
|
73
|
+
sub = {
|
|
74
|
+
on_next: on_next,
|
|
75
|
+
on_error: on_error || ->(_e) {},
|
|
76
|
+
on_complete: on_complete || -> {}
|
|
77
|
+
}
|
|
78
|
+
start = lambda do
|
|
79
|
+
send_json({ id: id, type: "subscribe", payload: { query: query, variables: variables } })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ready = @state_mutex.synchronize do
|
|
83
|
+
@subs[id] = sub
|
|
84
|
+
if @acked
|
|
85
|
+
true
|
|
86
|
+
else
|
|
87
|
+
@pending << start
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
start.call if ready
|
|
92
|
+
|
|
93
|
+
unsubscribed = false
|
|
94
|
+
lambda do
|
|
95
|
+
next if unsubscribed
|
|
96
|
+
|
|
97
|
+
unsubscribed = true
|
|
98
|
+
existed = @state_mutex.synchronize { !!@subs.delete(id) }
|
|
99
|
+
next unless existed
|
|
100
|
+
|
|
101
|
+
send_json({ id: id, type: "complete" }) if connected?
|
|
102
|
+
close_if_idle
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean] whether the socket is currently open.
|
|
107
|
+
def connected?
|
|
108
|
+
!@socket.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Close the socket immediately, regardless of open subscriptions. Used by
|
|
112
|
+
# {Bsdkrun::Client} teardown paths; normal use tears itself down when the
|
|
113
|
+
# last subscription unsubscribes.
|
|
114
|
+
# @return [void]
|
|
115
|
+
def close
|
|
116
|
+
@connect_mutex.synchronize { close_socket }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def ensure_connected
|
|
122
|
+
@connect_mutex.synchronize do
|
|
123
|
+
return if @socket
|
|
124
|
+
|
|
125
|
+
uri = URI.parse(@ws_url)
|
|
126
|
+
port = uri.port || (uri.scheme == "wss" ? 443 : 80)
|
|
127
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
128
|
+
socket = uri.scheme == "wss" ? wrap_tls(tcp, uri.host) : tcp
|
|
129
|
+
|
|
130
|
+
path = uri.path.to_s
|
|
131
|
+
path = "/" if path.empty?
|
|
132
|
+
path = "#{path}?#{uri.query}" if uri.query
|
|
133
|
+
|
|
134
|
+
handshake(socket, uri.host, port, path)
|
|
135
|
+
|
|
136
|
+
@socket = socket
|
|
137
|
+
@state_mutex.synchronize do
|
|
138
|
+
@acked = false
|
|
139
|
+
@pending = []
|
|
140
|
+
end
|
|
141
|
+
start_reader
|
|
142
|
+
send_json({ type: "connection_init", payload: { authorization: "Bearer #{@token}" } })
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def wrap_tls(tcp, hostname)
|
|
147
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
148
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
149
|
+
ssl.hostname = hostname
|
|
150
|
+
ssl.connect
|
|
151
|
+
ssl
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The RFC 6455 opening handshake: an HTTP/1.1 Upgrade request, and the
|
|
155
|
+
# server's +Sec-WebSocket-Accept+ verified against the client's key
|
|
156
|
+
# (SHA-1 of key+GUID, base64-encoded — the one bit of cryptography this
|
|
157
|
+
# protocol asks of a client).
|
|
158
|
+
def handshake(socket, host, port, path)
|
|
159
|
+
key = Base64.strict_encode64(Random.bytes(16))
|
|
160
|
+
request = +"GET #{path} HTTP/1.1\r\n"
|
|
161
|
+
request << "Host: #{host}:#{port}\r\n"
|
|
162
|
+
request << "Upgrade: websocket\r\n"
|
|
163
|
+
request << "Connection: Upgrade\r\n"
|
|
164
|
+
request << "Sec-WebSocket-Key: #{key}\r\n"
|
|
165
|
+
request << "Sec-WebSocket-Version: 13\r\n"
|
|
166
|
+
request << "Sec-WebSocket-Protocol: graphql-transport-ws\r\n"
|
|
167
|
+
request << "\r\n"
|
|
168
|
+
socket.write(request)
|
|
169
|
+
|
|
170
|
+
status_line = socket.gets
|
|
171
|
+
unless status_line
|
|
172
|
+
raise GraphQLError, "cannot reach the bsdkrun daemon at #{@ws_url} — connection closed during handshake"
|
|
173
|
+
end
|
|
174
|
+
unless status_line.include?(" 101 ")
|
|
175
|
+
raise GraphQLError, "websocket handshake with #{@ws_url} failed: #{status_line.strip}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
headers = {}
|
|
179
|
+
while (line = socket.gets) && line.strip != ""
|
|
180
|
+
k, v = line.split(":", 2)
|
|
181
|
+
headers[k.strip.downcase] = v.strip if k && v
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
|
|
185
|
+
return if headers["sec-websocket-accept"] == expected
|
|
186
|
+
|
|
187
|
+
raise GraphQLError, "websocket handshake with #{@ws_url} failed: bad Sec-WebSocket-Accept"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def start_reader
|
|
191
|
+
socket = @socket
|
|
192
|
+
@reader_thread = Thread.new { read_loop(socket) }
|
|
193
|
+
@reader_thread.report_on_exception = false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_loop(socket)
|
|
197
|
+
loop do
|
|
198
|
+
_fin, opcode, payload = WebSocketFrame.read(socket)
|
|
199
|
+
case opcode
|
|
200
|
+
when WebSocketFrame::OPCODES[:text]
|
|
201
|
+
dispatch(payload)
|
|
202
|
+
when WebSocketFrame::OPCODES[:ping]
|
|
203
|
+
write_frame(opcode: :pong, payload: payload)
|
|
204
|
+
when WebSocketFrame::OPCODES[:close]
|
|
205
|
+
break
|
|
206
|
+
else
|
|
207
|
+
# binary / pong / continuation: nothing this protocol sends that we
|
|
208
|
+
# need to act on. Fragmented text messages would arrive as
|
|
209
|
+
# continuation frames, which is the documented unsupported case.
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
rescue EOFError, IOError, Errno::ECONNRESET, OpenSSL::SSL::SSLError
|
|
213
|
+
# The socket closed — handled uniformly below.
|
|
214
|
+
ensure
|
|
215
|
+
handle_disconnect(socket)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def dispatch(raw)
|
|
219
|
+
msg = JSON.parse(raw)
|
|
220
|
+
case msg["type"]
|
|
221
|
+
when "connection_ack"
|
|
222
|
+
flush_pending
|
|
223
|
+
when "next"
|
|
224
|
+
sub = @state_mutex.synchronize { @subs[msg["id"]] }
|
|
225
|
+
sub&.[](:on_next)&.call(msg.dig("payload", "data"))
|
|
226
|
+
when "error"
|
|
227
|
+
sub = @state_mutex.synchronize { @subs.delete(msg["id"]) }
|
|
228
|
+
sub&.[](:on_error)&.call(GraphQLError.new(error_detail(msg["payload"])))
|
|
229
|
+
close_if_idle
|
|
230
|
+
when "complete"
|
|
231
|
+
sub = @state_mutex.synchronize { @subs.delete(msg["id"]) }
|
|
232
|
+
sub&.[](:on_complete)&.call
|
|
233
|
+
close_if_idle
|
|
234
|
+
when "ping"
|
|
235
|
+
send_json({ type: "pong" })
|
|
236
|
+
end
|
|
237
|
+
rescue JSON::ParserError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def error_detail(payload)
|
|
242
|
+
if payload.is_a?(Array)
|
|
243
|
+
payload.map { |e| e["message"] }.compact.join("; ")
|
|
244
|
+
else
|
|
245
|
+
payload.to_s
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def flush_pending
|
|
250
|
+
queued = @state_mutex.synchronize do
|
|
251
|
+
@acked = true
|
|
252
|
+
q = @pending
|
|
253
|
+
@pending = []
|
|
254
|
+
q
|
|
255
|
+
end
|
|
256
|
+
queued.each(&:call)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The reader thread's terminal state: fan out a close reason to every
|
|
260
|
+
# still-open subscription and drop the socket. Whether a +connection_ack+
|
|
261
|
+
# was ever received decides the reason — an unacked close means the
|
|
262
|
+
# daemon rejected our token; an acked close is just "the connection
|
|
263
|
+
# dropped."
|
|
264
|
+
def handle_disconnect(socket)
|
|
265
|
+
acked, subs = @state_mutex.synchronize do
|
|
266
|
+
next [@acked, {}] unless @socket.equal?(socket)
|
|
267
|
+
|
|
268
|
+
result = [@acked, @subs.dup]
|
|
269
|
+
@subs.clear
|
|
270
|
+
@pending = []
|
|
271
|
+
@acked = false
|
|
272
|
+
result
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
@connect_mutex.synchronize { close_socket if @socket.equal?(socket) }
|
|
276
|
+
|
|
277
|
+
return if subs.empty?
|
|
278
|
+
|
|
279
|
+
err = acked ? GraphQLError.new("the connection to the daemon was closed") : AuthError.new
|
|
280
|
+
subs.each_value { |sub| sub[:on_error]&.call(err) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def close_if_idle
|
|
284
|
+
close if @state_mutex.synchronize { @subs.empty? }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def close_socket
|
|
288
|
+
return unless @socket
|
|
289
|
+
|
|
290
|
+
socket = @socket
|
|
291
|
+
@socket = nil
|
|
292
|
+
begin
|
|
293
|
+
@write_mutex.synchronize { socket.write(WebSocketFrame.build(opcode: :close, payload: "".b)) }
|
|
294
|
+
rescue StandardError
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
begin
|
|
298
|
+
socket.close
|
|
299
|
+
rescue StandardError
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def send_json(obj)
|
|
305
|
+
write_frame(opcode: :text, payload: JSON.generate(obj).b)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def write_frame(opcode:, payload:)
|
|
309
|
+
frame = WebSocketFrame.build(opcode: opcode, payload: payload)
|
|
310
|
+
@write_mutex.synchronize { @socket&.write(frame) }
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|