siding 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLAUDE.md +248 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +51 -0
- data/exe/siding +5 -0
- data/lib/siding/boot_component.rb +18 -0
- data/lib/siding/cli.rb +459 -0
- data/lib/siding/client.rb +417 -0
- data/lib/siding/error.rb +5 -0
- data/lib/siding/invocation.rb +35 -0
- data/lib/siding/life_cycle.rb +114 -0
- data/lib/siding/load_manifest.rb +475 -0
- data/lib/siding/logger.rb +89 -0
- data/lib/siding/platform.rb +37 -0
- data/lib/siding/project_key.rb +64 -0
- data/lib/siding/protocol.rb +154 -0
- data/lib/siding/restarter.rb +205 -0
- data/lib/siding/runtime.rb +165 -0
- data/lib/siding/server.rb +398 -0
- data/lib/siding/staleness.rb +191 -0
- data/lib/siding/version.rb +5 -0
- data/lib/siding/watch.rb +115 -0
- data/lib/siding/worker.rb +266 -0
- data/lib/siding.rb +27 -0
- metadata +97 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
require_relative "error"
|
|
9
|
+
require_relative "version"
|
|
10
|
+
require_relative "project_key"
|
|
11
|
+
require_relative "runtime"
|
|
12
|
+
require_relative "logger"
|
|
13
|
+
require_relative "protocol"
|
|
14
|
+
require_relative "life_cycle"
|
|
15
|
+
require_relative "load_manifest"
|
|
16
|
+
require_relative "staleness"
|
|
17
|
+
require_relative "restarter"
|
|
18
|
+
require_relative "worker"
|
|
19
|
+
|
|
20
|
+
module Siding
|
|
21
|
+
class Server
|
|
22
|
+
BOOTSTRAP = <<~RUBY
|
|
23
|
+
begin
|
|
24
|
+
Process.setsid
|
|
25
|
+
rescue SystemCallError
|
|
26
|
+
begin
|
|
27
|
+
Process.setpgrp
|
|
28
|
+
rescue SystemCallError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
require ARGV.shift
|
|
33
|
+
Siding::Server.start(app_root: ARGV.shift, digest: ARGV.shift)
|
|
34
|
+
RUBY
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def spawn(project_key:, runtime:, env:)
|
|
38
|
+
log = File.open(runtime.boot_log_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600)
|
|
39
|
+
begin
|
|
40
|
+
Process.spawn(
|
|
41
|
+
spawn_env(project_key, env),
|
|
42
|
+
RbConfig.ruby, "-e", BOOTSTRAP, "--",
|
|
43
|
+
__FILE__, project_key.app_root, project_key.digest,
|
|
44
|
+
chdir: project_key.app_root,
|
|
45
|
+
in: File::NULL, out: log, err: log
|
|
46
|
+
)
|
|
47
|
+
ensure
|
|
48
|
+
log.close
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def spawn_env(project_key, env)
|
|
53
|
+
vars = { "SIDING_SERVER" => "1" }
|
|
54
|
+
gemfile = File.join(project_key.app_root, "Gemfile")
|
|
55
|
+
vars["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
|
|
56
|
+
vars["RAILS_ENV"] = project_key.app_env
|
|
57
|
+
vars["RACK_ENV"] = project_key.app_env
|
|
58
|
+
vars
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start(app_root:, digest:)
|
|
62
|
+
key = ProjectKey.for(app_root)
|
|
63
|
+
if key.digest != digest
|
|
64
|
+
warn("siding: project key mismatch (client #{digest}, server #{key.digest})")
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
new(project_key: key, runtime: Runtime.for(key)).run
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
DRAIN_INTERVAL = 0.05
|
|
73
|
+
SELECT_INTERVAL = 1.0
|
|
74
|
+
DEFAULT_IDLE_TIMEOUT = 900.0
|
|
75
|
+
|
|
76
|
+
attr_reader :project_key, :runtime, :logger, :manifest, :events, :restarter
|
|
77
|
+
|
|
78
|
+
def initialize(project_key:, runtime:, logger: nil, env: ENV)
|
|
79
|
+
@project_key = project_key
|
|
80
|
+
@runtime = runtime
|
|
81
|
+
@logger = logger || Logger.new(log_path: runtime.log_path)
|
|
82
|
+
@env = env
|
|
83
|
+
@events = Staleness::Events.new(path: runtime.events_path)
|
|
84
|
+
@workers = {}
|
|
85
|
+
@workers_mutex = Mutex.new
|
|
86
|
+
@shutting_down = false
|
|
87
|
+
@withdrawn = false
|
|
88
|
+
@served = 0
|
|
89
|
+
@idle_timeout = self.class.idle_timeout_from(env)
|
|
90
|
+
touch
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.idle_timeout_from(env)
|
|
94
|
+
timeout = env["SIDING_IDLE_TIMEOUT"].to_f
|
|
95
|
+
timeout.positive? ? timeout : DEFAULT_IDLE_TIMEOUT
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run
|
|
99
|
+
boot_application
|
|
100
|
+
listen
|
|
101
|
+
publish
|
|
102
|
+
install_signal_handlers
|
|
103
|
+
start_restarter
|
|
104
|
+
accept_loop
|
|
105
|
+
ensure
|
|
106
|
+
drain_workers
|
|
107
|
+
shutdown
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def start_restarter
|
|
111
|
+
@restarter = Restarter.new(manifest: manifest, project_key: project_key, runtime: runtime,
|
|
112
|
+
logger: logger,
|
|
113
|
+
busy: -> { busy? },
|
|
114
|
+
superseded: -> { !published_by_us? },
|
|
115
|
+
on_superseded: -> { retire })
|
|
116
|
+
@restarter.start
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def busy?
|
|
120
|
+
@shutting_down || @workers_mutex.synchronize { !@workers.empty? }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def retire
|
|
124
|
+
logger.debug("standing down; a replacement has taken over #{project_key.label}")
|
|
125
|
+
@shutting_down = true
|
|
126
|
+
wake
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def boot_application
|
|
132
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
133
|
+
@manifest = LoadManifest.around_boot(app_root: project_key.app_root) do
|
|
134
|
+
require "bundler/setup" if File.file?(File.join(project_key.app_root, "Gemfile"))
|
|
135
|
+
require File.join(project_key.app_root, "config", "environment")
|
|
136
|
+
end
|
|
137
|
+
@boot_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
138
|
+
@booted_at = Time.now.to_f
|
|
139
|
+
touch
|
|
140
|
+
logger.debug("booted #{manifest.revision_label} in #{(@boot_seconds * 1000).round}ms, watching #{manifest.file_entries.size} files")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def listen
|
|
144
|
+
staging = "#{runtime.socket_path}.#{Process.pid}"
|
|
145
|
+
File.unlink(staging) if File.exist?(staging)
|
|
146
|
+
@server_socket = UNIXServer.new(staging)
|
|
147
|
+
File.chmod(0o600, staging)
|
|
148
|
+
File.rename(staging, runtime.socket_path)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def publish
|
|
152
|
+
payload = {
|
|
153
|
+
"pid" => Process.pid,
|
|
154
|
+
"protocol_version" => Protocol::VERSION,
|
|
155
|
+
"tool_version" => Siding::VERSION,
|
|
156
|
+
"app_root" => project_key.app_root,
|
|
157
|
+
"app_env" => project_key.app_env,
|
|
158
|
+
"booted_at" => @booted_at,
|
|
159
|
+
"boot_seconds" => @boot_seconds&.round(3),
|
|
160
|
+
"revision_label" => manifest.revision_label
|
|
161
|
+
}
|
|
162
|
+
staging = "#{runtime.server_info_path}.#{Process.pid}"
|
|
163
|
+
File.write(staging, JSON.generate(payload))
|
|
164
|
+
File.chmod(0o600, staging)
|
|
165
|
+
File.rename(staging, runtime.server_info_path)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def install_signal_handlers
|
|
169
|
+
@wake_read, @wake_write = IO.pipe
|
|
170
|
+
LifeCycle::SERVER_SIGNALS.each do |signal|
|
|
171
|
+
previous = trap(signal) do
|
|
172
|
+
@forced = true if @shutting_down
|
|
173
|
+
@shutting_down = true
|
|
174
|
+
wake
|
|
175
|
+
end
|
|
176
|
+
LifeCycle.remember_signal_handler(signal, previous)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def wake
|
|
181
|
+
@wake_write.write_nonblock(".")
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def accept_loop
|
|
187
|
+
until @shutting_down
|
|
188
|
+
ready = IO.select([@server_socket, @wake_read], nil, nil, SELECT_INTERVAL)
|
|
189
|
+
if ready.nil?
|
|
190
|
+
expire_if_idle
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
break if ready[0].include?(@wake_read)
|
|
194
|
+
|
|
195
|
+
connection = accept
|
|
196
|
+
next if connection.nil?
|
|
197
|
+
|
|
198
|
+
handle(connection)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def expire_if_idle
|
|
203
|
+
return if busy?
|
|
204
|
+
return if idle_seconds < idle_timeout
|
|
205
|
+
|
|
206
|
+
logger.debug("idle for #{idle_seconds.round}s; leaving")
|
|
207
|
+
@shutting_down = true
|
|
208
|
+
unpublish
|
|
209
|
+
close_quietly(@server_socket)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def idle_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_activity
|
|
213
|
+
def idle_timeout = @idle_timeout
|
|
214
|
+
|
|
215
|
+
def touch
|
|
216
|
+
@last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
217
|
+
@last_activity_at = Time.now
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def accept
|
|
221
|
+
@server_socket.accept_nonblock
|
|
222
|
+
rescue IO::WaitReadable, Errno::EINTR
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def handle(connection)
|
|
227
|
+
return connection.close unless Protocol.server_handshake(connection)
|
|
228
|
+
|
|
229
|
+
message = Protocol.read_message(connection)
|
|
230
|
+
return connection.close if message.nil?
|
|
231
|
+
|
|
232
|
+
case message.type
|
|
233
|
+
when Protocol::RUN then dispatch(connection, message)
|
|
234
|
+
when Protocol::STATUS then respond_status(connection)
|
|
235
|
+
when Protocol::STOP then stop(connection)
|
|
236
|
+
else connection.close
|
|
237
|
+
end
|
|
238
|
+
rescue Protocol::ProtocolError => e
|
|
239
|
+
logger.debug("dropping connection: #{e.message}")
|
|
240
|
+
close_quietly(connection)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def dispatch(connection, message)
|
|
244
|
+
restarter&.busy!
|
|
245
|
+
touch
|
|
246
|
+
@served += 1
|
|
247
|
+
verdict = Staleness.validate(manifest, env: invocation_env(message))
|
|
248
|
+
return supersede(connection, verdict) if verdict.reboot?
|
|
249
|
+
|
|
250
|
+
serve(connection, message, verdict)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def invocation_env(message)
|
|
254
|
+
env = message["env"]
|
|
255
|
+
env.is_a?(Hash) ? env : ENV
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def serve(connection, message, verdict)
|
|
259
|
+
events.record(verdict)
|
|
260
|
+
|
|
261
|
+
pid = quiesced do
|
|
262
|
+
LifeCycle.prepare_for_fork
|
|
263
|
+
|
|
264
|
+
fork do
|
|
265
|
+
@server_socket.close
|
|
266
|
+
@wake_read.close
|
|
267
|
+
@wake_write.close
|
|
268
|
+
Worker.new(connection: connection, message: message, project_key: project_key, manifest: manifest, verdict: verdict, boot_seconds: @boot_seconds).run
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
Protocol.write_message(connection, Protocol::STARTED, pid: pid)
|
|
273
|
+
reap(pid, connection)
|
|
274
|
+
rescue LifeCycle::HookError => e
|
|
275
|
+
Protocol.write_message(connection, Protocol::BOOT_FAILED, output: "#{e.message}\n")
|
|
276
|
+
close_quietly(connection)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def supersede(connection, verdict)
|
|
280
|
+
logger.debug("superseded: #{verdict.summary} (#{verdict.reasons.join(', ')})")
|
|
281
|
+
events.record(verdict, resolution: "rebuild")
|
|
282
|
+
@shutting_down = true
|
|
283
|
+
unpublish
|
|
284
|
+
|
|
285
|
+
Protocol.write_message(connection, Protocol::BOOTING,
|
|
286
|
+
reason: verdict.summary,
|
|
287
|
+
restart: true,
|
|
288
|
+
replacement_pid: restarter&.replacement_pid,
|
|
289
|
+
estimated_seconds: estimated_boot_seconds)
|
|
290
|
+
close_quietly(connection)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def quiesced(&block)
|
|
294
|
+
return yield if restarter.nil?
|
|
295
|
+
|
|
296
|
+
restarter.around_fork(&block)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def estimated_boot_seconds = @boot_seconds&.round(1)
|
|
300
|
+
|
|
301
|
+
def reap(pid, connection)
|
|
302
|
+
@workers_mutex.synchronize { @workers[pid] = connection }
|
|
303
|
+
|
|
304
|
+
Thread.new do
|
|
305
|
+
_, status = Process.waitpid2(pid)
|
|
306
|
+
Protocol.write_message(connection, Protocol::FINISHED,
|
|
307
|
+
exit_code: status.exitstatus || 0, signal: status.termsig)
|
|
308
|
+
rescue StandardError => e
|
|
309
|
+
logger.debug("lost track of worker #{pid}: #{e.message}")
|
|
310
|
+
ensure
|
|
311
|
+
@workers_mutex.synchronize { @workers.delete(pid) }
|
|
312
|
+
# The idle clock starts when the last worker finishes, not when it started: a two-hour test
|
|
313
|
+
# run is not two hours of idleness.
|
|
314
|
+
touch
|
|
315
|
+
restarter&.idle!
|
|
316
|
+
close_quietly(connection)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def respond_status(connection)
|
|
321
|
+
touch
|
|
322
|
+
Protocol.write_message(connection, Protocol::STATUS_REPORT,
|
|
323
|
+
pid: Process.pid,
|
|
324
|
+
workers: @workers_mutex.synchronize { @workers.keys },
|
|
325
|
+
tool_version: Siding::VERSION,
|
|
326
|
+
revision_label: manifest&.revision_label,
|
|
327
|
+
booted_at: @booted_at,
|
|
328
|
+
boot_seconds: @boot_seconds&.round(3),
|
|
329
|
+
served: @served,
|
|
330
|
+
last_activity_at: @last_activity_at.to_f,
|
|
331
|
+
idle_timeout: idle_timeout,
|
|
332
|
+
watch: restarter&.watch_mode,
|
|
333
|
+
events: notable_events)
|
|
334
|
+
close_quietly(connection)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def notable_events
|
|
338
|
+
events.to_a.reject { |event| event.resolution == "fresh" }.last(10).map(&:to_h)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def stop(connection)
|
|
342
|
+
@shutting_down = true
|
|
343
|
+
unpublish
|
|
344
|
+
terminate_workers
|
|
345
|
+
Protocol.write_message(connection, Protocol::FINISHED, exit_code: 0, signal: nil)
|
|
346
|
+
close_quietly(connection)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def terminate_workers
|
|
350
|
+
@workers_mutex.synchronize { @workers.keys }.each do |pid|
|
|
351
|
+
Process.kill("TERM", -pid)
|
|
352
|
+
rescue SystemCallError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The `draining` state, which is where every departure passes through. Workers outlive
|
|
358
|
+
# the accept loop.
|
|
359
|
+
def drain_workers
|
|
360
|
+
sleep(DRAIN_INTERVAL) until @forced || @workers_mutex.synchronize { @workers.empty? }
|
|
361
|
+
rescue StandardError
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def shutdown
|
|
366
|
+
restarter&.stop
|
|
367
|
+
@server_socket&.close
|
|
368
|
+
unpublish
|
|
369
|
+
logger.close
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def unpublish
|
|
373
|
+
return if @withdrawn
|
|
374
|
+
|
|
375
|
+
@withdrawn = true
|
|
376
|
+
return unless published_by_us?
|
|
377
|
+
|
|
378
|
+
[runtime.socket_path, runtime.server_info_path].each do |path|
|
|
379
|
+
File.unlink(path)
|
|
380
|
+
rescue SystemCallError
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def published_by_us?
|
|
386
|
+
pid = JSON.parse(File.read(runtime.server_info_path))["pid"]
|
|
387
|
+
pid.nil? || pid == Process.pid
|
|
388
|
+
rescue StandardError
|
|
389
|
+
true
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def close_quietly(io)
|
|
393
|
+
io.close unless io.nil? || io.closed?
|
|
394
|
+
rescue IOError
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "load_manifest"
|
|
7
|
+
|
|
8
|
+
module Siding
|
|
9
|
+
module Staleness
|
|
10
|
+
FRESH = :fresh
|
|
11
|
+
RELOADABLE = :reloadable
|
|
12
|
+
REBOOT = :reboot
|
|
13
|
+
|
|
14
|
+
DEPENDENCIES_CHANGED = :dependencies_changed
|
|
15
|
+
SOURCE_CHANGED = :source_changed
|
|
16
|
+
SOURCE_ADDED_OR_REMOVED = :source_added_or_removed
|
|
17
|
+
ENVIRONMENT_CHANGED = :environment_changed
|
|
18
|
+
|
|
19
|
+
MAX_TRIGGER_PATHS = 25
|
|
20
|
+
|
|
21
|
+
Verdict = Struct.new(:strategy, :reasons, :trigger_paths, :revision_label, keyword_init: true) do
|
|
22
|
+
def fresh? = strategy == FRESH
|
|
23
|
+
def stale? = !fresh?
|
|
24
|
+
def reloadable? = strategy == RELOADABLE
|
|
25
|
+
def reboot? = strategy == REBOOT
|
|
26
|
+
|
|
27
|
+
def resolution
|
|
28
|
+
case strategy
|
|
29
|
+
when FRESH then "fresh"
|
|
30
|
+
when RELOADABLE then "reloaded_in_worker"
|
|
31
|
+
else "rebuild"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def summary
|
|
36
|
+
return "up to date" if fresh?
|
|
37
|
+
|
|
38
|
+
detail = trigger_paths.first
|
|
39
|
+
reason = reasons.first
|
|
40
|
+
detail ? "#{reason}: #{File.basename(detail)}" : reason.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def validate(manifest, env: ENV)
|
|
47
|
+
reasons = []
|
|
48
|
+
triggers = []
|
|
49
|
+
changed_scopes = []
|
|
50
|
+
|
|
51
|
+
bundle_token = LoadManifest.token_for(manifest.bundle_files)
|
|
52
|
+
if bundle_token != manifest.bundle_token
|
|
53
|
+
reasons << DEPENDENCIES_CHANGED
|
|
54
|
+
triggers.concat(manifest.bundle_files)
|
|
55
|
+
changed_scopes << LoadManifest::REBOOT
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
files = manifest.file_entries.map do |entry|
|
|
59
|
+
stamp = LoadManifest.stamp_for(entry.path)
|
|
60
|
+
unless stamp == stamp_of(entry)
|
|
61
|
+
reasons << SOURCE_CHANGED
|
|
62
|
+
triggers << entry.path
|
|
63
|
+
changed_scopes << entry.scope
|
|
64
|
+
end
|
|
65
|
+
[entry.path, stamp]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
directories = manifest.directory_entries.map do |entry|
|
|
69
|
+
digest = LoadManifest.entry_digest_for(entry.path, recursive: entry.recursive)
|
|
70
|
+
unless digest == entry.entry_digest
|
|
71
|
+
reasons << SOURCE_ADDED_OR_REMOVED
|
|
72
|
+
triggers << entry.path
|
|
73
|
+
changed_scopes << entry.scope
|
|
74
|
+
end
|
|
75
|
+
[entry.path, digest]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
envs = manifest.env_entries.map do |entry|
|
|
79
|
+
digest = LoadManifest.digest_env_value(env[entry.key])
|
|
80
|
+
unless digest == entry.value_digest
|
|
81
|
+
reasons << ENVIRONMENT_CHANGED
|
|
82
|
+
triggers << entry.key
|
|
83
|
+
# Always reboot-class: a value consumed at boot is already inside a constant, and no
|
|
84
|
+
# reloader can reach in there and change it.
|
|
85
|
+
changed_scopes << LoadManifest::REBOOT
|
|
86
|
+
end
|
|
87
|
+
[entry.key, digest]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Verdict.new(
|
|
91
|
+
strategy: strategy_for(changed_scopes),
|
|
92
|
+
reasons: reasons.uniq,
|
|
93
|
+
trigger_paths: triggers.first(MAX_TRIGGER_PATHS),
|
|
94
|
+
revision_label: LoadManifest.label_for(bundle_token:, files:, directories:, envs:)
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def strategy_for(changed_scopes)
|
|
99
|
+
return FRESH if changed_scopes.empty?
|
|
100
|
+
return RELOADABLE if changed_scopes.all? { |scope| scope == LoadManifest::RELOADABLE }
|
|
101
|
+
|
|
102
|
+
REBOOT
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stamp_of(entry) = "#{entry.size}:#{format('%.6f', entry.mtime)}"
|
|
106
|
+
|
|
107
|
+
class Events
|
|
108
|
+
LIMIT = 50
|
|
109
|
+
|
|
110
|
+
# When the file is rewritten rather than appended to. Rewriting on every record would turn a
|
|
111
|
+
# short append into a full read-modify-write for no benefit.
|
|
112
|
+
COMPACT_AT = LIMIT * 4
|
|
113
|
+
|
|
114
|
+
Event = Struct.new(:at, :reason, :trigger_paths, :resolution, :developer_wait,
|
|
115
|
+
keyword_init: true) do
|
|
116
|
+
def to_h
|
|
117
|
+
{
|
|
118
|
+
"at" => at.iso8601(3),
|
|
119
|
+
"reason" => reason.to_s,
|
|
120
|
+
"trigger_paths" => trigger_paths,
|
|
121
|
+
"resolution" => resolution,
|
|
122
|
+
"developer_wait" => developer_wait
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.from_h(hash)
|
|
127
|
+
new(at: Time.iso8601(hash["at"]), reason: hash["reason"],
|
|
128
|
+
trigger_paths: Array(hash["trigger_paths"]), resolution: hash["resolution"],
|
|
129
|
+
developer_wait: hash["developer_wait"])
|
|
130
|
+
rescue ArgumentError, TypeError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def initialize(limit: LIMIT, path: nil)
|
|
136
|
+
@limit = limit
|
|
137
|
+
@path = path
|
|
138
|
+
@events = load_recorded
|
|
139
|
+
@mutex = Mutex.new
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def record(verdict, resolution: nil, developer_wait: nil, at: Time.now)
|
|
143
|
+
event = Event.new(at: at, reason: verdict.reasons.first,
|
|
144
|
+
trigger_paths: verdict.trigger_paths,
|
|
145
|
+
resolution: resolution || verdict.resolution,
|
|
146
|
+
developer_wait: developer_wait)
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
@events << event
|
|
149
|
+
@events.shift while @events.size > @limit
|
|
150
|
+
end
|
|
151
|
+
persist(event)
|
|
152
|
+
event
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_a = @mutex.synchronize { @events.dup }
|
|
156
|
+
def last = @mutex.synchronize { @events.last }
|
|
157
|
+
def size = @mutex.synchronize { @events.size }
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def persist(event)
|
|
162
|
+
return if @path.nil?
|
|
163
|
+
|
|
164
|
+
File.open(@path, "a") { |file| file.write("#{JSON.generate(event.to_h)}\n") }
|
|
165
|
+
compact if File.size(@path) > COMPACT_AT * 512
|
|
166
|
+
rescue SystemCallError, JSON::GeneratorError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def compact
|
|
171
|
+
lines = File.readlines(@path).last(@limit)
|
|
172
|
+
File.write(@path, lines.join)
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def load_recorded
|
|
178
|
+
return [] if @path.nil? || !File.file?(@path)
|
|
179
|
+
|
|
180
|
+
File.readlines(@path).last(@limit).filter_map do |line|
|
|
181
|
+
parsed = JSON.parse(line)
|
|
182
|
+
Event.from_h(parsed) if parsed.is_a?(Hash)
|
|
183
|
+
rescue JSON::ParserError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
rescue SystemCallError
|
|
187
|
+
[]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/siding/watch.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
require "watchcat"
|
|
4
|
+
|
|
5
|
+
module Siding
|
|
6
|
+
class Watch
|
|
7
|
+
EVENTS = :events
|
|
8
|
+
POLL = :poll
|
|
9
|
+
|
|
10
|
+
RootEntry = Struct.new(:path, :recursive)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def mode_from(env, logger: nil)
|
|
14
|
+
value = env["SIDING_WATCH"]
|
|
15
|
+
return POLL if value == "poll"
|
|
16
|
+
return EVENTS if value.nil? || value == "events"
|
|
17
|
+
|
|
18
|
+
logger&.debug("restarter watch: unrecognized SIDING_WATCH=#{value.inspect}; using events")
|
|
19
|
+
EVENTS
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def roots_for(manifest)
|
|
23
|
+
recursive_paths = manifest.directory_entries.map(&:path).uniq
|
|
24
|
+
|
|
25
|
+
file_dirs = manifest.file_entries.map { |entry| File.dirname(entry.path) }
|
|
26
|
+
bundle_dirs = manifest.bundle_files.map { |path| File.dirname(path) }
|
|
27
|
+
non_recursive_paths = (file_dirs + bundle_dirs).uniq.reject do |path|
|
|
28
|
+
under_recursive_root?(path, recursive_paths)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
entries = recursive_paths.map { |path| RootEntry.new(path, true) } + non_recursive_paths.map { |path| RootEntry.new(path, false) }
|
|
32
|
+
entries.uniq(&:path).select { |entry| File.directory?(entry.path) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start(manifest:, env:, logger:, &on_change)
|
|
36
|
+
mode = mode_from(env, logger: logger)
|
|
37
|
+
entries = roots_for(manifest)
|
|
38
|
+
return nil if entries.empty?
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
executor = build_executor(entries, on_change, force_polling: mode == POLL)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
logger.debug("restarter watch: could not start watchcat (#{e.class}: #{e.message}); using poll")
|
|
44
|
+
return Watcher.unavailable("#{e.class}: #{e.message}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Watcher.active(executor, mode)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def under_recursive_root?(path, recursive_paths)
|
|
53
|
+
recursive_paths.any? { |root| path == root || path.start_with?("#{root}#{File::SEPARATOR}") }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_executor(entries, on_change, force_polling:)
|
|
57
|
+
recursive = entries.select(&:recursive).map(&:path)
|
|
58
|
+
non_recursive = entries.reject(&:recursive).map(&:path)
|
|
59
|
+
callback = proc { on_change.call }
|
|
60
|
+
|
|
61
|
+
filters = { ignore_access: true }
|
|
62
|
+
|
|
63
|
+
if recursive.any?
|
|
64
|
+
executor = Watchcat.watch(recursive, recursive: true, force_polling:, filters:, &callback)
|
|
65
|
+
# `force_polling` is fixed at construction and applies to the whole executor, so a
|
|
66
|
+
# second call to add the non-recursive roots inherits it -- there is no per-path knob.
|
|
67
|
+
executor.watch(non_recursive, recursive: false) if non_recursive.any?
|
|
68
|
+
else
|
|
69
|
+
executor = Watchcat.watch(non_recursive, recursive: false, force_polling:, filters:, &callback)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
executor
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Watcher
|
|
77
|
+
def self.active(executor, mode)
|
|
78
|
+
label = mode == POLL ? "poll (watchcat)" : "events (watchcat)"
|
|
79
|
+
new(executor:, label:, attempted: true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.unavailable(reason) = new(executor: nil, label: "poll -- watchcat could not start: #{reason}", attempted: false)
|
|
83
|
+
|
|
84
|
+
def initialize(executor:, label:, attempted:)
|
|
85
|
+
@executor = executor
|
|
86
|
+
@label = label
|
|
87
|
+
@attempted = attempted
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def watching?
|
|
91
|
+
@attempted && alive?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mode_label
|
|
95
|
+
return @label if @attempted && alive?
|
|
96
|
+
return @label unless @attempted
|
|
97
|
+
|
|
98
|
+
"poll -- watchcat watcher thread died"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stop
|
|
102
|
+
@executor&.stop
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def alive?
|
|
108
|
+
return true if @executor.nil?
|
|
109
|
+
@executor.alive?
|
|
110
|
+
rescue StandardError
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|