libbeachcomber 0.9.0 → 0.10.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: 992e638417a31c05c523419df10c2d0416f4006ef176f74ba4d40d348119350b
4
- data.tar.gz: a19784386cb00c64ae2773d097f3c12820382772b7cfc39416828bbba7077999
3
+ metadata.gz: d3c045383cb88693dccb3888f17a5dbed1d5fcea26e582898d59034095d04f98
4
+ data.tar.gz: 9bfa55709e0f57da1632fdba84b817836490c65c6fef75a16dd03beb0af78160
5
5
  SHA512:
6
- metadata.gz: 5e29f65e92f20d7ce0cb2c1018b55c448a1ce17ddec3943ec4153b831774b1356dec8dd25ca889f04c8e7cca6f4ce059c5c77df69f89bf8d35cdc6c281e7ea85
7
- data.tar.gz: 76acaa0d93b2d2891fea98d870d2abb6aca3df402a4129ab4a9af81d83958e3ad72e2b92d502090da38874bdbdbac650c316c48260abd5d28f0d8d514e0d0c8d
6
+ metadata.gz: 1fbc30c179de56aea27c151833d8a5934edc1ee5401957695a25191904b30ab6f134420b53df961b8af82bda333331de5a41176574b38ff1a1b1d37242fed6e0
7
+ data.tar.gz: 7951707bcabfe101803feef42ccc192dc1c434b9ff9eb2e1f95453fd60556514d759ff431d4f2552566418af12b40e0e6c95a3996465b321828e17092cf5e09d
@@ -2,268 +2,279 @@ require 'json'
2
2
 
3
3
  require_relative 'ffi'
4
4
  require_relative 'errors'
5
- require_relative 'result'
6
5
  require_relative 'types'
7
6
  require_relative 'watch_stream'
8
7
 
9
8
  module Beachcomber
10
- DEFAULT_TIMEOUT = 0.1 # seconds (100 ms) — matches libbeachcomber's own default.
11
-
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
9
+ # Request-building helpers shared by Client and Session.
10
+ module Requests
15
11
  private
16
12
 
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
- )
13
+ # The +options_json+ +bc_get+ and +bc_session_get+ take: the options the
14
+ # caller gave, +force+ when it is set and +wait+ as given; none is nil.
15
+ def get_options(force, wait)
16
+ options = {}
17
+ options['force'] = true if force
18
+ options['wait'] = wait unless wait.nil?
19
+ json_or_nil(options.empty? ? nil : options)
25
20
  end
26
21
 
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
- )
22
+ def json_or_nil(value)
23
+ value ? JSON.generate(value) : nil
32
24
  end
25
+ end
33
26
 
34
- def parse_cache_rows(data)
35
- raise ProtocolError, 'status data is not an array' unless data.is_a?(Array)
27
+ # The typed shapes, built from the JSON the ABI hands on: each Struct's
28
+ # members, a member the ABI left out nil.
29
+ module Typed
30
+ module_function
36
31
 
37
- data.map { |row| build_cache_row(row) }
32
+ def of(struct, json)
33
+ struct.new(**struct.members.to_h { |m| [m, json[m.to_s]] })
38
34
  end
39
35
 
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
- )
36
+ def cache_rows(data)
37
+ raise ParseError, 'status data is not an array' unless data.is_a?(Array)
38
+
39
+ data.map { |row| of(CacheRow, row) }
56
40
  end
57
41
 
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
- )
42
+ def daemon_health(data)
43
+ health = of(DaemonHealth, data)
44
+ health.reaper = data['reaper'] && of(ReaperStatus, data['reaper'])
45
+ health.verdicts = (data['verdicts'] || []).map { |v| of(Verdict, v) }
46
+ health
82
47
  end
83
48
 
84
- def parse_introspect(subject, data)
49
+ def introspect(subject, data)
85
50
  if subject == IntrospectSubject::DAEMON && data.is_a?(Hash)
86
- IntrospectResponse.new(subject: subject, daemon: parse_daemon_health(data), other: nil)
51
+ IntrospectResponse.new(subject: subject, daemon: daemon_health(data), other: nil)
87
52
  else
88
53
  IntrospectResponse.new(subject: subject, daemon: nil, other: data)
89
54
  end
90
55
  end
91
-
92
- def json_or_nil(value)
93
- value ? JSON.generate(value) : nil
94
- end
95
56
  end
96
57
 
97
58
  # Session holds a persistent connection to the daemon, held open by the
98
- # shared library, for {#get}/{#get_with_flags}/{#put}/{#set_context}.
59
+ # shared library, and a directory of its own ({#set_context}) that {#get},
60
+ # {#put} and {#refresh} read or write at when given no directory.
99
61
  #
100
62
  # Obtain a Session via {Client#session}:
101
63
  #
102
64
  # client.session do |s|
103
65
  # s.set_context('/repo')
104
- # r = s.get('git.branch')
66
+ # branch = s.get('git.branch')
105
67
  # end
106
68
  #
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.
69
+ # #status, #hello and #introspect address no directory and need no
70
+ # connection of their own, so they are the Client's alone.
112
71
  #
113
72
  # The underlying handle is guarded by the library's own mutex: a
114
73
  # concurrent caller on the same session gets {Beachcomber::BusyError}
115
74
  # rather than blocking or interleaving requests.
116
75
  class Session
117
- include ResponseParsing
76
+ include Requests
118
77
 
119
- def initialize(handle, client_handle)
78
+ def initialize(handle)
120
79
  @handle = handle
121
- @client_handle = client_handle
122
80
  @closed = false
123
81
  end
124
82
 
125
- # Sets the default path for subsequent {#get}/{#get_with_flags} queries
126
- # on this connection.
83
+ # Sets this session's directory: every later {#get} that names no +cwd+,
84
+ # and {#put} and {#refresh} that name no path, address it. The session
85
+ # keeps it and sends it with each request.
127
86
  #
