libbeachcomber 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdf64a93d0d40da45446e6a7e4151cdf7e491365a3c38fc4c5a64832ecf46e2d
4
- data.tar.gz: 241b2cf966cb502874f507dd8b702423a5bb5e202598f728abf3d0580fb4eabd
3
+ metadata.gz: 9913f2904d89a4bdff62c16c549796236c3b4ed9a12bcaed23d1eefbdd080a39
4
+ data.tar.gz: 50cf3b189a487242bd2e9259b41a41097bc58030e75bd731025db02a219a1f10
5
5
  SHA512:
6
- metadata.gz: 99d78e72b22a5cbc0fef051fb231e8372f2803ec747b453ec598a843740c778d6ed4b0241cdf3182feb625d249a95010269e1901ef6131d31e242aee60741fff
7
- data.tar.gz: a10938a4d29aa829b056528840ee18099cf3883bf95b34dd3d9f8bc1bab4a08e912c10495835a53fbd11e95e58f424e670b91eeda43006f57f6c04f36a3229f5
6
+ metadata.gz: b4eb991a7205ea712c21d7033d5a88335edd4f1599e53ea1a95ebd32d8d640110e1b22928a283d2c8e3ba367927d0beff54150bd1a54ccbef1bd52023340104e
7
+ data.tar.gz: 9ede8d2296696e916b499766a18394bce4e87a7948e082dd12a23ca30968572886cf1fae9608e8160657755936c532f95cd118f37b36467ded51bb2eb1328623
@@ -1,15 +1,101 @@
1
- require 'socket'
2
1
  require 'json'
3
2
 
4
- require_relative 'discovery'
3
+ require_relative 'ffi'
5
4
  require_relative 'errors'
6
5
  require_relative 'result'
6
+ require_relative 'types'
7
+ require_relative 'watch_stream'
7
8
 
8
9
  module Beachcomber
9
- DEFAULT_TIMEOUT = 0.1 # seconds (100 ms)
10
+ DEFAULT_TIMEOUT = 0.1 # seconds (100 ms) — matches libbeachcomber's own default.
10
11
 
11
- # Session holds a persistent connection to the daemon and sends multiple
12
- # requests over the same socket.
12
+ # Response-shaping helpers shared by Client and Session: the ABI's JSON
13
+ # payload shapes are identical regardless of which handle made the call.
14
+ module ResponseParsing
15
+ private
16
+
17
+ def build_result(payload)
18
+ Result.new(
19
+ ok: true,
20
+ data: payload['data'],
21
+ age_ms: (payload['age_ms'] || 0).to_i,
22
+ stale: payload['stale'] == true,
23
+ error: nil,
24
+ )
25
+ end
26
+
27
+ def parse_hello(data)
28
+ HelloInfo.new(
29
+ protocol_version: data['protocol_version'].to_s,
30
+ daemon_version: data['daemon_version'].to_s,
31
+ )
32
+ end
33
+
34
+ def parse_cache_rows(data)
35
+ raise ProtocolError, 'status data is not an array' unless data.is_a?(Array)
36
+
37
+ data.map { |row| build_cache_row(row) }
38
+ end
39
+
40
+ def build_cache_row(row)
41
+ CacheRow.new(
42
+ provider: row['provider'].to_s,
43
+ field: row['field'],
44
+ path: row['path'],
45
+ value: row['value'],
46
+ age_ms: Integer(row['age_ms'] || 0),
47
+ stale: row['stale'] == true,
48
+ kind: row['kind'],
49
+ poll_interval_secs: row['poll_interval_secs'],
50
+ keep_alive_polls: row['keep_alive_polls'],
51
+ fsevents_reinstate: row['fsevents_reinstate'],
52
+ polls_elapsed: row['polls_elapsed'],
53
+ failure: row['failure'],
54
+ source: row['source'],
55
+ )
56
+ end
57
+
58
+ def parse_daemon_health(data)
59
+ reaper = data['reaper']
60
+ DaemonHealth.new(
61
+ pid: Integer(data['pid'] || 0),
62
+ version: data['version'].to_s,
63
+ uptime_secs: Integer(data['uptime_secs'] || 0),
64
+ socket_path: data['socket_path'].to_s,
65
+ config_path: data['config_path'],
66
+ requests_total: Integer(data['requests_total'] || 0),
67
+ in_flight: Integer(data['in_flight'] || 0),
68
+ active_watchers: Integer(data['active_watchers'] || 0),
69
+ cache_entries: Integer(data['cache_entries'] || 0),
70
+ watch_backend: data['watch_backend'],
71
+ reaper: reaper && Reaper.new(
72
+ armed: reaper['armed'],
73
+ visibility: reaper['visibility'],
74
+ sweeps: reaper['sweeps'],
75
+ reaped: reaper['reaped'],
76
+ kill_denied: reaper['kill_denied'],
77
+ ),
78
+ verdicts: (data['verdicts'] || []).map do |v|
79
+ Verdict.new(level: v['level'].to_s, message: v['message'].to_s)
80
+ end,
81
+ )
82
+ end
83
+
84
+ def parse_introspect(subject, data)
85
+ if subject == IntrospectSubject::DAEMON && data.is_a?(Hash)
86
+ IntrospectResponse.new(subject: subject, daemon: parse_daemon_health(data), other: nil)
87
+ else
88
+ IntrospectResponse.new(subject: subject, daemon: nil, other: data)
89
+ end
90
+ end
91
+
92
+ def json_or_nil(value)
93
+ value ? JSON.generate(value) : nil
94
+ end
95
+ end
96
+
97
+ # Session holds a persistent connection to the daemon, held open by the
98
+ # shared library, for {#get}/{#get_with_flags}/{#put}/{#set_context}.
13
99
  #
14
100
  # Obtain a Session via {Client#session}:
15
101
  #
@@ -18,18 +104,30 @@ module Beachcomber
18
104
  # r = s.get('git.branch')
19
105
  # end
20
106
  #
21
- # Not thread-safe; use one session per thread.
107
+ # {#refresh}, {#status}, {#hello} and {#introspect} are also available for
108
+ # API-compatibility with the pre-ABI client, but the C ABI provides no
109
+ # session-scoped equivalents for them (only get/put/set_context reuse the
110
+ # persistent connection — see Task 3.6 of the client-ABI plan); they route
111
+ # through the parent Client's connection instead, one-shot per call.
112
+ #
113
+ # The underlying handle is guarded by the library's own mutex: a
114
+ # concurrent caller on the same session gets {Beachcomber::BusyError}
115
+ # rather than blocking or interleaving requests.
22
116
  class Session
