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