128
87
  # @param path [String]
129
88
  def set_context(path)
130
- Beachcomber::FFI.call!(:bc_session_set_context, @handle, path)
89
+ Beachcomber::FFI.call!(:bc_session_set_context, handle!, path)
131
90
  nil
132
91
  end
133
92
 
134
- # Reads a cached value.
93
+ # {Client#get} on this session's persistent connection. +cwd: nil+ is
94
+ # this session's directory ({#set_context}) when it has one, else the
95
+ # global entry.
135
96
  #
136
97
  # @param key [String] e.g. "git.branch" or "git"
137
- # @param path [String, nil] optional working-directory override
138
- # @return [Result]
139
- def get(key, path: nil)
140
- build_result(Beachcomber::FFI.call!(:bc_session_get, @handle, key, path, 0))
98
+ # @param cwd [String, nil]
99
+ # @param env [Hash, nil] the caller's environment for `env.*` refs
100
+ # @param force [Boolean]
101
+ # @param wait [String, nil] a duration or "forever", as {Client#get}
102
+ # @return [Object, nil] the value, or nil on a miss
103
+ def get(key, cwd: nil, env: nil, force: false, wait: nil)
104
+ Beachcomber::FFI.call!(:bc_session_get, handle!, key, cwd, json_or_nil(env), get_options(force, wait))
141
105
  end
142
106
 
143
- # Reads a cached value with protocol flags.
144
- #
145
- # @param key [String]
146
- # @param path [String, nil]
147
- # @param force [Boolean] bypass cache and recompute
148
- # @param wait [Boolean] block until a fresh value is available
149
- # @return [Result]
150
- def get_with_flags(key, path: nil, force: false, wait: false)
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))
153
- end
154
-
155
- # Forces the daemon to recompute a provider/key.
107
+ # {Client#refresh} on this session's connection; +path: nil+ is this
108
+ # session's directory.
156
109
  #
157
110
  # @param key [String]
158
111
  # @param path [String, nil]
159
112
  def refresh(key, path: nil)
160
- Beachcomber::FFI.call!(:bc_refresh, @client_handle, key, path)
113
+ Beachcomber::FFI.call!(:bc_session_refresh, handle!, key, path)
161
114
  nil
162
115
  end
163
116
 
164
- # Returns cache rows from the daemon.
165
- #
166
- # @return [Array<CacheRow>]
167
- def status
168
- parse_cache_rows(Beachcomber::FFI.call!(:bc_status, @client_handle))
169
- end
170
-
171
- # Sends a hello handshake and returns server info.
172
- #
173
- # @return [HelloInfo]
174
- def hello
175
- parse_hello(Beachcomber::FFI.call!(:bc_hello, @client_handle))
176
- end
177
-
178
- # Writes a value into the daemon cache on this session's connection.
179
- # +data = nil+ clears the entry.
117
+ # {Client#put} on this session's connection; +path: nil+ is this
118
+ # session's directory.
180
119
  #
181
120
  # @param key [String]
182
121
  # @param data [Object, nil]
183
122
  # @param ttl [Numeric, String, nil] time-to-live
184
123
  # @param path [String, nil]
124
+ # @param persist [Boolean] keep the put across a daemon restart, best effort
185
125
  # @return [nil]
186
- def put(key, data = nil, ttl: nil, path: nil)
187
- Beachcomber::FFI.call!(:bc_session_put, @handle, key, JSON.generate(data), ttl&.to_s, path)
126
+ def put(key, data, ttl: nil, path: nil, persist: false)
127
+ Beachcomber::FFI.call!(:bc_session_put, handle!, key, JSON.generate(data), ttl&.to_s, path, persist ? 1 : 0)
188
128
  nil
189
129
  end
190
130
 
191
- # Introspects a daemon subsystem.
192
- #
193
- # @param subject [String] one of the IntrospectSubject constants
194
- # @param duration_secs [Numeric, nil]
195
- # @return [IntrospectResponse]
196
- def introspect(subject, duration_secs: nil)
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)
200
- end
201
-
202
131
  # Closes the underlying connection. Idempotent.
203
132
  def close
204
133
  return if @closed
205
134
 
206
135
  @closed = true
207
136
  Beachcomber::FFI.close_session(@handle)
137
+ @handle = nil
138
+ end
139
+
140
+ private
141
+
142
+ # The handle every operation passes on. A closed session has none, and
143
+ # the library must never see a freed handle, so the call fails here.
144
+ def handle!
145
+ @handle or raise IoError, 'the session is closed'
208
146
  end
209
147
  end
210
148
 
211
149
  # Client sends individual requests through the shared library, which owns
212
150
  # socket handling, framing and JSON mapping. For workloads that issue many
213
151
  # queries per invocation, use {#session} to reuse a persistent connection.
152
+ # It follows the SDK API table, docs/sdk-api.md.
214
153
  #
215
154
  # Examples:
216
155
  #
217
156
  # client = Beachcomber::Client.new
218
- # result = client.get('git.branch', path: '/repo')
219
- # puts result.data if result.hit?
157
+ # branch = client.get('git.branch', cwd: '/repo') # nil on a miss
158
+ # client.get('git:template.branch') # the template, unrendered
159
+ # client.close
220
160
  class Client
221
- include ResponseParsing
161
+ include Requests
222
162
 
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).
163
+ # Each option given is passed to +bc_client_new+ as it is; nil is not
164
+ # given, and the library's default applies. This SDK has no defaults of
165
+ # its own.
166
+ #
167
+ # @param socket_path [String, nil] explicit socket path; nil is the
168
+ # library's discovery.
169
+ # @param timeout_ms [Integer, nil] how long one read or write on the
170
+ # socket may take, in milliseconds; at least 1. nil is the library's
171
+ # default (100).
172
+ # @param connect_timeout_ms [Integer, nil] how long connecting keeps
173
+ # retrying before it gives up, in milliseconds; 0 is one attempt. nil
174
+ # is the library's default (1500).
227
175
  # @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))
