libbeachcomber 0.5.1 → 0.6.1

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: 65ff0e037911901a14cbb995047fe1c36ae9a8a3e9702b3483f8c39956a37725
4
- data.tar.gz: d59492cc46a941bcfa704a095ac66dc9ee3af4dd680e91d8c241d88336b7dd98
3
+ metadata.gz: 49948fed9514c126aa098f0d469d9d00b455dfee1e295734459aa19d3d5e72be
4
+ data.tar.gz: 8d0ae8bc11f78cada0741b3cd2a668dc1a97d2a68299ca9fc37acb1d6a2806ba
5
5
  SHA512:
6
- metadata.gz: bd86083b020f22e8503e3a5560c8bc2783a7c3a4248df69ce8da147b65a536832f4c689d91604f4ae27c12be340fd4829923df8c2e6044aa3d60ad676989f8d6
7
- data.tar.gz: 77253cc43d62a984d64f99401c1f26e21007ce2cb369c2b7959d972ff5d19feece7d6e831dfafcff9bcf583c91f3666ba29c82e66e630ac9ee45450ccd9dd982
6
+ metadata.gz: a6e7f9bfbd336707802e4d45b9a797cf2ba6ee393fded7e674932a87be8d37169709af1142a0620b5087be38b6a3a7d47f26e5750e422cd0786bcb203f327c67
7
+ data.tar.gz: 62d84ba78bd44bcdff01f7ec990b5f3ba6ffa8760de1f7d8f83ed9b58c6682f79e51c756b401f5f0e96baa9957cee0991e3b5654196d0506efc1c4e39319d2f1
@@ -44,29 +44,74 @@ module Beachcomber
44
44
  roundtrip(req)
45
45
  end
46
46
 
47
+ # Reads a cached value with protocol flags.
48
+ #
49
+ # @param key [String]
50
+ # @param path [String, nil]
51
+ # @param force [Boolean] bypass cache and recompute
52
+ # @param wait [Boolean] block until a fresh value is available
53
+ # @return [Result]
54
+ 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)
60
+ end
61
+
47
62
  # Forces the daemon to recompute a provider/key.
48
63
  #
49
64
  # @param key [String]
50
65
  # @param path [String, nil]
51
- def poke(key, path: nil)
52
- req = { op: 'poke', key: key }
66
+ def refresh(key, path: nil)
67
+ req = { op: 'refresh', key: key }
53
68
  req[:path] = path if path
54
69
  roundtrip(req)
55
70
  nil
56
71
  end
57
72
 
58
- # Lists available providers.
73
+ # Returns cache rows from the daemon.
59
74
  #
60
- # @return [Result]
61
- def list
62
- roundtrip({ op: 'list' })
75
+ # @return [Array<CacheRow>]
76
+ def status
77
+ resp_obj = roundtrip_raw({ op: 'status' })
78
+ parse_cache_rows(resp_obj)
63
79
  end
64
80
 
65
- # Returns daemon status.
81
+ # Sends a hello handshake and returns server info.
66
82
  #
67
- # @return [Result]
68
- def status
69
- roundtrip({ op: 'status' })
83
+ # @return [HelloInfo]
84
+ def hello
85
+ resp = roundtrip_raw({ op: 'hello' })
86
+ parse_hello(resp)
87
+ end
88
+
89
+ # Writes a value into the daemon cache.
90
+ #
91
+ # @param key [String]
92
+ # @param data [Object, nil]
93
+ # @param ttl [Numeric, nil] time-to-live in seconds
94
+ # @param path [String, nil]
95
+ # @return [nil]
96
+ 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)
102
+ nil
103
+ end
104
+
105
+ # Introspects a daemon subsystem.
106
+ #
107
+ # @param subject [String] one of the IntrospectSubject constants
108
+ # @param duration_secs [Numeric, nil]
109
+ # @return [IntrospectResponse]
110
+ 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)
70
115
  end
71
116
 
72
117
  # Closes the underlying socket connection.
@@ -77,15 +122,20 @@ module Beachcomber
77
122
  private
78
123
 
79
124
  def roundtrip(req)
125
+ resp = roundtrip_raw(req)
126
+ build_result(resp)
127
+ end
128
+
129
+ def roundtrip_raw(req)
80
130
  line = JSON.generate(req) + "\n"
81
131
  @socket.write(line)
82
132
  raw = @socket.gets
83
133
  raise ProtocolError, "connection closed before response" if raw.nil?
84
134
 
85
- parse_response(raw.chomp)
135
+ parse_response_hash(raw.chomp)
86
136
  end
87
137
 
88
- def parse_response(raw)
138
+ def parse_response_hash(raw)
89
139
  begin
90
140
  resp = JSON.parse(raw)
91
141
  rescue JSON::ParserError => e
@@ -96,17 +146,76 @@ module Beachcomber
96
146
  raise ProtocolError, "expected JSON object, got #{resp.class}"
97
147
  end
98
148
 
