graphmind 0.6.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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Batches streamed deltas into `node.token` events.
5
+ #
6
+ # A streaming LLM produces tokens far faster than a canvas can paint, and one
7
+ # WebSocket frame per token would swamp both the socket and the viewer. Every
8
+ # delta lands in a per-node list, and a single background thread flushes them
9
+ # on a fixed interval (~30 Hz by default) or when the node finishes.
10
+ class TokenBatcher
11
+ def initialize(sink, interval = 0.034)
12
+ @sink = sink
13
+ @interval = interval
14
+ @mutex = Mutex.new
15
+ @pending = {}
16
+ @thread = nil
17
+ @disposed = false
18
+ end
19
+
20
+ def push(node_id, channel, value)
21
+ return if @disposed || value.nil? || value.empty?
22
+
23
+ @mutex.synchronize do
24
+ (@pending[node_id] ||= []) << { "t" => channel, "v" => value.to_s }
25
+ ensure_thread
26
+ end
27
+ nil
28
+ end
29
+
30
+ # Flush one node's pending deltas immediately (called before node.finished
31
+ # so a finished node never has tokens arriving after its result).
32
+ def flush_node(node_id)
33
+ deltas = @mutex.synchronize { @pending.delete(node_id) }
34
+ emit(node_id, deltas)
35
+ nil
36
+ end
37
+
38
+ def flush_all
39
+ pending = @mutex.synchronize do
40
+ snapshot = @pending
41
+ @pending = {}
42
+ snapshot
43
+ end
44
+ pending.each { |node_id, deltas| emit(node_id, deltas) }
45
+ nil
46
+ end
47
+
48
+ def dispose
49
+ @disposed = true
50
+ thread = @mutex.synchronize do
51
+ t = @thread
52
+ @thread = nil
53
+ t
54
+ end
55
+ thread&.kill
56
+ flush_all
57
+ nil
58
+ end
59
+
60
+ private
61
+
62
+ # Called with the mutex held.
63
+ def ensure_thread
64
+ return if @thread || @disposed
65
+
66
+ @thread = Thread.new do
67
+ until @disposed
68
+ sleep(@interval)
69
+ flush_all
70
+ end
71
+ end
72
+ @thread.name = "graphmind-tokens" if @thread.respond_to?(:name=)
73
+ @thread.report_on_exception = false
74
+ end
75
+
76
+ def emit(node_id, deltas)
77
+ return if deltas.nil? || deltas.empty?
78
+
79
+ @sink.call(node_id, deltas)
80
+ rescue StandardError
81
+ nil
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol"
4
+ require_relative "websocket"
5
+
6
+ module Graphmind
7
+ # WebSocket transport: lazy connect with a hard connect timeout, the
8
+ # hello/hello.ack handshake, background reconnect, and fail-open detach.
9
+ #
10
+ # Port of packages/client/src/transport.ts. It never raises into callers:
11
+ # every failure degrades to "detached" plus a rate-limited warning, and a
12
+ # background retry keeps trying.
13
+ #
14
+ # Threading: one supervisor thread owns the socket and the read loop; while
15
+ # attached it spawns one writer thread that drains the outbox, so a host
16
+ # thread calling #enqueue never blocks on the network. Both are ordinary
17
+ # (non-main) threads, so they cannot keep a Ruby process alive at exit.
18
+ #
19
+ # Reconnect timing has two regimes, because they are not the same problem:
20
+ # * never attached (no debugger running) — retry every retry_interval;
21
+ # * lost an established attachment (a blip) — the debugger existed
22
+ # milliseconds ago and every millisecond dark eats the ring buffer, so
23
+ # burst first (FAST_RETRY_STEPS) and then settle.
24
+ class Transport
25
+ FAST_RETRY_STEPS = [0.2, 0.4, 0.8].freeze
26
+
27
+ # Hard cap on frames waiting for a slow socket. Oldest are dropped first;
28
+ # the ring buffer, not this queue, is what guarantees replay-on-attach.
29
+ MAX_OUTBOX = 10_000
30
+
31
+ Hooks = Struct.new(:build_hello, :on_attached, :on_detached, :on_control, keyword_init: true)
32
+
33
+ attr_reader :url
34
+
35
+ def initialize(url:, hooks:, warner:, connect_timeout: 0.3, handshake_timeout: 1.0,
36
+ retry_interval: 10.0)
37
+ @url = url
38
+ @hooks = hooks
39
+ @warner = warner
40
+ @connect_timeout = connect_timeout
41
+ @handshake_timeout = handshake_timeout
42
+ @retry_interval = retry_interval
43
+
44
+ @state = :idle # :idle | :connecting | :attached | :disposed
45
+ @started = false
46
+ @mutex = Mutex.new
47
+ @wake = ConditionVariable.new
48
+ @kicked = false
49
+ @supervisor = nil
50
+ @connection = nil
51
+ @outbox = nil
52
+ @writer = nil
53
+ @fast_retry_step = FAST_RETRY_STEPS.length
54
+ end
55
+
56
+ def attached? = @state == :attached
57
+ def state = @state
58
+
59
+ # Begin connecting (idempotent). Called lazily on first session use.
60
+ def start
61
+ return if @started || @state == :disposed
62
+
63
+ @started = true
64
+ @supervisor = Thread.new { supervise }
65
+ @supervisor.name = "graphmind-transport" if @supervisor.respond_to?(:name=)
66
+ @supervisor.report_on_exception = false
67
+ nil
68
+ end
69
+
70
+ # Force an attempt now instead of waiting out the retry interval.
71
+ def kick
72
+ return if @state == :disposed
73
+ return start unless @started
74
+
75
+ @mutex.synchronize do
76
+ @kicked = true
77
+ @wake.broadcast
78
+ end
79
+ nil
80
+ end
81
+
82
+ # Queue one serialized frame. Non-blocking, safe from any thread.
83
+ def enqueue(frame)
84
+ return false unless @state == :attached
85
+
86
+ outbox = @outbox
87
+ return false if outbox.nil?
88
+
89
+ # Drop-oldest so a stalled socket cannot grow without bound.
90
+ begin
91
+ outbox.pop(true) while outbox.size >= MAX_OUTBOX
92
+ rescue ThreadError
93
+ nil
94
+ end
95
+ outbox << frame
96
+ true
97
+ rescue StandardError
98
+ false
99
+ end
100
+
101
+ # Approximate number of frames still waiting to be written.
102
+ def pending = (@outbox&.size || 0)
103
+
104
+ def dispose
105
+ return if @state == :disposed
106
+
107
+ @state = :disposed
108
+ teardown_connection
109
+ @mutex.synchronize { @wake.broadcast }
110
+ supervisor = @supervisor
111
+ @supervisor = nil
112
+ supervisor&.join(0.5)
113
+ supervisor&.kill if supervisor&.alive?
114
+ nil
115
+ end
116
+
117
+ private
118
+
119
+ def supervise
120
+ until @state == :disposed
121
+ @mutex.synchronize { @kicked = false }
122
+ begin
123
+ attempt
124
+ rescue StandardError => e
125
+ @warner.warn("transport-loop", "transport loop error", e)
126
+ end
127
+ break if @state == :disposed
128
+
129
+ wait_for_retry
130
+ end
131
+ rescue StandardError => e
132
+ @warner.warn("transport-loop", "transport supervisor stopped", e)
133
+ end
134
+
135
+ def wait_for_retry
136
+ delay = next_retry_delay
137
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
138
+ @mutex.synchronize do
139
+ loop do
140
+ break if @kicked || @state == :disposed
141
+
142
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ break if remaining <= 0
144
+
145
+ @wake.wait(@mutex, remaining)
146
+ end
147
+ @kicked = false
148
+ end
149
+ end
150
+
151
+ def next_retry_delay
152
+ step = FAST_RETRY_STEPS[@fast_retry_step]
153
+ return @retry_interval if step.nil?
154
+
155
+ @fast_retry_step += 1
156
+ step < @retry_interval ? step : @retry_interval
157
+ end
158
+
159
+ def attempt
160
+ return if @state == :disposed
161
+
162
+ @state = :connecting
163
+ connection =
164
+ begin
165
+ WebSocket.connect(@url, connect_timeout: @connect_timeout,
166
+ handshake_timeout: @handshake_timeout)
167
+ rescue WebSocket::Error => e
168
+ @state = :idle
169
+ @warner.warn("transport-connect", "could not reach the GraphMind viewer; staying detached", e)
170
+ return
171
+ rescue StandardError => e
172
+ @state = :idle
173
+ @warner.warn("transport-connect", "failed to open the viewer WebSocket", e)
174
+ return
175
+ end
176
+
177
+ was_attached = false
178
+ begin
179
+ connection.send_text(@hooks.build_hello.call)
180
+ ack = await_ack(connection)
181
+ return if ack.nil?
182
+
183
+ @connection = connection
184
+ @outbox = Thread::Queue.new
185
+ @state = :attached
186
+ was_attached = true
187
+ start_writer(connection, @outbox)
188
+ safely { @hooks.on_attached.call(ack) }
189
+ read_loop(connection)
190
+ rescue WebSocket::Error => e
191
+ @warner.warn("transport-io", "viewer connection lost", e)
192
+ rescue StandardError => e
193
+ @warner.warn("transport-io", "viewer connection error", e)
194
+ ensure
195
+ @state = :idle unless @state == :disposed
196
+ stop_writer
197
+ @connection = nil
198
+ @outbox = nil
199
+ begin
200
+ connection.abort!
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ if was_attached
205
+ # Losing a live attachment arms the fast-reconnect burst.
206
+ @fast_retry_step = 0
207
+ # FAIL-OPEN: no debugger, no holds.
208
+ safely { @hooks.on_detached.call }
209
+ end
210
+ end
211
+ end
212
+
213
+ # Read frames until hello.ack, honouring the handshake budget. Returns the
214
+ # ack payload, or nil when the connection must be abandoned.
215
+ def await_ack(connection)
216
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @handshake_timeout
217
+ loop do
218
+ message = connection.read_message(deadline: deadline)
219
+ if message.nil?
220
+ @warner.warn(
221
+ "transport-ack-timeout",
222
+ "viewer did not complete the handshake within #{(@handshake_timeout * 1000).round}ms; staying detached"
223
+ )
224
+ return nil
225
+ end
226
+ return nil if message == :closed
227
+
228
+ result = Protocol.parse_envelope_json(message)
229
+ case result.kind
230
+ when :version_mismatch
231
+ @warner.warn(
232
+ "transport-version",
233
+ "viewer speaks protocol v#{result.received}, this client speaks " \
234
+ "v#{Protocol::PROTOCOL_VERSION}; staying detached"
235
+ )
236
+ return nil
237
+ when :ok
238
+ envelope = result.envelope
239
+ next unless envelope["type"] == "hello.ack"
240
+
241
+ payload = envelope["payload"] || {}
242
+ versions = payload["versions"] || {}
243
+ if versions["protocol"] != Protocol::PROTOCOL_VERSION
244
+ @warner.warn(
245
+ "transport-version",
246
+ "viewer acked protocol v#{versions['protocol']}, this client speaks " \
247
+ "v#{Protocol::PROTOCOL_VERSION}; staying detached"
248
+ )
249
+ return nil
250
+ end
251
+ return payload
252
+ end
253
+ # invalid / unknown-type before the ack: ignore and keep reading.
254
+ end
255
+ end
256
+
257
+ def read_loop(connection)
258
+ while @state == :attached
259
+ message = connection.read_message
260
+ break if message.nil? || message == :closed
261
+
262
+ result = Protocol.parse_envelope_json(message)
263
+ case result.kind
264
+ when :ok
265
+ safely { @hooks.on_control.call(result.envelope) }
266
+ when :invalid
267
+ @warner.warn("transport-invalid", "ignoring invalid frame: #{result.reason}")
268
+ end
269
+ # unknown-type / version-mismatch mid-stream: tolerate silently.
270
+ end
271
+ end
272
+
273
+ def start_writer(connection, outbox)
274
+ @writer = Thread.new do
275
+ loop do
276
+ frame = outbox.pop
277
+ break if frame.nil? || frame.equal?(STOP)
278
+
279
+ begin
280
+ connection.send_text(frame)
281
+ rescue StandardError
282
+ # The socket is gone; the read loop will notice and reconnect.
283
+ begin
284
+ connection.abort!
285
+ rescue StandardError
286
+ nil
287
+ end
288
+ break
289
+ end
290
+ end
291
+ end
292
+ @writer.name = "graphmind-writer" if @writer.respond_to?(:name=)
293
+ @writer.report_on_exception = false
294
+ end
295
+
296
+ def stop_writer
297
+ writer = @writer
298
+ @writer = nil
299
+ return if writer.nil?
300
+
301
+ begin
302
+ @outbox&.<<(STOP)
303
+ rescue StandardError
304
+ nil
305
+ end
306
+ writer.join(0.2)
307
+ writer.kill if writer.alive?
308
+ end
309
+
310
+ def teardown_connection
311
+ connection = @connection
312
+ @connection = nil
313
+ return if connection.nil?
314
+
315
+ begin
316
+ connection.abort!
317
+ rescue StandardError
318
+ nil
319
+ end
320
+ end
321
+
322
+ def safely
323
+ yield
324
+ rescue StandardError => e
325
+ @warner.warn("transport-hook", "internal error in a transport callback", e)
326
+ end
327
+
328
+ STOP = Object.new
329
+ private_constant :STOP
330
+ end
331
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ VERSION = "0.6.0"
5
+ end