176
+ # running; nil is the library's default (on). The library only
177
+ # autostarts when the socket path is auto-discovered — never for an
178
+ # explicit socket_path.
179
+ # @param exec [Boolean, nil] whether a template this client renders
180
+ # ({#get}, {#eval}) may call +exec()+; nil is the library's default
181
+ # (off), and a template that calls it then fails with
182
+ # "exec() is disabled in this context".
183
+ def initialize(socket_path: nil, timeout_ms: nil, connect_timeout_ms: nil, autostart: nil, exec: nil)
184
+ options = self.class.options_json(
185
+ socket_path: socket_path, timeout_ms: timeout_ms, connect_timeout_ms: connect_timeout_ms,
186
+ autostart: autostart, exec: exec,
187
+ )
188
+ @handle = Beachcomber::FFI.new_client(options)
239
189
  ObjectSpace.define_finalizer(self, self.class.finalizer(@handle))
240
190
  end
241
191
 
192
+ # The +options_json+ +bc_client_new+ takes: exactly the options given
193
+ # (nil is not given), under the ABI's own names.
194
+ #
195
+ # @return [String]
196
+ def self.options_json(**options)
197
+ JSON.generate(options.compact)
198
+ end
199
+
242
200
  def self.finalizer(handle)
243
201
  proc { Beachcomber::FFI.free_client(handle) }
244
202
  end
245
203
 
246
- # Reads a cached value.
204
+ # The value `comb get` prints for this key, cwd and env. The daemon holds
205
+ # the templates (`<provider>:template`, seeded from configuration or
206
+ # {#put}); this client renders them. A plain field is its cached value,
207
+ # a templated one is rendered, and a bare provider name is the whole
208
+ # merged namespace. The unrendered halves are facets, read like any key:
209
+ # <tt>"p:template.x"</tt> is the template as stored, <tt>"p:put.x"</tt>
210
+ # and <tt>"p:source.x"</tt> those layers' values.
247
211
  #
248
- # @param key [String] e.g. "git.branch" or "git"
249
- # @param path [String, nil] optional working-directory context
250
- # @return [Result]
251
- # @raise [Beachcomber::DaemonNotRunning] when the socket cannot be reached
252
- # @raise [Beachcomber::ServerError] when the daemon returns ok: false
253
- def get(key, path: nil)
254
- build_result(Beachcomber::FFI.call!(:bc_get, @handle, key, path, 0))
212
+ # @param key [String] "provider.field" or a bare provider name
213
+ # @param cwd [String, nil] the directory the read addresses (`~` is home,
214
+ # a relative path is against this process's working directory); nil is
215
+ # the global entry
216
+ # @param env [Hash, nil] the caller's environment for `env.*` refs; nil
217
+ # is the calling process's environment, as `comb` renders
218
+ # @param force [Boolean] re-execute every Source the read demands first
219
+ # @param wait [String, nil] how long to wait for a run the read needs
220
+ # before answering what is stored, as `comb get --wait`: a duration
221
+ # ("0", "50ms", "2s") or "forever"; nil is the read's budget. The read
222
+ # may take +timeout_ms+ plus the wait; "forever" has no deadline
223
+ # @return [Object, nil] the value, or nil on a miss
224
+ # @raise [Beachcomber::DaemonNotRunningError] when no daemon answers
225
+ # @raise [Beachcomber::ServerError] when the daemon refuses the read
226
+ # @raise [Beachcomber::ParseError] when +wait+ is no duration
227
+ def get(key, cwd: nil, env: nil, force: false, wait: nil)
228
+ Beachcomber::FFI.call!(:bc_get, handle!, key, cwd, json_or_nil(env), get_options(force, wait))
229
+ end
230
+
231
+ # Renders a template that is stored nowhere — the same value `comb eval`
232
+ # prints. Every ref resolves exactly as it would inside a stored
233
+ # template, so <tt>eval('{{ p.f }}')</tt> and <tt>get('p.f')</tt> agree.
234
+ #
235
+ # +template+ accepts a single <tt>{{ expr }}</tt> tag, or literal
236
+ # text/several tags — the former keeps the expression's natural type,
237
+ # the latter is always a string (a source with no tags at all is its
238
+ # literal text).
239
+ #
240
+ # @param template [String]
241
+ # @param cwd [String, nil] read as {#get} reads it; nil is the global entry
242
+ # @param env [Hash, nil] the caller's environment for `env.*` refs
243
+ # @return [Object, nil] the value, or nil when it renders nothing
244
+ def eval(template, cwd: nil, env: nil)
245
+ Beachcomber::FFI.call!(:bc_eval, handle!, template, cwd, json_or_nil(env))
246
+ end
247
+
248
+ # Opens a persistent watch on the *value* of +key+ — what {#get}
249
+ # answers for it — and emits it again whenever it changes. A template
250
+ # edited under the watch fires it just as a changed cache record does.
251
+ # Returns a WatchStream (Enumerable). The caller is responsible for
252
+ # closing the stream.
253
+ #
254
+ # @param key [String] "provider.field" or a bare provider name
255
+ # @param cwd [String, nil] read as {#get} reads it — the directory every
256
+ # subscription addresses and every re-resolution runs at; nil is the
257
+ # global entry
258
+ # @param env [Hash, nil] the caller's environment for `env.*` refs
259
+ # @return [WatchStream]
260
+ def watch(key, cwd: nil, env: nil)
261
+ handle = Beachcomber::FFI.new_watch(handle!, key, cwd, json_or_nil(env))
262
+ raise Beachcomber::IoError, 'NULL returned from bc_watch_open (allocation failure)' if handle.nil? || handle.null?
263
+
264
+ WatchStream.new(handle)
255
265
  end
256
266
 
257
- # Reads a cached value with protocol flags.
267
+ # Writes a value into the daemon cache. +data = nil+ removes the node.
258
268
  #