23
- def initialize(socket, timeout)
24
- @socket = socket
25
- @timeout = timeout
117
+ include ResponseParsing
118
+
119
+ def initialize(handle, client_handle)
120
+ @handle = handle
121
+ @client_handle = client_handle
122
+ @closed = false
26
123
  end
27
124
 
28
- # Sets the default path for subsequent queries on this connection.
125
+ # Sets the default path for subsequent {#get}/{#get_with_flags} queries
126
+ # on this connection.
29
127
  #
30
128
  # @param path [String]
31
129
  def set_context(path)
32
- roundtrip({ op: 'context', path: path })
130
+ Beachcomber::FFI.call!(:bc_session_set_context, @handle, path)
33
131
  nil
34
132
  end
35
133
 
@@ -39,9 +137,7 @@ module Beachcomber
39
137
  # @param path [String, nil] optional working-directory override
40
138
  # @return [Result]
41
139
  def get(key, path: nil)
42
- req = { op: 'get', key: key }
43
- req[:path] = path if path
44
- roundtrip(req)
140
+ build_result(Beachcomber::FFI.call!(:bc_session_get, @handle, key, path, 0))
45
141
  end
46
142
 
47
143
  # Reads a cached value with protocol flags.
@@ -52,11 +148,8 @@ module Beachcomber
52
148
  # @param wait [Boolean] block until a fresh value is available
53
149
  # @return [Result]
54
150
  def get_with_flags(key, path: nil, force: false, wait: false)
55
- req = { op: 'get', key: key }
56
- req[:path] = path if path
57
- req[:force] = true if force
58
- req[:wait] = true if wait
59
- roundtrip(req)
151
+ flags = (force ? Beachcomber::FFI::GET_FORCE : 0) | (wait ? Beachcomber::FFI::GET_WAIT : 0)
152
+ build_result(Beachcomber::FFI.call!(:bc_session_get, @handle, key, path, flags))
60
153
  end
61
154
 
62
155
  # Forces the daemon to recompute a provider/key.
@@ -64,9 +157,7 @@ module Beachcomber
64
157
  # @param key [String]
65
158
  # @param path [String, nil]
66
159
  def refresh(key, path: nil)
67
- req = { op: 'refresh', key: key }
68
- req[:path] = path if path
69
- roundtrip(req)
160
+ Beachcomber::FFI.call!(:bc_refresh, @client_handle, key, path)
70
161
  nil
71
162
  end
72
163
 
@@ -74,31 +165,26 @@ module Beachcomber
74
165
  #
75
166
  # @return [Array<CacheRow>]
76
167
  def status
77
- resp_obj = roundtrip_raw({ op: 'status' })
78
- parse_cache_rows(resp_obj)
168
+ parse_cache_rows(Beachcomber::FFI.call!(:bc_status, @client_handle))
79
169
  end
80
170
 
81
171
  # Sends a hello handshake and returns server info.
82
172
  #
83
173
  # @return [HelloInfo]
84
174
  def hello
85
- resp = roundtrip_raw({ op: 'hello' })
86
- parse_hello(resp)
175
+ parse_hello(Beachcomber::FFI.call!(:bc_hello, @client_handle))
87
176
  end
88
177
 
89
- # Writes a value into the daemon cache.
178
+ # Writes a value into the daemon cache on this session's connection.
179
+ # +data = nil+ clears the entry.
90
180
  #
91
181
  # @param key [String]
92
182
  # @param data [Object, nil]
93
- # @param ttl [Numeric, nil] time-to-live in seconds
183
+ # @param ttl [Numeric, String, nil] time-to-live
94
184
  # @param path [String, nil]
95
185
  # @return [nil]
96
186
  def put(key, data = nil, ttl: nil, path: nil)
97
- req = { op: 'put', key: key }
98
- req[:data] = data unless data.nil?
99
- req[:ttl] = ttl if ttl
100
- req[:path] = path if path
101
- roundtrip(req)
187
+ Beachcomber::FFI.call!(:bc_session_put, @handle, key, JSON.generate(data), ttl&.to_s, path)
102
188
  nil
103
189
  end
104
190
 
@@ -108,120 +194,23 @@ module Beachcomber
108
194
  # @param duration_secs [Numeric, nil]
109
195
  # @return [IntrospectResponse]
110
196
  def introspect(subject, duration_secs: nil)
111
- req = { op: 'introspect', subject: subject.to_s }
112
- req[:duration_secs] = duration_secs if duration_secs
113
- resp = roundtrip_raw(req)
114
- parse_introspect(subject.to_s, resp)
197
+ options_json = duration_secs ? JSON.generate(duration_secs: duration_secs) : nil
198
+ data = Beachcomber::FFI.call!(:bc_introspect, @client_handle, subject.to_s, options_json)
199
+ parse_introspect(subject.to_s, data)
115
200
  end
116
201
 
117
- # Closes the underlying socket connection.
202
+ # Closes the underlying connection. Idempotent.
118
203
  def close
119
- @socket.close unless @socket.closed?
120
- end
121
-
122
- private
204
+ return if @closed
123
205
 
