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
@@ -11,43 +11,77 @@ module Asgard
11
11
  EVENTS = %i[changed added removed].freeze
12
12
  EVENT_ALIASES = { modified: :changed }.freeze
13
13
 
14
- # Output and scratch directories a task may write inside the project;
15
- # always ignored so a task never retriggers itself. Matched against the
16
- # path relative to the watched directory, at any depth.
17
- DEFAULT_IGNORES = %w[.git coverage pkg tmp log node_modules].map { |dir| %r{(?:\A|/)#{Regexp.escape(dir)}/} }.freeze
14
+ # Version-control and dependency directories, ignored at any depth.
15
+ # Each matches the bare directory too ("app/node_modules"), which is
16
+ # what Listen tests while it scans, so the tree beneath is never walked.
17
+ DEFAULT_IGNORES = %w[.git .hg .svn node_modules].map { |dir| %r{(?:\A|/)#{Regexp.escape(dir)}(?:/|\z)} }.freeze
18
18
 
19
- GLOB_CHARS = /[*?{]/
20
- GLOB_TOKENS = %r!\*\*/|\*\*|\*|\?|\{[^}]*\}|\{|[^*?{]+!
19
+ # Output and scratch directories a task or Bundler writes in the
20
+ # project root, ignored so a task never retriggers itself. Only the
21
+ # root's own: an app/log/ of yours is watched like any directory.
22
+ ROOT_IGNORES = %w[coverage pkg tmp log .bundle bundle vendor/bundle vendor/ruby]
23
+ .map { |dir| %r{\A#{Regexp.escape(dir)}(?:/|\z)} }.freeze
24
+
25
+ GLOB_CHARS = /[*?{\[]/
26
+ BRACE_DEPTH = Hash.new(0).update("{" => 1, "}" => -1).freeze
27
+ GLOB_TOKENS = %r~\*\*/|\*\*|\*|\?|(?<brace>\{(?:[^{}]|\g<brace>)*\})|\[!?[^\]/]+\]|\{|\[|[^*?{\[]+~
21
28
 
22
29
  module_function
23
30
 
24
31
  # Validates a declaration and returns it as a plain Hash. paths: entries
25
32
  # are directories, files or globs relative to the project root; the
26
- # Hash carries the directories Listen watches and one only: Regexp (or
27
- # nil) that stands for every pattern. events: picks which kinds of
28
- # change trigger the task; as: names the entry. Every ArgumentError
29
- # from the helpers below comes back prefixed with the task.
33
+ # Hash carries the directories Listen watches, and per directory the
34
+ # only: Regexp (or nil) for its own patterns and its ignore: list.
35
+ # events: picks which kinds of change trigger the task; as: names the
36
+ # entry. Every ArgumentError from the helpers below comes back
37
+ # prefixed with the task.
30
38
  def normalize(task, paths:, only: nil, ignore: nil, events: :all, debounce: 0.5, env: {}, as: nil)
31
39
  task = task.to_s
32
40
  raise ArgumentError, "task name #{task.inspect} must be a single word" unless task.match?(/\A[\w:-]+\z/)
33
41
 
34
- paths = relative_paths(paths)
35
- dirs, matched = watched(paths)
42
+ paths = relative_paths(paths)
43
+ globs = watched(paths)
44
+ filters = regexps(:only, only)
45
+ ignores = regexps(:ignore, ignore)
36
46
  {
37
47
  name: entry_name(as || task),
38
48
  task:,
39
49
  paths:,
40
- dirs:,
41
- only: combine_only(matched, regexps(:only, only)),
42
- ignore: [*regexps(:ignore, ignore), *DEFAULT_IGNORES],
50
+ dirs: globs.keys,
51
+ only: globs.to_h { |dir, patterns| [dir, combine_only(patterns.map { |pattern| glob_regexp(pattern) }, filters)] },
52
+ ignore: globs.keys.to_h { |dir| [dir, ignores_for(dir, ignores)] },
43
53
  events: normalize_events(events),
44
54
  debounce: non_negative_debounce(debounce),
45
- env: env.to_h { |key, value| [key.to_s, value.to_s] }
55
+ env: environment(env)
46
56
  }
47
57
  rescue ArgumentError => e
48
58
  raise ArgumentError, "watch :#{task} #{e.message}"
49
59
  end
50
60
 
61
+ # The ignore: list Listen gets for one watched directory: the
62
+ # declared ones, DEFAULT_IGNORES, and ROOT_IGNORES when the directory
63
+ # is the project root.
64
+ def ignores_for(dir, declared) = [*declared, *DEFAULT_IGNORES, *(dir == "." ? ROOT_IGNORES : [])]
65
+
66
+ # env: as a Hash of String names to String values, refused here when
67
+ # Process.spawn would refuse it later: a name that is empty, not a
68
+ # String or Symbol, or holds = or a NUL byte; a value holding a NUL.
69
+ def environment(env)
70
+ raise ArgumentError, "env: expects a Hash (got #{env.inspect})" unless env.is_a?(Hash)
71
+
72
+ env.to_h { |key, value| env_pair(key, value) }
73
+ end
74
+
75
+ def env_pair(key, value)
76
+ name = key.to_s if key.is_a?(String) || key.is_a?(Symbol)
77
+ raise ArgumentError, "env: #{key.inspect} is not a variable name" if name.nil? || name.empty? || name.match?(/[=\0]/)
78
+
79
+ value = value.to_s
80
+ raise ArgumentError, "env: the value of #{name} holds a NUL byte" if value.include?("\0")
81
+
82
+ [name, value]
83
+ end
84
+
51
85
  # The entry name (as:, or the task), checked for the characters a file
52
86
  # or label can carry.
53
87
  def entry_name(name)
@@ -68,50 +102,92 @@ module Asgard
68
102
  paths
69
103
  end
70
104
 
71
- # [directories, patterns] for a declaration's paths. A bare directory
72
- # takes everything beneath it, so it cannot share a declaration with a
73
- # pattern or a file: Listen applies one only: filter to every directory
74
- # it watches, and the pattern would silently filter the directory too.
105
+ # { directory => its glob patterns } for a declaration's paths, each
106
+ # directory listened to on its own so its patterns never filter
107
+ # another's. A bare directory takes everything beneath it (no
108
+ # patterns), so it cannot share a declaration with a pattern or a
109
+ # file: the pattern would silently filter the directory too.
75
110
  def watched(paths)
76
- splits = paths.map { |path| split_glob(path) }
77
- patterns = splits.filter_map(&:last)
78
- if !patterns.empty? && patterns.size < splits.size
111
+ root = declaration_root
112
+ splits = paths.map { |path| split_path(path, root:) }
113
+ globs = splits.filter_map(&:last)
114
+ if !globs.empty? && globs.size < splits.size
79
115
  raise ArgumentError,
80
116
  "mixes a bare directory with patterns or files, which would filter the directory too; " \
81
117
  "declare them separately or write the directory as dir/**/*"
82
118
  end
83
119
 
84
- [splits.map(&:first).uniq, patterns]
120
+ fold(splits)
85
121
  end
86
122
 
87
- # [directory, Regexp or nil] for one paths: entry.
123
+ # Groups [directory, pattern or nil] pairs by directory. A directory
124
+ # inside another of the same declaration folds into the outer one,
125
+ # its pattern prefixed with the path between them, so a change is
126
+ # reported once rather than by two listeners.
127
+ def fold(splits)
128
+ dirs = splits.map(&:first).uniq
129
+ splits.each_with_object({}) do |(dir, pattern), grouped|
130
+ outer = dirs.select { |other| inside?(dir, other) }.min_by(&:size)
131
+ list = grouped[outer] ||= []
132
+ list << rebase(pattern, dir, outer) if pattern
133
+ end
134
+ end
135
+
136
+ # Whether +dir+ is +other+ or lies beneath it.
137
+ def inside?(dir, other) = other == dir || other == "." || dir.start_with?("#{other}/")
138
+
139
+ # +pattern+ (relative to +dir+) made relative to +outer+, which holds dir.
140
+ def rebase(pattern, dir, outer)
141
+ return pattern if dir == outer
142
+
143
+ "#{outer == '.' ? dir : dir.delete_prefix("#{outer}/")}/#{pattern}"
144
+ end
145
+
146
+ # [directory, Regexp or nil] for one paths: entry (see split_path).
88
147
  # "lib/**/*.rb" => ["lib", /\A(?:.*\/)?[^\/]*\.rb\z/]
89
148
  # "lib/asgard.rb" => ["lib", /\Aasgard\.rb\z/]
90
149
  # "docs" => ["docs", nil]
91
150
  # The Regexp is matched against the path relative to the directory,
92
- # which is how Listen applies only:. A plain path names a file when
93
- # stat (File, or a stand-in in tests) finds one there or when it has
94
- # an extension, so a file that does not exist yet can be watched.
95
- def split_glob(path, stat: File)
96
- segments = path.delete_suffix("/").split("/")
151
+ # which is how Listen applies only:.
152
+ def split_glob(path, stat: File, root: Dir.pwd)
153
+ dir, pattern = split_path(path, stat:, root:)
154
+ [dir, pattern && glob_regexp(pattern)]
155
+ end
156
+
157
+ # [directory, glob pattern relative to it, or nil] for one paths:
158
+ # entry; "." and empty segments are dropped. A plain path is looked
159
+ # up under +root+ (the project root, wherever asgard runs from) with
160
+ # stat (File, or a stand-in in tests): a directory there is a
161
+ # directory, dots and all; otherwise it names a file when one is
162
+ # there, when it has an extension, or when it is a dotfile (.env), so
163
+ # a file that does not exist yet can be watched.
164
+ def split_path(path, stat: File, root: Dir.pwd)
165
+ segments = path.split("/").reject { |segment| segment.empty? || segment == "." }
97
166
  first = segments.index { |segment| segment.match?(GLOB_CHARS) }
98
- return [directory(segments[0...first]), glob_regexp(segments[first..].join("/"))] if first
167
+ return [directory(segments[0...first]), segments[first..].join("/")] if first
99
168
 
100
169
  *parents, last = segments
101
- last = last.to_s
102
- return [directory(segments), nil] unless stat.file?(path) || !File.extname(last).empty?
170
+ full = File.expand_path(path, root)
171
+ return [directory(segments), nil] if last.nil? || stat.directory?(full) || !file_name?(last, full, stat)
103
172
 
104
- [directory(parents), /\A#{Regexp.escape(last)}\z/]
173
+ [directory(parents), last]
105
174
  end
106
175
 
176
+ def file_name?(name, full, stat) = stat.file?(full) || !File.extname(name).empty? || name.start_with?(".")
177
+
178
+ # Where plain paths are looked up: the directory of the nearest .loki,
179
+ # or the current directory without one.
180
+ def declaration_root = (loki_up&.dirname || Dir.pwd).to_s
181
+
107
182
  def directory(segments) = segments.empty? ? "." : segments.join("/")
108
183
 
109
184
  # The Regexp a glob matches, shell-style: ** spans directories, * and ?
110
- # stay within one, {a,b} is an alternative. Anything else is literal.
111
- def glob_regexp(pattern)
112
- body = pattern.scan(GLOB_TOKENS).map { |token| glob_token(token) }.join
113
- /\A#{body}\z/
114
- end
185
+ # stay within one, {a,b} is an alternative (nested braces too), [ab]
186
+ # and [!ab] are character classes. Anything else, an unclosed { or [
187
+ # included, is literal.
188
+ def glob_regexp(pattern) = /\A#{glob_body(pattern)}\z/
189
+
190
+ def glob_body(pattern) = pattern.gsub(GLOB_TOKENS) { |token| glob_token(token) }
115
191
 
116
192
  def glob_token(token)
117
193
  case token
@@ -119,11 +195,30 @@ module Asgard
119
195
  when "**" then ".*"
120
196
  when "*" then "[^/]*"
121
197
  when "?" then "[^/]"
122
- when /\A\{(.*)\}\z/ then "(?:#{Regexp.last_match(1).split(',', -1).map { |alt| Regexp.escape(alt) }.join('|')})"
198
+ when /\A\{(.*)\}\z/m then "(?:#{brace_alternatives(Regexp.last_match(1)).map { |alt| glob_body(alt) }.join('|')})"
199
+ when /\A\[(.+)\]\z/m then char_class(Regexp.last_match(1))
123
200
  else Regexp.escape(token)
124
201
  end
125
202
  end
126
203
 
204
+ # The comma-separated alternatives of a brace's body, splitting only
205
+ # at commas outside any nested brace: "a,{b,c}" => ["a", "{b,c}"].
206
+ def brace_alternatives(body)
207
+ depth = 0
208
+ body.each_char.with_object([+""]) do |char, parts|
209
+ depth += BRACE_DEPTH[char]
210
+ char == "," && depth.zero? ? parts << +"" : parts.last << char
211
+ end
212
+ end
213
+
214
+ # A glob character class body ("ab", "!a", "a-c") as a Regexp class
215
+ # that never matches the directory separator.
216
+ def char_class(body)
217
+ negated = body.size > 1 && body.start_with?("!", "^")
218
+ body = body[1..] if negated
219
+ "(?!/)[#{'^' if negated}#{body.gsub(/[\\\[\]^&]/) { |char| "\\#{char}" }}]"
220
+ end
221
+
127
222
  # One only: Regexp (or nil for no filter) from the patterns the paths
128
223
  # produced and the declared only:. Within each group any may match;
129
224
  # when both groups are present a path must satisfy one from each.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "fileutils"
3
5
  require "ractor_queue"
4
6
 
5
7
  module Asgard
@@ -8,7 +10,8 @@ module Asgard
8
10
  # the declarations: a dispatch table from listener id (the entry name)
9
11
  # to its spec, and one worker per distinct task, so two declarations
10
12
  # for the same task share a worker and never overlap. Nothing here is
11
- # mutated after construction, and the loop never blocks on a worker.
13
+ # mutated after construction but a spill counter, and the loop never
14
+ # blocks on a worker: a trigger for a full queue is dropped and reported.
12
15
  class Dispatcher
13
16
  # Triggers a busy task can have waiting. RactorQueue rounds this up.
14
17
  QUEUE_CAPACITY = 256
@@ -22,13 +25,24 @@ module Asgard
22
25
  @context = context
23
26
  @reports = reports
24
27
  @clock = clock
28
+ @spills = 0
25
29
  @dispatch = specs.to_h { |spec| [spec[:name], spec] }.freeze
26
30
  @workers = specs.map { |spec| spec[:task] }.uniq.to_h do |task|
27
- queue = RactorQueue.new(capacity: QUEUE_CAPACITY)
28
- [task, { queue:, ractor: start.call(queue, reports) }.freeze]
31
+ queue = RactorQueue.new(capacity: QUEUE_CAPACITY)
32
+ ractor = start.call(queue, reports)
33
+ [task, { queue:, ractor:, mailbox: Mailbox.new(queue, ractor) }.freeze]
29
34
  end.freeze
30
35
  end
31
36
 
37
+ # Every item waiting on +queue+, oldest first, leaving it empty.
38
+ def self.take_waiting(queue)
39
+ items = []
40
+ until (item = queue.try_pop).equal?(RactorQueue::EMPTY)
41
+ items << item
42
+ end
43
+ items
44
+ end
45
+
32
46
  # Pops [listener id, batch] pairs from inbox until it pops nil.
33
47
  def run(inbox)
34
48
  while (id, batch = inbox.pop)
@@ -38,17 +52,54 @@ module Asgard
38
52
 
39
53
  def start(inbox) = Thread.new { run(inbox) }
40
54
 
41
- # One batch: drop the kinds the entry does not watch, build the frozen
42
- # description, report the trigger, hand it to the task's worker. The
43
- # description, or nil when nothing was left to run.
55
+ # One batch: drop the kinds the entry does not watch, report the
56
+ # trigger, and queue it for the task's worker, merged with any trigger
57
+ # of the same entry still waiting there, so a busy task runs once more
58
+ # with every change rather than once per save. The queued description,
59
+ # or nil when nothing was left to run or the queue was full.
44
60
  def route(id, batch)
45
61
  spec = @dispatch.fetch(id)
46
62
  changes = Watch.filter_events(batch, spec[:events]) or return nil
47
63
  task = spec[:task]
48
- description = Spawn.description(spec, changes, @context)
49
- @reports.push(Ractor.make_shareable({ kind: :trigger, entry: spec[:name], task:, at: @clock.now.to_f, changes: changes.to_h }))
50
- @workers.fetch(task)[:queue].push(description)
51
- description
64
+ report = { entry: spec[:name], task:, at: @clock.now.to_f, changes: changes.to_h }
65
+ @reports.push(Ractor.make_shareable({ kind: :trigger, **report }))
66
+ enqueue(spec, changes, @workers.fetch(task)[:mailbox]) || drop(report)
67
+ end
68
+
69
+ private
70
+
71
+ # Takes back what is still waiting in +mailbox+, folds this entry's
72
+ # waiting triggers into the new one and queues the rest back first, in
73
+ # their order. A worker that took one meanwhile simply runs it.
74
+ def enqueue(spec, changes, mailbox)
75
+ same, others = Dispatcher.take_waiting(mailbox.queue).partition { |item| item.is_a?(Hash) && item[:entry] == spec[:name] }
76
+ same.each { |item| Worker.remove_changes_file(item[:changes_file]) }
77
+ merged = Watch.merge_changes([*same.map { |item| Changes.new(**item[:changes]) }, changes])
78
+ description = Spawn.description(spec, merged, @context, changes_file: next_changes_file(spec))
79
+ others.each { |item| mailbox.try_push(item) }
80
+ write_changes_file(description) # before the push: the worker may start it at once
81
+ return description if mailbox.try_push(description)
82
+
83
+ Worker.remove_changes_file(description[:changes_file])
84
+ nil
85
+ end
86
+
87
+ def drop(report)
88
+ @reports.push(Ractor.make_shareable({ kind: :dropped, **report }))
89
+ nil
90
+ end
91
+
92
+ # Where this trigger's change lists go if they are too big for the
93
+ # environment; the file is only written when Spawn spilled to it.
94
+ def next_changes_file(spec)
95
+ @spills += 1
96
+ Spawn.changes_file(@context, spec[:name], @spills)
97
+ end
98
+
99
+ def write_changes_file(description)
100
+ path = description[:changes_file] or return
101
+ FileUtils.mkdir_p(File.dirname(path))
102
+ File.write(path, JSON.generate(description[:changes]))
52
103
  end
53
104
  end
54
105
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ractor_queue"
4
+
5
+ module Asgard
6
+ module Watch
7
+ # A bounded RactorQueue whose consumer sleeps instead of spinning.
8
+ # RactorQueue#pop polls every 100µs, which an idle daemon with a few
9
+ # workers pays for as a constant few percent of a core. Here the
10
+ # consumer pops with try_pop and, when the queue is empty, blocks in
11
+ # Ractor.receive; every push sends the consumer's Ractor (+owner+) a
12
+ # wake-up. A wake-up sent after the push can never be missed, and an
13
+ # extra one only costs one more empty try_pop. Ractor.receive is
14
+ # Ruby 3.x and 4.x alike.
15
+ Mailbox = Data.define(:queue, :owner) do
16
+ def self.ring(ractor)
17
+ ractor << :ring if ractor.is_a?(Ractor)
18
+ rescue Ractor::ClosedError
19
+ nil # the consumer has ended; nothing is waiting
20
+ end
21
+
22
+ # Blocking push (RactorQueue#push spins only while the queue is full).
23
+ def push(item)
24
+ queue.push(item)
25
+ Mailbox.ring(owner)
26
+ self
27
+ end
28
+
29
+ # false when the queue is full. Named after RactorQueue#try_push.
30
+ def try_push(item) # rubocop:disable Naming/PredicateMethod
31
+ return false unless queue.try_push(item)
32
+
33
+ Mailbox.ring(owner)
34
+ true
35
+ end
36
+
37
+ # Must be called in +owner+, the Ractor the wake-ups go to.
38
+ def pop
39
+ loop do
40
+ item = queue.try_pop
41
+ return item unless item.equal?(RactorQueue::EMPTY)
42
+
43
+ Ractor.receive
44
+ end
45
+ end
46
+
47
+ def empty? = queue.empty?
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "../pid"
5
+ require_relative "../state_dir"
4
6
 
5
7
  module Asgard
6
8
  module Watch
@@ -8,7 +10,8 @@ module Asgard
8
10
  # the dispatcher push frozen reports onto a shared queue; the reporter
9
11
  # drains it in a main-Ractor thread, appends a log line per report,
10
12
  # keeps the latest record per entry on disk through the state, and
11
- # tracks which pids are in flight so shutdown can wait for them.
13
+ # tracks which pids are in flight so shutdown can say what it waits
14
+ # for.
12
15
  class Reporter
13
16
  class << self
14
17
  # One log line for a report. behind: is the pid a trigger is queued
@@ -20,40 +23,58 @@ module Asgard
20
23
  case kind
21
24
  when :trigger then "#{head} #{changed}#{" (queued behind pid #{behind})" if behind}"
22
25
  when :spawn then "#{head} pid=#{pid} #{changed}"
23
- else "#{head} pid=#{pid} status=#{report[:status]} seconds=#{format('%.2f', report[:seconds])}"
26
+ when :dropped then "#{head} #{changed} (dropped: the task's queue is full)"
27
+ else exit_line(head, report)
24
28
  end
25
29
  end
26
30
 
31
+ def exit_line(head, report)
32
+ line = "#{head} pid=#{report[:pid]} status=#{report[:status]} seconds=#{format('%.2f', report[:seconds])}"
33
+ report[:error] ? "#{line} error=#{report[:error]}" : line
34
+ end
35
+
27
36
  # The entry's record after this report: a spawn starts a new record
28
37
  # (no finished/status yet, which is how `list` shows "running"), an
29
- # exit completes the previous one.
38
+ # exit completes the previous one when it is the same run (same
39
+ # pid). A failed spawn (no pid) is a record of its own, with the
40
+ # error, so it never wears the previous run's pid and start time.
30
41
  def entry_record(report, previous = nil)
31
42
  entry, task, pid, at = report.values_at(:entry, :task, :pid, :at)
32
43
  stamp = Time.at(at).iso8601
33
44
  return { entry:, task:, pid:, started: stamp, changes: report[:changes] } if report[:kind] == :spawn
34
45
 
35
- { entry:, task:, pid:, **previous.to_h, finished: stamp, status: report[:status], seconds: report[:seconds] }
46
+ run = previous.to_h[:pid] == pid ? previous.to_h : {}
47
+ record = { entry:, task:, pid:, **run, finished: stamp, status: report[:status], seconds: report[:seconds] }
48
+ report[:error] ? record.merge(error: report[:error]) : record
36
49
  end
37
50
 
38
51
  # The changed paths of a trigger or spawn report, space-separated; "" for an exit.
39
52
  def paths(report) = report.fetch(:changes) { {} }.values.flatten.join(" ")
40
53
 
41
54
  # Whether a process exists; one we may not signal still exists.
42
- def alive?(pid)
43
- Process.kill(0, pid)
44
- true
45
- rescue Errno::ESRCH
46
- false
47
- rescue Errno::EPERM
48
- true
49
- end
55
+ def alive?(pid) = Pid.alive?(pid)
56
+
57
+ # The command line of a live process, or nil (see Asgard::Pid).
58
+ def command_line(pid) = Pid.command_line(pid)
59
+
60
+ # Whether pid is alive and is an asgard process whose command line
61
+ # carries every word: ("watcher", "run") for a daemon, a task name
62
+ # for a trigger. The current process always counts. A pidfile left
63
+ # by a crash or a reboot can name a pid that an unrelated process
64
+ # now holds, which a bare liveness check would call ours.
65
+ def asgard_process?(pid, *words, command: nil) = Pid.matches?(pid, "asgard", *words, command:)
50
66
  end
51
67
 
52
- # state: responds to write_entry(name, record).
53
- def initialize(reports, log:, state:)
54
- @reports = reports
55
- @log = log
56
- @state = state
68
+ # state: responds to write_entry(name, record). max_log: is the size
69
+ # past which the log is rotated (StateDir.rotate); on_rotate: is
70
+ # called after each rotation, so the daemon can move its own output
71
+ # to the new log.
72
+ def initialize(reports, log:, state:, max_log: StateDir.log_limit, on_rotate: nil)
73
+ @reports = reports
74
+ @log = log
75
+ @max_log = max_log
76
+ @on_rotate = on_rotate
77
+ @state = state
57
78
  @records = {}
58
79
  @running = {} # pid => task
59
80
  @mutex = Mutex.new
@@ -68,30 +89,29 @@ module Asgard
68
89
 
69
90
  def start = Thread.new { run }
70
91
 
92
+ # Tracks the report, then logs it. A report that cannot be written
93
+ # (the state directory removed under a running daemon) is said so on
94
+ # stderr; the reporter goes on, since every worker waits on it.
71
95
  def handle(report)
72
- trigger = report[:kind] == :trigger
96
+ kind = report[:kind]
97
+ trigger = kind == :trigger
73
98
  line = Reporter.log_line(report, behind: trigger ? running_for(report[:task]) : nil)
99
+ track(report) if %i[spawn exit].include?(kind)
100
+ @on_rotate&.call if StateDir.rotate(@log, max_bytes: @max_log)
74
101
  File.open(@log, "a") { |file| file.puts(line) }
75
- track(report) unless trigger
102
+ rescue SystemCallError, IOError => e
103
+ $stderr.puts "watcher: could not write #{kind} report for #{report[:entry]}: #{e.message}" # rubocop:disable Style/StderrPuts
76
104
  end
77
105
 
78
106
  # Pids of tasks in flight, ascending.
79
107
  def running = @mutex.synchronize { @running.keys.sort }
80
108
 
109
+ # Tasks in flight, as { pid => task name }.
110
+ def running_tasks = @mutex.synchronize { @running.dup }
111
+
81
112
  # The in-flight pid of task, or nil.
82
113
  def running_for(task) = @mutex.synchronize { @running.key(task) }
83
114
 
84
- # Blocks until no task is in flight, saying what it waits for once
85
- # per interval. A pid that is gone without an exit report (a worker
86
- # died between its reports) is forgotten rather than waited on.
87
- def drain(interval: 1.0, out: $stdout)
88
- until (pids = running).empty?
89
- out.puts "watcher: waiting for pid #{pids.join(', ')}"
90
- sleep interval
91
- forget_dead
92
- end
93
- end
94
-
95
115
  private
96
116
 
97
117
  def track(report)
@@ -101,10 +121,6 @@ module Asgard
101
121
  @state.write_entry(entry, record)
102
122
  @mutex.synchronize { record.key?(:finished) ? @running.delete(pid) : @running[pid] = record[:task] }
103
123
  end
104
-
105
- def forget_dead
106
- @mutex.synchronize { @running.delete_if { |pid, _task| !Reporter.alive?(pid) } }
107
- end
108
124
  end
109
125
  end
110
126
  end
@@ -14,8 +14,18 @@ module Asgard
14
14
  # The only variables a task inherits from the daemon. Everything else
15
15
  # it sees comes from the declaration's env:, from .env and .envrc
16
16
  # loaded in the task's own process, or from the trigger itself.
17
- BASE_ENV_KEYS = %w[PATH HOME USER LOGNAME SHELL LANG TMPDIR SSH_AUTH_SOCK].freeze
18
- BASE_ENV_PATTERN = /\ALC_/
17
+ # RUBYOPT, RUBYLIB, GEM_*, RUBY_*, RBENV_* and BUNDLE(R)_* carry the
18
+ # Ruby setup the daemon itself was started under (an rvm gemset,
19
+ # chruby's RUBY_ROOT, rbenv's RBENV_VERSION, a vendored bundle,
20
+ # `bundle exec`), without which the task's own asgard may not load.
21
+ BASE_ENV_KEYS = %w[PATH HOME USER LOGNAME SHELL LANG TMPDIR SSH_AUTH_SOCK RUBYOPT RUBYLIB].freeze
22
+ BASE_ENV_PATTERN = /\A(?:LC_|BUNDLER?_|GEM_|RUBY_|RBENV_)/
23
+
24
+ # Bytes of change lists the environment carries; past this they go
25
+ # to a file named in ASGARD_CHANGES_FILE. Argument and environment
26
+ # space together is about 1 MB on macOS, and a branch switch under a
27
+ # ** watch can change thousands of files.
28
+ CHANGES_ENV_LIMIT = 128 * 1024
19
29
 
20
30
  # What every trigger of one daemon run shares: the real project root,
21
31
  # the log file tasks append to, the base environment captured at
@@ -29,10 +39,29 @@ module Asgard
29
39
 
30
40
  # The task's full environment, lowest precedence first: the context's
31
41
  # base, the declaration's env:, the trigger variables.
32
- def environment(spec, changes, context)
33
- context.base.merge(spec[:env], { "ASGARD_WATCH" => spec[:name], "ASGARD_WATCH_ROOT" => context.root }, changes.to_env)
42
+ def environment(spec, changes, context, changes_file: nil)
43
+ context.base.merge(spec[:env], { "ASGARD_WATCH" => spec[:name], "ASGARD_WATCH_ROOT" => context.root },
44
+ change_env(changes, changes_file))
45
+ end
46
+
47
+ # The trigger variables for +changes+, or only the name of +file+
48
+ # when there is a file to use and they would pass +limit+ bytes or
49
+ # a path holds a newline, which the one-per-line variables cannot
50
+ # carry.
51
+ def change_env(changes, file, limit: CHANGES_ENV_LIMIT)
52
+ vars = changes.to_env
53
+ return vars if file.nil? || fits_env?(vars, changes, limit)
54
+
55
+ { Changes::FILE_VARIABLE => file }
34
56
  end
35
57
 
58
+ def fits_env?(vars, changes, limit)
59
+ vars.sum { |key, value| key.bytesize + value.bytesize } <= limit && changes.all.none? { |path| path.include?("\n") }
60
+ end
61
+
62
+ # The spill file for the +sequence+th trigger of +entry+, beside the log.
63
+ def changes_file(context, entry, sequence) = File.join(File.dirname(context.log), "changes", "#{entry}-#{sequence}.json")
64
+
36
65
  # The task's argv: the context's asgard executable by absolute path, so
37
66
  # a trigger runs the very asgard that started the watcher whatever PATH
38
67
  # says, wrapped in direnv exec when available so .envrc loads in the
@@ -57,18 +86,14 @@ module Asgard
57
86
 
58
87
  # Everything a worker needs to run one trigger, deep-frozen and
59
88
  # Ractor-shareable: Strings, Arrays and Hashes only.
60
- def description(spec, changes, context)
61
- Ractor.make_shareable(
62
- {
63
- entry: spec[:name],
64
- task: spec[:task],
65
- argv: argv(spec, context),
66
- env: environment(spec, changes, context),
67
- root: context.root,
68
- log: context.log,
69
- changes: changes.to_h
70
- }
71
- )
89
+ # +changes_file+ is where the lists may spill; the description names
90
+ # it as :changes_file only when they did.
91
+ def description(spec, changes, context, changes_file: nil)
92
+ env = environment(spec, changes, context, changes_file:)
93
+ desc = { entry: spec[:name], task: spec[:task], argv: argv(spec, context), env:,
94
+ root: context.root, log: context.log, changes: changes.to_h }
95
+ desc[:changes_file] = changes_file if env.key?(Changes::FILE_VARIABLE)
96
+ Ractor.make_shareable(desc)
72
97
  end
73
98
  end
74
99
  end