259
269
  # @param key [String]
270
+ # @param data [Object, nil]
271
+ # @param ttl [Numeric, String, nil] time-to-live (e.g. "60s")
260
272
  # @param path [String, nil]
261
- # @param force [Boolean] bypass cache and recompute
262
- # @param wait [Boolean] block until a fresh value is available
263
- # @return [Result]
264
- def get_with_flags(key, path: nil, force: false, wait: false)
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))
273
+ # @param persist [Boolean] keep the put across a daemon restart, best effort
274
+ # @return [nil]
275
+ def put(key, data, ttl: nil, path: nil, persist: false)
276
+ Beachcomber::FFI.call!(:bc_put, handle!, key, JSON.generate(data), ttl&.to_s, path, persist ? 1 : 0)
277
+ nil
267
278
  end
268
279
 
269
280
  # Forces the daemon to recompute a provider/key.
@@ -271,39 +282,23 @@ module Beachcomber
271
282
  # @param key [String]
272
283
  # @param path [String, nil]
273
284
  def refresh(key, path: nil)
274
- Beachcomber::FFI.call!(:bc_refresh, @handle, key, path)
285
+ Beachcomber::FFI.call!(:bc_refresh, handle!, key, path)
275
286
  nil
276
287
  end
277
288
 
278
289
  # Returns cache rows from the daemon.
279
290
  #
280
291
  # @return [Array<CacheRow>]
292
+ # @raise [Beachcomber::ParseError] when the answer is not a list of rows
281
293
  def status
282
- parse_cache_rows(Beachcomber::FFI.call!(:bc_status, @handle))
294
+ Typed.cache_rows(Beachcomber::FFI.call!(:bc_status, handle!))
283
295
  end
284
296
 
285
297
  # Sends a hello handshake and returns server info.
286
298
  #
287
299
  # @return [HelloInfo]
288
300
  def hello
289
- parse_hello(Beachcomber::FFI.call!(:bc_hello, @handle))
290
- end
291
-
292
- # Writes a value into the daemon cache. +data = nil+ clears the entry
293
- # without dropping the registry entry.
294
- #
295
- # @param key [String]
296
- # @param data [Object, nil]
297
- # @param ttl [Numeric, String, nil] time-to-live (e.g. "60s")
298
- # @param path [String, nil]
299
- # @return [nil]
300
- def put(key, data = nil, ttl: nil, path: nil)
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
301
+ Typed.of(HelloInfo, Beachcomber::FFI.call!(:bc_hello, handle!))
307
302
  end
308
303
 
309
304
  # Introspects a daemon subsystem.
@@ -313,53 +308,8 @@ module Beachcomber
313
308
  # @return [IntrospectResponse]
314
309
  def introspect(subject, duration_secs: nil)
315
310
  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 a value expression against `env.*`/`cache.*` refs, using the
337
- # same evaluator {#resolve} uses for a declared field.
338
- #
339
- # +template+ accepts a bare expression, a single <tt>{{ expr }}</tt>
340
- # tag, or literal text/several tags — the first two keep the
341
- # expression's natural type, the third is always a string.
342
- #
343
- # @param template [String]
344
- # @param cwd [String] required, matching {#resolve}
345
- # @param env [Hash, nil]
346
- # @param overrides [Hash, nil]
347
- # @return [Object]
348
- def eval_expression(template, cwd:, env: nil, overrides: nil)
349
- Beachcomber::FFI.call!(:bc_eval, @handle, template, cwd, json_or_nil(env), json_or_nil(overrides))
350
- end
351
-
352
- # Opens a persistent watch subscription. Returns a WatchStream
353
- # (Enumerable). The caller is responsible for closing the stream.
354
- #
355
- # @param key [String]
356
- # @param path [String, nil]
357
- # @return [WatchStream]
358
- def watch(key, path: nil)
359
- handle = Beachcomber::FFI.new_watch(@handle, key, path)
360
- raise Beachcomber::Error, 'bc_watch_open returned NULL (allocation failure)' if handle.nil? || handle.null?
361
-
362
- WatchStream.new(handle)
311
+ data = Beachcomber::FFI.call!(:bc_introspect, handle!, subject.to_s, options_json)
312
+ Typed.introspect(subject.to_s, data)
363
313
  end
364
314
 
365
315
  # Opens a persistent session and yields it to the block. The connection
@@ -368,11 +318,29 @@ module Beachcomber
368
318
  # @yield [Session]
369
319
  # @return the block's return value
370
320
  def session
371
- handle = Beachcomber::FFI.new_session(@handle)
372
- sess = Session.new(handle, @handle)
321
+ sess = Session.new(Beachcomber::FFI.new_session(handle!))
373
322
  yield sess
374
323
  ensure
375
324
  sess&.close
376
325
  end
326
+
327
+ # Frees the client's handle. Idempotent; a later operation fails. The
328
+ # finalizer frees it when a client is never closed.
329
+ def close
330
+ return if @handle.nil?
331
+
332
+ ObjectSpace.undefine_finalizer(self)
333
+ Beachcomber::FFI.free_client(@handle)
334
+ @handle = nil
335
+ end
336
+
337
+ private
338
+
339
+ # The handle every operation passes on. A closed client has none, and
340
+ # the library must never see a NULL or freed handle, so the call fails
341
+ # here, as an io_error.
342
+ def handle!
343
+ @handle or raise IoError, 'the client is closed'
344
+ end
377
345
  end
378
346
  end
@@ -1,127 +1,59 @@
1
1
  module Beachcomber