99
- ok = resp['ok']
100
- data = resp['data']
101
- age_ms = (resp['age_ms'] || 0).to_i
102
- stale = resp['stale'] == true
103
- error = resp['error']
149
+ unless resp['ok']
150
+ raise ServerError, (resp['error'] || 'unknown error')
151
+ end
104
152
 
105
- unless ok
106
- raise ServerError, (error || 'unknown error')
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
+ )
107
192
  end
193
+ end
108
194
 
109
- Result.new(ok: ok, data: data, age_ms: age_ms, stale: stale, error: error)
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
110
219
  end
111
220
  end
112
221
 
@@ -137,7 +246,18 @@ module Beachcomber
137
246
  def get(key, path: nil)
138
247
  req = { op: 'get', key: key }
139
248
  req[:path] = path if path
140
- roundtrip(req)
249
+ with_session { |s| s.send(:roundtrip, req) }
250
+ end
251
+
252
+ # Reads a cached value with protocol flags.
253
+ #
254
+ # @param key [String]
255
+ # @param path [String, nil]
256
+ # @param force [Boolean] bypass cache and recompute
257
+ # @param wait [Boolean] block until a fresh value is available
258
+ # @return [Result]
259
+ 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) }
141
261
  end
142
262
 
143
263
  # Forces the daemon to recompute a provider/key.
@@ -146,25 +266,59 @@ module Beachcomber
146
266
  # @param path [String, nil]
147
267
  # @raise [DaemonNotRunning]
148
268
  # @raise [ServerError]
149
- def poke(key, path: nil)
150
- req = { op: 'poke', key: key }
269
+ def refresh(key, path: nil)
270
+ req = { op: 'refresh', key: key }
151
271
  req[:path] = path if path
152
- roundtrip(req)
272
+ with_session { |s| s.send(:roundtrip, req) }
153
273
  nil
154
274
  end
155
275
 
156
- # Lists available providers registered with the daemon.
276
+ # Returns cache rows from the daemon.
157
277
  #
158
- # @return [Result]
159
- def list
160
- roundtrip({ op: 'list' })
278
+ # @return [Array<CacheRow>]
279
+ def status
280
+ with_session { |s| s.status }
161
281
  end
162
282
 
163
- # Returns scheduler and cache status from the daemon.
283
+ # Sends a hello handshake and returns server info.
164
284
  #
165
- # @return [Result]
166
- def status
167
- roundtrip({ op: 'status' })
285
+ # @return [HelloInfo]
286
+ def hello
287
+ with_session { |s| s.hello }
288
+ end
289
+
290
+ # Writes a value into the daemon cache.
291
+ #
292
+ # @param key [String]
293
+ # @param data [Object, nil]
294
+ # @param ttl [Numeric, nil] time-to-live in seconds
295
+ # @param path [String, nil]
296
+ # @return [nil]
297
+ def put(key, data = nil, ttl: nil, path: nil)
298
+ with_session { |s| s.put(key, data, ttl: ttl, path: path) }
299
+ end
300
+
301
+ # Introspects a daemon subsystem.
302
+ #
303
+ # @param subject [String] one of the IntrospectSubject constants
304
+ # @param duration_secs [Numeric, nil]
305
+ # @return [IntrospectResponse]
306
+ def introspect(subject, duration_secs: nil)
307
+ with_session { |s| s.introspect(subject, duration_secs: duration_secs) }
308
+ end
309
+
310
+ # Opens a persistent watch subscription. Returns a WatchStream (Enumerable).
311
+ # The caller is responsible for closing the stream.
312
+ #
313
+ # @param key [String]
314
+ # @param path [String, nil]
315
+ # @return [WatchStream]
316
+ 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)
168
322
  end
169
323
 
170
324
  # Opens a persistent session and yields it to the block. The connection is
@@ -173,40 +327,59 @@ module Beachcomber
173
327
  # @yield [Session]
174
328
  # @return the block's return value
175
329
  def session
176
- sock = dial
177
- session = Session.new(sock, @timeout)
178
- yield session
330
+ sock = open_socket
331
+ sess = Session.new(sock, @timeout)
332
+ yield sess
179
333
  ensure
180
- session&.close
334
+ sess&.close
335
+ 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)
181
358
  end
182
359
 
183
360
  private
184
361
 
185
- def roundtrip(req)
186
- sock = dial
362
+ def with_session(&block)
363
+ sock = open_socket
187
364
  begin
188
365
  s = Session.new(sock, @timeout)
189
- s.send(:roundtrip, req)
366
+ block.call(s)
190
367
  ensure
191
368
  sock.close unless sock.closed?
192
369
  end
193
370
  end
194
371
 
195
- def dial
196
- sock = Socket.new(:UNIX, :STREAM)
197
- addr = Socket.pack_sockaddr_un(@socket_path)
198
-
199
- # Apply timeout to both connect and subsequent reads/writes.
200
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeval(@timeout))
201
- sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeval(@timeout))
202
-
372
+ def open_socket
203
373
  begin
204
- sock.connect(addr)
205
- rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES
206
- sock.close
374
+ sock = self.class._connect_with_retry(@socket_path)
375
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES => e
207
376
  raise DaemonNotRunning.new(@socket_path)
