libbeachcomber 0.9.1 → 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 +4 -4
- data/lib/beachcomber/client.rb +199 -231
- data/lib/beachcomber/errors.rb +43 -111
- data/lib/beachcomber/ffi.rb +79 -78
- data/lib/beachcomber/types.rb +16 -7
- data/lib/beachcomber/watch_stream.rb +29 -22
- data/lib/beachcomber.rb +3 -6
- metadata +2 -4
- data/lib/beachcomber/discovery.rb +0 -24
- data/lib/beachcomber/result.rb +0 -81
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3c045383cb88693dccb3888f17a5dbed1d5fcea26e582898d59034095d04f98
|
|
4
|
+
data.tar.gz: 9bfa55709e0f57da1632fdba84b817836490c65c6fef75a16dd03beb0af78160
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fbc30c179de56aea27c151833d8a5934edc1ee5401957695a25191904b30ab6f134420b53df961b8af82bda333331de5a41176574b38ff1a1b1d37242fed6e0
|
|
7
|
+
data.tar.gz: 7951707bcabfe101803feef42ccc192dc1c434b9ff9eb2e1f95453fd60556514d759ff431d4f2552566418af12b40e0e6c95a3996465b321828e17092cf5e09d
|
data/lib/beachcomber/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
49
|
+
def introspect(subject, data)
|
|
85
50
|
if subject == IntrospectSubject::DAEMON && data.is_a?(Hash)
|
|
86
|
-
IntrospectResponse.new(subject: subject, daemon:
|
|
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,
|
|
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
|
-
#
|
|
66
|
+
# branch = s.get('git.branch')
|
|
105
67
|
# end
|
|
106
68
|
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
|
76
|
+
include Requests
|
|
118
77
|
|
|
119
|
-
def initialize(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
|
|
126
|
-
#
|
|
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,
|
|
89
|
+
Beachcomber::FFI.call!(:bc_session_set_context, handle!, path)
|
|
131
90
|
nil
|
|
132
91
|
end
|
|
133
92
|
|
|
134
|
-
#
|
|
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
|
|
138
|
-
# @
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
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!(:
|
|
113
|
+
Beachcomber::FFI.call!(:bc_session_refresh, handle!, key, path)
|
|
161
114
|
nil
|
|
162
115
|
end
|
|
163
116
|
|
|
164
|
-
#
|
|
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
|
|
187
|
-
Beachcomber::FFI.call!(:bc_session_put,
|
|
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
|
-
#
|
|
219
|
-
#
|
|
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
|
|
161
|
+
include Requests
|
|
222
162
|
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
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
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
options
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
#
|
|
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]
|
|
249
|
-
# @param
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
# @
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
#
|
|
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
|
|
262
|
-
# @
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
317
|
-
|
|
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
|
-
|
|
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
|
data/lib/beachcomber/errors.rb
CHANGED
|
@@ -1,127 +1,59 @@
|
|
|
1
1
|
module Beachcomber
|
|
2
|
-
#
|
|
3
|
-
|
|
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
|
|
18
|
-
# +error.kind
|
|
19
|
-
# message
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 ->
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
119
|
-
#
|
|
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(
|
|
57
|
+
raise CombError.new(message, kind: kind)
|
|
126
58
|
end
|
|
127
59
|
end
|
data/lib/beachcomber/ffi.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
bc_session_open bc_session_close bc_session_get bc_session_put
|
|
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
|
-
#
|
|
59
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
139
|
-
|
|
140
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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::
|
|
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,
|
|
235
|
+
def self.new_watch(client_handle, key, cwd, env_json)
|
|
235
236
|
load!
|
|
236
|
-
@fn[:bc_watch_open].call(client_handle, key,
|
|
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
|
data/lib/beachcomber/types.rb
CHANGED
|
@@ -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, :
|
|
5
|
-
:kind, :poll_interval_secs, :
|
|
6
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
32
|
-
#
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
28
|
-
#
|
|
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]
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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::
|
|
57
|
+
raise Beachcomber::ParseError, "unknown watch outcome: #{envelope['outcome'].inspect}"
|
|
53
58
|
end
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
27
|
-
#
|
|
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.
|
|
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-
|
|
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
|
data/lib/beachcomber/result.rb
DELETED
|
@@ -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
|