2
- # Base class for all Beachcomber errors.
3
- class Error < StandardError; end
4
-
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.
2
+ # The base of every error this SDK raises: one subclass per kind in the
3
+ # SDK API table (docs/sdk-api.md), each named for its kind.
16
4
  #
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)
5
+ # +kind+ is the stable, machine-readable slug — the C ABI envelope's
6
+ # +error.kind+, or one this SDK raises itself (+library_not_found+,
7
+ # +library_symbol+, +unsupported+). +message+ is the ABI's message,
8
+ # unchanged. +library_version+ is the loaded library's bc_version(), nil
9
+ # when no library was loaded.
10
+ class CombError < StandardError
11
+ class << self
12
+ # The kind slug this class stands for; nil on the base class, which
13
+ # carries a kind this SDK does not know yet.
14
+ attr_reader :kind
15
+ end
16
+
17
+ attr_reader :kind, :library_version
18
+
19
+ def initialize(message = nil, kind: self.class.kind, library_version: Beachcomber::FFI.loaded_version)
20
+ super(message)
24
21
  @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)
59
- end
60
- end
61
-
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)
22
+ @library_version = library_version
67
23
  end
68
24
  end
69
25
 
70
- class ConnectionFailedError < CombError
71
- def initialize(message)
72
- super('connection_failed', message)
73
- end
74
- end
75
-
76
- class IoError < CombError
77
- def initialize(message)
78
- super('io_error', message)
79
- end
26
+ # Defines the error class for +kind+, named for it.
27
+ def self.define_error(name, kind)
28
+ klass = Class.new(CombError) { @kind = kind }
29
+ const_set(name, klass)
80
30
  end
31
+ private_class_method :define_error
81
32
 
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
33
+ define_error :DaemonNotRunningError, 'daemon_not_running'
34
+ define_error :ConnectionFailedError, 'connection_failed'
35
+ define_error :IoError, 'io_error'
36
+ define_error :ParseError, 'parse_error'
37
+ define_error :ServerError, 'server_error'
38
+ define_error :TimeoutError, 'timeout'
39
+ define_error :BusyError, 'busy'
40
+ define_error :PanicError, 'panic'
41
+ define_error :LibraryNotFoundError, 'library_not_found'
42
+ define_error :LibrarySymbolError, 'library_symbol'
43
+ define_error :UnsupportedError, 'unsupported'
102
44
 
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
45
+ # error.kind slug -> its class.
46
+ KIND_TO_CLASS = constants.map { |c| const_get(c) }
47
+ .select { |c| c.is_a?(Class) && c < CombError }
48
+ .to_h { |c| [c.kind, c] }
49
+ .freeze
117
50
 
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.
51
+ # Raises the error for an envelope's error.kind/error.message. A kind
52
+ # this SDK does not know yet still raises a CombError carrying it.
121
53
  def self.raise_for_error(kind, message)
122
54
  klass = KIND_TO_CLASS[kind]
123
55
  raise klass.new(message) if klass
124
56
 
125
- raise CombError.new(kind, message)
57
+ raise CombError.new(message, kind: kind)
126
58
  end
127
59
  end
@@ -7,46 +7,45 @@ module Beachcomber
7
7
  # Fiddle::Function objects, plus JSON-envelope decoding shared by
8
8
  # Client/Session/WatchStream.
9
9
  #
10
- # Discovery order (the shared contract every dynamic-language binding
11
- # follows):
10
+ # Discovery is the one rule every SDK follows (docs/sdk-api.md, "Library
11
+ # discovery"; the shared fixture is sdks/discovery.json):
12
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
13
+ # 1. $BEACHCOMBER_LIB, when set and not empty. It is then the only
14
+ # candidate: a path set on purpose that does not load is an error,
15
+ # not a reason to load some other library.
16
+ # 2. Beside `comb`: <dir>/../lib/<file>, where <dir> is the directory of
17
+ # the first executable `comb` on $PATH, its symlinks resolved (a
18
+ # Homebrew `comb` is a symlink into its Cellar, the library beside it).
19
+ # 3. The system loader, given the bare file name.
16
20
  #
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.
21
+ # The candidates are tried in order and the first that loads wins. When
22
+ # none does, LibraryNotFoundError names every candidate and why it failed;
23
+ # a missing required symbol is LibrarySymbolError. There is no fallback to
24
+ # a subprocess transport.
26
25
  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
26
+ # The library's file name: libbeachcomber.dylib on macOS and
27
+ # libbeachcomber.so on every other platform.
28
+ def self.library_basename(host_os = RbConfig::CONFIG['host_os'])
29
+ host_os =~ /darwin/i ? 'libbeachcomber.dylib' : 'libbeachcomber.so'
30
+ end
31
+
32
+ LIB_BASENAME = library_basename
33
+
34
+ # One location the loader tries: where it came from, and the path (or,
35
+ # for the system loader, the bare file name) handed to dlopen.
36
+ Candidate = Struct.new(:description, :path)
36
37
 
37
38
  # The 22 bc_* symbols this binding calls, checked at load (not on first
38
39
  # use) so a version-skewed or partial install fails loudly up front.
39
40
  REQUIRED_SYMBOLS = %w[
40
41
  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
42
+ bc_get bc_put bc_refresh bc_status bc_introspect bc_hello
43
+ bc_eval
44
+ bc_session_open bc_session_close bc_session_get bc_session_put bc_session_refresh
45
+ bc_session_set_context
44
46
  bc_watch_open bc_watch_next bc_watch_cancel bc_watch_free
45
47
  ].freeze
46
48
 
47
- GET_FORCE = 1 << 0
48
- GET_WAIT = 1 << 1
49
-
50
49
  VOIDP = Fiddle::TYPE_VOIDP
51
50
  INT = Fiddle::TYPE_INT
52
51
  VOID = Fiddle::TYPE_VOID
@@ -55,9 +54,8 @@ module Beachcomber
55
54
  attr_reader :library_path
56
55
  end
57
56
 
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.
57
+ # The first executable `comb` on $PATH, its symlinks resolved; nil when
58
+ # there is none.
61
59
  def self.resolved_comb_path
62
60
  ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
63
61
  next if dir.nil? || dir.empty?
