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

Commit c43a8cc

Browse files
committed
Implement Bisect::ForkRunner.
1 parent 5069475 commit c43a8cc

File tree

6 files changed

+312
-4
lines changed

6 files changed

+312
-4
lines changed

lib/rspec/core/bisect/fork_runner.rb

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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 initialize(shell_command, spec_runner)
44+
@shell_command = shell_command
45+
@channel = Channel.new
46+
@run_dispatcher = RunDispatcher.new(spec_runner, @channel)
47+
end
48+
49+
def run(locations)
50+
run_descriptor = ExampleSetDescriptor.new(locations, original_results.failed_example_ids)
51+
dispatch_run(run_descriptor)
52+
end
53+
54+
def original_results
55+
@original_results ||= dispatch_run(ExampleSetDescriptor.new(
56+
@shell_command.original_locations, []))
57+
end
58+
59+
def shutdown
60+
@channel.close
61+
end
62+
63+
private
64+
65+
def dispatch_run(run_descriptor)
66+
@run_dispatcher.dispatch_specs(run_descriptor)
67+
@channel.receive.tap do |result|
68+
if result.is_a?(String)
69+
raise BisectFailedError.for_failed_spec_run(result)
70+
end
71+
end
72+
end
73+
74+
# @private
75+
class RunDispatcher
76+
def initialize(runner, channel)
77+
@runner = runner
78+
@channel = channel
79+
80+
@spec_output = StringIO.new
81+
82+
runner.configuration.tap do |c|
83+
c.reset_reporter
84+
c.output_stream = @spec_output
85+
c.error_stream = @spec_output
86+
end
87+
end
88+
89+
def dispatch_specs(run_descriptor)
90+
pid = fork { run_specs(run_descriptor) }
91+
Process.waitpid(pid)
92+
end
93+
94+
private
95+
96+
def run_specs(run_descriptor)
97+
$stdout = $stderr = @spec_output
98+
formatter = CaptureFormatter.new(run_descriptor.failed_example_ids)
99+
100+
@runner.configuration.tap do |c|
101+
c.files_or_directories_to_run = run_descriptor.all_example_ids
102+
c.formatter = formatter
103+
c.load_spec_files
104+
end
105+
106+
# `announce_filters` has the side effect of implementing the logic
107+
# that honors `config.run_all_when_everything_filtered` so we need
108+
# to call it here. When we remove `run_all_when_everything_filtered`
109+
# (slated for RSpec 4), we can remove this call to `announce_filters`.
110+
@runner.world.announce_filters
111+
112+
@runner.run_specs(@runner.world.ordered_example_groups)
113+
latest_run_results = formatter.results
114+
115+
if latest_run_results.nil? || latest_run_results.all_example_ids.empty?
116+
@channel.send(@spec_output.string)
117+
else
118+
@channel.send(latest_run_results)
119+
end
120+
end
121+
end
122+
123+
class CaptureFormatter < Formatters::BaseBisectFormatter
124+
attr_accessor :results
125+
alias_method :notify_results, :results=
126+
end
127+
end
128+
end
129+
end
130+
end

lib/rspec/core/bisect/shell_runner.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ 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=nil)
1414
Server.run do |server|
1515
yield new(server, shell_command)
1616
end

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/runner.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,7 @@ def run(err, out)
9494
# @param err [IO] error stream
9595
# @param out [IO] output stream
9696
def setup(err, out)
97-
@configuration.error_stream = err
98-
@configuration.output_stream = out if @configuration.output_stream == $stdout
99-
@options.configure(@configuration)
97+
configure(err, out)
10098
@configuration.load_spec_files
10199
@world.announce_filters
102100
end
@@ -122,6 +120,13 @@ def run_specs(example_groups)
122120
success ? 0 : @configuration.failure_exit_code
123121
end
124122