124
- def roundtrip(req)
125
- resp = roundtrip_raw(req)
126
- build_result(resp)
127
- end
128
-
129
- def roundtrip_raw(req)
130
- line = JSON.generate(req) + "\n"
131
- @socket.write(line)
132
- raw = @socket.gets
133
- raise ProtocolError, "connection closed before response" if raw.nil?
134
-
135
- parse_response_hash(raw.chomp)
136
- end
137
-
138
- def parse_response_hash(raw)
139
- begin
140
- resp = JSON.parse(raw)
141
- rescue JSON::ParserError => e
142
- raise ProtocolError, "malformed JSON: #{e.message}"
143
- end
144
-
145
- unless resp.is_a?(Hash)
146
- raise ProtocolError, "expected JSON object, got #{resp.class}"
147
- end
148
-
149
- unless resp['ok']
150
- raise ServerError, (resp['error'] || 'unknown error')
151
- end
152
-
153
- resp
154
- end
155
-
156
- def build_result(resp)
157
- Result.new(
158
- ok: resp['ok'],
159
- data: resp['data'],
160
- age_ms: (resp['age_ms'] || 0).to_i,
161
- stale: resp['stale'] == true,
162
- error: resp['error'],
163
- )
164
- end
165
-
166
- def parse_hello(resp)
167
- data = resp["data"] || {}
168
- HelloInfo.new(
169
- protocol_version: data["protocol_version"].to_s,
170
- daemon_version: data["daemon_version"].to_s,
171
- )
172
- end
173
-
174
- def parse_cache_rows(resp)
175
- arr = resp["data"]
176
- raise ProtocolError, "status data is not an array" unless arr.is_a?(Array)
177
- arr.map do |row|
178
- CacheRow.new(
179
- provider: row["provider"].to_s,
180
- field: row["field"],
181
- path: row["path"],
182
- value: row["value"],
183
- age_ms: Integer(row["age_ms"] || 0),
184
- stale: row["stale"] == true,
185
- kind: row["kind"],
186
- poll_interval_secs: row["poll_interval_secs"],
187
- keep_alive_polls: row["keep_alive_polls"],
188
- fsevents_reinstate: row["fsevents_reinstate"],
189
- failure: row["failure"],
190
- source: row["source"],
191
- )
192
- end
193
- end
194
-
195
- def parse_daemon_health(data)
196
- DaemonHealth.new(
197
- pid: Integer(data["pid"] || 0),
198
- version: data["version"].to_s,
199
- uptime_secs: Integer(data["uptime_secs"] || 0),
200
- socket_path: data["socket_path"].to_s,
201
- config_path: data["config_path"],
202
- requests_total: Integer(data["requests_total"] || 0),
203
- in_flight: Integer(data["in_flight"] || 0),
204
- active_watchers: Integer(data["active_watchers"] || 0),
205
- cache_entries: Integer(data["cache_entries"] || 0),
206
- verdicts: (data["verdicts"] || []).map do |v|
207
- Verdict.new(level: v["level"].to_s, message: v["message"].to_s)
208
- end,
209
- )
210
- end
211
-
212
- def parse_introspect(subject, resp)
213
- data = resp["data"]
214
- if subject == IntrospectSubject::DAEMON && data.is_a?(Hash)
215
- IntrospectResponse.new(subject: subject, daemon: parse_daemon_health(data), other: nil)
216
- else
217
- IntrospectResponse.new(subject: subject, daemon: nil, other: data)
218
- end
206
+ @closed = true
207
+ Beachcomber::FFI.close_session(@handle)
219
208
  end
220
209
  end
221
210
 
222
- # Client sends individual requests, opening a fresh socket connection for
223
- # each call. For workloads that issue many queries per invocation, use
224
- # {#session} to reuse a persistent connection.
211
+ # Client sends individual requests through the shared library, which owns
212
+ # socket handling, framing and JSON mapping. For workloads that issue many
213
+ # queries per invocation, use {#session} to reuse a persistent connection.
225
214
  #
226
215
  # Examples:
227
216
  #
@@ -229,11 +218,29 @@ module Beachcomber
229
218
  # result = client.get('git.branch', path: '/repo')
230
219
  # puts result.data if result.hit?
231
220
  class Client
232
- # @param socket_path [String, nil] explicit socket path; auto-discovered when nil
233
- # @param timeout [Numeric] connect/read timeout in seconds (default 0.1)
234
- def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT)
235
- @socket_path = socket_path || Discovery.socket_path
236
- @timeout = timeout
221
+ include ResponseParsing
222
+
223
+ # @param socket_path [String, nil] explicit socket path; library default
224
+ # discovery applies when nil.
225
+ # @param timeout [Numeric, nil] socket read/write timeout in seconds
226
+ # (default 0.1 / 100ms, matching the library's own default).
227
+ # @param autostart [Boolean, nil] attempt to start the daemon if it isn't
228
+ # running. When nil (the default) the shared library's default applies
229
+ # (autostart on); pass false to disable. The library only autostarts
230
+ # when the socket path is auto-discovered — never for an explicit
231
+ # socket_path.
232
+ def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT, autostart: nil)
233
+ options = {}
234
+ options[:autostart] = autostart unless autostart.nil?
235
+ options[:socket_path] = socket_path if socket_path
236
+ options[:timeout_ms] = (timeout * 1000).round if timeout
237
+
238
+ @handle = Beachcomber::FFI.new_client(JSON.generate(options))
239
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@handle))
240
+ end
241
+
242
+ def self.finalizer(handle)
243
+ proc { Beachcomber::FFI.free_client(handle) }
237
244
  end
238
245
 
239
246
  # Reads a cached value.
@@ -241,12 +248,10 @@ module Beachcomber
241
248
  # @param key [String] e.g. "git.branch" or "git"
242
249
  # @param path [String, nil] optional working-directory context
243
250
  # @return [Result]
244
- # @raise [DaemonNotRunning] when the socket cannot be reached
245
- # @raise [ServerError] when the daemon returns ok: false
251
+ # @raise [Beachcomber::DaemonNotRunning] when the socket cannot be reached
252
+ # @raise [Beachcomber::ServerError] when the daemon returns ok: false
246
253
  def get(key, path: nil)
247
- req = { op: 'get', key: key }
248
- req[:path] = path if path
249
- with_session { |s| s.send(:roundtrip, req) }
254
+ build_result(Beachcomber::FFI.call!(:bc_get, @handle, key, path, 0))
250
255
  end
251
256
 
252
257
  # Reads a cached value with protocol flags.
@@ -257,19 +262,16 @@ module Beachcomber
257
262
  # @param wait [Boolean] block until a fresh value is available
258
263
  # @return [Result]
259
264
  def get_with_flags(key, path: nil, force: false, wait: false)
260
- with_session { |s| s.get_with_flags(key, path: path, force: force, wait: wait) }
265
+ flags = (force ? Beachcomber::FFI::GET_FORCE : 0) | (wait ? Beachcomber::FFI::GET_WAIT : 0)
266
+ build_result(Beachcomber::FFI.call!(:bc_get, @handle, key, path, flags))
261
267
  end
262
268
 
263
269
  # Forces the daemon to recompute a provider/key.
264
270
  #
265
271
  # @param key [String]
266
272
  # @param path [String, nil]
267
- # @raise [DaemonNotRunning]
268
- # @raise [ServerError]
269
273
  def refresh(key, path: nil)