@@ -72,48 +70,43 @@ module Beachcomber
72
70
  nil
73
71
  end
74
72
 
75
- def self.candidate_beside_comb
73
+ # The locations to try for +file+, in order. Reads the environment and
74
+ # $PATH; loads nothing.
75
+ #
76
+ # @return [Array<Candidate>]
77
+ def self.candidates(file = LIB_BASENAME)
78
+ explicit = ENV['BEACHCOMBER_LIB']
79
+ return [Candidate.new('$BEACHCOMBER_LIB', explicit)] if explicit && !explicit.empty?
80
+
81
+ out = []
76
82
  comb = resolved_comb_path
77
- return nil unless comb
83
+ if comb
84
+ beside = File.expand_path(File.join(File.dirname(comb), '..', 'lib', file))
85
+ out << Candidate.new("beside comb (#{comb})", beside)
86
+ end
87
+ out << Candidate.new('the system loader', file)
88
+ end
78
89
 
79
- File.expand_path(File.join(File.dirname(comb), '..', 'lib', LIB_BASENAME))
90
+ # The first of +candidates+ that loads, as [handle, path]; when none
91
+ # does, LibraryNotFoundError naming each and why it failed.
92
+ def self.open_first(candidates)
93
+ tried = candidates.map do |candidate|
94
+ return [Fiddle.dlopen(candidate.path), candidate.path]
95
+ rescue Fiddle::DLError => e
96
+ "#{candidate.description} (#{candidate.path}): #{e.message}"
97
+ end
98
+ raise Beachcomber::LibraryNotFoundError.new(
99
+ "could not load libbeachcomber; tried, in order: #{tried.join('; ')}", library_version: nil,
100
+ )
80
101
  end
81
102
 
82
103
  # Loads the library (idempotent) and returns the Fiddle::Handle.
83
104
  def self.load!
84
105
  return @handle if defined?(@handle) && @handle
85
106
 
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(', ')}"
107
+ finish_load!(*open_first(candidates))
108
108
  end
109
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
110
  def self.finish_load!(handle, path)
118
111
  @handle = handle
119
112
  @library_path = path
@@ -135,9 +128,10 @@ module Beachcomber
135
128
  missing = REQUIRED_SYMBOLS.reject { |sym| symbol_present?(sym) }
136
129
  return if missing.empty?
137
130
 
138
- raise Beachcomber::MissingSymbol,
139
- "#{@library_path} is missing required symbol(s): #{missing.join(', ')} " \
140
- "(bc_version=#{safe_version_for_error})"
131
+ raise Beachcomber::LibrarySymbolError.new(
132
+ "#{@library_path} is missing required symbol(s): #{missing.join(', ')}",
133
+ library_version: safe_version_for_error,
134
+ )
141
135
  end
142
136
  private_class_method :check_symbols!
143
137
 
@@ -158,21 +152,20 @@ module Beachcomber
158
152
  bc_client_new: fnew('bc_client_new', [VOIDP], VOIDP),
159
153
  bc_client_free: fnew('bc_client_free', [VOIDP], VOID),
160
154
  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),
155
+ bc_get: fnew('bc_get', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
156
+ bc_put: fnew('bc_put', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP, INT], VOIDP),
164
157
  bc_refresh: fnew('bc_refresh', [VOIDP, VOIDP, VOIDP], VOIDP),
165
158
  bc_status: fnew('bc_status', [VOIDP], VOIDP),
166
159
  bc_introspect: fnew('bc_introspect', [VOIDP, VOIDP, VOIDP], VOIDP),
167
160
  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),
