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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/AGENT_PROMPT.md +211 -0
  3. data/CHANGELOG.md +281 -0
  4. data/LICENSE +21 -0
  5. data/README.md +354 -0
  6. data/docs/README.md +23 -0
  7. data/docs/benchmarks.md +289 -0
  8. data/docs/ci.md +141 -0
  9. data/docs/configuration.md +429 -0
  10. data/docs/how-it-works.md +207 -0
  11. data/docs/json.md +306 -0
  12. data/docs/plugins.md +408 -0
  13. data/docs/troubleshooting.md +105 -0
  14. data/exe/duel +6 -0
  15. data/lib/duel/across.rb +48 -0
  16. data/lib/duel/baseline.rb +166 -0
  17. data/lib/duel/cli.rb +264 -0
  18. data/lib/duel/config.rb +343 -0
  19. data/lib/duel/context.rb +630 -0
  20. data/lib/duel/init.rb +211 -0
  21. data/lib/duel/json_report.rb +107 -0
  22. data/lib/duel/lock.rb +38 -0
  23. data/lib/duel/metric.rb +496 -0
  24. data/lib/duel/native.rb +162 -0
  25. data/lib/duel/process_sample.rb +144 -0
  26. data/lib/duel/profile.rb +116 -0
  27. data/lib/duel/rake_task.rb +25 -0
  28. data/lib/duel/resource.rb +209 -0
  29. data/lib/duel/rubies.rb +149 -0
  30. data/lib/duel/runner.rb +1114 -0
  31. data/lib/duel/stats.rb +73 -0
  32. data/lib/duel/summary.rb +237 -0
  33. data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
  34. data/lib/duel/templates/duel.rb.erb +35 -0
  35. data/lib/duel/templates/perf.yml.erb +97 -0
  36. data/lib/duel/testing.rb +241 -0
  37. data/lib/duel/version.rb +5 -0
  38. data/lib/duel/worker.rb +211 -0
  39. data/lib/duel.rb +413 -0
  40. data/sig/duel/across.rbs +21 -0
  41. data/sig/duel/baseline.rbs +61 -0
  42. data/sig/duel/cli.rbs +95 -0
  43. data/sig/duel/config.rbs +181 -0
  44. data/sig/duel/context.rbs +387 -0
  45. data/sig/duel/init.rbs +97 -0
  46. data/sig/duel/json_report.rbs +47 -0
  47. data/sig/duel/lock.rbs +12 -0
  48. data/sig/duel/metric.rbs +351 -0
  49. data/sig/duel/native.rbs +98 -0
  50. data/sig/duel/process_sample.rbs +54 -0
  51. data/sig/duel/profile.rbs +64 -0
  52. data/sig/duel/rake_task.rbs +9 -0
  53. data/sig/duel/resource.rbs +106 -0
  54. data/sig/duel/rubies.rbs +45 -0
  55. data/sig/duel/runner.rbs +530 -0
  56. data/sig/duel/stats.rbs +26 -0
  57. data/sig/duel/summary.rbs +87 -0
  58. data/sig/duel/testing.rbs +91 -0
  59. data/sig/duel/version.rbs +3 -0
  60. data/sig/duel/worker.rbs +74 -0
  61. data/sig/duel.rbs +276 -0
  62. data/sig/manifest.yaml +4 -0
  63. 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
@@ -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
@@ -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