asgard 0.5.1 → 0.6.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/CHANGELOG.md +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- data/xyzzy.loki +0 -12
data/lib/asgard/watch/daemon.rb
CHANGED
|
@@ -3,26 +3,39 @@
|
|
|
3
3
|
module Asgard
|
|
4
4
|
module Watch
|
|
5
5
|
# `asgard watcher run`: the whole watcher in one foreground process.
|
|
6
|
-
# One Listen listener per
|
|
7
|
-
# the dispatcher routes them to worker Ractors;
|
|
8
|
-
# log and the entry records. A change to any
|
|
9
|
-
# process so declarations are never stale
|
|
10
|
-
#
|
|
11
|
-
#
|
|
6
|
+
# One Listen listener per watched directory of each declaration pushes
|
|
7
|
+
# batches onto the inbox; the dispatcher routes them to worker Ractors;
|
|
8
|
+
# the reporter writes the log and the entry records. A change to any
|
|
9
|
+
# .loki file re-execs the process so declarations are never stale: the
|
|
10
|
+
# listener only raises a flag, and the main thread shuts down and
|
|
11
|
+
# execs, since a listener cannot stop itself from its own callback
|
|
12
|
+
# thread. INT or TERM stops listening, drops triggers that have not
|
|
13
|
+
# started, waits for running tasks to exit, removes the pidfile and
|
|
14
|
+
# returns; a second INT or TERM ends the running tasks.
|
|
12
15
|
class Daemon
|
|
13
16
|
# What a run is made of: the normalized declarations, the real
|
|
14
|
-
# project root, the State, the Spawn::Context,
|
|
15
|
-
# tasks the project defines
|
|
16
|
-
|
|
17
|
+
# project root, the State, the Spawn::Context, the names of the
|
|
18
|
+
# tasks the project defines, and the pending-changes file a reload
|
|
19
|
+
# handed this run (State::PENDING_ENV), if any.
|
|
20
|
+
Config = Data.define(:declarations, :root, :state, :context, :tasks, :pending) do
|
|
21
|
+
def initialize(pending: nil, **) = super
|
|
22
|
+
end
|
|
17
23
|
|
|
18
24
|
LOKI_DEBOUNCE = 0.5
|
|
19
25
|
|
|
26
|
+
# Seconds between the TERM a second stop sends running tasks and the
|
|
27
|
+
# KILL for any still running.
|
|
28
|
+
FORCE_GRACE = 5.0
|
|
29
|
+
|
|
20
30
|
attr_reader :inbox, :listeners
|
|
21
31
|
|
|
22
32
|
# listen: is the Listen module (loaded only when nothing else is
|
|
23
33
|
# given) or a stand-in; exec: replaces the process on reload; out:
|
|
24
|
-
# is where shutdown progress goes
|
|
25
|
-
|
|
34
|
+
# is where shutdown progress goes; dispatcher: is the Dispatcher class;
|
|
35
|
+
# validate: answers whether the edited project would run (asgard,
|
|
36
|
+
# root); grace: is FORCE_GRACE.
|
|
37
|
+
def initialize(config, listen: Daemon.listen_module, exec: Kernel.method(:exec), out: $stdout, dispatcher: Dispatcher,
|
|
38
|
+
validate: Daemon.method(:loki_valid?), grace: FORCE_GRACE)
|
|
26
39
|
@config = config
|
|
27
40
|
@listen = listen
|
|
28
41
|
@exec = exec
|
|
@@ -30,8 +43,14 @@ module Asgard
|
|
|
30
43
|
@inbox = Queue.new
|
|
31
44
|
@listeners = []
|
|
32
45
|
@stopping = false
|
|
46
|
+
@force = false
|
|
47
|
+
@forced_at = nil
|
|
48
|
+
@reload = false
|
|
33
49
|
@reporter = nil
|
|
34
50
|
@dispatcher = nil
|
|
51
|
+
@dispatcher_class = dispatcher
|
|
52
|
+
@validate = validate
|
|
53
|
+
@grace = grace
|
|
35
54
|
end
|
|
36
55
|
|
|
37
56
|
class << self
|
|
@@ -41,82 +60,354 @@ module Asgard
|
|
|
41
60
|
Listen
|
|
42
61
|
end
|
|
43
62
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
# Listen's own list of editor and tool temp files (swap files,
|
|
64
|
+
# .DS_Store, ...), kept when its directory defaults are replaced.
|
|
65
|
+
def editor_ignores
|
|
66
|
+
require "listen/silencer"
|
|
67
|
+
Listen::Silencer::DEFAULT_IGNORED_EXTENSIONS
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The Listen options for one watched directory of a declaration.
|
|
71
|
+
# ignore! replaces Listen's defaults, which ignore log/ and tmp/
|
|
72
|
+
# under every watched directory, with the declaration's own list
|
|
73
|
+
# (Watch.ignores_for) plus +editor+ (editor_ignores).
|
|
74
|
+
def listen_options(spec, dir, editor: editor_ignores)
|
|
75
|
+
{ only: spec[:only][dir], ignore!: [*spec[:ignore][dir], editor], wait_for_delay: spec[:debounce], relative: true }.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# What the main loop does next: a stop wins over a pending reload.
|
|
79
|
+
def next_action(stopping:, reload:)
|
|
80
|
+
return :stop if stopping
|
|
81
|
+
|
|
82
|
+
reload ? :reload : :wait
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Drops the triggers still waiting in a worker's +mailbox+, with
|
|
86
|
+
# any spilled change lists, and queues the nil that ends the worker
|
|
87
|
+
# once its running task exits. The dropped triggers, oldest first.
|
|
88
|
+
def close_queue(mailbox)
|
|
89
|
+
dropped = Dispatcher.take_waiting(mailbox.queue)
|
|
90
|
+
dropped.each { |item| Worker.remove_changes_file(item[:changes_file]) if item.is_a?(Hash) }
|
|
91
|
+
mailbox.push(nil)
|
|
92
|
+
dropped
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Waits for a worker to end; one that died has nothing to wait for.
|
|
96
|
+
def join_worker(ractor)
|
|
97
|
+
ractor.respond_to?(:join) ? ractor.join : ractor.take
|
|
98
|
+
rescue Ractor::Error
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# [entry, { modified:, added:, removed: }] pairs as the pending
|
|
103
|
+
# list a reload hands on: one { entry:, changes: } per entry, its
|
|
104
|
+
# changes merged in order.
|
|
105
|
+
def pending(pairs)
|
|
106
|
+
pairs.group_by(&:first).map do |entry, batches|
|
|
107
|
+
{ entry:, changes: Watch.merge_changes(batches.map { |_, lists| Changes.new(**lists.slice(*Changes.members)) }).to_h }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The paths, relative to +root+, whose change reloads the daemon:
|
|
112
|
+
# every task file in +loaded+ (what $LOADED_FEATURES holds) inside
|
|
113
|
+
# the root, and any .loki directly in the root, where an
|
|
114
|
+
# `import "*.loki"` would find a new one.
|
|
115
|
+
def loki_pattern(root, loaded)
|
|
116
|
+
real = File.realpath(root.to_s)
|
|
117
|
+
files = loaded.select { |path| path.to_s.end_with?(".loki") }.filter_map { |path| relative_to(real, path) }
|
|
118
|
+
Regexp.union(%r{\A[^/]*\.loki\z}, *files.map { |file| /\A#{Regexp.escape(file)}\z/ })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# +path+ relative to +root+ (a real path), or nil outside it. Real
|
|
122
|
+
# paths on both sides where the file exists, since a symlinked
|
|
123
|
+
# temp dir (/var -> /private/var) would otherwise never match.
|
|
124
|
+
def relative_to(root, path)
|
|
125
|
+
full = File.exist?(path.to_s) ? File.realpath(path.to_s) : File.expand_path(path.to_s)
|
|
126
|
+
full.delete_prefix("#{root}/") if full.start_with?("#{root}/")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether the edited project would run: `asgard watcher check`
|
|
130
|
+
# exits 0 in +root+, which loads the task files and checks the
|
|
131
|
+
# declarations exactly as `watcher run` does (some watch declared,
|
|
132
|
+
# every watched task defined). Its errors go to the daemon's log,
|
|
133
|
+
# so the reason shows.
|
|
134
|
+
def loki_valid?(asgard, root) = system(asgard, "watcher", "check", chdir: root, in: File::NULL, out: File::NULL) || false
|
|
135
|
+
|
|
136
|
+
# Runs a clean-up step whose failure must not hide the error that
|
|
137
|
+
# made it necessary.
|
|
138
|
+
def quietly
|
|
139
|
+
yield
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
47
142
|
end
|
|
48
143
|
|
|
49
144
|
# The tasks declared by watch but not defined by the project.
|
|
50
145
|
def unknown_tasks(declarations, tasks) = declarations.values.map { |spec| spec[:task] }.uniq - tasks
|
|
146
|
+
|
|
147
|
+
# Sends +signal+ to the process group of the task running as +pid+,
|
|
148
|
+
# only when task_group? proves it is one; whether it was sent. The
|
|
149
|
+
# keywords stand in for the system in tests.
|
|
150
|
+
def signal_task(signal, pid, task, own_pgrp: Process.getpgrp, pgid_of: Process.method(:getpgid),
|
|
151
|
+
command: Pid.method(:command_line), kill: Process.method(:kill))
|
|
152
|
+
return false unless task_group?(pid, task, own_pgrp:, pgid_of:, command:)
|
|
153
|
+
|
|
154
|
+
kill.call(signal, -pid)
|
|
155
|
+
true
|
|
156
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Whether -pid may be signalled as a watched task's group: pid is an
|
|
161
|
+
# Integer above 1 (kill(2) gives a group of 0 or 1, and -1, meanings
|
|
162
|
+
# far beyond one task), neither this daemon nor its group, still the
|
|
163
|
+
# leader of its own group as a task spawned with pgroup: is, and
|
|
164
|
+
# still running asgard +task+ (a pid reused since is not ours).
|
|
165
|
+
def task_group?(pid, task, own_pgrp:, pgid_of:, command:)
|
|
166
|
+
return false unless pid.is_a?(Integer) && pid > 1 && pid != own_pgrp && pid != Process.pid
|
|
167
|
+
|
|
168
|
+
pgid_of.call(pid) == pid && State.task_process?(task, command: command.call(pid))
|
|
169
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The signal a forced stop sends next: TERM first, KILL once
|
|
174
|
+
# +grace+ seconds have passed since (+forced_at+), else nil.
|
|
175
|
+
def escalation(forced_at, now, grace)
|
|
176
|
+
return "TERM" unless forced_at
|
|
177
|
+
|
|
178
|
+
"KILL" if now - forced_at >= grace
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Whether +io+ is an open File on +path+: how the daemon knows its
|
|
182
|
+
# output is the watcher log `start` pointed it at.
|
|
183
|
+
def writes_to?(io, path) = io.is_a?(File) && !io.closed? && File.exist?(path) && File.identical?(io, path)
|
|
184
|
+
|
|
185
|
+
# Points each of +ios+ at a fresh +path+ (after the log rotated
|
|
186
|
+
# away beneath them), unbuffered.
|
|
187
|
+
def reopen_log(ios, path)
|
|
188
|
+
ios.each do |io|
|
|
189
|
+
io.reopen(path, "a")
|
|
190
|
+
io.sync = true
|
|
191
|
+
end
|
|
192
|
+
end
|
|
51
193
|
end
|
|
52
194
|
|
|
195
|
+
# Traps go in first: Listen can take seconds to start on a large
|
|
196
|
+
# tree, and a TERM meanwhile must stop the daemon cleanly, not kill
|
|
197
|
+
# it. A start that fails takes down what it started (abandon).
|
|
53
198
|
def run
|
|
54
199
|
check_tasks!
|
|
55
200
|
Dir.chdir(@config.root)
|
|
56
201
|
state = @config.state.prepare
|
|
202
|
+
@lock = state.lock or raise Error, "another watcher is already running for this project (#{state.lock_path} is held)"
|
|
203
|
+
state.write_declarations(@config.declarations.values)
|
|
204
|
+
trap_signals
|
|
205
|
+
begin
|
|
206
|
+
start_watching(state)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
abandon(state)
|
|
209
|
+
raise
|
|
210
|
+
end
|
|
211
|
+
wait_for_stop_or_good_reload
|
|
212
|
+
finish(state, next_action == :reload)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Asks run to stop; the trap handler calls this. A second call, while
|
|
216
|
+
# running tasks are still awaited, asks for them to be ended too.
|
|
217
|
+
def stop
|
|
218
|
+
@force = true if @stopping
|
|
219
|
+
@stopping = true
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Asks run to replace the process with a fresh `watcher run` so new
|
|
223
|
+
# declarations take effect, once running tasks have exited. The
|
|
224
|
+
# .loki listener calls this from its own thread, so it only sets a
|
|
225
|
+
# flag.
|
|
226
|
+
def reload = @reload = true
|
|
227
|
+
|
|
228
|
+
def next_action = Daemon.next_action(stopping: @stopping, reload: @reload)
|
|
229
|
+
|
|
230
|
+
# Waits for a stop, or for a reload into a project that would run. A
|
|
231
|
+
# .loki saved with an error, or one that renames a watched task or
|
|
232
|
+
# drops the last watch, would make the new process abort, so the
|
|
233
|
+
# running declarations go on watching until the next save.
|
|
234
|
+
def wait_for_stop_or_good_reload
|
|
235
|
+
loop do
|
|
236
|
+
sleep 0.2 while next_action == :wait
|
|
237
|
+
return if next_action == :stop || @validate.call(@config.context.asgard, @config.root)
|
|
238
|
+
|
|
239
|
+
say "watcher: the task files do not load or declare no runnable watch; keeping the running declarations"
|
|
240
|
+
@reload = false
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
# Shuts down, then execs the new daemon or lets go of the lock. A
|
|
247
|
+
# reload keeps listening until its running tasks have exited, and
|
|
248
|
+
# hands what was dropped or heard meanwhile to the next daemon. A
|
|
249
|
+
# stop that arrives while a reload waits wins.
|
|
250
|
+
def finish(state, reloading)
|
|
251
|
+
stop_listening unless reloading
|
|
252
|
+
dropped = shutdown(state)
|
|
253
|
+
stop_listening
|
|
254
|
+
unless next_action == :reload
|
|
255
|
+
say "watcher: stopped"
|
|
256
|
+
return @lock.close
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
say "watcher: reloading"
|
|
260
|
+
exec_with_lock(write_pending(state, dropped + heard))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def stop_listening
|
|
264
|
+
@listeners.each(&:stop)
|
|
265
|
+
@listeners = []
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def start_watching(state)
|
|
57
269
|
start_pipeline(state)
|
|
270
|
+
replay(State.take_pending(@config.pending))
|
|
58
271
|
@listeners = build_listeners + [loki_listener]
|
|
59
272
|
@listeners.each(&:start)
|
|
60
273
|
state.write_pid(Process.pid)
|
|
61
|
-
trap_signals
|
|
62
|
-
sleep 0.2 until @stopping
|
|
63
|
-
shutdown(state)
|
|
64
274
|
end
|
|
65
275
|
|
|
66
|
-
#
|
|
67
|
-
|
|
276
|
+
# Routes what the reloading daemon before this one saw but did not
|
|
277
|
+
# run, for entries that are still declared.
|
|
278
|
+
def replay(pending)
|
|
279
|
+
pending.each do |item|
|
|
280
|
+
name = item[:entry]
|
|
281
|
+
@inbox.push([name, item[:changes]]) if @config.declarations.key?(name)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
68
284
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@
|
|
74
|
-
@
|
|
285
|
+
# After a failed start: stop whatever listens, end the pipeline
|
|
286
|
+
# without waiting on anything, drop the pidfile and the lock.
|
|
287
|
+
def abandon(state)
|
|
288
|
+
@listeners.each { |listener| Daemon.quietly { listener.stop } }
|
|
289
|
+
@inbox.push(nil)
|
|
290
|
+
@dispatcher&.workers&.each_value { |worker| Daemon.close_queue(worker[:mailbox]) }
|
|
291
|
+
@reports&.push(nil)
|
|
292
|
+
state.clear
|
|
293
|
+
@lock.close
|
|
75
294
|
end
|
|
76
295
|
|
|
77
|
-
|
|
296
|
+
# The new process inherits the held lock, so no other start can slip
|
|
297
|
+
# in between this daemon and its replacement, and is told where the
|
|
298
|
+
# pending changes are (nil unsets the variable).
|
|
299
|
+
def exec_with_lock(pending)
|
|
300
|
+
fd = @lock.fileno
|
|
301
|
+
@exec.call({ State::LOCK_ENV => fd.to_s, State::PENDING_ENV => pending }, @config.context.asgard, "watcher", "run", fd => fd)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Writes the pending list for the next daemon; its path, or nil when
|
|
305
|
+
# nothing is pending.
|
|
306
|
+
def write_pending(state, pairs)
|
|
307
|
+
pending = Daemon.pending(pairs)
|
|
308
|
+
return nil if pending.empty?
|
|
309
|
+
|
|
310
|
+
State.write_json(state.pending_path, pending)
|
|
311
|
+
state.pending_path
|
|
312
|
+
end
|
|
78
313
|
|
|
79
314
|
def check_tasks!
|
|
80
315
|
unknown = Daemon.unknown_tasks(@config.declarations, @config.tasks)
|
|
81
316
|
raise Error, "watch declares tasks this project does not define: #{unknown.join(', ')}" unless unknown.empty?
|
|
82
317
|
end
|
|
83
318
|
|
|
319
|
+
# When `start` pointed the daemon's output at the log, that output
|
|
320
|
+
# follows the log through rotation (and is unbuffered), so the
|
|
321
|
+
# daemon's own messages never land in a rotated-away file.
|
|
84
322
|
def start_pipeline(state)
|
|
85
|
-
|
|
86
|
-
@
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
323
|
+
log = state.log_path
|
|
324
|
+
@log_outputs = [@out, $stderr].uniq.select { |io| Daemon.writes_to?(io, log) }
|
|
325
|
+
Daemon.reopen_log(@log_outputs, log)
|
|
326
|
+
# Workers push from their Ractors; the reporter thread pops here, in
|
|
327
|
+
# the main Ractor, which the Mailbox wakes on every push.
|
|
328
|
+
@reports = Ractor.make_shareable(Mailbox.new(RactorQueue.new(capacity: 1024), Ractor.current))
|
|
329
|
+
@reporter = Reporter.new(@reports, log:, state:, on_rotate: -> { Daemon.reopen_log(@log_outputs, log) })
|
|
330
|
+
@dispatcher = @dispatcher_class.new(@config.declarations, @config.context, reports: @reports)
|
|
331
|
+
@reporter_thread = @reporter.start
|
|
332
|
+
@dispatcher_thread = @dispatcher.start(@inbox)
|
|
90
333
|
end
|
|
91
334
|
|
|
92
|
-
def build_listeners = @config.declarations.values.
|
|
335
|
+
def build_listeners = @config.declarations.values.flat_map { |spec| spec[:dirs].map { |dir| listener_for(spec, dir) } }
|
|
93
336
|
|
|
94
|
-
# One Listen listener
|
|
95
|
-
|
|
337
|
+
# One Listen listener for one directory of a declaration, whose
|
|
338
|
+
# callback pushes [entry name, batch] onto the inbox.
|
|
339
|
+
def listener_for(spec, dir)
|
|
96
340
|
name = spec[:name]
|
|
97
|
-
@listen.to(
|
|
341
|
+
@listen.to(dir, **Daemon.listen_options(spec, dir)) do |modified, added, removed|
|
|
98
342
|
@inbox.push([name, { modified:, added:, removed: }])
|
|
99
343
|
end
|
|
100
344
|
end
|
|
101
345
|
|
|
102
346
|
def loki_listener
|
|
103
|
-
|
|
347
|
+
only = Daemon.loki_pattern(@config.root, $LOADED_FEATURES)
|
|
348
|
+
@listen.to(@config.root, only:, ignore: Watch.ignores_for(".", []), wait_for_delay: LOKI_DEBOUNCE, relative: true) { reload }
|
|
104
349
|
end
|
|
105
350
|
|
|
106
351
|
def trap_signals
|
|
107
352
|
%w[INT TERM].each { |signal| trap(signal) { stop } }
|
|
108
353
|
end
|
|
109
354
|
|
|
355
|
+
# One line of the daemon's own output, written out at once: an exec
|
|
356
|
+
# would otherwise drop what is still buffered.
|
|
357
|
+
def say(line)
|
|
358
|
+
@out.puts line
|
|
359
|
+
@out.flush
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Upstream first, so nothing is in flight when it returns: the
|
|
363
|
+
# dispatcher done routing (anything heard later stays on the inbox),
|
|
364
|
+
# the triggers no worker has started dropped, each worker ended
|
|
365
|
+
# after its running task exits, and only then the reporter, once
|
|
366
|
+
# every report is written. The dropped triggers as [entry, lists].
|
|
110
367
|
def shutdown(state)
|
|
111
|
-
@listeners.each(&:stop)
|
|
112
368
|
@inbox.push(nil)
|
|
113
|
-
@
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@
|
|
369
|
+
@dispatcher_thread.join
|
|
370
|
+
workers = @dispatcher.workers.values
|
|
371
|
+
dropped = workers.flat_map { |worker| Daemon.close_queue(worker[:mailbox]) }
|
|
372
|
+
wait_for_workers(workers.map { |worker| worker[:ractor] })
|
|
373
|
+
@reports.push(nil)
|
|
374
|
+
@reporter_thread.join
|
|
118
375
|
state.clear
|
|
119
|
-
|
|
376
|
+
dropped.filter_map { |item| [item[:entry], item[:changes]] if item.is_a?(Hash) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# The batches pushed onto the inbox after the dispatcher stopped.
|
|
380
|
+
def heard
|
|
381
|
+
batches = []
|
|
382
|
+
batches << @inbox.pop until @inbox.empty?
|
|
383
|
+
batches
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Says which pids it waits for, once per interval, until every
|
|
387
|
+
# worker has ended, ending the tasks after a second stop (escalate).
|
|
388
|
+
def wait_for_workers(ractors, interval: 1.0)
|
|
389
|
+
waiters = ractors.map { |ractor| Thread.new { Daemon.join_worker(ractor) } }
|
|
390
|
+
say "watcher: INT or TERM again ends the running tasks" if @stopping && !@reporter.running.empty?
|
|
391
|
+
loop do
|
|
392
|
+
pids = @reporter.running
|
|
393
|
+
say "watcher: waiting for pid #{pids.join(', ')}" unless pids.empty?
|
|
394
|
+
break if waiters.all? { |waiter| waiter.join(interval) }
|
|
395
|
+
|
|
396
|
+
escalate
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# After a second stop: TERM to every running task's process group,
|
|
401
|
+
# then KILL to any still running once the grace period has passed.
|
|
402
|
+
# Each group is signalled only through Daemon.signal_task.
|
|
403
|
+
def escalate
|
|
404
|
+
return unless @force
|
|
405
|
+
|
|
406
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
407
|
+
signal = Daemon.escalation(@forced_at, now, @grace) or return
|
|
408
|
+
@forced_at ||= now
|
|
409
|
+
say "watcher: killing running tasks (#{signal})"
|
|
410
|
+
@reporter.running_tasks.each { |pid, task| Daemon.signal_task(signal, pid, task) }
|
|
120
411
|
end
|
|
121
412
|
end
|
|
122
413
|
end
|