161
+ bc_eval: fnew('bc_eval', [VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
170
162
  bc_session_open: fnew('bc_session_open', [VOIDP], VOIDP),
171
163
  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),
164
+ bc_session_get: fnew('bc_session_get', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
165
+ bc_session_put: fnew('bc_session_put', [VOIDP, VOIDP, VOIDP, VOIDP, VOIDP, INT], VOIDP),
166
+ bc_session_refresh: fnew('bc_session_refresh', [VOIDP, VOIDP, VOIDP], VOIDP),
174
167
  bc_session_set_context: fnew('bc_session_set_context', [VOIDP, VOIDP], VOIDP),
175
- bc_watch_open: fnew('bc_watch_open', [VOIDP, VOIDP, VOIDP], VOIDP),
168
+ bc_watch_open: fnew('bc_watch_open', [VOIDP, VOIDP, VOIDP, VOIDP], VOIDP),
176
169
  bc_watch_next: fnew('bc_watch_next', [VOIDP, INT], VOIDP),
177
170
  bc_watch_cancel: fnew('bc_watch_cancel', [VOIDP], VOID),
178
171
  bc_watch_free: fnew('bc_watch_free', [VOIDP], VOID),
@@ -192,6 +185,14 @@ module Beachcomber
192
185
  ptr.null? ? '' : ptr.to_s
193
186
  end
194
187
 
188
+ # The build version of the library already loaded, or nil when none is:
189
+ # never loads one, so an error raised while loading can ask for it.
190
+ def self.loaded_version
191
+ return nil unless defined?(@fn) && @fn
192
+
193
+ version
194
+ end
195
+
195
196
  # Calls a void*(...)-returning bc_* function, reads the NUL-terminated
196
197
  # JSON result, frees it via bc_string_free, and returns the raw JSON
197
198
  # string. Not for bc_version, whose result must never be freed.
@@ -199,7 +200,7 @@ module Beachcomber
199
200
  load!
200
201
  fn = @fn.fetch(sym) { raise ArgumentError, "unknown bc_* function #{sym}" }
201
202
  ptr = fn.call(*args)
202
- raise Beachcomber::Error, "unexpected NULL pointer from #{sym}" if ptr.nil? || ptr.null?
203
+ raise Beachcomber::IoError, "NULL returned from #{sym}" if ptr.nil? || ptr.null?
203
204
 
204
205
  json = ptr.to_s
205
206
  @fn[:bc_string_free].call(ptr)
@@ -231,9 +232,9 @@ module Beachcomber
231
232
  @fn[:bc_session_open].call(client_handle)
232
233
  end
233
234
 
234
- def self.new_watch(client_handle, key, path)
235
+ def self.new_watch(client_handle, key, cwd, env_json)
235
236
  load!
236
- @fn[:bc_watch_open].call(client_handle, key, path)
237
+ @fn[:bc_watch_open].call(client_handle, key, cwd, env_json)
237
238
  end
238
239
 
239
240
  # Void-returning teardown calls (null-safe on the C side; harmless if
@@ -1,20 +1,29 @@
1
1
  module Beachcomber
2
+ # The typed shapes of the SDK API table (docs/sdk-api.md): exactly the
3
+ # fields libbeachcomber::protocol defines, as bc_hello, bc_status and
4
+ # bc_introspect hand them on. A field the ABI leaves out is nil.
2
5
  HelloInfo = Struct.new(:protocol_version, :daemon_version, keyword_init: true)
6
+
7
+ # One row of #status. +kind+ and +failure+ are the JSON the ABI hands on.
3
8
  CacheRow = Struct.new(
4
- :provider, :field, :path, :value, :age_ms, :stale,
5
- :kind, :poll_interval_secs, :keep_alive_polls, :fsevents_reinstate, :polls_elapsed,
6
- :failure, :source,
9
+ :provider, :path, :source, :field, :value, :age_ms,
10
+ :kind, :poll_interval_secs, :poll_count, :polls_elapsed,
11
+ :next_poll_in_secs, :failure,
7
12
  keyword_init: true
8
13
  )
9
14
  Verdict = Struct.new(:level, :message, keyword_init: true)
10
- Reaper = Struct.new(:armed, :visibility, :sweeps, :reaped, :kill_denied, keyword_init: true)
15
+ ReaperStatus = Struct.new(:armed, :visibility, :sweeps, :reaped, :kill_denied, keyword_init: true)
11
16
  DaemonHealth = Struct.new(
12
17
  :pid, :version, :uptime_secs, :socket_path, :config_path,
13
18
  :requests_total, :in_flight, :active_watchers, :cache_entries,
14
19
  :watch_backend, :reaper, :verdicts,
15
20
  keyword_init: true
16
21
  )
17
- WatchEvent = Struct.new(:data, :age_ms, :stale, keyword_init: true)
22
+ # One emitted change on a watch. +data+ is the value — what `comb get`
23
+ # would print for the watched key — or nil when nothing is stored there.
24
+ # A value resolved from several cache records has no single age or
25
+ # staleness, so a watch reports neither.
26
+ WatchEvent = Struct.new(:data, keyword_init: true)
18
27
 
19
28
  module IntrospectSubject
20
29
  DAEMON = "daemon"
@@ -28,7 +37,7 @@ module Beachcomber
28
37
  PROCS = "procs"
29
38
  end
30
39
 
31
- # Introspect response wrapper. For DAEMON subject: #daemon is populated.
32
- # For others: #other holds the raw Hash/Array.
40
+ # An #introspect answer. For the DAEMON subject #daemon is a DaemonHealth;
41
+ # for the others #other is the JSON the ABI hands on.
33
42
  IntrospectResponse = Struct.new(:subject, :daemon, :other, keyword_init: true)
34
43
  end
@@ -2,11 +2,14 @@ module Beachcomber
2
2
  # Iterates events from a bc_watch_open handle. Create via
3
3
  # {Client#watch} rather than directly.
4
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.
5
+ # Each event is the value of the watched key — what `comb get` would print
6
+ # for it — re-resolved whenever any key the resolution reads changes,
7
+ # including the provider's template tree.
8
+ #
9
+ # bc_watch_next has five outcomes: event, timeout, eof, cancelled, error.
10
+ # #next_event answers a WatchEvent for an event, nil once the stream has
11
+ # ended (eof or cancelled), and raises: TimeoutError when its wait runs
12
+ # out, the error's own class for an error.
10
13
  class WatchStream
11
14
  include Enumerable
12
15
 
@@ -24,13 +27,18 @@ module Beachcomber
24
27
  end
25
28
  end
26
29
 
27
- # Waits for the next event. +timeout_ms+: -1 (default) blocks
28
- # indefinitely, 0 polls once, >0 waits that long.
30
+ # Waits for the next event: indefinitely when +timeout_ms+ is nil, else
31
+ # that long (0 polls once).
29
32
  #
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)
33
+ # @return [WatchEvent, nil] the value on an emitted change; nil once the
34
+ # stream has ended (the daemon closed it, or it was cancelled). An
35
+ # event whose +data+ is nil is a miss, not the end of the stream.
36
+ # @raise [Beachcomber::TimeoutError] when +timeout_ms+ runs out with no
37
+ # event; the stream is still open
38
+ def next_event(timeout_ms = nil)
39
+ return nil if @closed
40
+
41
+ json = Beachcomber::FFI.raw_call(:bc_watch_next, @handle, timeout_ms || -1)
34
42
  envelope = JSON.parse(json)
35
43
 
36
44
  unless envelope['ok']
@@ -40,30 +48,29 @@ module Beachcomber
40
48
 
41
49
  case envelope['outcome']
42
50
  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,
48
- )
49
- when 'timeout', 'eof', 'cancelled'
51
+ WatchEvent.new(data: envelope['data'])
52
+ when 'eof', 'cancelled'
50
53
  nil
54
+ when 'timeout'
55
+ raise Beachcomber::TimeoutError, "no event within #{timeout_ms}ms"
51
56
  else
52
- raise Beachcomber::ProtocolError, "unknown watch outcome: #{envelope['outcome'].inspect}"
57
+ raise Beachcomber::ParseError, "unknown watch outcome: #{envelope['outcome'].inspect}"
53
58
  end
54
59
  end