270
- req = { op: 'refresh', key: key }
271
- req[:path] = path if path
272
- with_session { |s| s.send(:roundtrip, req) }
274
+ Beachcomber::FFI.call!(:bc_refresh, @handle, key, path)
273
275
  nil
274
276
  end
275
277
 
@@ -277,25 +279,31 @@ module Beachcomber
277
279
  #
278
280
  # @return [Array<CacheRow>]
279
281
  def status
280
- with_session { |s| s.status }
282
+ parse_cache_rows(Beachcomber::FFI.call!(:bc_status, @handle))
281
283
  end
282
284
 
283
285
  # Sends a hello handshake and returns server info.
284
286
  #
285
287
  # @return [HelloInfo]
286
288
  def hello
287
- with_session { |s| s.hello }
289
+ parse_hello(Beachcomber::FFI.call!(:bc_hello, @handle))
288
290
  end
289
291
 
290
- # Writes a value into the daemon cache.
292
+ # Writes a value into the daemon cache. +data = nil+ clears the entry
293
+ # without dropping the registry entry.
291
294
  #
292
295
  # @param key [String]
293
296
  # @param data [Object, nil]
294
- # @param ttl [Numeric, nil] time-to-live in seconds
297
+ # @param ttl [Numeric, String, nil] time-to-live (e.g. "60s")
295
298
  # @param path [String, nil]
296
299
  # @return [nil]
297
300
  def put(key, data = nil, ttl: nil, path: nil)
298
- with_session { |s| s.put(key, data, ttl: ttl, path: path) }
301
+ if data.nil?
302
+ Beachcomber::FFI.call!(:bc_put_null, @handle, key, path)
303
+ else
304
+ Beachcomber::FFI.call!(:bc_put, @handle, key, JSON.generate(data), ttl&.to_s, path)
305
+ end
306
+ nil
299
307
  end
300
308
 
301
309
  # Introspects a daemon subsystem.
@@ -304,90 +312,63 @@ module Beachcomber
304
312
  # @param duration_secs [Numeric, nil]
305
313
  # @return [IntrospectResponse]
306
314
  def introspect(subject, duration_secs: nil)
307
- with_session { |s| s.introspect(subject, duration_secs: duration_secs) }
315
+ options_json = duration_secs ? JSON.generate(duration_secs: duration_secs) : nil
316
+ data = Beachcomber::FFI.call!(:bc_introspect, @handle, subject.to_s, options_json)
317
+ parse_introspect(subject.to_s, data)
318
+ end
319
+
320
+ # Resolves a virtual field ("provider.field") or a provider's path
321
+ # expression ("provider") client-side, exactly as `comb get`'s
322
+ # resolution layer does. `cache.*` refs the expression makes are fetched
323
+ # live through this client.
324
+ #
325
+ # @param key [String] "provider.field" or a bare provider name
326
+ # @param cwd [String] required — path-expression evaluation has no
327
+ # ambient fallback; the library never reads the process's own cwd.
328
+ # @param env [Hash, nil] env var values `env.*` refs resolve against
329
+ # @param overrides [Hash, nil] expression overrides, keyed
330
+ # "provider.field" or a bare provider name
331
+ # @return [Object, nil] the resolved value, or nil on a path-expression miss
332
+ def resolve(key, cwd:, env: nil, overrides: nil)
333
+ Beachcomber::FFI.call!(:bc_resolve, @handle, key, cwd, json_or_nil(env), json_or_nil(overrides))
334
+ end
335
+
336
+ # Evaluates an arbitrary expression string against `env.*`/`cache.*`
337
+ # refs, using the same evaluator {#resolve} uses for a declared field.
338
+ #
339
+ # @param template [String]
340
+ # @param cwd [String] required, matching {#resolve}
341
+ # @param env [Hash, nil]
342
+ # @param overrides [Hash, nil]
343
+ # @return [String]
344
+ def eval_expression(template, cwd:, env: nil, overrides: nil)
345
+ Beachcomber::FFI.call!(:bc_eval, @handle, template, cwd, json_or_nil(env), json_or_nil(overrides))
308
346
  end
309
347
 
310
- # Opens a persistent watch subscription. Returns a WatchStream (Enumerable).
311
- # The caller is responsible for closing the stream.
348
+ # Opens a persistent watch subscription. Returns a WatchStream
349
+ # (Enumerable). The caller is responsible for closing the stream.
312
350
  #
313
351
  # @param key [String]
314
352
  # @param path [String, nil]
315
353
  # @return [WatchStream]
316
354
  def watch(key, path: nil)
317
- sock = open_socket
318
- req = { op: 'watch', key: key }
319
- req[:path] = path if path
320
- sock.write(JSON.generate(req) + "\n")
321
- WatchStream.new(sock)
355
+ handle = Beachcomber::FFI.new_watch(@handle, key, path)
356
+ raise Beachcomber::Error, 'bc_watch_open returned NULL (allocation failure)' if handle.nil? || handle.null?
357
+
358
+ WatchStream.new(handle)
322
359
  end
323
360
 
324
- # Opens a persistent session and yields it to the block. The connection is
325
- # closed automatically when the block returns (even on exception).
361
+ # Opens a persistent session and yields it to the block. The connection
362
+ # is closed automatically when the block returns (even on exception).
326
363
  #
327
364
  # @yield [Session]
328
365
  # @return the block's return value
329
366
  def session
330
- sock = open_socket
331
- sess = Session.new(sock, @timeout)
367
+ handle = Beachcomber::FFI.new_session(@handle)
368
+ sess = Session.new(handle, @handle)
332
369
  yield sess
333
370
  ensure
334
371
  sess&.close
335
372
  end
