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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. data/xyzzy.loki +0 -12
@@ -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 declaration pushes batches onto the inbox;
7
- # the dispatcher routes them to worker Ractors; the reporter writes the
8
- # log and the entry records. A change to any .loki file re-execs the
9
- # process so declarations are never stale. INT or TERM stops listening,
10
- # drops triggers that have not started, waits for running tasks to
11
- # exit, removes the pidfile and returns.
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, and the names of the
15
- # tasks the project defines.
16
- Config = Data.define(:declarations, :root, :state, :context, :tasks)
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
- def initialize(config, listen: Daemon.listen_module, exec: Kernel.method(:exec), out: $stdout)
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
- # The Listen options for one declaration.
45
- def listen_options(spec)
46
- { only: spec[:only], ignore: spec[:ignore], wait_for_delay: spec[:debounce], relative: true }.compact
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
- # Asks run to stop; the trap handler calls this.
67
- def stop = @stopping = true
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
- # Stops listening and replaces the process with a fresh `watcher run`
70
- # so new declarations take effect. Running tasks, in their own
71
- # process groups, carry on; the new daemon does not know about them.
72
- def reload
73
- @listeners.each(&:stop)
74
- @exec.call(@config.context.asgard, "watcher", "run")
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
- private
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
- reports = RactorQueue.new(capacity: 1024)
86
- @reporter = Reporter.new(reports, log: state.log_path, state:)
87
- @dispatcher = Dispatcher.new(@config.declarations, @config.context, reports:)
88
- @reporter.start
89
- @dispatcher.start(@inbox)
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.map { |spec| listener_for(spec) }
335
+ def build_listeners = @config.declarations.values.flat_map { |spec| spec[:dirs].map { |dir| listener_for(spec, dir) } }
93
336
 
94
- # One Listen listener whose callback pushes [entry name, batch] onto the inbox.
95
- def listener_for(spec)
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(*spec[:dirs], **Daemon.listen_options(spec)) do |modified, added, removed|
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
- @listen.to(@config.root, only: /\.loki\z/, ignore: DEFAULT_IGNORES, wait_for_delay: LOKI_DEBOUNCE, relative: true) { reload }
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
- @dispatcher.workers.each_value do |worker|
114
- queue = worker[:queue]
115
- queue.try_pop until queue.empty?
116
- end
117
- @reporter.drain(out: @out)
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
- @out.puts "watcher: stopped"
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