holdfast 0.1.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.
@@ -0,0 +1,197 @@
1
+ # Runs every sequence definition in SEQUENCES_OUT, each in its own process
2
+ # on its own numbered test database, until the seeds are clean or one fails,
3
+ # and prints one table. The hunt stage runs it as a script:
4
+ #
5
+ # ruby run.rb [-j 4] [--cases 300] [--seeds 1,2,3] [--timeout S] [--json FILE] [FILE...]
6
+ require "json"
7
+ require "open3"
8
+ require "etc"
9
+ require "timeout"
10
+ require "fileutils"
11
+
12
+ $stdout.sync = true
13
+ # Logs hold UTF-8 (zone names, refusals); a caller with no LANG must not make them US-ASCII.
14
+ Encoding.default_external = Encoding::UTF_8
15
+
16
+ LIB = __dir__
17
+
18
+ args = ARGV.dup
19
+ take = ->(flag, default) { (i = args.index(flag)) ? args.slice!(i, 2).last : default }
20
+ jobs = Integer(take.("-j", [Etc.nprocessors, 4].min))
21
+ cases = Integer(take.("--cases", 300))
22
+ seeds = take.("--seeds", Array.new(3) { rand(1..2**31 - 1) }.join(","))
23
+ limit = Integer(take.("--timeout", 3600))
24
+ json_out = take.("--json", nil)
25
+ defs = ENV.fetch("SEQUENCES_OUT", "spec/sequences")
26
+ files = args.empty? ? Dir["#{defs}/*.rb"].reject { File.basename(it) == "invariants.rb" }.sort : args
27
+ abort "no definitions in #{defs}: holdfast run drafts them" if files.empty?
28
+
29
+ out = File.expand_path(ENV.fetch("SEQUENCES_WORK", "tmp/sequences"))
30
+ FileUtils.mkdir_p(out)
31
+ jobs = [jobs, files.size].min
32
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+
34
+ # One database per worker, so two areas never truncate each other's rows.
35
+ # SEQUENCES_DATABASE_URL names a database prefix of the caller's, so two
36
+ # apps on one server never share app_test1.
37
+ worker_env = lambda do |i|
38
+ url = ENV["SEQUENCES_DATABASE_URL"]
39
+ { "TEST_ENV_NUMBER" => (i + 1).to_s }.merge(url ? { "DATABASE_URL" => "#{url}_seq#{i + 1}" } : {})
40
+ end
41
+ puts "preparing #{jobs} test databases (TEST_ENV_NUMBER=1..#{jobs})"
42
+ jobs.times.map do |i|
43
+ Thread.new do
44
+ log, status = Open3.capture2e(worker_env.(i).merge("RAILS_ENV" => "test"),
45
+ "bin/rails", "db:create", "db:schema:load")
46
+ abort "database #{i + 1} failed:\n#{log}" unless status.success?
47
+ end
48
+ end.each(&:join)
49
+ puts "seeds #{seeds}, #{cases} cases each, #{files.size} areas, #{jobs} at a time"
50
+
51
+ queue = Queue.new
52
+ files.each { queue << it }
53
+ results = {}
54
+ running = {}
55
+ lock = Mutex.new
56
+
57
+ # Each refusal names what to change: a param the text mentions, or the kind
58
+ # of input a refusal like it needs.
59
+ def hint(text, params)
60
+ low = text.to_s.downcase
61
+ named = params.select do |param|
62
+ words = param.to_s.sub(/_(id|at|on|from)\z/, "").split("_")
63
+ low.include?(param.to_s) || words.all? { low.include?(it) }
64
+ end
65
+ return "change #{named.join(", ")}" if named.any?
66
+
67
+ case low
68
+ when /only one door picks|one door only|one door makes a new/
69
+ "the two doors cannot take the same inputs; drop the twins line, or give both doors the same params"
70
+ when /ends? before|duration|longest|shortest|minutes/
71
+ "draw the end from the start: #{params.grep(/end|until|minutes/).first || "ends_at"}: after(:starts_at, minutes: [...])"
72
+ when /missing param (\w+)/ then "add the param #{$1} to the action body"
73
+ when /param is missing|is missing|is invalid/ then "send the param the message names"
74
+ when /unauthori[sz]ed|not recogni[sz]ed|pin|token|unknown kiosk|forbidden/ then "a credential: the caller pool, auth_for, or the setup"
75
+ when /not found|couldn't find/ then "the record it looks up is not there for this caller: narrow the pool or make it in setup"
76
+ when /closed|not open|hours|weekday/ then "a time param inside opening hours (BOUNDARY_TIMES, clock start)"
77
+ when /passed|ahead|too early|too late|closed at|has closed/ then "a time param or the clock: how far ahead it is drawn"
78
+ else "make the state it needs in setup, or narrow a pool to records it accepts"
79
+ end
80
+ end
81
+
82
+ def clean_refusal(text) = text.to_s.sub(/\A(HTTP \d+ )?refused: /, "").gsub(/\s+/, " ")[0, 70]
83
+
84
+ # An action reached its target when one run ended other than refused; one
85
+ # that was only refused, never drawn, or left as TODO checked nothing.
86
+ def scope(report)
87
+ reached = report["reached"]
88
+ missing = report["actions"].filter_map do |action|
89
+ kinds = reached[action] || {}
90
+ params = report.dig("params", action.sub(/ \[other offset\]\z/, "")) || []
91
+ if kinds.empty? then "#{action} (never drawn)"
92
+ elsif kinds.keys.all? { it.include?("refused") }
93
+ texts = report.dig("refused_with", action) || []
94
+ text = texts.first
95
+ also = texts.drop(1).map { clean_refusal(it) }.uniq - [clean_refusal(text)]
96
+ "#{action} (only refused: #{clean_refusal(text)}#{"; also: #{also.join("; ")}" unless also.empty?}; hint: #{hint(text, params)})"
97
+ end
98
+ end
99
+ done = report["actions"].size - missing.size
100
+ missing += report["skipped"].map { |action, why| "#{action} (#{why})" }
101
+ [done, report["actions"].size + report["skipped"].size, missing]
102
+ end
103
+
104
+ RANK = { "FAILURE" => 0, "unchecked" => 1, "partly checked" => 2, "clean" => 3 }.freeze
105
+
106
+ # A verdict of clean means every action, offset replay and pair of doors
107
+ # reached its target at least once; anything less is partly checked.
108
+ def verdict(r, limit)
109
+ rep = r[:report]
110
+ if rep.nil?
111
+ tail = File.readlines(r[:log]).grep(/Error|error:|uninitialized|undefined/).first.to_s.strip
112
+ return ["unchecked", "-", "unchecked: #{r[:status] == :timeout ? "timed out after #{limit} s" : "no report (#{tail[0, 100]})"}"]
113
+ end
114
+ done, total, missing = scope(rep)
115
+ # The time limit stopped the area between cases with no failure found:
116
+ # what ran still counts, and the row says it was cut short.
117
+ if rep["verdict"].nil?
118
+ ran = File.read(r[:log])[/: (\d+) runs in/, 1]
119
+ rep = rep.merge("verdict" => "clean", "seeds" => [nil])
120
+ cut = "cut by the time limit after #{ran || "some"} of #{rep["cases"]} cases"
121
+ end
122
+ case rep["verdict"]
123
+ when "clean"
124
+ runs = cut || "#{rep["seeds"].size} seeds x #{rep["cases"]} cases"
125
+ return ["clean", "#{done}/#{total}", "clean: #{runs}"] if missing.empty?
126
+
127
+ ["partly checked", "#{done}/#{total}", "partly checked: #{runs}; not covered:\n#{missing.map { " - #{it}" }.join("\n")}"]
128
+ when "failure" then ["FAILURE", "#{done}/#{total}", "FAILURE: #{rep["reason"].to_s.lines.first.to_s.strip}"]
129
+ else ["unchecked", "#{done}/#{total}", "unchecked: #{rep["reason"].to_s.lines.first.to_s.strip}"]
130
+ end
131
+ end
132
+
133
+ now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
134
+ estimated = false
135
+ heartbeat = Thread.new do
136
+ loop do
137
+ sleep 20
138
+ lock.synchronize do
139
+ busy = running.map { |name, t0| "#{name} #{(now.() - t0).round} s" }.join(", ")
140
+ puts " ... #{(now.() - started).round} s: #{results.size} of #{files.size} done; running #{busy}"
141
+ end
142
+ end
143
+ end
144
+
145
+ jobs.times.map do |i|
146
+ Thread.new do
147
+ while (file = (queue.pop(true) rescue nil))
148
+ name = File.basename(file, ".rb")
149
+ report = File.join(out, "#{name}.json")
150
+ log = File.join(out, "#{name}.log")
151
+ File.delete(report) if File.exist?(report)
152
+ env = { **worker_env.(i), "SEQ_CASES" => cases.to_s, "SEQ_SEEDS" => seeds,
153
+ "SEQ_REPORT" => report, "SEQUENCES_LIB" => File.join(LIB, "sequences.rb") }
154
+ t0 = now.()
155
+ lock.synchronize { running[name] = t0 }
156
+ pid = Process.spawn(env, "bundle", "exec", "rspec", file, out: log, err: [:child, :out], pgroup: true)
157
+ status = begin
158
+ Timeout.timeout(limit) { Process.wait2(pid).last }
159
+ rescue Timeout::Error
160
+ Process.kill("TERM", -pid)
161
+ Process.wait(pid)
162
+ :timeout
163
+ end
164
+ seconds = (now.() - t0).round
165
+ lock.synchronize do
166
+ running.delete(name)
167
+ results[name] = { file:, log:, seconds:, status:, report: (JSON.parse(File.read(report)) if File.exist?(report)) }
168
+ kind, reached, = verdict(results[name], limit)
169
+ puts " #{name}: #{kind} (#{reached} reached) in #{seconds} s"
170
+ unless estimated
171
+ estimated = true
172
+ rounds = (files.size.to_f / jobs).ceil
173
+ puts " estimate: about #{(rounds * seconds / 60.0).ceil} min for all #{files.size} areas (#{rounds} rounds of #{jobs}, the first area took #{seconds} s)"
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end.each(&:join)
179
+ heartbeat.kill
180
+
181
+ rows = results.map { |name, r| [name, *verdict(r, limit), r] }.sort_by { |name, kind, *| [RANK.fetch(kind), name] }
182
+ width = rows.map { it[0].size }.max
183
+ puts format("%-#{width}s %-7s %s", "area", "reached", "verdict")
184
+ rows.each do |name, kind, reached, text, r|
185
+ first, *rest = text.lines
186
+ puts format("%-#{width}s %-7s %s", name, reached, first.chomp)
187
+ rest.each { puts "#{" " * (width + 9)}#{it}" }
188
+ puts format("%-#{width}s %-7s log: %s (%d s)", "", "", r[:log].sub("#{Dir.pwd}/", ""), r[:seconds]) unless kind == "clean"
189
+ end
190
+ puts "total #{(now.() - started).round} s, seeds #{seeds}"
191
+ if json_out
192
+ File.write(json_out, JSON.generate(rows.map do |name, kind, reached, text, r|
193
+ { "area" => name, "verdict" => kind, "reached" => reached, "text" => text, "log" => r[:log], "seconds" => r[:seconds],
194
+ "file" => r[:file], "cases" => cases, "seeds" => seeds }
195
+ end))
196
+ end
197
+ exit(rows.any? { %w[FAILURE unchecked].include?(it[1]) } ? 1 : 0)