diffbroker 0.11.0 → 0.12.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/exe/diffbroker +3 -0
- data/lib/diffbroker/cable_client.rb +57 -7
- data/lib/diffbroker/cli.rb +84 -11
- data/lib/diffbroker/daemon.rb +109 -15
- data/lib/diffbroker/daemon_lock.rb +34 -0
- data/lib/diffbroker/daemon_state.rb +53 -0
- data/lib/diffbroker/lifecycle.rb +6 -1
- data/lib/diffbroker/reconciler.rb +121 -0
- data/lib/diffbroker/service_installer.rb +99 -13
- data/lib/diffbroker/version.rb +1 -1
- data/lib/diffbroker.rb +3 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b3251c8cf1dc34ca4cfda98157ac33e2cdd3181d2b7464b6732a73b6731de43
|
|
4
|
+
data.tar.gz: cf1c80fb6862c480b58442d6432ef38d05e7817a44f5813642b402d2b1450a7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4561d11449c3387cd2f97b44bb60b2f4bf0f3259ae5d5bc97fb7c13764c00c68a60f787fec09f45a876dccb1c861edd4d7f8acf24448fec0d2f16b3878e0e336
|
|
7
|
+
data.tar.gz: 79116f0dff8028b473d6c526606cca711c091e1b71bdf7fdc304482d41e2ca9d171e3d0aa01b1396c0c4e22870abf1300b8aa2360ed2a66ccbf566ea6e8cfab5
|
data/exe/diffbroker
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative "../lib/diffbroker"
|
|
5
5
|
|
|
6
|
+
# A daemon's log is read when something is wrong, so lines must reach the file as they happen, not when a buffer fills.
|
|
7
|
+
$stdout.sync = $stderr.sync = true
|
|
8
|
+
|
|
6
9
|
begin
|
|
7
10
|
exit Diffbroker::CLI.new(ARGV).run
|
|
8
11
|
rescue Interrupt
|
|
@@ -49,17 +49,35 @@ module Diffbroker
|
|
|
49
49
|
POLL_EVERY = 1
|
|
50
50
|
SELECT_TIMEOUT = 1
|
|
51
51
|
CONNECT_TIMEOUT = 10
|
|
52
|
+
SUBSCRIBE_TIMEOUT = 15
|
|
53
|
+
WRITE_TIMEOUT = 15
|
|
52
54
|
READ_CHUNK = 16_384
|
|
53
55
|
|
|
56
|
+
# Writes never block for ever on a peer that vanished (a laptop that slept, a dropped Wi-Fi): after WRITE_TIMEOUT the
|
|
57
|
+
# connection is an error and the daemon reconnects.
|
|
54
58
|
SocketAdapter = Struct.new(:url, :io) do
|
|
55
|
-
def write(data)
|
|
59
|
+
def write(data)
|
|
60
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + WRITE_TIMEOUT
|
|
61
|
+
until data.empty?
|
|
62
|
+
written = io.write_nonblock(data, exception: false)
|
|
63
|
+
case written
|
|
64
|
+
when Integer then data = data.byteslice(written..)
|
|
65
|
+
when :wait_readable, :wait_writable
|
|
66
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
raise Errno::ETIMEDOUT, "write to #{url}" if left <= 0
|
|
68
|
+
|
|
69
|
+
written == :wait_readable ? io.wait_readable(left) : io.wait_writable(left)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
56
73
|
end
|
|
57
74
|
|
|
58
|
-
def initialize(url:, token:, origin:, protocol: Protocol.new)
|
|
75
|
+
def initialize(url:, token:, origin:, protocol: Protocol.new, subscribe_timeout: SUBSCRIBE_TIMEOUT)
|
|
59
76
|
@url = url
|
|
60
77
|
@token = token
|
|
61
78
|
@origin = origin
|
|
62
79
|
@protocol = protocol
|
|
80
|
+
@subscribe_timeout = subscribe_timeout
|
|
63
81
|
@outbox = Queue.new
|
|
64
82
|
@wake_reader, @wake_writer = IO.pipe
|
|
65
83
|
end
|
|
@@ -91,8 +109,9 @@ module Diffbroker
|
|
|
91
109
|
pump
|
|
92
110
|
final_flush
|
|
93
111
|
ensure
|
|
94
|
-
@io
|
|
95
|
-
|
|
112
|
+
close_quietly(@io)
|
|
113
|
+
close_quietly(@wake_reader)
|
|
114
|
+
close_quietly(@wake_writer)
|
|
96
115
|
end
|
|
97
116
|
|
|
98
117
|
private
|
|
@@ -147,6 +166,7 @@ module Diffbroker
|
|
|
147
166
|
def stale!
|
|
148
167
|
if @welcomed
|
|
149
168
|
@stopped = true if monotonic - @last_ping > STALE_AFTER
|
|
169
|
+
raise HandshakeError, "The server never confirmed the device channel subscription" if !@subscribed && monotonic - @welcomed_at > @subscribe_timeout
|
|
150
170
|
elsif monotonic - @started > STALE_AFTER
|
|
151
171
|
raise HandshakeError, "No welcome from #{@url} within #{STALE_AFTER} s"
|
|
152
172
|
end
|
|
@@ -171,7 +191,7 @@ module Diffbroker
|
|
|
171
191
|
case kind
|
|
172
192
|
when :welcome
|
|
173
193
|
@welcomed = true
|
|
174
|
-
@last_ping = monotonic
|
|
194
|
+
@welcomed_at = @last_ping = monotonic
|
|
175
195
|
@driver.text(@protocol.subscribe)
|
|
176
196
|
when :ping then @last_ping = monotonic
|
|
177
197
|
when :confirmed
|
|
@@ -186,6 +206,19 @@ module Diffbroker
|
|
|
186
206
|
end
|
|
187
207
|
end
|
|
188
208
|
|
|
209
|
+
# Best effort: lets the OS notice a peer that vanished without a goodbye.
|
|
210
|
+
def keep_alive(tcp)
|
|
211
|
+
tcp.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
|
212
|
+
rescue SystemCallError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def close_quietly(io)
|
|
217
|
+
io.close if io && !io.closed?
|
|
218
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
189
222
|
def wake
|
|
190
223
|
@wake_writer.write_nonblock(".", exception: false)
|
|
191
224
|
rescue IOError
|
|
@@ -196,7 +229,8 @@ module Diffbroker
|
|
|
196
229
|
uri = URI(@url)
|
|
197
230
|
raise Error, "Invalid cable URL #{@url.inspect}" unless %w[ws wss].include?(uri.scheme) && uri.host
|
|
198
231
|
|
|
199
|
-
tcp = Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT)
|
|
232
|
+
tcp = Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT, resolv_timeout: CONNECT_TIMEOUT)
|
|
233
|
+
keep_alive(tcp)
|
|
200
234
|
return tcp unless uri.scheme == "wss"
|
|
201
235
|
|
|
202
236
|
context = OpenSSL::SSL::SSLContext.new
|
|
@@ -204,10 +238,26 @@ module Diffbroker
|
|
|
204
238
|
ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
205
239
|
ssl.hostname = uri.host
|
|
206
240
|
ssl.sync_close = true
|
|
207
|
-
ssl
|
|
241
|
+
connect_tls(ssl)
|
|
208
242
|
ssl
|
|
209
243
|
end
|
|
210
244
|
|
|
245
|
+
# The TLS handshake has no timeout of its own: a captive portal or a stalled VPN would hold the daemon for ever.
|
|
246
|
+
def connect_tls(ssl)
|
|
247
|
+
deadline = monotonic + CONNECT_TIMEOUT
|
|
248
|
+
loop do
|
|
249
|
+
case ssl.connect_nonblock(exception: false)
|
|
250
|
+
when :wait_readable then ssl.wait_readable([ deadline - monotonic, 0 ].max)
|
|
251
|
+
when :wait_writable then ssl.wait_writable([ deadline - monotonic, 0 ].max)
|
|
252
|
+
else return
|
|
253
|
+
end
|
|
254
|
+
raise Errno::ETIMEDOUT, "TLS handshake with #{@url}" if monotonic > deadline
|
|
255
|
+
end
|
|
256
|
+
rescue StandardError
|
|
257
|
+
close_quietly(ssl)
|
|
258
|
+
raise
|
|
259
|
+
end
|
|
260
|
+
|
|
211
261
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
212
262
|
end
|
|
213
263
|
end
|
data/lib/diffbroker/cli.rb
CHANGED
|
@@ -36,10 +36,13 @@ module Diffbroker
|
|
|
36
36
|
Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
|
|
37
37
|
TEXT
|
|
38
38
|
|
|
39
|
-
def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin, maintenance: nil, lifecycle: nil
|
|
39
|
+
def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin, maintenance: nil, lifecycle: nil, reconciler: nil,
|
|
40
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
40
41
|
@argv = argv.dup
|
|
41
42
|
@maintenance = maintenance
|
|
42
43
|
@lifecycle = lifecycle
|
|
44
|
+
@reconciler = reconciler
|
|
45
|
+
@sleeper = sleeper
|
|
43
46
|
@out = out
|
|
44
47
|
@err = err
|
|
45
48
|
@env = env
|
|
@@ -134,6 +137,7 @@ module Diffbroker
|
|
|
134
137
|
raise Error, "This machine is not paired. Run `diffbroker start` in a terminal to pair it." unless config.paired?
|
|
135
138
|
|
|
136
139
|
@out.puts "Diff Broker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
|
|
140
|
+
heal_service_definition
|
|
137
141
|
Daemon.build(config, out: @out).run
|
|
138
142
|
rescue Daemon::Rejected
|
|
139
143
|
# The pairing is dead. Forget it and remove the service, so nothing restarts a daemon that can never connect.
|
|
@@ -142,11 +146,20 @@ module Diffbroker
|
|
|
142
146
|
raise
|
|
143
147
|
end
|
|
144
148
|
|
|
149
|
+
# A daemon the service manager started under an old definition (another launcher or Ruby, or the old restart policy)
|
|
150
|
+
# corrects the file for the next load. Only the file: the running job keeps going.
|
|
151
|
+
def heal_service_definition
|
|
152
|
+
service = ServiceInstaller.new(home: @home, env: @env)
|
|
153
|
+
service.rewrite! if lifecycle.service_process? && service.installed? && service.stale?
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
145
158
|
def set_up(name)
|
|
146
159
|
forget_dead_pairing if config.paired? && pairing_rejected?
|
|
147
160
|
Login.new(config:, out: @out, name:).run unless config.paired?
|
|
161
|
+
remove_legacy_service
|
|
148
162
|
install_background_service
|
|
149
|
-
@out.puts "Diff Broker is running in the background. Open #{config.server} to see it connected."
|
|
150
163
|
rescue Error => e
|
|
151
164
|
raise unless service_unavailable?(e)
|
|
152
165
|
|
|
@@ -155,8 +168,16 @@ module Diffbroker
|
|
|
155
168
|
run_daemon
|
|
156
169
|
end
|
|
157
170
|
|
|
158
|
-
# True only when the server answered "unauthorized"
|
|
171
|
+
# True only when the server answered "unauthorized" twice, a few seconds apart (a deploy or a restore can say it once).
|
|
172
|
+
# An unreachable server proves nothing, so the pairing is kept.
|
|
159
173
|
def pairing_rejected?
|
|
174
|
+
return false unless ping_unauthorized?
|
|
175
|
+
|
|
176
|
+
@sleeper.call(3)
|
|
177
|
+
ping_unauthorized?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def ping_unauthorized?
|
|
160
181
|
Api.new(server: config.server, token: config.device_token).get("/device/ping")
|
|
161
182
|
false
|
|
162
183
|
rescue Api::HttpError => e
|
|
@@ -171,16 +192,37 @@ module Diffbroker
|
|
|
171
192
|
ServiceInstaller.new(home: @home, env: @env).uninstall!
|
|
172
193
|
end
|
|
173
194
|
|
|
174
|
-
# Installs what is missing
|
|
175
|
-
#
|
|
195
|
+
# Installs what is missing, then makes sure the newest companion runs and is connected (Reconciler): an installed
|
|
196
|
+
# service is not just started, because starting a running job does nothing and the daemon in it may be an older version.
|
|
176
197
|
def install_background_service
|
|
177
198
|
service = ServiceInstaller.new(home: @home, env: @env)
|
|
178
|
-
|
|
199
|
+
install_service unless service.installed?
|
|
200
|
+
raise Error, "The companion is not connected yet." unless reconciler(service).call
|
|
201
|
+
end
|
|
179
202
|
|
|
180
|
-
|
|
203
|
+
def service_unavailable?(error)
|
|
204
|
+
error.is_a?(ServiceInstaller::Unavailable) || error.message.include?("not found on PATH") || error.message.include?("systemctl")
|
|
181
205
|
end
|
|
182
206
|
|
|
183
|
-
|
|
207
|
+
# The service of the companion's old name (gitbroker) keeps crash-looping against a domain that no longer answers.
|
|
208
|
+
def remove_legacy_service
|
|
209
|
+
@out.puts "Removed the background service of the companion's old name (gitbroker)." if ServiceInstaller.new(home: @home, env: @env).remove_legacy!
|
|
210
|
+
rescue Error
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def reconciler(service = ServiceInstaller.new(home: @home, env: @env), version: VERSION)
|
|
215
|
+
@reconciler ||= Reconciler.new(service:, lifecycle:, state: DaemonState.new(dir: config.dir), out: @out, server: config.server, version:,
|
|
216
|
+
on_refresh: method(:refresh_integrations))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A rewritten service runs a new launcher: the Claude Code hooks and the start link name it too.
|
|
220
|
+
def refresh_integrations
|
|
221
|
+
refresh_hooks
|
|
222
|
+
UrlHandlerInstaller.new(home: @home).install!
|
|
223
|
+
rescue Error
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
184
226
|
|
|
185
227
|
def install_service
|
|
186
228
|
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
@@ -200,7 +242,8 @@ module Diffbroker
|
|
|
200
242
|
# service, or the daemon itself when none is installed. Safe to run when it is already running.
|
|
201
243
|
def launch
|
|
202
244
|
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
203
|
-
|
|
245
|
+
|
|
246
|
+
reconciler.call(verify: false)
|
|
204
247
|
end
|
|
205
248
|
|
|
206
249
|
def lifecycle = @lifecycle ||= Lifecycle.new(dir: config.dir, home: @home, service: ServiceInstaller.new(home: @home, env: @env))
|
|
@@ -214,8 +257,8 @@ module Diffbroker
|
|
|
214
257
|
def restart
|
|
215
258
|
raise Error, "This machine is not paired. Run `diffbroker start` first." unless config.paired?
|
|
216
259
|
|
|
217
|
-
lifecycle.restart
|
|
218
260
|
@out.puts "Restarting the companion."
|
|
261
|
+
raise Error, "The companion is not connected yet." unless reconciler.call(force: true)
|
|
219
262
|
end
|
|
220
263
|
|
|
221
264
|
def maintenance = @maintenance ||= Maintenance.new(hooks: hooks_installer)
|
|
@@ -226,8 +269,14 @@ module Diffbroker
|
|
|
226
269
|
maintenance.upgrade!
|
|
227
270
|
return @out.puts("Upgraded. Run `diffbroker start` to pair this machine.") unless config.paired?
|
|
228
271
|
|
|
229
|
-
lifecycle.restart
|
|
230
272
|
@out.puts "Upgraded. Restarting the companion."
|
|
273
|
+
raise Error, "The companion is not connected yet." unless reconciler(version: newest_installed_version).call(force: true)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# This process runs the old code, so the version to wait for is the newest one on disk.
|
|
277
|
+
def newest_installed_version
|
|
278
|
+
Gem::Specification.reset
|
|
279
|
+
Gem::Specification.find_all_by_name("diffbroker").map(&:version).max&.to_s || VERSION
|
|
231
280
|
end
|
|
232
281
|
|
|
233
282
|
def uninstall
|
|
@@ -249,6 +298,7 @@ module Diffbroker
|
|
|
249
298
|
@out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `diffbroker start`)"}"
|
|
250
299
|
@out.puts "Agent: #{config.agent}"
|
|
251
300
|
@out.puts "Terminal: #{config.terminal}"
|
|
301
|
+
daemon_status
|
|
252
302
|
@out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (diffbroker install-hooks)"}"
|
|
253
303
|
@out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
|
|
254
304
|
@out.puts "Worktrees: #{config.worktree_root}"
|
|
@@ -258,6 +308,29 @@ module Diffbroker
|
|
|
258
308
|
repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
|
|
259
309
|
end
|
|
260
310
|
|
|
311
|
+
# What the daemon wrote about itself (DaemonState), next to what this installed version and service would run.
|
|
312
|
+
def daemon_status
|
|
313
|
+
data = DaemonState.new(dir: config.dir).read
|
|
314
|
+
pid = data&.dig("pid")
|
|
315
|
+
running = pid && Process.kill(0, pid.to_i) && true
|
|
316
|
+
if running
|
|
317
|
+
line = "running (pid #{pid}, #{data["state"]}"
|
|
318
|
+
line += ", version #{data["version"]} but #{VERSION} is installed: run `diffbroker start`" if data["version"] != VERSION
|
|
319
|
+
@out.puts "Daemon: #{line})"
|
|
320
|
+
elsif (legacy = lifecycle.running_pid)
|
|
321
|
+
@out.puts "Daemon: running an older version (pid #{legacy}) that reports no state: run `diffbroker start` to replace it"
|
|
322
|
+
else
|
|
323
|
+
@out.puts "Daemon: not running (run `diffbroker start`)#{" — last state: #{data["state"]}" if data}"
|
|
324
|
+
end
|
|
325
|
+
error = data&.dig("last_error")
|
|
326
|
+
@out.puts "Last error: #{error["message"]} (#{error["kind"]})" if error
|
|
327
|
+
service = ServiceInstaller.new(home: @home, env: @env)
|
|
328
|
+
@out.puts "Service: #{service.installed? ? (service.stale? ? "installed but out of date: run `diffbroker start`" : "installed") : "not installed"}"
|
|
329
|
+
@out.puts "Log: #{lifecycle.log_path}"
|
|
330
|
+
rescue SystemCallError
|
|
331
|
+
@out.puts "Daemon: not running (run `diffbroker start`)"
|
|
332
|
+
end
|
|
333
|
+
|
|
261
334
|
# `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
|
|
262
335
|
def clean
|
|
263
336
|
worktree = Worktree.new(root: config.worktree_root)
|
data/lib/diffbroker/daemon.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "set"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "rbconfig"
|
|
6
8
|
|
|
7
9
|
module Diffbroker
|
|
8
10
|
# `diffbroker start`: keep the device connected, report checkouts, claim and run tasks, and relay the status of
|
|
@@ -10,6 +12,17 @@ module Diffbroker
|
|
|
10
12
|
class Daemon
|
|
11
13
|
class Rejected < Error; end
|
|
12
14
|
|
|
15
|
+
# The daemon's own log lines carry the time, so a log that stops can be read against the clock.
|
|
16
|
+
class Stamped
|
|
17
|
+
def initialize(out, clock: -> { Time.now })
|
|
18
|
+
@out = out
|
|
19
|
+
@clock = clock
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def puts(*lines) = lines.flatten.each { @out.puts("#{@clock.call.strftime("%F %T")} #{_1}") }
|
|
23
|
+
def flush = @out.flush
|
|
24
|
+
end
|
|
25
|
+
|
|
13
26
|
RESCAN_EVERY = 300
|
|
14
27
|
USAGE_EVERY = 300
|
|
15
28
|
MAX_BACKOFF = 60
|
|
@@ -26,9 +39,21 @@ module Diffbroker
|
|
|
26
39
|
|
|
27
40
|
new(scanner: config.repo_scanner, roots_changed: roots_changed(config), device_roots: config.device_roots, ignored_roots: config.ignored_roots,
|
|
28
41
|
client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
|
|
29
|
-
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out
|
|
42
|
+
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out: Stamped.new(out), relay:, process_scanner:, agent: config.agent,
|
|
30
43
|
plan_usage: PlanUsage.new(agent: config.agent),
|
|
31
|
-
lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root)
|
|
44
|
+
lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root),
|
|
45
|
+
state: DaemonState.new(dir: config.dir), lock: DaemonLock.new(dir: config.dir), server: config.server,
|
|
46
|
+
rejection_confirmed: -> { server_rejects?(config) }, jitter: ->(seconds) { seconds * rand(0.8..1.25) })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True only when the server itself answers 401 to the device token. An unreachable or erroring server proves nothing.
|
|
50
|
+
def self.server_rejects?(config)
|
|
51
|
+
Api.new(server: config.server, token: config.device_token).get("/device/ping")
|
|
52
|
+
false
|
|
53
|
+
rescue Api::HttpError => e
|
|
54
|
+
e.status == 401
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
32
57
|
end
|
|
33
58
|
|
|
34
59
|
# The server's "roots" message: remember the folders in config.yml (so they survive a restart without the server) and
|
|
@@ -56,7 +81,8 @@ module Diffbroker
|
|
|
56
81
|
|
|
57
82
|
def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
|
|
58
83
|
clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil,
|
|
59
|
-
maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil, plan_usage: nil
|
|
84
|
+
maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil, plan_usage: nil,
|
|
85
|
+
state: nil, lock: nil, rejection_confirmed: nil, server: nil, jitter: ->(seconds) { seconds })
|
|
60
86
|
@scanner = scanner
|
|
61
87
|
@device_roots = device_roots
|
|
62
88
|
@ignored_roots = ignored_roots
|
|
@@ -74,6 +100,11 @@ module Diffbroker
|
|
|
74
100
|
@agent = agent
|
|
75
101
|
@reaper = reaper
|
|
76
102
|
@plan_usage = plan_usage
|
|
103
|
+
@state = state
|
|
104
|
+
@lock = lock
|
|
105
|
+
@rejection_confirmed = rejection_confirmed
|
|
106
|
+
@server = server
|
|
107
|
+
@jitter = jitter
|
|
77
108
|
@repositories = []
|
|
78
109
|
@running = Set.new
|
|
79
110
|
@running_lock = Mutex.new
|
|
@@ -82,32 +113,65 @@ module Diffbroker
|
|
|
82
113
|
def run
|
|
83
114
|
@backoff = 1
|
|
84
115
|
@rejections = 0
|
|
116
|
+
# One daemon per settings directory. A second one (a hand-started daemon next to the service, a double click on
|
|
117
|
+
# "Start companion") leaves quietly and untouched: the first keeps the relay socket and the connection.
|
|
118
|
+
if @lock && !@lock.acquire
|
|
119
|
+
@out.puts "Another diffbroker daemon already runs here; leaving it alone."
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
@owner = true
|
|
85
123
|
@relay&.start
|
|
86
124
|
@lifecycle&.write_pid
|
|
125
|
+
@state&.update(pid: Process.pid, version: VERSION, ruby: RbConfig.ruby, server: @server, started_at: @clock.call.to_i,
|
|
126
|
+
service_managed: @lifecycle ? @lifecycle.service_process? : false, state: "starting", busy_tasks: 0, error: nil)
|
|
87
127
|
sweep_strays
|
|
88
128
|
until @stopping
|
|
89
129
|
begin
|
|
90
130
|
@client = @client_factory.call
|
|
131
|
+
@state&.update(state: "connecting")
|
|
91
132
|
wire(@client)
|
|
92
133
|
@client.run
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
rescue
|
|
99
|
-
|
|
134
|
+
@out.puts "Connection closed; reconnecting." unless @stopping
|
|
135
|
+
rescue CableClient::Unauthorized => e
|
|
136
|
+
give_up_or_retry(e)
|
|
137
|
+
rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError, Timeout::Error => e
|
|
138
|
+
failed(e)
|
|
139
|
+
rescue StandardError, ScriptError => e
|
|
140
|
+
# A bug or a half-loaded gem after an upgrade must cost one reconnect, never the daemon: with no service
|
|
141
|
+
# manager nothing would bring it back.
|
|
142
|
+
failed(e, backtrace: true)
|
|
100
143
|
end
|
|
101
144
|
break if @stopping
|
|
102
145
|
|
|
103
|
-
@
|
|
146
|
+
@state&.update(state: "backoff")
|
|
147
|
+
@sleeper.call(@jitter.call(@backoff))
|
|
104
148
|
@backoff = [ @backoff * 2, MAX_BACKOFF ].min
|
|
105
149
|
end
|
|
106
150
|
ensure
|
|
107
|
-
@
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
if @owner
|
|
152
|
+
@relay&.stop
|
|
153
|
+
@lifecycle&.clear_pid
|
|
154
|
+
@state&.clear(pid: Process.pid) unless @rejected # a rejected daemon leaves its reason for `status`
|
|
155
|
+
@lock&.release
|
|
156
|
+
# Only now, with the connection closed, so two daemons never share the device.
|
|
157
|
+
@lifecycle.spawn_daemon if @respawn && @lifecycle
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# What went wrong, as a kind a person can act on and the message to show next to it.
|
|
162
|
+
def self.classify(error)
|
|
163
|
+
message = error.message.to_s
|
|
164
|
+
status = message[/response code: (\d+)/, 1]&.to_i
|
|
165
|
+
kind = case error
|
|
166
|
+
when SocketError then "dns"
|
|
167
|
+
when Errno::ECONNREFUSED then "refused"
|
|
168
|
+
when OpenSSL::SSL::SSLError then "tls"
|
|
169
|
+
when Errno::ETIMEDOUT, Timeout::Error then "timeout"
|
|
170
|
+
when CableClient::HandshakeError then "handshake"
|
|
171
|
+
when Errno::ECONNRESET, Errno::EPIPE, EOFError, IOError, SystemCallError then "network"
|
|
172
|
+
else "internal"
|
|
173
|
+
end
|
|
174
|
+
{ kind:, message: message.lines.first.to_s.strip[0, 300], status: }.compact
|
|
111
175
|
end
|
|
112
176
|
|
|
113
177
|
def stop
|
|
@@ -121,10 +185,38 @@ module Diffbroker
|
|
|
121
185
|
|
|
122
186
|
private
|
|
123
187
|
|
|
188
|
+
def failed(error, backtrace: false)
|
|
189
|
+
info = self.class.classify(error)
|
|
190
|
+
@state&.update(error: info.merge(at: @clock.call.to_i))
|
|
191
|
+
@out.puts "Connection problem (#{info[:kind]}): #{info[:message]}"
|
|
192
|
+
@out.puts error.backtrace.first(5).map { " #{_1}" } if backtrace && error.backtrace
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The server said "unauthorized". Three in a row are not proof: a deploy or a restore can answer that for a moment.
|
|
196
|
+
# The pairing is only dead when the server's own HTTP check (GET /device/ping) says 401 too.
|
|
197
|
+
def give_up_or_retry(error)
|
|
198
|
+
@rejections += 1
|
|
199
|
+
if @rejections < REJECTIONS_BEFORE_GIVING_UP
|
|
200
|
+
@out.puts "Diff Broker rejected this device (#{@rejections} of #{REJECTIONS_BEFORE_GIVING_UP}); trying again."
|
|
201
|
+
return
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if @rejection_confirmed.nil? || @rejection_confirmed.call
|
|
205
|
+
@rejected = true
|
|
206
|
+
@state&.update(state: "rejected", error: { kind: "unauthorized", message: error.message, at: @clock.call.to_i })
|
|
207
|
+
raise Rejected, "Diff Broker no longer knows this device (revoked?). Run `diffbroker start` to pair again."
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
@rejections = 0
|
|
211
|
+
@state&.update(error: { kind: "unauthorized", message: "rejected on the cable, but the server still knows this device", at: @clock.call.to_i })
|
|
212
|
+
@out.puts "Diff Broker rejected the connection, but the server still knows this device; trying again."
|
|
213
|
+
end
|
|
214
|
+
|
|
124
215
|
def wire(client)
|
|
125
216
|
client.on_subscribed do
|
|
126
217
|
@backoff = 1
|
|
127
218
|
@rejections = 0
|
|
219
|
+
@state&.update(state: "connected", last_connect_at: @clock.call.to_i, error: nil)
|
|
128
220
|
rescan(client)
|
|
129
221
|
@usage_at = nil
|
|
130
222
|
client.perform("hello", "version" => VERSION, "platform" => Diffbroker.platform,
|
|
@@ -271,6 +363,7 @@ module Diffbroker
|
|
|
271
363
|
def start_task(id, message)
|
|
272
364
|
return unless @running_lock.synchronize { !@upgrading && @running.add?(id) } # the upgrade's restart would kill it
|
|
273
365
|
|
|
366
|
+
@state&.update(busy_tasks: @running_lock.synchronize { @running.size })
|
|
274
367
|
repositories = @repositories
|
|
275
368
|
@spawner.call do
|
|
276
369
|
@task_runner.run(message, repositories:)
|
|
@@ -278,6 +371,7 @@ module Diffbroker
|
|
|
278
371
|
@out.puts "Task #{id} crashed: #{e.class}: #{e.message}"
|
|
279
372
|
ensure
|
|
280
373
|
upgrade_now = @running_lock.synchronize { @running.delete(id) && @pending_upgrade && @running.empty? }
|
|
374
|
+
@state&.update(busy_tasks: @running_lock.synchronize { @running.size })
|
|
281
375
|
upgrade if upgrade_now
|
|
282
376
|
sweep_strays
|
|
283
377
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Diffbroker
|
|
6
|
+
# One daemon per settings directory. An exclusive flock held for the daemon's life: the kernel releases it when the
|
|
7
|
+
# process dies, however it dies, so there is never a stale lock to clean up (unlike a pid file).
|
|
8
|
+
class DaemonLock
|
|
9
|
+
FILE = "daemon.lock"
|
|
10
|
+
|
|
11
|
+
def initialize(dir:)
|
|
12
|
+
@dir = dir
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def path = File.join(@dir, FILE)
|
|
16
|
+
|
|
17
|
+
# True when this process now holds the lock.
|
|
18
|
+
def acquire
|
|
19
|
+
FileUtils.mkdir_p(@dir, mode: 0o700)
|
|
20
|
+
@file = File.open(path, File::RDWR | File::CREAT, 0o600)
|
|
21
|
+
return true if @file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
22
|
+
|
|
23
|
+
@file.close
|
|
24
|
+
@file = nil
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def release
|
|
29
|
+
@file&.flock(File::LOCK_UN)
|
|
30
|
+
@file&.close
|
|
31
|
+
@file = nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Diffbroker
|
|
7
|
+
# What the running daemon is doing, as a small JSON file next to config.yml. The daemon writes it on every transition;
|
|
8
|
+
# `diffbroker start` / `status` read it to tell a connected daemon of this version from a stale, dead or failing one.
|
|
9
|
+
# Writes are atomic (temp file + rename), so a reader never sees half a file.
|
|
10
|
+
class DaemonState
|
|
11
|
+
FILE = "daemon.state"
|
|
12
|
+
|
|
13
|
+
def initialize(dir:)
|
|
14
|
+
@dir = dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def path = File.join(@dir, FILE)
|
|
18
|
+
|
|
19
|
+
def read
|
|
20
|
+
data = JSON.parse(File.read(path))
|
|
21
|
+
data.is_a?(Hash) ? data : nil
|
|
22
|
+
rescue SystemCallError, JSON::ParserError
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `error: nil` clears the last error; a Hash sets it. Other keys are merged in as given.
|
|
27
|
+
def update(**changes)
|
|
28
|
+
current = read || {}
|
|
29
|
+
error = changes.key?(:error) ? changes.delete(:error) : current["last_error"]
|
|
30
|
+
data = current.merge(changes.transform_keys(&:to_s))
|
|
31
|
+
error ? data["last_error"] = error.transform_keys(&:to_s) : data.delete("last_error")
|
|
32
|
+
write(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Only the owner removes it, so a late-exiting old daemon never erases a newer daemon's state.
|
|
36
|
+
def clear(pid:)
|
|
37
|
+
File.delete(path) if read&.dig("pid") == pid
|
|
38
|
+
rescue SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def write(data)
|
|
45
|
+
FileUtils.mkdir_p(@dir, mode: 0o700)
|
|
46
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
47
|
+
File.write(temp, JSON.generate(data), perm: 0o600)
|
|
48
|
+
File.rename(temp, path)
|
|
49
|
+
rescue SystemCallError
|
|
50
|
+
nil # the state file is a convenience; a read-only home never stops the daemon
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/diffbroker/lifecycle.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Diffbroker
|
|
|
9
9
|
# restarted through its manager, otherwise it would resurrect a stopped daemon (KeepAlive / Restart=always).
|
|
10
10
|
class Lifecycle
|
|
11
11
|
PID_FILE = "daemon.pid"
|
|
12
|
+
# `diffbroker start` / `launch` as a process: a recycled pid that is `tail -f ~/.config/diffbroker/companion.log` is not it.
|
|
13
|
+
DAEMON_COMMAND = %r{(?:\A|[/\s])diffbroker\s+(?:start|launch)\b}
|
|
12
14
|
WAIT = 10
|
|
13
15
|
|
|
14
16
|
def initialize(dir:, home: Dir.home, service: ServiceInstaller.new(home:), runner: Runner.new, exe: ServiceInstaller.launcher,
|
|
@@ -87,11 +89,14 @@ module Diffbroker
|
|
|
87
89
|
return nil if pid <= 1 || pid == @pid
|
|
88
90
|
|
|
89
91
|
Process.kill(0, pid)
|
|
90
|
-
@runner.capture("ps", "-p", pid.to_s, "-o", "command=").
|
|
92
|
+
@runner.capture("ps", "-p", pid.to_s, "-o", "command=").match?(DAEMON_COMMAND) ? pid : nil
|
|
91
93
|
rescue SystemCallError
|
|
92
94
|
nil
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
# Ends a daemon started outside the service manager (and waits for it to go).
|
|
98
|
+
def terminate_daemon = terminate_running
|
|
99
|
+
|
|
95
100
|
private
|
|
96
101
|
|
|
97
102
|
def terminate_running
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diffbroker
|
|
4
|
+
# Brings this machine to "the newest companion is running and connected", whatever it looks like now: an older daemon
|
|
5
|
+
# still running after `gem install`, a service definition that names a deleted launcher or another Ruby, a daemon
|
|
6
|
+
# started by hand next to the service, nothing running at all. Safe to run any number of times: a machine that is
|
|
7
|
+
# already right is left untouched. `diffbroker start`, `launch`, `restart` and `upgrade` all end here.
|
|
8
|
+
class Reconciler
|
|
9
|
+
INTERVAL = 1
|
|
10
|
+
|
|
11
|
+
# `attempts` x INTERVAL seconds are given to the daemon to report "connected" before it counts as a failure.
|
|
12
|
+
def initialize(service:, lifecycle:, state:, out:, server:, sleeper: ->(seconds) { sleep(seconds) }, alive: method(:process_alive?),
|
|
13
|
+
attempts: 20, version: VERSION, on_refresh: nil)
|
|
14
|
+
@service = service
|
|
15
|
+
@lifecycle = lifecycle
|
|
16
|
+
@state = state
|
|
17
|
+
@out = out
|
|
18
|
+
@server = server
|
|
19
|
+
@sleeper = sleeper
|
|
20
|
+
@alive = alive
|
|
21
|
+
@attempts = attempts
|
|
22
|
+
@version = version
|
|
23
|
+
@on_refresh = on_refresh
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when the daemon is (or, with verify: false, was asked to be) running the current version. `force` restarts a
|
|
27
|
+
# current daemon too (`diffbroker restart`, the end of an upgrade).
|
|
28
|
+
def call(force: false, verify: true)
|
|
29
|
+
current = running_daemon
|
|
30
|
+
if current && busy?(current) && (force || stale?(current))
|
|
31
|
+
@out.puts "The companion is running a task; leaving it until the task ends. Run this again then."
|
|
32
|
+
return true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
act(current, force:)
|
|
36
|
+
verify ? wait_until_connected : true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def act(current, force:)
|
|
42
|
+
if @service.installed? && @service.stale?
|
|
43
|
+
# launchd keeps the definition it loaded, so the new one only counts after the job is loaded again; that ends the
|
|
44
|
+
# daemon it was running and starts the new one.
|
|
45
|
+
@lifecycle.terminate_daemon if current && !current["service_managed"]
|
|
46
|
+
@service.refresh!
|
|
47
|
+
@on_refresh&.call
|
|
48
|
+
@out.puts "Updated the background service so it runs this version."
|
|
49
|
+
elsif current && (force || stale?(current))
|
|
50
|
+
restart(current)
|
|
51
|
+
elsif !current
|
|
52
|
+
@lifecycle.launch
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Under a service the manager restarts the daemon (it loads the newest gem); a daemon started by hand is ended first,
|
|
57
|
+
# because the manager does not know it and would leave it running next to the new one.
|
|
58
|
+
def restart(current)
|
|
59
|
+
return @lifecycle.restart unless @service.installed?
|
|
60
|
+
|
|
61
|
+
@lifecycle.terminate_daemon unless current["service_managed"]
|
|
62
|
+
done = current["service_managed"] ? @service.restart! : @service.start!
|
|
63
|
+
@lifecycle.spawn_daemon unless done
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Not the current version, aimed at another server, left by a rejection, or too old to have a state file.
|
|
67
|
+
def stale?(current)
|
|
68
|
+
current["legacy"] || current["version"] != @version || current["server"] != @server || current["state"] == "rejected"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def busy?(current) = current["busy_tasks"].to_i.positive?
|
|
72
|
+
|
|
73
|
+
# The live daemon as its state file describes it; a pid file without a state file is a daemon from before this
|
|
74
|
+
# release, which has to be restarted once to start writing one.
|
|
75
|
+
def running_daemon
|
|
76
|
+
data = @state.read
|
|
77
|
+
return data if data && data["pid"] && @alive.call(data["pid"])
|
|
78
|
+
|
|
79
|
+
pid = @lifecycle.running_pid
|
|
80
|
+
pid ? { "pid" => pid, "legacy" => true } : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verified?
|
|
84
|
+
data = @state.read
|
|
85
|
+
connected = data && data["state"] == "connected" && data["version"] == @version && data["pid"] && @alive.call(data["pid"])
|
|
86
|
+
@out.puts "Connected (companion #{@version}, pid #{data["pid"]})." if connected
|
|
87
|
+
connected
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def wait_until_connected
|
|
91
|
+
@attempts.times do
|
|
92
|
+
return true if verified?
|
|
93
|
+
|
|
94
|
+
@sleeper.call(INTERVAL)
|
|
95
|
+
end
|
|
96
|
+
return true if verified?
|
|
97
|
+
|
|
98
|
+
report_failure
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def report_failure
|
|
103
|
+
data = @state.read || {}
|
|
104
|
+
error = data["last_error"]
|
|
105
|
+
@out.puts "The companion did not connect within #{@attempts * INTERVAL} seconds."
|
|
106
|
+
@out.puts "Last problem: #{error["message"]} (#{error["kind"]})" if error
|
|
107
|
+
@out.puts "State: #{data["state"] || "no daemon is running"}#{" (version #{data["version"]}, wanted #{@version})" if data["version"] && data["version"] != @version}"
|
|
108
|
+
@out.puts "Log: #{@lifecycle.log_path}"
|
|
109
|
+
@out.puts "It keeps trying in the background; `diffbroker status` shows what it sees."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def process_alive?(pid)
|
|
113
|
+
Process.kill(0, pid.to_i)
|
|
114
|
+
true
|
|
115
|
+
rescue Errno::EPERM
|
|
116
|
+
true
|
|
117
|
+
rescue SystemCallError
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -7,6 +7,11 @@ module Diffbroker
|
|
|
7
7
|
# Runs `diffbroker start` in the background as the logged-in user (launchd on macOS, systemd --user on Linux).
|
|
8
8
|
class ServiceInstaller
|
|
9
9
|
LABEL = "broker.diff.companion"
|
|
10
|
+
LEGACY_LABEL = "broker.git.companion"
|
|
11
|
+
# The service manager refused (no GUI session over ssh, no systemd user bus): the caller falls back to a daemon of its own.
|
|
12
|
+
class Unavailable < Error; end
|
|
13
|
+
|
|
14
|
+
SETTLE_ATTEMPTS = 20
|
|
10
15
|
EXE = File.join(Diffbroker::ROOT, "exe", "diffbroker")
|
|
11
16
|
|
|
12
17
|
# What the service and the "Start companion" app run. RubyGems' own wrapper activates the newest installed diffbroker
|
|
@@ -27,7 +32,8 @@ module Diffbroker
|
|
|
27
32
|
def self.pinned?(launcher = self.launcher) = launcher == EXE
|
|
28
33
|
|
|
29
34
|
def initialize(platform: Diffbroker.platform, home: Dir.home, ruby: RbConfig.ruby, exe: ServiceInstaller.launcher, env: ENV, runner: Runner.new,
|
|
30
|
-
uid: Process.uid)
|
|
35
|
+
uid: Process.uid, sleeper: ->(seconds) { sleep(seconds) })
|
|
36
|
+
@sleeper = sleeper
|
|
31
37
|
@platform = platform
|
|
32
38
|
@home = home
|
|
33
39
|
@ruby = ruby
|
|
@@ -39,7 +45,63 @@ module Diffbroker
|
|
|
39
45
|
|
|
40
46
|
def install! = @platform == "macos" ? install_launchd! : install_systemd!
|
|
41
47
|
|
|
42
|
-
def installed? = File.exist?(
|
|
48
|
+
def installed? = File.exist?(definition_path)
|
|
49
|
+
|
|
50
|
+
# True when the installed definition would not start what this process is: another Ruby or launcher, a launcher or Ruby
|
|
51
|
+
# that no longer exists (`gem cleanup` removed the version a pinned definition names), or a definition from before
|
|
52
|
+
# the duplicate-safe restart policy. PATH is left out: it differs between a terminal and the service, so comparing it
|
|
53
|
+
# would call every definition stale.
|
|
54
|
+
def stale?
|
|
55
|
+
return false unless installed?
|
|
56
|
+
|
|
57
|
+
text = File.read(definition_path)
|
|
58
|
+
!text.include?(program_arguments) || !text.include?(restart_policy_marker) || !File.exist?(@ruby) || !File.exist?(@exe)
|
|
59
|
+
rescue SystemCallError
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The companion was called gitbroker before the rename. Its service points at a domain that no longer answers and
|
|
64
|
+
# restarts for ever, so it goes. Returns true when one was there.
|
|
65
|
+
def remove_legacy!
|
|
66
|
+
if @platform == "macos"
|
|
67
|
+
path = File.join(@home, "Library", "LaunchAgents", "#{LEGACY_LABEL}.plist")
|
|
68
|
+
return false unless File.exist?(path)
|
|
69
|
+
|
|
70
|
+
File.delete(path)
|
|
71
|
+
@runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LEGACY_LABEL}")
|
|
72
|
+
else
|
|
73
|
+
path = File.join(@home, ".config", "systemd", "user", "gitbroker.service")
|
|
74
|
+
return false unless File.exist?(path)
|
|
75
|
+
|
|
76
|
+
@runner.success?("systemctl", "--user", "disable", "--now", "gitbroker.service")
|
|
77
|
+
File.delete(path)
|
|
78
|
+
@runner.success?("systemctl", "--user", "daemon-reload")
|
|
79
|
+
end
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Writes the current definition without touching the manager (the running job keeps what it loaded).
|
|
84
|
+
def rewrite!
|
|
85
|
+
path = definition_path
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
87
|
+
FileUtils.mkdir_p(File.join(@home, "Library", "Logs")) if @platform == "macos"
|
|
88
|
+
File.write(path, @platform == "macos" ? plist : unit)
|
|
89
|
+
path
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Writes the current definition and makes the manager use it. launchd keeps the plist it loaded, so the job is booted
|
|
93
|
+
# out and loaded again (this ends a running daemon); systemd re-reads its unit and the next restart uses it.
|
|
94
|
+
def refresh!
|
|
95
|
+
path = rewrite!
|
|
96
|
+
if @platform == "macos"
|
|
97
|
+
reload_launchd!(path)
|
|
98
|
+
else
|
|
99
|
+
@runner.run!("systemctl", "--user", "daemon-reload")
|
|
100
|
+
end
|
|
101
|
+
path
|
|
102
|
+
rescue Error => e
|
|
103
|
+
raise Unavailable, e.message
|
|
104
|
+
end
|
|
43
105
|
|
|
44
106
|
# Starts the installed service if it is not running (a running one is left alone). False when there is no
|
|
45
107
|
# service or the manager refused, so the caller can start the daemon itself.
|
|
@@ -117,7 +179,12 @@ module Diffbroker
|
|
|
117
179
|
<key>RunAtLoad</key>
|
|
118
180
|
<true/>
|
|
119
181
|
<key>KeepAlive</key>
|
|
120
|
-
<
|
|
182
|
+
<dict>
|
|
183
|
+
<key>SuccessfulExit</key>
|
|
184
|
+
<false/>
|
|
185
|
+
</dict>
|
|
186
|
+
<key>ThrottleInterval</key>
|
|
187
|
+
<integer>30</integer>
|
|
121
188
|
<key>StandardOutPath</key>
|
|
122
189
|
<string>#{log}</string>
|
|
123
190
|
<key>StandardErrorPath</key>
|
|
@@ -132,10 +199,11 @@ module Diffbroker
|
|
|
132
199
|
[Unit]
|
|
133
200
|
Description=Diff Broker companion (runs your own agents on your pull requests)
|
|
134
201
|
After=network-online.target
|
|
202
|
+
StartLimitIntervalSec=0
|
|
135
203
|
|
|
136
204
|
[Service]
|
|
137
205
|
ExecStart="#{@ruby}" "#{@exe}" start
|
|
138
|
-
Restart=
|
|
206
|
+
Restart=on-failure
|
|
139
207
|
RestartSec=5
|
|
140
208
|
Environment="PATH=#{@env["PATH"]}"
|
|
141
209
|
Environment="SHELL=#{@env["SHELL"] || "/bin/bash"}"
|
|
@@ -158,22 +226,40 @@ module Diffbroker
|
|
|
158
226
|
def systemd_path = File.join(@home, ".config", "systemd", "user", "diffbroker.service")
|
|
159
227
|
|
|
160
228
|
def install_launchd!
|
|
161
|
-
path =
|
|
162
|
-
|
|
163
|
-
FileUtils.mkdir_p(File.join(@home, "Library", "Logs"))
|
|
164
|
-
File.write(path, plist)
|
|
165
|
-
@runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LABEL}") # not loaded yet is fine
|
|
166
|
-
@runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
|
|
229
|
+
path = rewrite!
|
|
230
|
+
reload_launchd!(path)
|
|
167
231
|
path
|
|
232
|
+
rescue Error => e
|
|
233
|
+
raise Unavailable, e.message
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# bootout answers before the job is gone, and bootstrap onto a job that is still there fails ("Bootstrap failed: 5"),
|
|
237
|
+
# so wait until launchd no longer knows the label.
|
|
238
|
+
def reload_launchd!(path)
|
|
239
|
+
@runner.success?("launchctl", "bootout", target) # not loaded yet is fine
|
|
240
|
+
SETTLE_ATTEMPTS.times do
|
|
241
|
+
break unless @runner.success?("launchctl", "print", target)
|
|
242
|
+
|
|
243
|
+
@sleeper.call(0.25)
|
|
244
|
+
end
|
|
245
|
+
@runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
|
|
168
246
|
end
|
|
169
247
|
|
|
170
248
|
def install_systemd!
|
|
171
|
-
path =
|
|
172
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
173
|
-
File.write(path, unit)
|
|
249
|
+
path = rewrite!
|
|
174
250
|
@runner.run!("systemctl", "--user", "daemon-reload")
|
|
175
251
|
@runner.run!("systemctl", "--user", "enable", "--now", "diffbroker.service")
|
|
176
252
|
path
|
|
253
|
+
rescue Error => e
|
|
254
|
+
raise Unavailable, e.message
|
|
177
255
|
end
|
|
256
|
+
|
|
257
|
+
def definition_path = @platform == "macos" ? launchd_path : systemd_path
|
|
258
|
+
|
|
259
|
+
def program_arguments
|
|
260
|
+
@platform == "macos" ? [ @ruby, @exe, "start" ].map { " <string>#{xml(_1)}</string>" }.join("\n") : %(ExecStart="#{@ruby}" "#{@exe}" start)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def restart_policy_marker = @platform == "macos" ? "<key>SuccessfulExit</key>" : "Restart=on-failure"
|
|
178
264
|
end
|
|
179
265
|
end
|
data/lib/diffbroker/version.rb
CHANGED
data/lib/diffbroker.rb
CHANGED
|
@@ -21,6 +21,8 @@ module Diffbroker
|
|
|
21
21
|
autoload :CLI, File.expand_path("diffbroker/cli", __dir__)
|
|
22
22
|
autoload :Config, File.expand_path("diffbroker/config", __dir__)
|
|
23
23
|
autoload :Daemon, File.expand_path("diffbroker/daemon", __dir__)
|
|
24
|
+
autoload :DaemonLock, File.expand_path("diffbroker/daemon_lock", __dir__)
|
|
25
|
+
autoload :DaemonState, File.expand_path("diffbroker/daemon_state", __dir__)
|
|
24
26
|
autoload :ExplainVerifier, File.expand_path("diffbroker/explain_verifier", __dir__)
|
|
25
27
|
autoload :GhCheck, File.expand_path("diffbroker/gh_check", __dir__)
|
|
26
28
|
autoload :GitContext, File.expand_path("diffbroker/git_context", __dir__)
|
|
@@ -35,6 +37,7 @@ module Diffbroker
|
|
|
35
37
|
autoload :PlanUsage, File.expand_path("diffbroker/plan_usage", __dir__)
|
|
36
38
|
autoload :ProcessScanner, File.expand_path("diffbroker/process_scanner", __dir__)
|
|
37
39
|
autoload :Reaper, File.expand_path("diffbroker/reaper", __dir__)
|
|
40
|
+
autoload :Reconciler, File.expand_path("diffbroker/reconciler", __dir__)
|
|
38
41
|
autoload :RepoScanner, File.expand_path("diffbroker/repo_scanner", __dir__)
|
|
39
42
|
autoload :RunScript, File.expand_path("diffbroker/run_script", __dir__)
|
|
40
43
|
autoload :Runner, File.expand_path("diffbroker/runner", __dir__)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: diffbroker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuri Sidorov (@newstler)
|
|
@@ -45,6 +45,8 @@ files:
|
|
|
45
45
|
- lib/diffbroker/cli.rb
|
|
46
46
|
- lib/diffbroker/config.rb
|
|
47
47
|
- lib/diffbroker/daemon.rb
|
|
48
|
+
- lib/diffbroker/daemon_lock.rb
|
|
49
|
+
- lib/diffbroker/daemon_state.rb
|
|
48
50
|
- lib/diffbroker/explain_verifier.rb
|
|
49
51
|
- lib/diffbroker/gh_check.rb
|
|
50
52
|
- lib/diffbroker/git_context.rb
|
|
@@ -59,6 +61,7 @@ files:
|
|
|
59
61
|
- lib/diffbroker/plan_usage.rb
|
|
60
62
|
- lib/diffbroker/process_scanner.rb
|
|
61
63
|
- lib/diffbroker/reaper.rb
|
|
64
|
+
- lib/diffbroker/reconciler.rb
|
|
62
65
|
- lib/diffbroker/repo_scanner.rb
|
|
63
66
|
- lib/diffbroker/run_script.rb
|
|
64
67
|
- lib/diffbroker/runner.rb
|