336
-
337
- RETRY_BACKOFFS = [0.250, 0.500, 1.000].freeze
338
-
339
- # Connect to a Unix socket with 3 retries (250ms/500ms/1s exponential).
340
- # Retries on ECONNREFUSED and ENOENT only — other errors surface immediately.
341
- # Intended to cover the brief restart window when the daemon is restarting.
342
- #
343
- # @param sock_path [String] absolute path to the Unix domain socket
344
- # @return [UNIXSocket]
345
- # @raise [Errno::ECONNREFUSED, Errno::ENOENT] after all retries are exhausted
346
- def self._connect_with_retry(sock_path)
347
- last_error = nil
348
- RETRY_BACKOFFS.each do |backoff|
349
- begin
350
- return UNIXSocket.new(sock_path)
351
- rescue Errno::ECONNREFUSED, Errno::ENOENT => e
352
- last_error = e
353
- sleep backoff
354
- end
355
- end
356
- # Final attempt — raises if still failing.
357
- UNIXSocket.new(sock_path)
358
- end
359
-
360
- private
361
-
362
- def with_session(&block)
363
- sock = open_socket
364
- begin
365
- s = Session.new(sock, @timeout)
366
- block.call(s)
367
- ensure
368
- sock.close unless sock.closed?
369
- end
370
- end
371
-
372
- def open_socket
373
- begin
374
- sock = self.class._connect_with_retry(@socket_path)
375
- rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES => e
376
- raise DaemonNotRunning.new(@socket_path)
377
- end
378
-
379
- # Apply timeouts to the connected socket.
380
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeval(@timeout))
381
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeval(@timeout))
382
-
383
- sock
384
- end
385
-
386
- # Packs a Float (seconds) into the C timeval structure expected by setsockopt.
387
- def timeval(seconds)
388
- secs = seconds.to_i
389
- usecs = ((seconds - secs) * 1_000_000).to_i
390
- [secs, usecs].pack('l_2')
391
- end
392
373
  end
393
374
  end
@@ -6,21 +6,18 @@ module Beachcomber
6
6
  # Mirrors the daemon's bind-path resolution (Config::resolve_socket_path),
7
7
  # minus the config-file step which is daemon-only. Discovery order:
8
8
  # 1. $BEACHCOMBER_SOCKET (if set and non-empty)
9
- # 2. $XDG_RUNTIME_DIR/beachcomber/sock (if XDG_RUNTIME_DIR is set)
10
- # 3. /tmp/beachcomber-<uid>/sock
9
+ # 2. /tmp/beachcomber-<uid>/sock
11
10
  #
12
- # There is no existence probe and $TMPDIR is not consulted: the result is the
13
- # single path the daemon binds for the same environment. Non-standard setups
14
- # point clients at the daemon via BEACHCOMBER_SOCKET.
11
+ # There is no existence probe and no session-scoped environment is
12
+ # consulted ($TMPDIR, $XDG_RUNTIME_DIR): the result is the single path the
13
+ # daemon binds for the same environment. Non-standard setups point clients
14
+ # at the daemon via BEACHCOMBER_SOCKET.
15
15
  module Discovery
16
16
  # @return [String] the resolved socket path
17
17
  def self.socket_path
18
18
  sock = ENV['BEACHCOMBER_SOCKET']
19
19
  return sock if sock && !sock.empty?
20
20
 
21
- xdg = ENV['XDG_RUNTIME_DIR']
22
- return File.join(xdg, 'beachcomber', 'sock') if xdg && !xdg.empty?
23
-
24
21
  File.join('/tmp', "beachcomber-#{Process.uid}", 'sock')
25
22
  end
26
23
  end
@@ -2,27 +2,126 @@ module Beachcomber
2
2
  # Base class for all Beachcomber errors.
3
3
  class Error < StandardError; end
4
4
 
5
- # Raised when the daemon socket cannot be reached.
6
- class DaemonNotRunning < Error
7
- def initialize(socket_path)
8
- super("beachcomber daemon is not running (socket: #{socket_path})")
5
+ # Raised when libbeachcomber cannot be located. Names every location tried,
6
+ # in order, per the shared discovery contract.
7
+ class LibraryNotFound < Error; end
8
+
9
+ # Raised when a loaded libbeachcomber is missing a required bc_* symbol.
10
+ # Names the symbol and the bc_version() of what was actually loaded.
11
+ class MissingSymbol < Error; end
12
+
13
+ # Base class for every `ok: false` envelope the library returns, plus the
14
+ # library-level conditions (bad_flags, busy, panic, version_skew) it can
15
+ # raise outside the CombError variant set.
16
+ #
17
+ # +kind+ is the stable, machine-readable slug from the envelope's
18
+ # +error.kind+ field (e.g. "server_error", "daemon_not_running"). The
19
+ # message always includes the loaded library's bc_version().
20
+ class CombError < Error
21
+ attr_reader :kind
22
+
23
+ def initialize(kind, message)
24
+ @kind = kind
25
+ super("beachcomber: #{message} (kind=#{kind}, lib_version=#{safe_version})")
26
+ end
27
+
28
+ private
29
+
30
+ def safe_version
31
+ Beachcomber::FFI.version
32
+ rescue StandardError
33
+ 'unknown'
34
+ end
35
+ end
36
+
37
+ # FFI-boundary conditions (no CombError variant on the Rust side).
38
+ class BadFlagsError < CombError
39
+ def initialize(message)
40
+ super('bad_flags', message)
41
+ end
42
+ end
43
+
44
+ class BusyError < CombError
45
+ def initialize(message)
46
+ super('busy', message)
47
+ end
48
+ end
49
+
50
+ class PanicError < CombError
51
+ def initialize(message)
52
+ super('panic', message)
53
+ end
54
+ end
55
+
56
+ class VersionSkewError < CombError
57
+ def initialize(message)
58
+ super('version_skew', message)
9
59
  end
10
60
  end
11
61
 
12
- # Raised when the daemon responds with ok: false.
13
- class ServerError < Error
14
- attr_reader :message
62
+ # One class per `CombError` variant on the Rust side (see
63
+ # libbeachcomber-ffi/src/envelope.rs).
64
+ class DaemonNotRunning < CombError
65
+ def initialize(message)
66
+ super('daemon_not_running', message)
67
+ end
68
+ end
15
69
 
70
+ class ConnectionFailedError < CombError
16
71
  def initialize(message)
17
- @message = message
18
- super("beachcomber: daemon error: #{message}")
72
+ super('connection_failed', message)
19
73
  end
20
74
  end
21
75
 
22
- # Raised when a response cannot be parsed.
23
- class ProtocolError < Error
24
- def initialize(detail)
25
- super("beachcomber: protocol error: #{detail}")
76
+ class IoError < CombError
77
+ def initialize(message)
78
+ super('io_error', message)
26
79
  end
27
80
  end
