duel 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.
- checksums.yaml +7 -0
- data/AGENT_PROMPT.md +211 -0
- data/CHANGELOG.md +281 -0
- data/LICENSE +21 -0
- data/README.md +354 -0
- data/docs/README.md +23 -0
- data/docs/benchmarks.md +289 -0
- data/docs/ci.md +141 -0
- data/docs/configuration.md +429 -0
- data/docs/how-it-works.md +207 -0
- data/docs/json.md +306 -0
- data/docs/plugins.md +408 -0
- data/docs/troubleshooting.md +105 -0
- data/exe/duel +6 -0
- data/lib/duel/across.rb +48 -0
- data/lib/duel/baseline.rb +166 -0
- data/lib/duel/cli.rb +264 -0
- data/lib/duel/config.rb +343 -0
- data/lib/duel/context.rb +630 -0
- data/lib/duel/init.rb +211 -0
- data/lib/duel/json_report.rb +107 -0
- data/lib/duel/lock.rb +38 -0
- data/lib/duel/metric.rb +496 -0
- data/lib/duel/native.rb +162 -0
- data/lib/duel/process_sample.rb +144 -0
- data/lib/duel/profile.rb +116 -0
- data/lib/duel/rake_task.rb +25 -0
- data/lib/duel/resource.rb +209 -0
- data/lib/duel/rubies.rb +149 -0
- data/lib/duel/runner.rb +1114 -0
- data/lib/duel/stats.rb +73 -0
- data/lib/duel/summary.rb +237 -0
- data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
- data/lib/duel/templates/duel.rb.erb +35 -0
- data/lib/duel/templates/perf.yml.erb +97 -0
- data/lib/duel/testing.rb +241 -0
- data/lib/duel/version.rb +5 -0
- data/lib/duel/worker.rb +211 -0
- data/lib/duel.rb +413 -0
- data/sig/duel/across.rbs +21 -0
- data/sig/duel/baseline.rbs +61 -0
- data/sig/duel/cli.rbs +95 -0
- data/sig/duel/config.rbs +181 -0
- data/sig/duel/context.rbs +387 -0
- data/sig/duel/init.rbs +97 -0
- data/sig/duel/json_report.rbs +47 -0
- data/sig/duel/lock.rbs +12 -0
- data/sig/duel/metric.rbs +351 -0
- data/sig/duel/native.rbs +98 -0
- data/sig/duel/process_sample.rbs +54 -0
- data/sig/duel/profile.rbs +64 -0
- data/sig/duel/rake_task.rbs +9 -0
- data/sig/duel/resource.rbs +106 -0
- data/sig/duel/rubies.rbs +45 -0
- data/sig/duel/runner.rbs +530 -0
- data/sig/duel/stats.rbs +26 -0
- data/sig/duel/summary.rbs +87 -0
- data/sig/duel/testing.rbs +91 -0
- data/sig/duel/version.rbs +3 -0
- data/sig/duel/worker.rbs +74 -0
- data/sig/duel.rbs +276 -0
- data/sig/manifest.yaml +4 -0
- metadata +124 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "native"
|
|
5
|
+
|
|
6
|
+
module Duel
|
|
7
|
+
# The runs of a process benchmark (see DSL#duel_process) for one worker:
|
|
8
|
+
# each a fresh process, started and waited for by a Ruby process of its
|
|
9
|
+
# own (the runner's Ruby, out of any bundle) that has nothing else to
|
|
10
|
+
# wait for, so what its child took is the process's alone, even while
|
|
11
|
+
# the runner waits for others. That Ruby process starts with the first
|
|
12
|
+
# run and is kept for the worker's life, so that its start is paid once,
|
|
13
|
+
# not for every run. For each run, it reports a line of JSON: the CPU
|
|
14
|
+
# time the process took (user and system, its children's too), its peak
|
|
15
|
+
# memory where it can tell, and how it ended.
|
|
16
|
+
# @api private
|
|
17
|
+
class ProcessSample
|
|
18
|
+
# The lines of its output a failure shows.
|
|
19
|
+
LOG_LINES = 10
|
|
20
|
+
|
|
21
|
+
# Runs for the worker +label+, in +chdir+ with +env+ (over the
|
|
22
|
+
# runner's own), each process's output to +log+. A run that takes
|
|
23
|
+
# more than +timeout+ seconds fails.
|
|
24
|
+
def initialize(label, env:, chdir:, log:, timeout:)
|
|
25
|
+
@label = label
|
|
26
|
+
@env = env
|
|
27
|
+
@chdir = chdir
|
|
28
|
+
@log = log
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The fastest of +samples+ (one version's runs, as #run returns them):
|
|
33
|
+
# the least CPU time, and the least peak memory where every one has
|
|
34
|
+
# it.
|
|
35
|
+
def self.fastest(samples)
|
|
36
|
+
peaks = samples.map { |sample| sample.fetch("peak") }
|
|
37
|
+
cpu = samples.map { |sample| sample.fetch("timed").fetch("cpu") }.min #: Integer
|
|
38
|
+
{"timed" => {"cpu" => cpu}, "counted" => {}, "heap" => 0, "peak" => (peaks.min unless peaks.include?(nil))}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs +command+ (as Process.spawn takes it) once, and returns its
|
|
42
|
+
# sample ({"timed" => {"cpu" => nanoseconds}, "peak" => bytes or nil},
|
|
43
|
+
# as a worker's has it). One that fails, or takes more than the
|
|
44
|
+
# timeout (it's killed, with what it started, and the Ruby process
|
|
45
|
+
# that ran it), fails.
|
|
46
|
+
def run(command)
|
|
47
|
+
start unless @pid
|
|
48
|
+
@requests.puts JSON.generate(command)
|
|
49
|
+
unless @replies.wait_readable(@timeout)
|
|
50
|
+
kill
|
|
51
|
+
raise Failed, "#{@label}: #{command.join(" ")} didn't end within #{@timeout}s (killed)"
|
|
52
|
+
end
|
|
53
|
+
reply = @replies.gets
|
|
54
|
+
unless reply
|
|
55
|
+
stop
|
|
56
|
+
raise Failed, "#{@label}: the process running #{command.join(" ")} exited"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ended = JSON.parse(reply)
|
|
60
|
+
failed = ended.fetch("failed")
|
|
61
|
+
if failed
|
|
62
|
+
tail = File.readlines(@log).last(LOG_LINES).join
|
|
63
|
+
raise Failed, "#{@label}: #{command.join(" ")} #{failed}#{"\n#{tail}" unless tail.empty?}"
|
|
64
|
+
end
|
|
65
|
+
{"timed" => {"cpu" => ended.fetch("cpu")}, "counted" => {}, "heap" => 0, "peak" => ended.fetch("peak")}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ends the Ruby process of its own, if it started: closing its input
|
|
69
|
+
# does.
|
|
70
|
+
def stop
|
|
71
|
+
pid = @pid
|
|
72
|
+
return unless pid
|
|
73
|
+
|
|
74
|
+
@requests.close
|
|
75
|
+
Process.wait(pid)
|
|
76
|
+
@replies.close
|
|
77
|
+
@pid = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Runs a process for each line of +input+ (a command, in JSON), with
|
|
81
|
+
# what +spec+ says ({"env", "chdir", "log"}), and reports each (see
|
|
82
|
+
# .once) as a line of +output+, at once, until the input ends: run in
|
|
83
|
+
# the Ruby process of its own.
|
|
84
|
+
def self.main(spec, input: $stdin, output: $stdout)
|
|
85
|
+
spec = JSON.parse(spec)
|
|
86
|
+
output.sync = true
|
|
87
|
+
while (line = input.gets)
|
|
88
|
+
output.puts JSON.generate(once(JSON.parse(line), spec.fetch("env"), spec.fetch("chdir"), spec.fetch("log")))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Runs +command+ once, in +chdir+ with +env+ alone, its output to
|
|
93
|
+
# +log+, and returns what it took: its CPU time, its peak memory (or
|
|
94
|
+
# nil), and how it failed (or nil).
|
|
95
|
+
def self.once(command, env, chdir, log)
|
|
96
|
+
before = cpu
|
|
97
|
+
code, signal, peak = Native::Child.wait(Process.spawn(env, *command, chdir: chdir, in: File::NULL, out: [log, "w"],
|
|
98
|
+
err: [:child, :out], unsetenv_others: true, close_others: true))
|
|
99
|
+
{"cpu" => cpu - before, "peak" => peak, "failed" => (why(code, signal) unless code == 0)}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The CPU time of the children waited for so far, in nanoseconds.
|
|
103
|
+
def self.cpu
|
|
104
|
+
times = Process.times
|
|
105
|
+
((times.cutime + times.cstime) * 1_000_000_000).round
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# How a process that failed ended: its exit status +code+, or the
|
|
109
|
+
# +signal+ that killed it.
|
|
110
|
+
def self.why(code, signal)
|
|
111
|
+
return "exited with #{code}" if code
|
|
112
|
+
|
|
113
|
+
killed = signal #: Integer
|
|
114
|
+
"was killed by SIG#{Signal.signame(killed)}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Starts the Ruby process of its own, given the environment, the
|
|
120
|
+
# directory and the log, and connected by a pipe each way (not a
|
|
121
|
+
# socket: JRuby's spawn can't give one).
|
|
122
|
+
def start
|
|
123
|
+
spec = JSON.generate({"env" => {**ENV, **@env}, "chdir" => @chdir, "log" => @log})
|
|
124
|
+
requests, @requests = IO.pipe
|
|
125
|
+
@replies, replies = IO.pipe
|
|
126
|
+
# (Out of the runner's bundle: it needs nothing of it.)
|
|
127
|
+
@pid = Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, RbConfig.ruby,
|
|
128
|
+
*WorkerProcess.load_path(RbConfig.ruby).flat_map { |lib| ["-I", lib] }, "-r", "duel/process_sample", "-e",
|
|
129
|
+
"Duel::ProcessSample.main(*ARGV)", spec, in: requests, out: replies, pgroup: true, close_others: true)
|
|
130
|
+
requests.close
|
|
131
|
+
replies.close
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Kills the Ruby process of its own, and what it started.
|
|
135
|
+
def kill
|
|
136
|
+
pid = @pid #: Integer
|
|
137
|
+
Process.kill("KILL", -pid)
|
|
138
|
+
Process.wait(pid)
|
|
139
|
+
@requests.close
|
|
140
|
+
@replies.close
|
|
141
|
+
@pid = nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/duel/profile.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Duel
|
|
4
|
+
# Where a regression's time went: StackProf profiles of each version,
|
|
5
|
+
# over the same iterations (Lane#profile), so a frame's samples compare
|
|
6
|
+
# directly: the frames that gained the most CPU, in themselves, are the
|
|
7
|
+
# place to start looking.
|
|
8
|
+
# @api private
|
|
9
|
+
class Profile
|
|
10
|
+
# Why there's no profile: StackProf runs on CRuby only, so the hint
|
|
11
|
+
# reads right on JRuby and TruffleRuby too.
|
|
12
|
+
UNAVAILABLE = "a profile of each version needs CRuby, with the stackprof gem in the bundle"
|
|
13
|
+
TOP = 8
|
|
14
|
+
# Frames kept in the report, per version.
|
|
15
|
+
KEPT = 200
|
|
16
|
+
|
|
17
|
+
# StackProf's frames for garbage collection, which isn't the
|
|
18
|
+
# benchmark's code: what it gained is reported apart (see #gc).
|
|
19
|
+
GC = "(garbage collection)"
|
|
20
|
+
GC_FRAMES = [GC, "(marking)", "(sweeping)"].freeze
|
|
21
|
+
|
|
22
|
+
# The frames that mark a benchmark's measured work (see Harness and
|
|
23
|
+
# ProfileSample): its iterations, and what they left to do, are inside
|
|
24
|
+
# Harness#iterate, an untimed block and around_iteration hooks' own
|
|
25
|
+
# work are inside ProfileSample#pause, and an iteration inside its
|
|
26
|
+
# hooks is inside Harness#iteration, inside that pause.
|
|
27
|
+
ITERATE = "Duel::Harness#iterate"
|
|
28
|
+
PAUSE = "Duel::ProfileSample#pause"
|
|
29
|
+
ITERATION = "Duel::Harness#iteration"
|
|
30
|
+
|
|
31
|
+
# A version's profile, from StackProf's +results+ (raw, so with each
|
|
32
|
+
# sample's stack): every frame's samples [name, own, total], most total
|
|
33
|
+
# first, and how many samples there were. Only the benchmark's work is
|
|
34
|
+
# kept, and garbage collection (reported apart: see #gc). Frames of
|
|
35
|
+
# one name, from different places, are one.
|
|
36
|
+
def self.of(results)
|
|
37
|
+
names = results.fetch(:frames).transform_values { |frame| frame.fetch(:name) }
|
|
38
|
+
# @type var frames: Hash[String, [Integer, Integer]]
|
|
39
|
+
frames = Hash.new do |h, k|
|
|
40
|
+
h[k] = [0, 0] #: [Integer, Integer]
|
|
41
|
+
end
|
|
42
|
+
samples = 0
|
|
43
|
+
# (Each stack: its depth, its frames, outermost first, and how many
|
|
44
|
+
# samples in a row had it. None at all without samples.)
|
|
45
|
+
raw = results.fetch(:raw, []) #: Array[Integer]
|
|
46
|
+
i = 0
|
|
47
|
+
while i < raw.size
|
|
48
|
+
depth = raw.fetch(i)
|
|
49
|
+
stack = Array.new(depth) { |j| names.fetch(raw.fetch(i + 1 + j)) }
|
|
50
|
+
count = raw.fetch(i + 1 + depth)
|
|
51
|
+
i += depth + 2
|
|
52
|
+
next unless kept?(stack)
|
|
53
|
+
|
|
54
|
+
samples += count
|
|
55
|
+
frames[stack.last][0] += count
|
|
56
|
+
stack.uniq.each { |name| frames[name][1] += count }
|
|
57
|
+
end
|
|
58
|
+
{"samples" => samples, "frames" => frames.sort_by { |_, (_, total)| -total }.map { |frame, (own, total)| [frame, own, total] }}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether a sample, by its stack's frames' names (outermost first), is
|
|
62
|
+
# of garbage collection or the benchmark's measured work: inside its
|
|
63
|
+
# iterations and not paused, or, if paused, in an iteration inside the
|
|
64
|
+
# innermost pause. So the work of pausing and resuming around each
|
|
65
|
+
# iteration, which a Sample takes away (see Harness#leak), is left
|
|
66
|
+
# out too.
|
|
67
|
+
def self.kept?(stack)
|
|
68
|
+
return true if GC_FRAMES.include?(stack.first)
|
|
69
|
+
|
|
70
|
+
paused = stack.rindex(PAUSE)
|
|
71
|
+
return stack.include?(ITERATE) unless paused
|
|
72
|
+
|
|
73
|
+
iteration = stack.rindex(ITERATION)
|
|
74
|
+
!iteration.nil? && iteration > paused
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(profiles)
|
|
78
|
+
@baseline = profiles.fetch("baseline")
|
|
79
|
+
@candidate = profiles.fetch("candidate")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# [frame, own, total] for the TOP frames that gained the most samples
|
|
83
|
+
# of their own: what each gained, in itself and with what it called,
|
|
84
|
+
# as a share of all the baseline's samples (+0.05: 5% more CPU there).
|
|
85
|
+
def grew
|
|
86
|
+
base = counts(@baseline)
|
|
87
|
+
whole = samples
|
|
88
|
+
# (Only frames the candidate has can have gained.)
|
|
89
|
+
@candidate.fetch("frames").filter_map do |frame, own_now, total_now|
|
|
90
|
+
# (Steep takes the default for an Array of any length.)
|
|
91
|
+
own_was, total_was = base.fetch(frame, [0, 0]) #: [Integer, Integer]
|
|
92
|
+
next if own_now <= own_was || GC_FRAMES.include?(frame)
|
|
93
|
+
|
|
94
|
+
[frame, (own_now - own_was) / whole, (total_now - total_was) / whole] #: growth
|
|
95
|
+
end.sort_by { |_, own, _| -own }.first(TOP)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# What garbage collection gained, as a share of all the baseline's
|
|
99
|
+
# samples: collecting the garbage of each sample before the next (a
|
|
100
|
+
# profile's samples hold the collector off, as measured ones do).
|
|
101
|
+
def gc = (collecting(@candidate) - collecting(@baseline)) / samples
|
|
102
|
+
|
|
103
|
+
def to_h = {"grew" => grew, "gc" => gc, "baseline" => trimmed(@baseline), "candidate" => trimmed(@candidate)}
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def samples = [@baseline.fetch("samples"), 1].max.to_f
|
|
108
|
+
|
|
109
|
+
def counts(profile) = profile.fetch("frames").to_h { |frame, own, total| [frame, [own, total]] }
|
|
110
|
+
|
|
111
|
+
# A profile's samples in garbage collection.
|
|
112
|
+
def collecting(profile) = profile.fetch("frames").find { |frame, _, _| frame == GC }&.last || 0
|
|
113
|
+
|
|
114
|
+
def trimmed(profile) = {"samples" => profile.fetch("samples"), "frames" => profile.fetch("frames").first(KEPT)}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require_relative "cli"
|
|
5
|
+
|
|
6
|
+
module Duel
|
|
7
|
+
# A rake task running `duel` with +args+:
|
|
8
|
+
#
|
|
9
|
+
# require "duel/rake_task"
|
|
10
|
+
# Duel::RakeTask.new(:perf)
|
|
11
|
+
# Duel::RakeTask.new(:perf_since_release, "--base", "last-release")
|
|
12
|
+
class RakeTask
|
|
13
|
+
include Rake::DSL
|
|
14
|
+
|
|
15
|
+
def initialize(name = :duel, *args)
|
|
16
|
+
desc "Fail if this tree is slower than its baseline at any benchmark (duel)"
|
|
17
|
+
task(name) do
|
|
18
|
+
case CLI.new(args).run
|
|
19
|
+
when 1 then abort "duel: slower than the baseline"
|
|
20
|
+
when 2 then abort "duel: couldn't compare"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Duel
|
|
7
|
+
# What a prepare block (see Config#prepare) gets: a worker's, or a
|
|
8
|
+
# version's, own directory, and help running commands and starting a
|
|
9
|
+
# server there, and stopping it.
|
|
10
|
+
#
|
|
11
|
+
# c.prepare(:worker) do |w|
|
|
12
|
+
# socket = w.path("redis.sock")
|
|
13
|
+
# w.spawn("redis-server", "--port", "0", "--unixsocket", socket, "--save", "")
|
|
14
|
+
# w.wait_until { File.socket?(socket) }
|
|
15
|
+
# {"REDIS_URL" => "unix://#{socket}"}
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# The block returns the environment the worker (or the version's
|
|
19
|
+
# workers) gets. When the worker is done (or the run, for a version),
|
|
20
|
+
# Duel runs the #at_stop blocks (last first), then stops what was
|
|
21
|
+
# spawned.
|
|
22
|
+
class Resource
|
|
23
|
+
# Seconds between looks while waiting (#wait_until).
|
|
24
|
+
# @api private
|
|
25
|
+
POLL = 0.01
|
|
26
|
+
|
|
27
|
+
# Seconds a process gets to end after its stop signal before it's
|
|
28
|
+
# killed.
|
|
29
|
+
# @api private
|
|
30
|
+
STOP_WAIT = 5
|
|
31
|
+
|
|
32
|
+
# The lines of its log a failure shows.
|
|
33
|
+
# @api private
|
|
34
|
+
LOG_LINES = 10
|
|
35
|
+
|
|
36
|
+
# What a cache key may be.
|
|
37
|
+
# @api private
|
|
38
|
+
KEY = /\A[\w.-]+\z/
|
|
39
|
+
|
|
40
|
+
# The lock on building a cache (each worker is prepared on a thread
|
|
41
|
+
# of its own).
|
|
42
|
+
# @api private
|
|
43
|
+
CACHE = Mutex.new
|
|
44
|
+
|
|
45
|
+
# The worker it's for ("baseline-1", say), or the version ("baseline",
|
|
46
|
+
# for Config#prepare(:version)), the directory it has of its own, the
|
|
47
|
+
# tree of the version that worker runs, and the Ruby it runs on (see
|
|
48
|
+
# Config#ruby).
|
|
49
|
+
attr_reader :name, :dir, :root, :ruby
|
|
50
|
+
|
|
51
|
+
# What its commands (#spawn, #run) get in their environment, under
|
|
52
|
+
# their own: its version's (what Config#prepare(:version) returned, and
|
|
53
|
+
# its Ruby's: see Rubies.environment).
|
|
54
|
+
attr_reader :env
|
|
55
|
+
|
|
56
|
+
# +env+ with +vars+ (a prepare block's, for +name+) too: two that set
|
|
57
|
+
# a variable differently fail.
|
|
58
|
+
# @api private
|
|
59
|
+
def self.environment(name, env, vars)
|
|
60
|
+
env.merge(vars) do |var, mine, theirs|
|
|
61
|
+
raise Failed, "#{name}: two prepare blocks set #{var} (#{mine}, #{theirs})" unless mine == theirs
|
|
62
|
+
|
|
63
|
+
mine
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
def initialize(dir, name, root, timeout:, cache:, ruby: RbConfig.ruby, env: {})
|
|
69
|
+
@name = name
|
|
70
|
+
@dir = dir
|
|
71
|
+
@root = root
|
|
72
|
+
@ruby = ruby
|
|
73
|
+
@env = env
|
|
74
|
+
@timeout = timeout
|
|
75
|
+
@cache = cache
|
|
76
|
+
@processes = {}
|
|
77
|
+
@stops = []
|
|
78
|
+
FileUtils.mkdir_p(dir)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A path in its directory. (Unix socket paths are short: up to 104
|
|
82
|
+
# bytes on macOS. Duel's directories leave room.)
|
|
83
|
+
def path(file) = File.join(@dir, file)
|
|
84
|
+
|
|
85
|
+
# A directory built once, by the block, for every worker of this run
|
|
86
|
+
# and later ones (a database's initial files, say, for each worker to
|
|
87
|
+
# copy): the first that asks for +key+ builds it, in a directory the
|
|
88
|
+
# block is given, and the rest get it built. A key names what's built
|
|
89
|
+
# (put a version in it), as a file name: tmp/duel/cache/+key+, which
|
|
90
|
+
# `duel --clean` removes. A build that fails leaves nothing behind.
|
|
91
|
+
def cached(key)
|
|
92
|
+
raise ArgumentError, "a cache key is a file name (letters, digits, - _ .), not #{key.inspect}" unless key.match?(KEY)
|
|
93
|
+
|
|
94
|
+
dir = File.join(@cache, key)
|
|
95
|
+
CACHE.synchronize { build(dir) { |building| yield building } unless File.directory?(dir) }
|
|
96
|
+
dir
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Starts a process (as Process.spawn takes it), its output added to a
|
|
100
|
+
# log in the directory (+log+.log: by default, its program's name),
|
|
101
|
+
# and stopped with the resource, by +stop_signal+ (then KILL, five
|
|
102
|
+
# seconds later). Returns its pid. Its input is
|
|
103
|
+
# nothing, and it gets no other file descriptors, unless +options+
|
|
104
|
+
# say otherwise (in:, close_others:). It runs in its version's
|
|
105
|
+
# environment (on its Ruby: see Config#ruby), under any it's given.
|
|
106
|
+
def spawn(*command, log: nil, stop_signal: "TERM", **options)
|
|
107
|
+
log = log_path(command, log)
|
|
108
|
+
pid = start(command, log, options)
|
|
109
|
+
@processes[pid] = [File.basename(log, ".log"), log, stop_signal]
|
|
110
|
+
pid
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Runs a command (setting something up, say) to its end, its output
|
|
114
|
+
# added to a log as #spawn's is. It fails, showing it, unless it
|
|
115
|
+
# succeeds.
|
|
116
|
+
def run(*command, log: nil, **options)
|
|
117
|
+
log = log_path(command, log)
|
|
118
|
+
_, status = Process.wait2(start(command, log, options))
|
|
119
|
+
return if status.success?
|
|
120
|
+
|
|
121
|
+
code = status.exitstatus
|
|
122
|
+
signal = status.termsig #: Integer
|
|
123
|
+
failed("#{File.basename(log, ".log")} #{code ? "exited with #{code}" : "was killed by SIG#{Signal.signame(signal)}"}", log: log)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Waits until the block is true: fails if a process spawned dies
|
|
127
|
+
# first, or the run's timeout (--timeout) passes.
|
|
128
|
+
def wait_until
|
|
129
|
+
deadline = monotonic + @timeout
|
|
130
|
+
until yield
|
|
131
|
+
ended = @processes.find { |pid, _| Process.wait(pid, Process::WNOHANG) }
|
|
132
|
+
if ended
|
|
133
|
+
# (It may have got ready, and ended, since the last look.)
|
|
134
|
+
break if yield
|
|
135
|
+
|
|
136
|
+
program, log = ended.last
|
|
137
|
+
failed("#{program} exited before it was ready", log: log)
|
|
138
|
+
end
|
|
139
|
+
failed("not ready within #{@timeout}s", log: @processes.values.last&.fetch(1)) if monotonic > deadline
|
|
140
|
+
sleep POLL
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Runs the block when the resource stops, before its processes do.
|
|
145
|
+
def at_stop(&block) = @stops << block
|
|
146
|
+
|
|
147
|
+
# Fails, saying it's this resource that did, with the end of +log+ (a
|
|
148
|
+
# path) to say why.
|
|
149
|
+
def failed(message, log: nil)
|
|
150
|
+
tail = log ? File.readlines(log).last(LOG_LINES).join : ""
|
|
151
|
+
raise Failed, "#{@name}'s resource: #{message}#{"\n#{tail}" unless tail.empty?}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Stops it: the at_stop blocks, last first, then each process.
|
|
155
|
+
# @api private
|
|
156
|
+
def stop
|
|
157
|
+
@stops.reverse_each(&:call)
|
|
158
|
+
@processes.each { |pid, (_, _, signal)| end_process(pid, signal) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Builds +dir+ in a directory of its own, then renames it into place:
|
|
164
|
+
# if another run got there first, its build stands.
|
|
165
|
+
def build(dir)
|
|
166
|
+
building = "#{dir}.#{Process.pid}.building"
|
|
167
|
+
begin
|
|
168
|
+
FileUtils.rm_rf(building)
|
|
169
|
+
FileUtils.mkdir_p(building)
|
|
170
|
+
yield building
|
|
171
|
+
File.rename(building, dir)
|
|
172
|
+
rescue SystemCallError, IOError # (JRuby's, renaming onto a directory)
|
|
173
|
+
raise unless File.directory?(dir)
|
|
174
|
+
ensure
|
|
175
|
+
FileUtils.rm_rf(building)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The log a command's output goes to: +name+.log, or its program's.
|
|
180
|
+
def log_path(command, name) = path("#{name || File.basename(command.grep(String).fetch(0).split.fetch(0))}.log")
|
|
181
|
+
|
|
182
|
+
def start(command, log, options)
|
|
183
|
+
# (Its own environment, if it's given one, over its version's.)
|
|
184
|
+
none = {} #: Hash[String, String?]
|
|
185
|
+
env, *program = command.first.is_a?(Hash) ? command : [none, *command]
|
|
186
|
+
out = File.new(log, "a") # (here, so it's there for a failure to show)
|
|
187
|
+
begin
|
|
188
|
+
Process.spawn(@env.merge(env), *program, in: File::NULL, out: out, err: out, close_others: true, **options)
|
|
189
|
+
ensure
|
|
190
|
+
out.close
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# (Waited for on a thread of its own: a Timeout can't interrupt a wait
|
|
195
|
+
# on JRuby.)
|
|
196
|
+
def end_process(pid, signal)
|
|
197
|
+
Process.kill(signal, pid)
|
|
198
|
+
waiter = Process.detach(pid)
|
|
199
|
+
return if waiter.join(STOP_WAIT)
|
|
200
|
+
|
|
201
|
+
Process.kill("KILL", pid)
|
|
202
|
+
waiter.join
|
|
203
|
+
rescue Errno::ESRCH # gone already
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
208
|
+
end
|
|
209
|
+
end
|
data/lib/duel/rubies.rb
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "../duel"
|
|
6
|
+
|
|
7
|
+
module Duel
|
|
8
|
+
# The Ruby each version runs on (Config#ruby): by default the runner's.
|
|
9
|
+
# With :version_file, it's the one the version's own version file names
|
|
10
|
+
# (.ruby-version, .tool-versions or mise.toml, in its tree or above it):
|
|
11
|
+
# the runner's own, if it's that one, or else the one installed with
|
|
12
|
+
# whichever of rbenv, chruby, mise and asdf has it. Or it's a path, or
|
|
13
|
+
# what a block makes of the version's tree.
|
|
14
|
+
# @api private
|
|
15
|
+
module Rubies
|
|
16
|
+
# The files that name a Ruby, in the order they're looked at in each
|
|
17
|
+
# directory.
|
|
18
|
+
VERSION_FILES = %w[.ruby-version .tool-versions mise.toml .mise.toml].freeze
|
|
19
|
+
|
|
20
|
+
# Where chruby finds Rubies.
|
|
21
|
+
CHRUBY = ["/opt/rubies", File.join(Dir.home, ".rubies")].freeze
|
|
22
|
+
|
|
23
|
+
# Where each manager has a version of Ruby installed (its prefix), or
|
|
24
|
+
# nil: rbenv's and asdf's by exact name (without "ruby-"), chruby's as
|
|
25
|
+
# chruby matches it, and mise's as mise does (3.4: the newest 3.4).
|
|
26
|
+
MANAGERS = {
|
|
27
|
+
"rbenv" => ->(version) { Rubies.command("rbenv", "prefix", version.delete_prefix("ruby-")) },
|
|
28
|
+
"chruby" => ->(version) { Rubies.chruby(version) },
|
|
29
|
+
"mise" => ->(version) { Rubies.command("mise", "where", "ruby@#{version.delete_prefix("ruby-")}") },
|
|
30
|
+
"asdf" => ->(version) { Rubies.command("asdf", "where", "ruby", version.delete_prefix("ruby-")) }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# A Ruby's own variables, set by a manager (chruby's gems, say) for the
|
|
34
|
+
# Ruby it chose: wrong for any other.
|
|
35
|
+
VARIABLES = %w[GEM_HOME GEM_PATH GEM_ROOT RUBY_ROOT RUBY_ENGINE RUBY_VERSION RUBY_PATCHLEVEL RBENV_VERSION
|
|
36
|
+
MISE_RUBY_VERSION ASDF_RUBY_VERSION].freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# The Ruby (its executable) that +choice+ gives the version +name+d
|
|
40
|
+
# ("baseline") at +root+: one that isn't there fails.
|
|
41
|
+
def find(choice, root, name)
|
|
42
|
+
ruby = case choice
|
|
43
|
+
when nil then RbConfig.ruby
|
|
44
|
+
when :version_file then from_version_file(root, name)
|
|
45
|
+
when String then choice
|
|
46
|
+
else choice.call(root)
|
|
47
|
+
end
|
|
48
|
+
raise Error, "the #{name}'s Ruby (#{ruby}) isn't there" unless File.file?(ruby) && File.executable?(ruby)
|
|
49
|
+
|
|
50
|
+
ruby
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether +ruby+ is the runner's own.
|
|
54
|
+
def runner?(ruby) = File.realpath(ruby) == File.realpath(RbConfig.ruby)
|
|
55
|
+
|
|
56
|
+
# What a version's processes on +ruby+ get in their environment:
|
|
57
|
+
# nothing on the runner's Ruby. On another, its directory first on
|
|
58
|
+
# +path+ (for what they start by name, `bundle` say), and none of the
|
|
59
|
+
# runner's Ruby's VARIABLES.
|
|
60
|
+
def environment(ruby, path: ENV["PATH"])
|
|
61
|
+
return {} if runner?(ruby)
|
|
62
|
+
|
|
63
|
+
VARIABLES.to_h { |variable| [variable, nil] }.merge("PATH" => [File.dirname(ruby), path].compact.join(File::PATH_SEPARATOR))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The nearest version file, from +root+ up, that names a Ruby, and the
|
|
67
|
+
# version it names: [file, version], or nil.
|
|
68
|
+
def requested(root)
|
|
69
|
+
dir = File.expand_path(root)
|
|
70
|
+
loop do
|
|
71
|
+
VERSION_FILES.each do |name|
|
|
72
|
+
file = File.join(dir, name)
|
|
73
|
+
version = (read(file) if File.file?(file))
|
|
74
|
+
return [file, version] if version
|
|
75
|
+
end
|
|
76
|
+
return if dir == (dir = File.dirname(dir))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The version of Ruby a version file names, or nil (a .tool-versions
|
|
81
|
+
# of other tools, say).
|
|
82
|
+
def read(file)
|
|
83
|
+
text = File.read(file)
|
|
84
|
+
case File.basename(file)
|
|
85
|
+
when ".ruby-version" then text.split.first
|
|
86
|
+
when ".tool-versions" then text[/^ruby[ \t]+(\S+)/, 1]
|
|
87
|
+
else
|
|
88
|
+
# (mise.toml's [tools], up to the next table: ruby = "3.4",
|
|
89
|
+
# ruby = ["3.4"], or ruby = { version = "3.4" }.)
|
|
90
|
+
tools = text.partition(/^\[tools\]/).last.split(/^\[/).first
|
|
91
|
+
tools&.[](/^[ \t]*"?ruby"?[ \t]*=[ \t]*[\[{]?[ \t]*(?:version[ \t]*=[ \t]*)?"([^"]+)"/, 1)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Where chruby has +version+: the Ruby of that name, or else the last
|
|
96
|
+
# (by name) whose name has it in it.
|
|
97
|
+
def chruby(version)
|
|
98
|
+
dirs = CHRUBY.flat_map { |rubies| Dir.glob(File.join(rubies, "*")) }
|
|
99
|
+
dirs.find { |dir| File.basename(dir) == version } || dirs.reverse.find { |dir| File.basename(dir).include?(version) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether +version+, as a version file names it, is the runner's own
|
|
103
|
+
# Ruby (by its +engine+ and its +engine_version+), as the managers
|
|
104
|
+
# match one: with or without "ruby-" for CRuby (a bare version is
|
|
105
|
+
# CRuby's), with its engine's name for another ("jruby-10.1.2.0",
|
|
106
|
+
# "truffleruby+graalvm-24.1.0"), and exactly or by its first parts
|
|
107
|
+
# ("3.4" for 3.4.11, as mise and chruby take it).
|
|
108
|
+
def runner_asked?(version, engine: RUBY_ENGINE, engine_version: RUBY_ENGINE_VERSION)
|
|
109
|
+
match = /\A(?:([a-z]+)(?:\+[^-]+)?-)?(\d[\w.]*)\z/.match(version)
|
|
110
|
+
return false unless match
|
|
111
|
+
|
|
112
|
+
name, number = match.captures
|
|
113
|
+
(name || "ruby") == engine && (engine_version == number || engine_version.start_with?("#{number}."))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# What a command prints, or nil if it fails (or isn't there).
|
|
117
|
+
def command(*command)
|
|
118
|
+
out, _, status = Open3.capture3(*command)
|
|
119
|
+
out.strip if status.success?
|
|
120
|
+
rescue SystemCallError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# The Ruby the nearest version file names (see #requested): the
|
|
127
|
+
# runner's, if it's that one (see #runner_asked?) or there's no
|
|
128
|
+
# version file, or else the first manager's that has it.
|
|
129
|
+
def from_version_file(root, name)
|
|
130
|
+
requested = requested(root)
|
|
131
|
+
return RbConfig.ruby unless requested
|
|
132
|
+
|
|
133
|
+
file, version = requested
|
|
134
|
+
# (The runner's own, if it's the one asked for: installed where no
|
|
135
|
+
# manager looks, by CI's setup step, say.)
|
|
136
|
+
return RbConfig.ruby if runner_asked?(version)
|
|
137
|
+
|
|
138
|
+
MANAGERS.each_value do |manager|
|
|
139
|
+
prefix = manager.call(version)
|
|
140
|
+
# (JRuby's may have no bin/ruby.)
|
|
141
|
+
ruby = prefix && %w[ruby jruby].map { |program| File.join(prefix, "bin", program) }.find { |path| File.executable?(path) }
|
|
142
|
+
return ruby if ruby
|
|
143
|
+
end
|
|
144
|
+
raise Error, "the #{name}'s #{file} asks for Ruby #{version}, which isn't installed with rbenv, chruby, mise or " \
|
|
145
|
+
"asdf: install it, or set c.ruby in .duel.rb"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|