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
data/lib/duel/init.rb
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "optparse"
|
|
7
|
+
require "pathname"
|
|
8
|
+
require_relative "../duel"
|
|
9
|
+
|
|
10
|
+
module Duel
|
|
11
|
+
# `duel init`: sets Duel up in a gem or an app that isn't a Rails app
|
|
12
|
+
# (duel-rails's generator sets a Rails app up). It writes .duel.rb, a
|
|
13
|
+
# benchmark to start from, and a GitHub Actions workflow, adds a Rake
|
|
14
|
+
# task to the Rakefile, and adds StackProf to the Gemfile (for profiles
|
|
15
|
+
# of regressions, on CRuby). It overwrites nothing without --force, and
|
|
16
|
+
# writes nothing with --dry-run.
|
|
17
|
+
# @api private
|
|
18
|
+
class Init
|
|
19
|
+
USAGE = "Usage: duel init [options]\n\n" \
|
|
20
|
+
"Sets Duel up in this project: .duel.rb, a benchmark to start from in duel/, a GitHub Actions workflow,\n" \
|
|
21
|
+
"a Rake task, and StackProf in the Gemfile.\n\n"
|
|
22
|
+
|
|
23
|
+
TEMPLATES = File.join(__dir__, "templates")
|
|
24
|
+
|
|
25
|
+
# What's added to a Rakefile.
|
|
26
|
+
RAKE_TASK = %(require "duel/rake_task"\nDuel::RakeTask.new\n)
|
|
27
|
+
|
|
28
|
+
# What's added to a Gemfile, for profiles of regressions: StackProf
|
|
29
|
+
# runs on CRuby only, so it's left out of JRuby's and TruffleRuby's
|
|
30
|
+
# bundles.
|
|
31
|
+
STACKPROF = %(gem "stackprof", platforms: :mri)
|
|
32
|
+
|
|
33
|
+
# A Gemfile's development group (or development and test's), with the
|
|
34
|
+
# indentation of its first line.
|
|
35
|
+
DEVELOPMENT = /^([ \t]*)group[ \t(]+(?::development(?:,[ \t]*:test)?|:test,[ \t]*:development)[ \t)]+do[ \t]*\n/
|
|
36
|
+
|
|
37
|
+
def initialize(argv, out: $stdout, err: $stderr, root: Dir.pwd)
|
|
38
|
+
@argv = argv
|
|
39
|
+
@out = out
|
|
40
|
+
@err = err
|
|
41
|
+
@root = root
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the exit status: 0 set up, 1 a Rails app (not set up), 2 a
|
|
45
|
+
# bad option.
|
|
46
|
+
def run
|
|
47
|
+
rest = catch(:help) { parser.parse(@argv) } or return 0
|
|
48
|
+
raise OptionParser::NeedlessArgument, rest.first unless rest.empty?
|
|
49
|
+
return rails if File.exist?(File.join(@root, "config", "application.rb"))
|
|
50
|
+
|
|
51
|
+
@out.puts "duel init: a dry run, so nothing is written" if @dry_run
|
|
52
|
+
files.each { |path, content| write(path, content) }
|
|
53
|
+
add_rake_task
|
|
54
|
+
add_stackprof
|
|
55
|
+
@out.print next_steps
|
|
56
|
+
0
|
|
57
|
+
rescue OptionParser::ParseError => e
|
|
58
|
+
@err.puts "duel init: #{e}"
|
|
59
|
+
2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The files it writes, from the project's root, to what's in them.
|
|
63
|
+
def files
|
|
64
|
+
workflow = Pathname(File.realpath(repository)).relative_path_from(File.realpath(@root)).join(".github", "workflows", workflow_name)
|
|
65
|
+
{".duel.rb" => render("duel.rb"), benchmark_file => render("benchmark_duel.rb"), workflow.to_s => render("perf.yml")}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parser
|
|
71
|
+
OptionParser.new do |o|
|
|
72
|
+
o.banner = USAGE
|
|
73
|
+
o.on("--force", "Overwrite files that exist") { @force = true }
|
|
74
|
+
o.on("--dry-run", "Say what it would do, and write nothing") { @dry_run = true }
|
|
75
|
+
o.on("-h", "--help", "Print this help") do
|
|
76
|
+
@out.puts o
|
|
77
|
+
throw :help
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Says to use duel-rails's generator instead.
|
|
83
|
+
def rails
|
|
84
|
+
@err.puts "duel init: this is a Rails app (config/application.rb). Add duel-rails to the Gemfile's development group, " \
|
|
85
|
+
"and run `bin/rails generate duel:install` instead"
|
|
86
|
+
1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Writes +content+ to +path+, unless it exists (without --force), and
|
|
90
|
+
# says what it did.
|
|
91
|
+
def write(path, content)
|
|
92
|
+
full = File.join(@root, path)
|
|
93
|
+
exists = File.exist?(full)
|
|
94
|
+
return said("skip", "#{path} (it exists: --force to overwrite)") if exists && !@force
|
|
95
|
+
|
|
96
|
+
said(exists ? "overwrite" : "create", path)
|
|
97
|
+
return if @dry_run
|
|
98
|
+
|
|
99
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
100
|
+
File.write(full, content)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Adds Duel's Rake task to the Rakefile, if there is one without it.
|
|
104
|
+
def add_rake_task
|
|
105
|
+
rakefile = File.join(@root, "Rakefile")
|
|
106
|
+
return unless File.exist?(rakefile)
|
|
107
|
+
|
|
108
|
+
text = File.read(rakefile)
|
|
109
|
+
return said("skip", "Rakefile (it has Duel's task)") if text.include?("duel/rake_task")
|
|
110
|
+
|
|
111
|
+
said("append", "Rakefile")
|
|
112
|
+
File.write(rakefile, gap(text) + RAKE_TASK, mode: "a") unless @dry_run
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Adds StackProf to the Gemfile, if there is one without it: in its
|
|
116
|
+
# development group, if it has one, or else at its end.
|
|
117
|
+
def add_stackprof
|
|
118
|
+
gemfile = File.join(@root, "Gemfile")
|
|
119
|
+
return unless File.exist?(gemfile)
|
|
120
|
+
|
|
121
|
+
text = File.read(gemfile)
|
|
122
|
+
return said("skip", "Gemfile (it has stackprof)") if text.include?("stackprof")
|
|
123
|
+
|
|
124
|
+
@stackprof = true
|
|
125
|
+
grouped = in_development_group(text)
|
|
126
|
+
said(grouped ? "insert" : "append", "Gemfile (#{STACKPROF}#{", in the development group" if grouped})")
|
|
127
|
+
File.write(gemfile, grouped || at_the_end(text)) unless @dry_run
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The Gemfile's +text+ with StackProf last in its development group,
|
|
131
|
+
# or nil without one (or without the end of one).
|
|
132
|
+
def in_development_group(text)
|
|
133
|
+
group = DEVELOPMENT.match(text) or return
|
|
134
|
+
indent = group[1]
|
|
135
|
+
ending = /^#{indent}end\b/.match(group.post_match) or return
|
|
136
|
+
"#{group.pre_match}#{group}#{ending.pre_match}#{indent} #{STACKPROF}\n#{ending}#{ending.post_match}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# What goes between a file's +text+ and what's added at its end: a
|
|
140
|
+
# blank line, if there's anything before it.
|
|
141
|
+
def gap(text) = text.empty? ? "" : "#{"\n" unless text.end_with?("\n")}\n"
|
|
142
|
+
|
|
143
|
+
# The Gemfile's +text+ with StackProf at its end.
|
|
144
|
+
def at_the_end(text) = "#{text}#{gap(text)}# For profiles of regressions (StackProf runs on CRuby only)\n#{STACKPROF}\n"
|
|
145
|
+
|
|
146
|
+
def said(what, path) = @out.puts(format(" %-10s%s", what, path))
|
|
147
|
+
|
|
148
|
+
# What to do next, numbered, after a blank line.
|
|
149
|
+
def next_steps
|
|
150
|
+
steps = [] #: Array[String]
|
|
151
|
+
if !gemfile?
|
|
152
|
+
steps << %(Add gem "duel" to the Gemfile's development group, and run `bundle install`.)
|
|
153
|
+
elsif @stackprof
|
|
154
|
+
steps << "Run `bundle install`, for StackProf (added to the Gemfile)."
|
|
155
|
+
end
|
|
156
|
+
steps << "Set c.library in .duel.rb to what each version's workers require." unless required
|
|
157
|
+
steps << "Replace the example in #{benchmark_file} with benchmarks of your own code."
|
|
158
|
+
steps << "Run `bundle exec duel --force`. It compares the code with itself, and passes when the benchmarks are sound."
|
|
159
|
+
steps << "Commit and push. On a pull request, the workflow compares the code with the main branch, and comments with the report."
|
|
160
|
+
"\nNext:\n#{steps.each_with_index.map { |step, i| " #{i + 1}. #{step}\n" }.join}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def render(name) = ERB.new(File.read(File.join(TEMPLATES, "#{name}.erb")), trim_mode: "-").result(binding)
|
|
164
|
+
|
|
165
|
+
# The library the gemspec names (see Config#library), or nil.
|
|
166
|
+
def library = Config.new(@root).library
|
|
167
|
+
|
|
168
|
+
# The library, without a gemspec: lib/'s only Ruby file, or nil.
|
|
169
|
+
def guess
|
|
170
|
+
found = Dir.glob("*.rb", base: File.join(@root, "lib"))
|
|
171
|
+
File.basename(found.first, ".rb") if found.size == 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# What each version's workers require: the library, or the guess.
|
|
175
|
+
def required = library || guess
|
|
176
|
+
|
|
177
|
+
def benchmark_file = File.join("duel", "#{(required || "example").tr("/", "_")}_duel.rb")
|
|
178
|
+
|
|
179
|
+
def gemfile? = File.exist?(File.join(@root, "Gemfile")) && File.read(File.join(@root, "Gemfile")).match?(/gem ["']duel["']/)
|
|
180
|
+
|
|
181
|
+
def ext = Dir.exist?(File.join(@root, "ext"))
|
|
182
|
+
|
|
183
|
+
def version_file = File.exist?(File.join(@root, ".ruby-version"))
|
|
184
|
+
|
|
185
|
+
# The Ruby the workflow sets up: this one's (its minor version), or
|
|
186
|
+
# none, where .ruby-version names it.
|
|
187
|
+
def ruby_version = (RUBY_VERSION[/\A\d+\.\d+/] unless version_file)
|
|
188
|
+
|
|
189
|
+
# The root of the project's git repository: the project's own
|
|
190
|
+
# directory, outside one.
|
|
191
|
+
def repository
|
|
192
|
+
top, _, status = Open3.capture3("git", "-C", @root, "rev-parse", "--show-toplevel")
|
|
193
|
+
status.success? ? top.chomp : @root
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The project's directory, from its repository's root ("gems/parser"),
|
|
197
|
+
# or nil at the root.
|
|
198
|
+
def working_directory
|
|
199
|
+
path = Pathname(File.realpath(@root)).relative_path_from(File.realpath(repository)).to_s
|
|
200
|
+
path unless path == "."
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# perf.yml, or for a project in a subdirectory of its repository,
|
|
204
|
+
# perf-<its path>.yml (perf-gems-parser.yml), so that each of a
|
|
205
|
+
# monorepo's projects can have its own.
|
|
206
|
+
def workflow_name = working_directory&.then { |dir| "perf-#{dir.tr("/", "-")}.yml" } || "perf.yml"
|
|
207
|
+
|
|
208
|
+
# What marks the workflow's comment on a pull request.
|
|
209
|
+
def marker = ["duel", working_directory].compact.join(" ")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "runner"
|
|
5
|
+
require_relative "summary"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module Duel
|
|
9
|
+
# A run's report as JSON, for a program to read (`--json PATH`, see
|
|
10
|
+
# CLI): the same results the Markdown report has (see Summary), in plain
|
|
11
|
+
# JSON types, numbers as numbers. Its schema is documented in
|
|
12
|
+
# docs/json.md, and SCHEMA is its version, raised for any change that
|
|
13
|
+
# isn't a new key.
|
|
14
|
+
# @api private
|
|
15
|
+
class JSONReport
|
|
16
|
+
SCHEMA = 1
|
|
17
|
+
|
|
18
|
+
# A run that didn't compare: +status+ ("nothing_to_compare", or
|
|
19
|
+
# "error"), its +exit+ status, and +message+, what it said.
|
|
20
|
+
def self.note(status, exit, message, settings)
|
|
21
|
+
none = [] #: Array[value]
|
|
22
|
+
nothing = {} #: report
|
|
23
|
+
head(status, exit, message, settings).merge("seconds" => nil, "baseline" => nil, "candidate" => nil, "plugins" => none,
|
|
24
|
+
"benchmarks" => none, "across" => nothing, "errors" => none)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# What every report starts with.
|
|
28
|
+
def self.head(status, exit, message, settings)
|
|
29
|
+
{"schema" => SCHEMA, "duel_version" => VERSION, "status" => status, "exit" => exit, "message" => message,
|
|
30
|
+
"settings" => {"confidence" => settings.confidence, "limit" => settings.limit, "filter" => settings.filter.source,
|
|
31
|
+
"jobs" => settings.jobs, "processes" => settings.processes, "timeout" => settings.timeout,
|
|
32
|
+
"warm" => settings.warm, "force" => settings.force}}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +runner+ ran, and made +results+, comparing +baseline+ with
|
|
36
|
+
# +candidate+ (each {"ref", "commit", "root"} or {"commit",
|
|
37
|
+
# "uncommitted_changes", "root"}) as +settings+ say, and exits with
|
|
38
|
+
# +exit+.
|
|
39
|
+
def initialize(runner, results, settings:, exit:, baseline:, candidate:)
|
|
40
|
+
@runner = runner
|
|
41
|
+
@results = results
|
|
42
|
+
@settings = settings
|
|
43
|
+
@exit = exit
|
|
44
|
+
@versions = {"baseline" => baseline, "candidate" => candidate}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
rubies = @runner.rubies
|
|
49
|
+
JSONReport.head(@exit.zero? ? "passed" : "failed", @exit, nil, @settings).merge(
|
|
50
|
+
"seconds" => @runner.seconds,
|
|
51
|
+
**@versions.to_h { |side, version| [side, version.merge("ruby" => rubies.fetch(side))] },
|
|
52
|
+
"plugins" => @runner.plugins.map(&:to_s),
|
|
53
|
+
"benchmarks" => @results.map { |result| benchmark(result) },
|
|
54
|
+
"across" => across,
|
|
55
|
+
"errors" => @results.select(&:error).map { |result| {"benchmark" => result.name, "message" => result.error} }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# (Runner#explained says nothing of a benchmark that didn't regress.)
|
|
62
|
+
def benchmark(result)
|
|
63
|
+
{"name" => result.name, "verdict" => Summary.verdict(result), "passed" => result.passed?, "regressed" => result.regressed,
|
|
64
|
+
"unsettled" => result.unsettled?, "iterations" => result.n, "seconds" => result.seconds,
|
|
65
|
+
"confirmations" => result.confirmations.size, "timed" => timed(result), "counted" => counted(result),
|
|
66
|
+
"peak_memory" => peak_memory(result), "details" => result.details,
|
|
67
|
+
"explanation" => @runner.explained(result), "error" => result.error, "skipped" => result.skipped}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Each timed measure: its ratio's median, interval and verdict, and
|
|
71
|
+
# each version's median an iteration.
|
|
72
|
+
def timed(result)
|
|
73
|
+
# (Timed, so measured, with an n.)
|
|
74
|
+
result.timed.to_h do |metric, t|
|
|
75
|
+
n = result.n #: Integer
|
|
76
|
+
[metric, {"ratio" => t.fetch("median"), "interval" => t.fetch("interval"), "limit" => t.fetch("limit"),
|
|
77
|
+
"pairs" => t.fetch("pairs"), "verdict" => t.fetch("verdict"), "baseline" => t.fetch("baseline").fdiv(n),
|
|
78
|
+
"candidate" => t.fetch("candidate").fdiv(n), "unit" => t.fetch("unit"), "plural" => t.fetch("plural"),
|
|
79
|
+
"across" => t.fetch("across")}]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Each counted measure: each version's fewest an iteration, and
|
|
84
|
+
# whether that's worse.
|
|
85
|
+
def counted(result)
|
|
86
|
+
worse = result.worse
|
|
87
|
+
result.counted.to_h do |metric, c|
|
|
88
|
+
n = c.fetch("n")
|
|
89
|
+
[metric, {"baseline" => c.fetch("baseline").fdiv(n), "candidate" => c.fetch("candidate").fdiv(n), "unit" => c.fetch("unit"),
|
|
90
|
+
"plural" => c.fetch("plural"), "worse" => worse.include?(metric)}]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def peak_memory(result)
|
|
95
|
+
result.memory&.then do |memory|
|
|
96
|
+
{"baseline" => memory.fetch("baseline"), "candidate" => memory.fetch("candidate"), "unit" => "B", "worse" => result.more_memory?}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def across
|
|
101
|
+
@runner.across.transform_values do |v|
|
|
102
|
+
{"ratio" => v.fetch("median"), "interval" => v.fetch("interval"), "limit" => v.fetch("limit"), "benchmarks" => v.fetch("benchmarks"),
|
|
103
|
+
"verdict" => v.fetch("verdict"), "confirmed" => v.fetch("confirmed", false)}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/duel/lock.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../duel"
|
|
5
|
+
|
|
6
|
+
module Duel
|
|
7
|
+
# One run at a time in a project: two would share its tmp/duel and its
|
|
8
|
+
# baseline's checkout, each checking out and building over the other's.
|
|
9
|
+
# A run holds an flock on tmp/duel/lock, which ends with its process
|
|
10
|
+
# however that ends, so a run that crashed leaves nothing to clear. The
|
|
11
|
+
# file says whose it is, for a run that finds it taken.
|
|
12
|
+
# @api private
|
|
13
|
+
module Lock
|
|
14
|
+
# An exclusive lock, or none if another holds it.
|
|
15
|
+
EXCLUSIVE = File::LOCK_EX | File::LOCK_NB
|
|
16
|
+
|
|
17
|
+
# Runs the block holding +dir+'s lock. Fails, naming the run that
|
|
18
|
+
# holds it, if another does.
|
|
19
|
+
def self.hold(dir)
|
|
20
|
+
FileUtils.mkdir_p(dir)
|
|
21
|
+
# (Read from the start, and written at the end: there's no more.)
|
|
22
|
+
file = File.new(File.join(dir, "lock"), "a+")
|
|
23
|
+
begin
|
|
24
|
+
unless file.flock(EXCLUSIVE)
|
|
25
|
+
# (Empty for a moment, while its run writes its pid.)
|
|
26
|
+
pid = file.read
|
|
27
|
+
raise Error, "another duel run#{" (pid #{pid})" unless pid.empty?} is using #{dir}: wait for it to end, or stop it"
|
|
28
|
+
end
|
|
29
|
+
file.truncate(0)
|
|
30
|
+
file.write(Process.pid)
|
|
31
|
+
file.flush
|
|
32
|
+
yield
|
|
33
|
+
ensure
|
|
34
|
+
file.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|