Skip to content

Commit ab538b2

Browse files
etiennebarriebyroot
andcommitted
Implement bisection
Used to identify global state leak. Co-Authored-By: Jean Boussier <[email protected]>
1 parent a5bebc6 commit ab538b2

File tree

7 files changed

+158
-12
lines changed

7 files changed

+158
-12
lines changed

TODO.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
### Wants
22

3-
- Test leak bisect
4-
- See ci-queue bisect.
5-
63
- `-j` for forkless environments (Windows / JRuby / TruffleRuby)
74

85
- `minitest/mocks`

fixtures/leak/leaky_test.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module TestedApp
4+
singleton_class.attr_accessor :config
5+
self.config = :default
6+
7+
class LeakyTest < Megatest::Test
8+
test "leak cause" do
9+
TestedApp.config = :leak
10+
assert true
11+
end
12+
13+
100.times do |i|
14+
test "test something #{i}" do
15+
assert true
16+
end
17+
end
18+
19+
test "leak sensitive" do
20+
assert_equal :default, TestedApp.config
21+
end
22+
end
23+
end

lib/megatest/cli.rb

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def run!
2828
undef_method :puts, :print # Should only use @out.puts or @err.puts
2929

3030
RUNNERS = {
31+
"bisect" => :bisect,
3132
"report" => :report,
3233
"run" => :run,
3334
}.freeze
@@ -51,6 +52,8 @@ def run
5152
report
5253
when nil, :run
5354
run_tests
55+
when :bisect
56+
bisect_tests
5457
else
5558
raise InvalidArgument, "Parsing failure"
5659
end
@@ -116,6 +119,32 @@ def report
116119
QueueReporter.new(@config, queue, @out).run(default_reporters) ? 0 : 1
117120
end
118121

122+
def bisect_tests
123+
require "megatest/multi_process"
124+
125+
queue = @config.build_queue
126+
raise InvalidArgument, "Distributed queues can't be bisected" if queue.distributed?
127+
128+
@config.selectors = Selector.parse(@argv)
129+
Megatest.load_config(@config)
130+
Megatest.init(@config)
131+
test_cases = Megatest.load_tests(@config)
132+
queue.populate(test_cases)
133+
candidates = queue.dup
134+
135+
if test_cases.empty?
136+
@err.puts "No tests to run"
137+
return 1
138+
end
139+
140+
unless failure = find_failing_test(queue)
141+
@err.puts "No failing test"
142+
return 1
143+
end
144+
145+
bisect_queue(candidates, failure.test_id)
146+
end
147+
119148
private
120149

121150
def default_reporters
@@ -141,6 +170,67 @@ def default_reporters
141170
reporters
142171
end
143172

173+
def find_failing_test(queue)
174+
@config.max_consecutive_failures = 1
175+
@config.jobs_count = 1
176+
177+
executor = MultiProcess::Executor.new(@config.dup, @out)
178+
executor.run(queue, default_reporters)
179+
queue.summary.failures.first
180+
end
181+
182+
def bisect_queue(queue, failing_test_id)
183+
err = Output.new(@err)
184+
tests = queue.to_a
185+
failing_test_index = tests.index { |test| test.id == failing_test_id }
186+
failing_test = tests[failing_test_index]
187+
suspects = tests[0...failing_test_index]
188+
189+
check_passing = @config.build_queue
190+
check_passing.populate([failing_test])
191+
executor = MultiProcess::Executor.new(@config.dup, @out, managed: true)
192+
executor.run(check_passing, [])
193+
unless check_passing.success?
194+
err.puts err.red("Test failed by itself, no need to bisect")
195+
return 1
196+
end
197+
198+
run_index = 0
199+
while suspects.size > 1
200+
run_index += 1
201+
err.puts "Attempt #{run_index}, #{suspects.size} suspects left."
202+
203+
before, after = suspects[0...(suspects.size / 2)], suspects[(suspects.size / 2)..]
204+
candidates = @config.build_queue
205+
candidates.populate(before + [failing_test])
206+
207+
executor = MultiProcess::Executor.new(@config.dup, @out, managed: true)
208+
executor.run(candidates, default_reporters)
209+
210+
if candidates.success?
211+
suspects = after
212+
else
213+
suspects = before
214+
end
215+
216+
err.puts
217+
end
218+
suspect = suspects.first
219+
220+
validation_queue = @config.build_queue
221+
validation_queue.populate([suspect, failing_test])
222+
executor = MultiProcess::Executor.new(@config.dup, @out, managed: true)
223+
executor.run(validation_queue, [])
224+
if validation_queue.success?
225+
err.puts err.red("Bisect inconclusive")
226+
return 1
227+
end
228+
229+
err.print "Found test leak: "
230+
err.puts err.yellow "#{@config.program_name} #{Megatest.relative_path(suspect.location_id)} #{Megatest.relative_path(failing_test.location_id)}"
231+
0
232+
end
233+
144234
def open_file(path)
145235
File.open(path, "w+")
146236
rescue Errno::ENOENT
@@ -176,6 +266,8 @@ def build_parser(runner)
176266
opts.banner = "Usage: #{@program_name} report [options]"
177267
when :run
178268
opts.banner = "Usage: #{@program_name} run [options] [files or directories]"
269+
when :bisect
270+
opts.banner = "Usage: #{@program_name} bisect [options] [files or directories]"
179271
else
180272
opts.banner = "Usage: #{@program_name} command [options] [files or directories]"
181273
opts.separator ""
@@ -190,6 +282,11 @@ def build_parser(runner)
190282
opts.separator "\treport\t\tWait for the queue to be entirely processed and report the status"
191283
opts.separator "\t\t\t $ #{@program_name} report --queue redis://ci-queue.example.com --build-id $CI_BUILD_ID"
192284
opts.separator ""
285+
286+
opts.separator "\tbisect\t\tRepeatedly run subsets of the given tests."
287+
opts.separator "\t\t\t $ #{@program_name} bisect --seed 12345 test/integration"
288+
opts.separator "\t\t\t $ #{@program_name} bisect --queue path/to/test_order.log"
289+
opts.separator ""
193290
end
194291

