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.
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Bsdkrun
4
4
  # The bsdkrun Ruby SDK version.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
@@ -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