123+
# @private
124+
def configure(err, out)
125+
@configuration.error_stream = err
126+
@configuration.output_stream = out if @configuration.output_stream == $stdout
127+
@options.configure(@configuration)
128+
end
129+
125130
# @private
126131
def self.disable_autorun!
127132
@autorun_disabled = true
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
require 'support/aruba_support'
2+
require 'rspec/core/bisect/shell_command'
3+
require 'rspec/core/bisect/shell_runner'
4+
require 'rspec/core/bisect/fork_runner'
5+
6+
module RSpec::Core
7+
RSpec.shared_examples_for "a bisect runner" do
8+
include_context "aruba support"
9+
before { clean_current_dir }
10+
11+
let(:shell_command) { Bisect::ShellCommand.new([]) }
12+
13+
def with_runner(&block)
14+
handle_current_dir_change do
15+
in_current_dir do
16+
options = ConfigurationOptions.new(shell_command.original_cli_args)
17+
runner = Runner.new(options)
18+
output = StringIO.new
19+
runner.configure(output, output)
20+
described_class.start(shell_command, runner, &block)
21+
end
22+
end
23+
end
24+
25+
it 'runs the specs in an isolated environment and reports the results' do
26+
RSpec.configuration.formatter = 'progress'
27+
28+
write_file 'spec/a_spec.rb', "
29+
formatters = RSpec.configuration.formatter_loader.formatters
30+
if formatters.any? { |f| f.is_a?(RSpec::Core::Formatters::ProgressFormatter) }
31+
raise 'Leaked progress formatter from host environment'
32+
end
33+
34+
RSpec.describe 'A group' do
35+
it('passes') { expect(1).to eq 1 }
36+
it('fails') { expect(1).to eq 2 }
37+
end
38+
"
39+
40+
with_runner do |runner|
41+
expect(runner.original_results).to have_attributes(
42+
:all_example_ids => %w[ ./spec/a_spec.rb[1:1] ./spec/a_spec.rb[1:2] ],
43+
:failed_example_ids => %w[ ./spec/a_spec.rb[1:2] ]
44+
)
45+
46+
expect(runner.run(%w[ ./spec/a_spec.rb[1:1] ])).to have_attributes(
47+
:all_example_ids => %w[ ./spec/a_spec.rb[1:1] ],
48+
:failed_example_ids => %w[]
49+
)
50+
end
51+
end
52+
53+
it 'honors `run_all_when_everything_filtered`' do
54+
write_file 'spec/a_spec.rb', "
55+
RSpec.configure do |c|
56+
c.filter_run :focus
57+
c.run_all_when_everything_filtered = true
58+
end
59+
60+
RSpec.describe 'A group' do
61+
it('passes') { expect(1).to eq 1 }
62+
it('fails') { expect(1).to eq 2 }
63+
end
64+
"
65+
66+
with_runner do |runner|
67+
expect(runner.original_results).to have_attributes(
68+
:all_example_ids => %w[ ./spec/a_spec.rb[1:1] ./spec/a_spec.rb[1:2] ],
69+
:failed_example_ids => %w[ ./spec/a_spec.rb[1:2] ]
70+
)
71+
end
72+
end
73+
74+
it 'raises BisectFailedError with all run output when it encounters an error loading spec files' do
75+
write_file 'spec/a_spec.rb', "
76+
puts 'stdout in a_spec'
77+
warn 'stderr in a_spec'
78+
79+
RSpec.escribe 'A group' do
80+
it('passes') { expect(1).to eq 1 }
81+
it('fails') { expect(1).to eq 2 }
82+
end
83+
"
84+
85+
with_runner do |runner|
86+
expect {
87+
runner.original_results
88+
}.to raise_error(Bisect::BisectFailedError, a_string_including(
89+
"undefined method `escribe' for RSpec:Module",
90+
'stdout in a_spec',
91+
'stderr in a_spec'
92+
))
93+
end
94+
end
95+
end
96+
97+
RSpec.describe Bisect::ShellRunner, :slow do
98+
include_examples 'a bisect runner'
99+
end
100+
101+
RSpec.describe Bisect::ForkRunner, :if => Process.respond_to?(:fork) do
102+
include_examples 'a bisect runner'
103+
104+
context 'when a `--require` option has been provided' do
105+
let(:shell_command) { Bisect::ShellCommand.new(['--require', './spec/a_spec_helper']) }
106+
107+
it 'loads the specified file only once (rather than once per subset run)' do
108+
write_file 'spec_helper_loads', ''
109+
write_file 'spec/a_spec_helper.rb', "
110+
File.open('spec_helper_loads', 'a') do |f|
111+
f.print('.')
112+
end
113+
"
114+
115+
write_file 'spec/a_spec.rb', "
116+
RSpec.describe 'A group' do
117+
it('passes') { expect(1).to eq 1 }
118+
it('fails') { expect(1).to eq 2 }
119+
end
120+
"
121+
122+
with_runner do |runner|
123+
runner.run(%w[ ./spec/a_spec.rb[1:1] ])
124+
runner.run(%w[ ./spec/a_spec.rb[1:1] ])
125+
end
126+
127+
in_current_dir do
128+
expect(File.read('spec_helper_loads')).to eq(".")
129+
end
130+
end
131+
end
132+
end
133+
end

spec/rspec/core/bisect/utilities_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,18 @@ def foo(notification); end
2323
}.not_to raise_error
2424
end
2525
end
26+
27+
RSpec.describe Bisect::Channel do
28+
include RSpec::Support::InSubProcess
29+
30+
it "supports sending objects from a child process back to the parent" do
31+
channel = Bisect::Channel.new
32+
33+
in_sub_process do
34+
channel.send(:value_from_child)
35+
end
36+
37+
expect(channel.receive).to eq :value_from_child
38+
end
39+
end
2640
end

0 commit comments

Comments
 (0)