195292
opts.separator ""
@@ -214,11 +311,13 @@ def build_parser(runner)
214311
@junit = path
215312
end
216313

217-
if runner == :run
314+
if %i[run bisect].include?(runner)
218315
opts.on("--seed SEED", Integer, "The seed used to define run order") do |seed|
219316
@config.seed = seed
220317
end
318+
end
221319

320+
if runner == :run
222321
opts.on("-j", "--jobs JOBS", Integer, "Number of processes to use") do |jobs|
223322
@config.jobs_count = jobs
224323
end
@@ -245,8 +344,10 @@ def build_parser(runner)
245344
@config.queue_url = queue_url
246345
end
247346

248-
opts.on("--build-id ID", String, "Unique identifier for the CI build") do |build_id|
249-
@config.build_id = build_id
347+
if %i[run report].include?(runner)
348+
opts.on("--build-id ID", String, "Unique identifier for the CI build") do |build_id|
349+
@config.build_id = build_id
350+
end
250351
end
251352

252353
if runner == :run

lib/megatest/config.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ def initialize(env)
171171
CIService.configure(self, env)
172172
end
173173

174+
def initialize_dup(_)
175+
super
176+
@circuit_breaker = @circuit_breaker.dup
177+
end
178+
174179
def program_name
175180
@program_name || "megatest"
176181
end

lib/megatest/multi_process.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,10 @@ def tick
206206
class Executor
207207
attr_reader :wall_time
208208

209-
def initialize(config, out)
209+
def initialize(config, out, managed: false)
210210
@config = config
211211
@out = Output.new(out, colors: config.colors)
212+
@managed = managed
212213
end
213214

214215
def concurrent?
@@ -260,7 +261,7 @@ def run(queue, reporters)
260261
@wall_time = Megatest.now - start_time
261262
reporters.each { |r| r.summary(self, queue, queue.summary) }
262263

263-
if @config.circuit_breaker.break?
264+
if @config.circuit_breaker.break? && !@managed
264265
@out.error("Exited early because too many failures were encountered")
265266
end
266267

lib/megatest/queue.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ def initialize(results = [])
9494
@results = results
9595
end
9696

97+
def initialize_dup(_)
98+
super
99+
@results = @results.dup
100+
end
101+
97102
# When running distributed queues, it's possible
98103
# that a test is considered lost and end up with both
99104
# a successful and a failed result.
@@ -173,6 +178,18 @@ def initialize(config)
173178
@leases = {}
174179
end
175180

181+
def initialize_dup(_other)
182+
super
183+
@queue = @queue.dup
184+
@summary = @summary.dup
185+
@retries = @retries.dup
186+
@leases = @leases.dup
187+
end
188+
189+
def to_a
190+
@queue.reverse
191+
end
192+
176193
def distributed?
177194
false
178195
end

test/megatest/selector_test.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def test_negative_path_loading
5050
"fixtures/generated_test.rb",
5151
"fixtures/inheritance/inheritance_test.rb",
5252
"fixtures/large/large_test.rb",
53+
"fixtures/leak/leaky_test.rb",
5354
"fixtures/simple/error_test.rb",
5455
"fixtures/tags/tagged_test.rb",
5556
]
@@ -59,13 +60,14 @@ def test_negative_path_loading
5960
def test_directory_path_shuffling
6061
selector = Selector.parse(["fixtures", "!", "fixtures/simple", "fixtures/simple/error_test.rb"])
6162
expected = [
62-
"fixtures/errors/isolated_test.rb",
63-
"fixtures/callbacks/callbacks_test.rb",
6463
"fixtures/simple/error_test.rb",
65-
"fixtures/tags/tagged_test.rb",
64+
"fixtures/leak/leaky_test.rb",
65+
"fixtures/callbacks/callbacks_test.rb",
66+
"fixtures/large/large_test.rb",
67+
"fixtures/errors/isolated_test.rb",
6668
"fixtures/context/callbacks_test.rb",
6769
"fixtures/compat/compat_test.rb",
68-
"fixtures/large/large_test.rb",
70+
"fixtures/tags/tagged_test.rb",
6971
"fixtures/crash/crash_test.rb",
7072
"fixtures/inheritance/inheritance_test.rb",
7173
"fixtures/context/context_test.rb",

0 commit comments

Comments
 (0)