Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Commit cf84052

Browse files
authored
Merge pull request #2511 from rspec/myron/bisect-forker
Improve performance of bisect using new forking bisect runner
2 parents a9e64bc + d53a601 commit cf84052

20 files changed

+560
-58
lines changed

Changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ Enhancements:
77
that it works on `Pathname` objects. (Andrew Vit, #2479)
88
* Nicely format errors encountered while loading files specified
99
by `--require` option. (Myron Marston, #2504)
10+
* Significantly improve the performance of `--bisect` on platforms that
11+
support forking by replacing the shell-based runner with one that uses
12+
forking so that RSpec and the application environment can be booted only
13+
once, instead of once per spec run. (Myron Marston, #2511)
14+
* Provide a configuration API to pick which bisect runner is used for
15+
`--bisect`. Pick a runner via `config.bisect_runner = :shell` or
16+
`config.bisect_runner = :fork` in a file loaded by a `--require`
17+
option passed at the command line or set in `.rspec`. (Myron Marston, #2511)
1018

1119
### 3.7.1 / 2018-01-02
1220
[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.7.0...v3.7.1)

features/command_line/bisect.feature

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Feature: Bisect
9494
When I run `rspec --seed 1234 --bisect=verbose`
9595
Then bisect should succeed with output like:
9696
"""
97-
Bisect started using options: "--seed 1234"
97+
Bisect started using options: "--seed 1234" and bisect runner: :fork
9898
Running suite to find failures... (0.16528 seconds)
9999
- Failing examples (1):
100100
- ./spec/calculator_1_spec.rb[1:1]
@@ -156,3 +156,23 @@ Feature: Bisect
156156
"""
157157
When I run `rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234`
158158
Then the output should contain "2 examples, 1 failure"
159+
160+
Scenario: Pick a bisect runner via a config option
161+
Given a file named "spec/spec_helper.rb" with:
162+
"""
163+
RSpec.configure do |c|
164+
c.bisect_runner = :shell
165+
end
166+
"""
167+
And a file named ".rspec" with:
168+
"""
169+
--require spec_helper
170+
"""
171+
When I run `rspec --seed 1234 --bisect=verbose`
172+
Then bisect should succeed with output like:
173+
"""
174+
Bisect started using options: "--seed 1234" and bisect runner: :shell
175+
# ...
176+
The minimal reproduction command is:
177+
rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234
178+
"""

features/step_definitions/additional_cli_steps.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,14 @@
180180
"Output:\n\n#{last_process.stdout}"
181181

182182
expected = normalize_durations(expected_output)
183-
actual = normalize_durations(last_process.stdout)
183+
actual = normalize_durations(last_process.stdout).sub(/\n+\Z/, '')
184184

185-
expect(actual.sub(/\n+\Z/, '')).to eq(expected)
185+
if expected.include?("# ...")
186+
expected_start, expected_end = expected.split("# ...")
187+
expect(actual).to start_with(expected_start).and end_with(expected_end)
188+
else
189+
expect(actual).to eq(expected)
190+
end
186191
end
187192

188193
When(/^I run `([^`]+)` and abort in the middle with ctrl\-c$/) do |cmd|

lib/rspec/core/bisect/coordinator.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,30 @@
11
RSpec::Support.require_rspec_core "bisect/shell_command"
2-
RSpec::Support.require_rspec_core "bisect/shell_runner"
32
RSpec::Support.require_rspec_core "bisect/example_minimizer"
43
RSpec::Support.require_rspec_core "bisect/utilities"
54
RSpec::Support.require_rspec_core "formatters/bisect_progress_formatter"
65

76
module RSpec
87
module Core
98
module Bisect
10-
# @private
119
# The main entry point into the bisect logic. Coordinates among:
1210
# - Bisect::ShellCommand: Generates shell commands to run spec subsets
13-
# - Bisect::ShellRunner: Runs a set of examples and returns the results.
1411
# - Bisect::ExampleMinimizer: Contains the core bisect logic.
15-
# - Formatters::BisectProgressFormatter: provides progress updates
16-
# to the user.
12+
# - A bisect runner: runs a set of examples and returns the results.
13+
# - A bisect formatter: provides progress updates to the user.
14+
# @private
1715
class Coordinator
18-
def self.bisect_with(original_cli_args, formatter)
19-
new(original_cli_args, formatter).bisect
16+
def self.bisect_with(spec_runner, original_cli_args, formatter)
17+
new(spec_runner, original_cli_args, formatter).bisect
2018
end
2119

22-
def initialize(original_cli_args, formatter)
20+
def initialize(spec_runner, original_cli_args, formatter)
21+
@spec_runner = spec_runner
2322
@shell_command = ShellCommand.new(original_cli_args)
2423
@notifier = Bisect::Notifier.new(formatter)
2524
end
2625

2726
def bisect
28-
repro = ShellRunner.start(@shell_command) do |runner|
27+
repro = start_bisect_runner do |runner|
2928
minimizer = ExampleMinimizer.new(@shell_command, runner, @notifier)
3029

3130
gracefully_abort_on_sigint(minimizer)
@@ -45,6 +44,11 @@ def bisect
4544

4645
private
4746

47+
def start_bisect_runner(&block)
48+
klass = @spec_runner.configuration.bisect_runner_class
49+
klass.start(@shell_command, @spec_runner, &block)
50+
end
51+
4852
def gracefully_abort_on_sigint(minimizer)
4953
trap('INT') do
5054
repro = minimizer.repro_command_for_currently_needed_ids

lib/rspec/core/bisect/example_minimizer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ def example_range(ids)
111111
end
112112

113113
def prep
114-
notify(:bisect_starting, :original_cli_args => shell_command.original_cli_args)
114+
notify(:bisect_starting, :original_cli_args => shell_command.original_cli_args,
115+
:bisect_runner => runner.class.name)
115116

116117
_, duration = track_duration do
117118
original_results = runner.original_results

lib/rspec/core/bisect/fork_runner.rb

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
require 'stringio'
2+
RSpec::Support.require_rspec_core "formatters/base_bisect_formatter"
3+
RSpec::Support.require_rspec_core "bisect/utilities"
4+
5+
module RSpec
6+
module Core
7+
module Bisect
8+
# A Bisect runner that runs requested subsets of the suite by forking
9+
# sub-processes. The master process bootstraps RSpec and the application
10+
# environment (including preloading files specified via `--require`) so
11+
# that the individual spec runs do not have to re-pay that cost. Each
12+
# spec run happens in a forked process, ensuring that the spec files are
13+
# not loaded in the main process.
14+
#
15+
# For most projects, bisections that use `ForkRunner` instead of
16+
# `ShellRunner` will finish significantly faster, because the `ShellRunner`
17+
# pays the cost of booting RSpec and the app environment on _every_ run of
18+
# a subset. In contrast, `ForkRunner` pays that cost only once.
19+
#
20+
# However, not all projects can use `ForkRunner`. Obviously, on platforms
21+
# that do not support forking (e.g. Windows), it cannot be used. In addition,
22+
# it can cause problems for some projects that put side-effectful spec
23+
# bootstrapping logic that should run on every spec run directly at the top
24+
# level in a file loaded by `--require`, rather than in a `before(:suite)`
25+
# hook. For example, consider a project that relies on some top-level logic
26+
# in `spec_helper` to boot a Redis server for the test suite, intending the
27+
# Redis bootstrapping to happen on every spec run. With `ShellRunner`, the
28+
# bootstrapping logic will happen for each run of any subset of the suite,
29+
# but for `ForkRunner`, such logic will only get run once, when the
30+
# `RunDispatcher` boots the application environment. This might cause
31+
# problems. The solution is for users to move the bootstrapping logic into
32+
# a `before(:suite)` hook, or use the slower `ShellRunner`.
33+
#
34+
# @private
35+
class ForkRunner
36+
def self.start(shell_command, spec_runner)
37+
instance = new(shell_command, spec_runner)
38+
yield instance
39+
ensure
40+
instance.shutdown
41+
end
42+
43+
def self.name
44+
:fork
45+
end
46+
47+
def initialize(shell_command, spec_runner)
48+
@shell_command = shell_command
49+
@channel = Channel.new
50+
@run_dispatcher = RunDispatcher.new(spec_runner, @channel)
51+
end
52+
53+
def run(locations)
54+
run_descriptor = ExampleSetDescriptor.new(locations, original_results.failed_example_ids)
55+
dispatch_run(run_descriptor)
56+
end
57+
58+
def original_results
59+
@original_results ||= dispatch_run(ExampleSetDescriptor.new(
60+
@shell_command.original_locations, []))
61+
end
62+
63+
def shutdown
64+
@channel.close
65+
end
66+
67+
private
68+
69+
def dispatch_run(run_descriptor)
70+
@run_dispatcher.dispatch_specs(run_descriptor)
71+
@channel.receive.tap do |result|
72+
if result.is_a?(String)
73+
raise BisectFailedError.for_failed_spec_run(result)
74+
end
75+
end
76+
end
77+
78+
# @private
79+
class RunDispatcher
80+
def initialize(runner, channel)
81+
@runner = runner
82+
@channel = channel
83+
84+
@spec_output = StringIO.new
85+
86+
runner.configuration.tap do |c|
87+
c.reset_reporter
88+
c.output_stream = @spec_output
89+
c.error_stream = @spec_output
90+
end
91+
end
92+
93+
def dispatch_specs(run_descriptor)
94+
pid = fork { run_specs(run_descriptor) }
95+
Process.waitpid(pid)
96+
end
97+
98+
private
99+
100+
def run_specs(run_descriptor)
101+
$stdout = $stderr = @spec_output
102+
formatter = CaptureFormatter.new(run_descriptor.failed_example_ids)
103+
104+
@runner.configuration.tap do |c|
105+
c.files_or_directories_to_run = run_descriptor.all_example_ids
106+
c.formatter = formatter
107+
c.load_spec_files
108+
end
109+
110+
# `announce_filters` has the side effect of implementing the logic
111+
# that honors `config.run_all_when_everything_filtered` so we need
112+
# to call it here. When we remove `run_all_when_everything_filtered`
113+
# (slated for RSpec 4), we can remove this call to `announce_filters`.
114+
@runner.world.announce_filters
115+
116+
@runner.run_specs(@runner.world.ordered_example_groups)
117+
latest_run_results = formatter.results
118+
119+
if latest_run_results.nil? || latest_run_results.all_example_ids.empty?
120+
@channel.send(@spec_output.string)
121+
else
122+
@channel.send(latest_run_results)
123+
end
124+
end
125+
end
126+
127+
class CaptureFormatter < Formatters::BaseBisectFormatter
128+
attr_accessor :results
129+
alias_method :notify_results, :results=
130+
end
131+
end
132+
end
133+
end
134+
end

lib/rspec/core/bisect/shell_runner.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ module Bisect
1010
# Sets of specs are run by shelling out.
1111
# @private
1212
class ShellRunner
13-
def self.start(shell_command)
13+
def self.start(shell_command, _spec_runner)
1414
Server.run do |server|
1515
yield new(server, shell_command)
1616
end
1717
end
1818

19+
def self.name
20+
:shell
21+
end
22+
1923
def initialize(server, shell_command)
2024
@server = server
2125
@shell_command = shell_command

lib/rspec/core/bisect/utilities.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,32 @@ def publish(event, *args)
2727
@formatter.__send__(event, notification)
2828
end
2929
end
30+
31+
# Wraps a pipe to support sending objects between a child and
32+
# parent process.
33+
# @private
34+
class Channel
35+
def initialize
36+
@read_io, @write_io = IO.pipe
37+
end
38+
39+
def send(message)
40+
packet = Marshal.dump(message)
41+
@write_io.write("#{packet.bytesize}\n#{packet}")
42+
end
43+
44+
# rubocop:disable Security/MarshalLoad
45+
def receive
46+
packet_size = Integer(@read_io.gets)
47+
Marshal.load(@read_io.read(packet_size))
48+
end
49+
# rubocop:enable Security/MarshalLoad
50+
51+
def close
52+
@read_io.close
53+
@write_io.close
54+
end
55+
end
3056
end
3157
end
3258
end

lib/rspec/core/configuration.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,38 @@ def shared_context_metadata_behavior=(value)
413413
# return [Integer]
414414
add_setting :max_displayed_failure_line_count
415415

416+
# Determines which bisect runner implementation gets used to run subsets
417+
# of the suite during a bisection. Your choices are:
418+
#
419+
# - `:shell`: Performs a spec run by shelling out, booting RSpec and your
420+
# application environment each time. This runner is the most widely
421+
# compatible runner, but is not as fast. On platforms that do not
422+
# support forking, this is the default.
423+
# - `:fork`: Pre-boots RSpec and your application environment in a parent
424+
# process, and then forks a child process for each spec run. This runner
425+
# tends to be significantly faster than the `:shell` runner but cannot
426+
# be used in some situations. On platforms that support forking, this
427+
# is the default. If you use this runner, you should ensure that all
428+
# of your one-time setup logic goes in a `before(:suite)` hook instead
429+
# of getting run at the top-level of a file loaded by `--require`.
430+
#
431+
# @note This option will only be used by `--bisect` if you set it in a file
432+
# loaded via `--require`.
433+
#
434+
# @return [Symbol]
435+
attr_reader :bisect_runner
436+
def bisect_runner=(value)
437+
if @bisect_runner_class && value != @bisect_runner
438+
raise "`config.bisect_runner = #{value.inspect}` can no longer take " \
439+
"effect as the #{@bisect_runner.inspect} bisect runnner is already " \
440+
"in use. This config setting must be set in a file loaded by a " \
441+
"`--require` option (passed at the CLI or in a `.rspec` file) for " \
442+
"it to have any effect."
443+
end
444+
445+
@bisect_runner = value
446+
end
447+
416448
# @private
417449
# @deprecated Use {#color_mode} = :on, instead of {#color} with {#tty}
418450
add_setting :tty
@@ -437,6 +469,9 @@ def initialize
437469
@extend_modules = FilterableItemRepository::QueryOptimized.new(:any?)
438470
@prepend_modules = FilterableItemRepository::QueryOptimized.new(:any?)
439471

472+
@bisect_runner = RSpec::Support::RubyFeatures.fork_supported? ? :fork : :shell
473+
@bisect_runner_class = nil
474+
440475
@before_suite_hooks = []
441476
@after_suite_hooks = []
442477

@@ -1962,6 +1997,23 @@ def on_example_group_definition_callbacks
19621997
@on_example_group_definition_callbacks ||= []
19631998
end
19641999

2000+
# @private
2001+
def bisect_runner_class
2002+
@bisect_runner_class ||= begin
2003+
case bisect_runner
2004+
when :fork
2005+
RSpec::Support.require_rspec_core 'bisect/fork_runner'
2006+
Bisect::ForkRunner
2007+
when :shell
2008+
RSpec::Support.require_rspec_core 'bisect/shell_runner'
2009+
Bisect::ShellRunner
2010+
else
2011+
raise "Unsupported value for `bisect_runner` (#{bisect_runner.inspect}). " \
2012+
"Only `:fork` and `:shell` are supported."
2013+
end
2014+
end
2015+
end
2016+
19652017
private
19662018

19672019
def load_file_handling_errors(method, file)

0 commit comments

Comments
 (0)