55
60
 
56
- # Unblocks a pending or future #next_event call. Safe to call from
57
- # another thread while a call is in flight.
61
+ # Ends the stream: a pending or later #next_event answers nil. Safe to
62
+ # call from another thread while a call is in flight.
58
63
  def cancel
59
- Beachcomber::FFI.cancel_watch(@handle)
64
+ Beachcomber::FFI.cancel_watch(@handle) unless @closed
60
65
  nil
61
66
  end
62
67
 
68
+ # Cancels the stream and frees its handle. Idempotent.
63
69
  def close
64
70
  return if @closed
65
71
 
66
72
  @closed = true
73
+ Beachcomber::FFI.cancel_watch(@handle)
67
74
  Beachcomber::FFI.free_watch(@handle)
68
75
  end
69
76
  end
data/lib/beachcomber.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  require_relative 'beachcomber/errors'
2
2
  require_relative 'beachcomber/ffi'
3
- require_relative 'beachcomber/result'
4
3
  require_relative 'beachcomber/types'
5
- require_relative 'beachcomber/discovery'
6
4
  require_relative 'beachcomber/watch_stream'
7
5
  require_relative 'beachcomber/client'
8
6
 
@@ -16,15 +14,14 @@ require_relative 'beachcomber/client'
16
14
  # require 'beachcomber'
17
15
  #
18
16
  # client = Beachcomber::Client.new
19
- # result = client.get('git.branch', path: '/path/to/repo')
20
- # puts result.data if result.hit?
17
+ # branch = client.get('git.branch', cwd: '/path/to/repo') # nil on a miss
21
18
  #
22
19
  # Persistent session (one connection, multiple queries):
23
20
  #
24
21
  # client.session do |s|
25
22
  # s.set_context('/path/to/repo')
26
- # r1 = s.get('git.branch')
27
- # r2 = s.get('git.dirty')
23
+ # branch = s.get('git.branch')
24
+ # dirty = s.get('git.dirty')
28
25
  # end
29
26
  module Beachcomber
30
27
  VERSION = '0.1.0'
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.9.0
4
+ version: 0.10.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-08-31 00:00:00.000000000 Z
11
+ date: 2026-11-06 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.).
@@ -19,10 +19,8 @@ extra_rdoc_files: []
19
19
  files:
20
20
  - lib/beachcomber.rb
21
21
  - lib/beachcomber/client.rb
22
- - lib/beachcomber/discovery.rb
23
22
  - lib/beachcomber/errors.rb
24
23
  - lib/beachcomber/ffi.rb
25
- - lib/beachcomber/result.rb
26
24
  - lib/beachcomber/types.rb
27
25
  - lib/beachcomber/watch_stream.rb
28
26
  homepage: https://github.com/NavistAu/beachcomber
@@ -1,24 +0,0 @@
1
- require 'etc'
2
-
3
- module Beachcomber
4
- # Discovers the Unix socket path for the beachcomber daemon.
5
- #
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. /tmp/beachcomber-<uid>/sock
10
- #
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
- module Discovery
16
- # @return [String] the resolved socket path
17
- def self.socket_path
18
- sock = ENV['BEACHCOMBER_SOCKET']
19
- return sock if sock && !sock.empty?
20
-
21
- File.join('/tmp', "beachcomber-#{Process.uid}", 'sock')
22
- end
23
- end
24
- end
@@ -1,81 +0,0 @@
1
- module Beachcomber
2
- # Wraps a daemon response.
3
- #
4
- # A result can represent a cache hit (data present), a miss (ok but no data),
5
- # or an error (ok: false — though errors are normally raised as ServerError).
6
- #
7
- # Examples:
8
- #
9
- # result = client.get('git.branch', path: '/repo')
10
- # if result.hit?
11
- # puts result.data # "main"
12
- # puts result.age_ms # 42
13
- # end
14
- #
15
- # # hash data access
16
- # result = client.get('git', path: '/repo')
17
- # puts result['branch']
18
- class Result
19
- # @return [Boolean] whether the daemon reported success
20
- attr_reader :ok
21
-
22
- # @return [Object, nil] decoded payload (String, Integer, Hash, Array, etc.)
23
- attr_reader :data
24
-
25
- # @return [Integer] age of the cached value in milliseconds
26
- attr_reader :age_ms
27
-
28
- # @return [String, nil] error message when ok is false
29
- attr_reader :error
30
-
31
- def initialize(ok:, data:, age_ms:, stale:, error:)
32
- @ok = ok
33
- @data = data
34
- @age_ms = age_ms
35
- @stale = stale
36
- @error = error
37
- end
38
-
39
- # @return [Boolean]
40
- def ok?
41
- @ok
42
- end
43
-
44
- # @return [Boolean] true when the response carried data (cache hit)
45
- def hit?
46
- @ok && !@data.nil?
47
- end
48
-
49
- # @return [Boolean] true when the response was successful but had no data
50
- def miss?
51
- @ok && @data.nil?
52
- end
53
-
54
- # @return [Boolean] true when the cached value is stale
55
- def stale?
56
- @stale
57
- end
58
-
59
- # Delegates field access to the data hash.
60
- #
61
- # @param key [String] field name
62
- # @return [Object, nil]
63
- # @raise [TypeError] if data is not a Hash
64
- def [](key)
65
- unless @data.is_a?(Hash)
66
- raise TypeError, "Result#[] requires hash data, got #{@data.class}"
67
- end
68
-
69
- @data[key]
70
- end
71
-
72
- def inspect
73
- parts = ["ok=#{@ok}"]
74
- parts << "data=#{@data.inspect}" unless @data.nil?
75
- parts << "age_ms=#{@age_ms}" if @age_ms > 0
76
- parts << "stale=true" if @stale
77
- parts << "error=#{@error.inspect}" if @error
78
- "#<Beachcomber::Result #{parts.join(', ')}>"
79
- end
80
- end
81
- end