208
377
  end
209
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
+
210
383
  sock
211
384
  end
212
385
 
@@ -3,23 +3,25 @@ require 'etc'
3
3
  module Beachcomber
4
4
  # Discovers the Unix socket path for the beachcomber daemon.
5
5
  #
6
- # Discovery order:
7
- # 1. $XDG_RUNTIME_DIR/beachcomber/sock (if set and the path exists)
8
- # 2. $TMPDIR/beachcomber-<uid>/sock
6
+ # Mirrors the daemon's bind-path resolution (Config::resolve_socket_path),
7
+ # minus the config-file step which is daemon-only. Discovery order:
8
+ # 1. $BEACHCOMBER_SOCKET (if set and non-empty)
9
+ # 2. $XDG_RUNTIME_DIR/beachcomber/sock (if XDG_RUNTIME_DIR is set)
9
10
  # 3. /tmp/beachcomber-<uid>/sock
11
+ #
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.
10
15
  module Discovery
11
- # @return [String] the discovered or best-guess socket path
16
+ # @return [String] the resolved socket path
12
17
  def self.socket_path
18
+ sock = ENV['BEACHCOMBER_SOCKET']
19
+ return sock if sock && !sock.empty?
20
+
13
21
  xdg = ENV['XDG_RUNTIME_DIR']
14
- if xdg && !xdg.empty?
15
- candidate = File.join(xdg, 'beachcomber', 'sock')
16
- return candidate if File.exist?(candidate)
17
- end
22
+ return File.join(xdg, 'beachcomber', 'sock') if xdg && !xdg.empty?
18
23
 
19
- uid = Process.uid
20
- dir = "beachcomber-#{uid}"
21
- base = ENV.fetch('TMPDIR', nil) || '/tmp'
22
- File.join(base, dir, 'sock')
24
+ File.join('/tmp', "beachcomber-#{Process.uid}", 'sock')
23
25
  end
24
26
  end
25
27
  end
@@ -0,0 +1,32 @@
1
+ module Beachcomber
2
+ HelloInfo = Struct.new(:protocol_version, :daemon_version, keyword_init: true)
3
+ CacheRow = Struct.new(
4
+ :provider, :field, :path, :value, :age_ms, :stale,
5
+ :kind, :poll_interval_secs, :keep_alive_polls, :fsevents_reinstate, :failure,
6
+ :source,
7
+ keyword_init: true
8
+ )
9
+ Verdict = Struct.new(:level, :message, keyword_init: true)
10
+ DaemonHealth = Struct.new(
11
+ :pid, :version, :uptime_secs, :socket_path, :config_path,
12
+ :requests_total, :in_flight, :active_watchers, :cache_entries, :verdicts,
13
+ keyword_init: true
14
+ )
15
+ WatchEvent = Struct.new(:data, :age_ms, :stale, keyword_init: true)
16
+
17
+ module IntrospectSubject
18
+ DAEMON = "daemon"
19
+ PROVIDERS = "providers"
20
+ CONFIG = "config"
21
+ CACHE = "cache"
22
+ LIFECYCLE = "lifecycle"
23
+ WATCHES = "watches"
24
+ TIMERS = "timers"
25
+ DEMAND = "demand"
26
+ PROCS = "procs"
27
+ end
28
+
29
+ # Introspect response wrapper. For DAEMON subject: #daemon is populated.
30
+ # For others: #other holds the raw Hash/Array.
31
+ IntrospectResponse = Struct.new(:subject, :daemon, :other, keyword_init: true)
32
+ end
@@ -0,0 +1,50 @@
1
+ module Beachcomber
2
+ class WatchStream
3
+ include Enumerable
4
+
5
+ def initialize(socket)
6
+ @socket = socket
7
+ end
8
+
9
+ # Yields a WatchEvent per emitted change.
10
+ def each
11
+ 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
+ )
24
+ end
25
+ end
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,
42
+ )
43
+ end
44
+ end
45
+
46
+ def close
47
+ @socket.close
48
+ end
49
+ end
50
+ end
data/lib/beachcomber.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require_relative 'beachcomber/errors'
2
2
  require_relative 'beachcomber/result'
3
+ require_relative 'beachcomber/types'
3
4
  require_relative 'beachcomber/discovery'
5
+ require_relative 'beachcomber/watch_stream'
4
6
  require_relative 'beachcomber/client'
5
7
 
6
8
  # Beachcomber is a Ruby client for the beachcomber daemon.
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.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NavistAu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-21 00:00:00.000000000 Z
11
+ date: 2026-05-29 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.).
@@ -22,6 +22,8 @@ files:
22
22
  - lib/beachcomber/discovery.rb
23
23
  - lib/beachcomber/errors.rb
24
24
  - lib/beachcomber/result.rb
25
+ - lib/beachcomber/types.rb
26
+ - lib/beachcomber/watch_stream.rb
25
27
  homepage: https://github.com/NavistAu/beachcomber
26
28
  licenses:
27
29
  - MIT