81
+
82
+ # Raised when a response cannot be parsed as valid JSON, or the daemon
83
+ # rejects malformed input.
84
+ class ProtocolError < CombError
85
+ def initialize(message)
86
+ super('parse_error', message)
87
+ end
88
+ end
89
+
90
+ # Raised when the daemon returns ok: false for an op it actually executed.
91
+ class ServerError < CombError
92
+ def initialize(message)
93
+ super('server_error', message)
94
+ end
95
+ end
96
+
97
+ class TimeoutError < CombError
98
+ def initialize(message)
99
+ super('timeout', message)
100
+ end
101
+ end
102
+
103
+ # error.kind slug -> exception class. Kept in sync with
104
+ # libbeachcomber-ffi/src/envelope.rs's ErrorKind enum.
105
+ KIND_TO_CLASS = {
106
+ 'bad_flags' => BadFlagsError,
107
+ 'busy' => BusyError,
108
+ 'panic' => PanicError,
109
+ 'version_skew' => VersionSkewError,
110
+ 'daemon_not_running' => DaemonNotRunning,
111
+ 'connection_failed' => ConnectionFailedError,
112
+ 'io_error' => IoError,
113
+ 'parse_error' => ProtocolError,
114
+ 'server_error' => ServerError,
115
+ 'timeout' => TimeoutError,
116
+ }.freeze
117
+
118
+ # Raises the idiomatic exception for an envelope's error.kind/error.message.
119
+ # An unrecognised kind (future ABI additions) still raises CombError with
120
+ # that kind preserved, rather than failing to raise at all.
121
+ def self.raise_for_error(kind, message)
122
+ klass = KIND_TO_CLASS[kind]
123
+ raise klass.new(message) if klass
124
+
125
+ raise CombError.new(kind, message)
126
+ end
28
127
  end
