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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Bounded FIFO with drop-oldest semantics, holding the most recent frames
5
+ # while no viewer is attached so they can be replayed on attach.
6
+ #
7
+ # Two independent bounds, both drop-oldest:
8
+ # * capacity — a hard item count;
9
+ # * max_bytes — an approximate memory ceiling, in UTF-8 bytes of the
10
+ # frames. Without it, N slots cost N x the largest payload the host ever
11
+ # emits.
12
+ #
13
+ # An item bigger than the whole byte budget is REFUSED (push returns false,
14
+ # `rejected` counts it) and nothing is evicted for it: one oversized frame
15
+ # must never cost the replay buffer every older frame — while the debugger
16
+ # was unreachable that turned one 17 MB event into the loss of every event
17
+ # before it. The caller decides the refused item's fate (the session sends
18
+ # it live when attached, else counts it lost). `reject_oversize: false`
19
+ # restores the old behaviour: the item is kept alone and every other item is
20
+ # evicted. Port of packages/client/src/ring-buffer.ts (`rejectOversize`,
21
+ # which the TypeScript session always turns on).
22
+ #
23
+ # Not thread-safe on its own — the session owns it under its mutex.
24
+ class RingBuffer
25
+ attr_reader :capacity, :dropped, :byte_size, :rejected, :max_bytes
26
+
27
+ def initialize(capacity, max_bytes = nil, reject_oversize: true)
28
+ raise ArgumentError, "capacity must be a positive integer" unless capacity.is_a?(Integer) && capacity >= 1
29
+ raise ArgumentError, "max_bytes must be > 0" if max_bytes && !(max_bytes.is_a?(Numeric) && max_bytes.positive?)
30
+
31
+ @capacity = capacity
32
+ @max_bytes = max_bytes
33
+ @reject_oversize = reject_oversize ? true : false
34
+ @items = []
35
+ @dropped = 0
36
+ @rejected = 0
37
+ @byte_size = 0
38
+ end
39
+
40
+ # Append one serialized frame; drops the oldest when either bound trips.
41
+ # Returns false only when the frame alone is larger than max_bytes and was
42
+ # refused (the buffer is then exactly as it was); true otherwise.
43
+ def push(frame)
44
+ size = frame.bytesize
45
+ if @max_bytes && @reject_oversize && size > @max_bytes
46
+ @rejected += 1
47
+ return false
48
+ end
49
+
50
+ @items << frame
51
+ @byte_size += size
52
+ while @items.size > @capacity
53
+ @byte_size -= @items.shift.bytesize
54
+ @dropped += 1
55
+ end
56
+ if @max_bytes
57
+ # Never down to empty: the frame just pushed is the newest and stays.
58
+ while @byte_size > @max_bytes && @items.size > 1
59
+ @byte_size -= @items.shift.bytesize
60
+ @dropped += 1
61
+ end
62
+ end
63
+ true
64
+ end
65
+
66
+ def to_a = @items.dup
67
+ def size = @items.size
68
+
69
+ def clear
70
+ @items.clear
71
+ @byte_size = 0
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Process-wide shutdown plumbing.
5
+ #
6
+ # A gate held on the main thread would otherwise block interpreter exit
7
+ # forever, so every live session registers a fail-open hook here and a single
8
+ # at_exit handler releases them all. One handler, not one per session, so a
9
+ # test suite that builds hundreds of sessions does not build hundreds of
10
+ # at_exit callbacks.
11
+ module Runtime
12
+ @mutex = Mutex.new
13
+ @hooks = []
14
+ @installed = false
15
+
16
+ class << self
17
+ def register(hook)
18
+ @mutex.synchronize do
19
+ @hooks << hook
20
+ install!
21
+ end
22
+ nil
23
+ end
24
+
25
+ def unregister(hook)
26
+ @mutex.synchronize { @hooks.delete(hook) }
27
+ nil
28
+ end
29
+
30
+ def fail_open_all
31
+ hooks = @mutex.synchronize { @hooks.dup }
32
+ hooks.each do |hook|
33
+ hook.call
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def install!
43
+ return if @installed
44
+
45
+ @installed = true
46
+ at_exit { Graphmind::Runtime.fail_open_all }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # "Never raise into the host app" plumbing.
5
+ #
6
+ # All internal failures degrade to a no-op plus a rate-limited warning: at
7
+ # most one warning per key per interval, so a permanently broken transport
8
+ # cannot spam a Rails log.
9
+ class Warner
10
+ # `Kernel.warn`, explicitly: a bare `warn(message)` inside this class binds
11
+ # to Warner#warn (arity 2+) and raises ArgumentError, which the rescue at
12
+ # the bottom of #warn would then swallow — leaving the default sink
13
+ # silently printing nothing.
14
+ def initialize(interval = 60.0, sink = nil)
15
+ @interval = interval
16
+ @sink = sink || ->(message) { Kernel.warn(message) }
17
+ @last = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def warn(key, message, cause = nil)
22
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ emit = @mutex.synchronize do
24
+ previous = @last[key]
25
+ if previous && (now - previous) < @interval
26
+ false
27
+ else
28
+ @last[key] = now
29
+ true
30
+ end
31
+ end
32
+ return unless emit
33
+
34
+ suffix =
35
+ if cause.is_a?(Exception)
36
+ " (#{cause.class}: #{cause.message})"
37
+ elsif cause.nil?
38
+ ""
39
+ else
40
+ " (#{cause})"
41
+ end
42
+ @sink.call("[graphmind] #{message}#{suffix}")
43
+ rescue StandardError
44
+ # Even a raising sink must not propagate into the host.
45
+ nil
46
+ end
47
+ end
48
+ end