@@ -0,0 +1,261 @@
1
+ require 'fiddle'
2
+ require 'json'
3
+ require 'rbconfig'
4
+
5
+ module Beachcomber
6
+ # Loads libbeachcomber and exposes the bc_* C ABI as callable
7
+ # Fiddle::Function objects, plus JSON-envelope decoding shared by
8
+ # Client/Session/WatchStream.
9
+ #
10
+ # Discovery order (the shared contract every dynamic-language binding
11
+ # follows):
12
+ #
13
+ # 1. $BEACHCOMBER_LIB
14
+ # 2. ../lib/<libname> relative to the resolved `comb` on $PATH
15
+ # 3. the platform default dynamic-linker search path
16
+ #
17
+ # `../lib/` beside `comb` is checked before the system path deliberately:
18
+ # library and binary ship together, so the copy next to the `comb` you
19
+ # would actually run is the matching one, and a stale system-wide copy
20
+ # must not win.
21
+ #
22
+ # A failure to find or load the library, or a missing required symbol, is
23
+ # a loud error naming every location tried (or the missing symbol) plus
24
+ # the loaded library's bc_version() where known. There is no silent
25
+ # fallback to a subprocess transport.
26
+ module FFI
27
+ LIB_BASENAME =
28
+ case RbConfig::CONFIG['host_os']
29
+ when /darwin/i
30
+ 'libbeachcomber.dylib'
31
+ when /linux/i
32
+ 'libbeachcomber.so'
33
+ else
34
+ raise Beachcomber::Error, "unsupported platform: #{RbConfig::CONFIG['host_os']}"
35
+ end
36
+
37
+ # The 22 bc_* symbols this binding calls, checked at load (not on first
38
+ # use) so a version-skewed or partial install fails loudly up front.
39
+ REQUIRED_SYMBOLS = %w[
40
+ bc_version bc_client_new bc_client_free bc_string_free
41
+ bc_get bc_put bc_put_null bc_refresh bc_status bc_introspect bc_hello
42
+ bc_resolve bc_eval
43
+ bc_session_open bc_session_close bc_session_get bc_session_put bc_session_set_context
44
+ bc_watch_open bc_watch_next bc_watch_cancel bc_watch_free
45
+ ].freeze
46
+
47
+ GET_FORCE = 1 << 0
48
+ GET_WAIT = 1 << 1
49
+
50
+ VOIDP = Fiddle::TYPE_VOIDP
51
+ INT = Fiddle::TYPE_INT
52
+ VOID = Fiddle::TYPE_VOID
53
+
54
+ class << self
55
+ attr_reader :library_path
56
+ end
57
+
58
+ # Finds `comb` on $PATH the way a shell would, resolving symlinks (a
59
+ # Homebrew-linked binary, for instance) so `../lib/` is computed
60
+ # relative to where the binary actually lives.
61
+ def self.resolved_comb_path
62
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
63
+ next if dir.nil? || dir.empty?
64
+
65
+ candidate = File.join(dir, 'comb')
66
+ next unless File.file?(candidate) && File.executable?(candidate)
67
+
68
+ return File.realpath(candidate)
69
+ end
70
+ nil
71
+ rescue Errno::ENOENT, Errno::EACCES
72
+ nil
73
+ end
74
+
75
+ def self.candidate_beside_comb
76
+ comb = resolved_comb_path
77
+ return nil unless comb
78
+
79
+ File.expand_path(File.join(File.dirname(comb), '..', 'lib', LIB_BASENAME))
80
+ end
81
+
82
+ # Loads the library (idempotent) and returns the Fiddle::Handle.
83
+ def self.load!
84
+ return @handle if defined?(@handle) && @handle
85
+
86
+ tried = []
87
+
88
+ env_lib = ENV['BEACHCOMBER_LIB']
89
+ if env_lib && !env_lib.empty?
90
+ tried << env_lib
91
+ handle = try_open(env_lib)
92
+ return finish_load!(handle, env_lib) if handle
93
+ end
94
+
95
+ candidate = candidate_beside_comb
96
+ if candidate
97
+ tried << candidate
98
+ handle = try_open(candidate)
99
+ return finish_load!(handle, candidate) if handle
100
+ end
101
+
102
+ tried << "#{LIB_BASENAME} (platform default search path)"
103
+ handle = try_open(LIB_BASENAME)
104
+ return finish_load!(handle, LIB_BASENAME) if handle
105
+
106
+ raise Beachcomber::LibraryNotFound,
107
+ "could not locate #{LIB_BASENAME}; tried: #{tried.join(', ')}"
108
+ end
109
+
110
+ def self.try_open(path)
111
+ Fiddle.dlopen(path)
112
+ rescue Fiddle::DLError
113
+ nil
114
+ end
115
+ private_class_method :try_open
116
+
117
+ def self.finish_load!(handle, path)
118
+ @handle = handle
119
+ @library_path = path
120
+ check_symbols!
121
+ bind_functions!
122
+ @handle
123
+ end
124
+ private_class_method :finish_load!
125
+
126
+ def self.symbol_present?(name)
127
+ @handle[name]
128
+ true
129
+ rescue Fiddle::DLError
130
+ false
131
+ end
132
+ private_class_method :symbol_present?
133
+
134
+ def self.check_symbols!
135
+ missing = REQUIRED_SYMBOLS.reject { |sym| symbol_present?(sym) }
136
+ return if missing.empty?
137
+
138
+ raise Beachcomber::MissingSymbol,
139
+ "#{@library_path} is missing required symbol(s): #{missing.join(', ')} " \
140
+ "(bc_version=#{safe_version_for_error})"
141
+ end
142
+ private_class_method :check_symbols!
143
+
144
+ def self.safe_version_for_error
145
+ return 'unknown' unless symbol_present?('bc_version')
146
+
147
+ fn = Fiddle::Function.new(@handle['bc_version'], [], VOIDP)
148
+ ptr = fn.call
149
+ ptr.null? ? 'unknown' : ptr.to_s
150
+ rescue StandardError
151
+ 'unknown'
152
+ end
153
+ private_class_method :safe_version_for_error
154
+
155
+ def self.bind_functions!
156
+ @fn = {
157
+ bc_version: fnew('bc_version', [], VOIDP),
158
+ bc_client_new: fnew('bc_client_new', [VOIDP], VOIDP),
159
+ bc_client_free: fnew('bc_client_free', [VOIDP], VOID),
160
+ bc_string_free: fnew('bc_string_free', [VOIDP], VOID),
161
+ bc_get: fnew('bc_get', [VOIDP, VOIDP, VOIDP, INT], VOIDP),
162
+ bc_put: fnew('bc_put', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
163
+ bc_put_null: fnew('bc_put_null', [VOIDP, VOIDP, VOIDP], VOIDP),
164
+ bc_refresh: fnew('bc_refresh', [VOIDP, VOIDP, VOIDP], VOIDP),
165
+ bc_status: fnew('bc_status', [VOIDP], VOIDP),
166
+ bc_introspect: fnew('bc_introspect', [VOIDP, VOIDP, VOIDP], VOIDP),
167
+ bc_hello: fnew('bc_hello', [VOIDP], VOIDP),
168
+ bc_resolve: fnew('bc_resolve', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
169
+ bc_eval: fnew('bc_eval', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
170
+ bc_session_open: fnew('bc_session_open', [VOIDP], VOIDP),
171
+ bc_session_close: fnew('bc_session_close', [VOIDP], VOID),
172
+ bc_session_get: fnew('bc_session_get', [VOIDP, VOIDP, VOIDP, INT], VOIDP),
173
+ bc_session_put: fnew('bc_session_put', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
174
+ bc_session_set_context: fnew('bc_session_set_context', [VOIDP, VOIDP], VOIDP),
175
+ bc_watch_open: fnew('bc_watch_open', [VOIDP, VOIDP, VOIDP], VOIDP),
176
+ bc_watch_next: fnew('bc_watch_next', [VOIDP, INT], VOIDP),
177
+ bc_watch_cancel: fnew('bc_watch_cancel', [VOIDP], VOID),
178
+ bc_watch_free: fnew('bc_watch_free', [VOIDP], VOID),
179
+ }
180
+ end
181
+ private_class_method :bind_functions!
182
+
183
+ def self.fnew(name, args, ret)
184
+ Fiddle::Function.new(@handle[name], args, ret)
185
+ end
186
+ private_class_method :fnew
187
+
188
+ # The loaded library's build version. Static string; never freed.
189
+ def self.version
190
+ load!
191
+ ptr = @fn[:bc_version].call
192
+ ptr.null? ? '' : ptr.to_s
193
+ end
194
+
195
+ # Calls a void*(...)-returning bc_* function, reads the NUL-terminated
196
+ # JSON result, frees it via bc_string_free, and returns the raw JSON
197
+ # string. Not for bc_version, whose result must never be freed.
198
+ def self.raw_call(sym, *args)
199
+ load!
200
+ fn = @fn.fetch(sym) { raise ArgumentError, "unknown bc_* function #{sym}" }
201
+ ptr = fn.call(*args)
202
+ raise Beachcomber::Error, "unexpected NULL pointer from #{sym}" if ptr.nil? || ptr.null?
203
+
204
+ json = ptr.to_s
205
+ @fn[:bc_string_free].call(ptr)
206
+ json
207
+ end
208
+
209
+ # Like raw_call, but decodes the {"ok":...} envelope and raises the
210
+ # idiomatic exception for ok:false, returning only the op's `data` on
211
+ # success. Not for bc_watch_next, whose envelope has a different shape.
212
+ def self.call!(sym, *args)
213
+ envelope = JSON.parse(raw_call(sym, *args))
214
+ unless envelope['ok']
215
+ err = envelope['error'] || {}
216
+ Beachcomber.raise_for_error(err['kind'] || 'server_error', err['message'] || 'unknown error')
217
+ end
218
+ envelope['data']
219
+ end
220
+
221
+ # Opaque-handle constructors. These return a raw pointer (BcClient*,
222
+ # BcSession*, BcWatch*), not a JSON envelope — never NULL except
223
+ # BcWatch* on allocation failure.
224
+ def self.new_client(options_json)
225
+ load!
226
+ @fn[:bc_client_new].call(options_json)
227
+ end
228
+
229
+ def self.new_session(client_handle)
230
+ load!
231
+ @fn[:bc_session_open].call(client_handle)
232
+ end
233
+
234
+ def self.new_watch(client_handle, key, path)
235
+ load!
236
+ @fn[:bc_watch_open].call(client_handle, key, path)
237
+ end
238
+
239
+ # Void-returning teardown calls (null-safe on the C side; harmless if
240
+ # called more than once from a Ruby finalizer racing an explicit close).
241
+ def self.free_client(handle)
242
+ load!
243
+ @fn[:bc_client_free].call(handle)
244
+ end
245
+
246
+ def self.close_session(handle)
247
+ load!
248
+ @fn[:bc_session_close].call(handle)
249
+ end
250
+
251
+ def self.cancel_watch(handle)
252
+ load!
253
+ @fn[:bc_watch_cancel].call(handle)
254
+ end
255
+
256
+ def self.free_watch(handle)
257
+ load!
258
+ @fn[:bc_watch_free].call(handle)
259
+ end
260
+ end
261
+ end
@@ -2,14 +2,16 @@ module Beachcomber
2
2
  HelloInfo = Struct.new(:protocol_version, :daemon_version, keyword_init: true)
3
3
  CacheRow = Struct.new(
4
4
  :provider, :field, :path, :value, :age_ms, :stale,
5
- :kind, :poll_interval_secs, :keep_alive_polls, :fsevents_reinstate, :failure,
6
- :source,
5
+ :kind, :poll_interval_secs, :keep_alive_polls, :fsevents_reinstate, :polls_elapsed,
6
+ :failure, :source,
7
7
  keyword_init: true
8
8
  )
9
9
  Verdict = Struct.new(:level, :message, keyword_init: true)
10
+ Reaper = Struct.new(:armed, :visibility, :sweeps, :reaped, :kill_denied, keyword_init: true)
10
11
  DaemonHealth = Struct.new(
11
12
  :pid, :version, :uptime_secs, :socket_path, :config_path,
12
- :requests_total, :in_flight, :active_watchers, :cache_entries, :verdicts,
13
+ :requests_total, :in_flight, :active_watchers, :cache_entries,
14
+ :watch_backend, :reaper, :verdicts,
13
15
  keyword_init: true
14
16
  )
15
17
  WatchEvent = Struct.new(:data, :age_ms, :stale, keyword_init: true)
@@ -1,50 +1,70 @@
1
1
  module Beachcomber
2
+ # Iterates events from a bc_watch_open handle. Create via
3
+ # {Client#watch} rather than directly.
4
+ #
5
+ # bc_watch_next distinguishes five outcomes: event, timeout, eof,
6
+ # cancelled, error. #next_event folds timeout/eof/cancelled into a single
7
+ # nil (matching the previous socket-based API's "nil means the stream is
8
+ # over" contract) and raises the idiomatic exception for error. Pass an
9
+ # explicit +timeout_ms+ to observe timeouts distinctly.
2
10
  class WatchStream
3
11
  include Enumerable
4
12
 
5
- def initialize(socket)
6
- @socket = socket
13
+ def initialize(handle)
14
+ @handle = handle
15
+ @closed = false
7
16
  end
8
17
 
9
- # Yields a WatchEvent per emitted change.
18
+ # Yields a WatchEvent per emitted change until the stream ends.
10
19
  def each
11
20
  return enum_for(:each) unless block_given?
12
- while (line = @socket.gets)
13
- line.strip!
14
- next if line.empty?
15
- resp = JSON.parse(line)
16
- unless resp["ok"]
17
- raise ServerError, resp["error"] || "watch error"
18
- end
19
- yield WatchEvent.new(
20
- data: resp["data"],
21
- age_ms: Integer(resp["age_ms"] || 0),
22
- stale: resp["stale"] == true,
23
- )
21
+
22
+ while (event = next_event)
23
+ yield event
24
24
  end
25
25
  end
26
26
 
27
- # Read the next event; returns nil on connection close.
28
- def next_event
29
- loop do
30
- line = @socket.gets
31
- return nil if line.nil?
32
- line.strip!
33
- next if line.empty?
34
- resp = JSON.parse(line)
35
- unless resp["ok"]
36
- raise ServerError, resp["error"] || "watch error"
37
- end
38
- return WatchEvent.new(
39
- data: resp["data"],
40
- age_ms: Integer(resp["age_ms"] || 0),
41
- stale: resp["stale"] == true,
27
+ # Waits for the next event. +timeout_ms+: -1 (default) blocks
28
+ # indefinitely, 0 polls once, >0 waits that long.
29
+ #
30
+ # @return [WatchEvent, nil] nil on end-of-stream (daemon closed the
31
+ # connection, the watch was cancelled, or the wait elapsed).
32
+ def next_event(timeout_ms = -1)
33
+ json = Beachcomber::FFI.raw_call(:bc_watch_next, @handle, timeout_ms)
34
+ envelope = JSON.parse(json)
35
+
36
+ unless envelope['ok']
37
+ err = envelope['error'] || {}
38
+ Beachcomber.raise_for_error(err['kind'] || 'server_error', err['message'] || 'unknown error')
39
+ end
40
+
41
+ case envelope['outcome']
42
+ when 'event'
43
+ payload = envelope['data']
44
+ WatchEvent.new(
45
+ data: payload['data'],
46
+ age_ms: (payload['age_ms'] || 0).to_i,
47
+ stale: payload['stale'] == true,
42
48
  )
49
+ when 'timeout', 'eof', 'cancelled'
50
+ nil
51
+ else
52
+ raise Beachcomber::ProtocolError, "unknown watch outcome: #{envelope['outcome'].inspect}"
43
53
  end
44
54
  end
45
55
 
56
+ # Unblocks a pending or future #next_event call. Safe to call from
57
+ # another thread while a call is in flight.
58
+ def cancel
59
+ Beachcomber::FFI.cancel_watch(@handle)
60
+ nil
61
+ end
62
+
46
63
  def close
47
- @socket.close
64
+ return if @closed
65
+
66
+ @closed = true
67
+ Beachcomber::FFI.free_watch(@handle)
48
68
  end
49
69
  end
50
70
  end
data/lib/beachcomber.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative 'beachcomber/errors'
2
+ require_relative 'beachcomber/ffi'
2
3
  require_relative 'beachcomber/result'
3
4
  require_relative 'beachcomber/types'
4
5
  require_relative 'beachcomber/discovery'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libbeachcomber
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - NavistAu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-23 00:00:00.000000000 Z
11
+ date: 2026-08-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Communicates with the beachcomber daemon over a Unix domain socket to
14
14
  query cached shell-environment data (git state, hostname, battery, etc.).
@@ -21,6 +21,7 @@ files:
21
21
  - lib/beachcomber/client.rb
22
22
  - lib/beachcomber/discovery.rb
23
23
  - lib/beachcomber/errors.rb
24
+ - lib/beachcomber/ffi.rb
24
25
  - lib/beachcomber/result.rb
25
26
  - lib/beachcomber/types.rb
26
27
  - lib/beachcomber/watch_stream.rb