rspec-core-parallel 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/CHANGELOG.md +11 -0
- data/LICENSE.md +27 -0
- data/README.md +148 -0
- data/exe/rspec-parallel +8 -0
- data/lib/rspec/core/parallel/channel.rb +58 -0
- data/lib/rspec/core/parallel/coordinator.rb +428 -0
- data/lib/rspec/core/parallel/core_ext.rb +253 -0
- data/lib/rspec/core/parallel/exception_serializer.rb +122 -0
- data/lib/rspec/core/parallel/rails_integration.rb +49 -0
- data/lib/rspec/core/parallel/registry.rb +45 -0
- data/lib/rspec/core/parallel/scheduler.rb +162 -0
- data/lib/rspec/core/parallel/simplecov_integration.rb +30 -0
- data/lib/rspec/core/parallel/version.rb +7 -0
- data/lib/rspec/core/parallel/worker.rb +187 -0
- data/lib/rspec/core/parallel/worker_reporter.rb +212 -0
- data/lib/rspec/core/parallel.rb +95 -0
- data/lib/rspec-core-parallel.rb +1 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 03f7e28ca852dcd4b84d72d272a6db18e6d8df1e9c090517613f548fbe374a5f
|
|
4
|
+
data.tar.gz: 7f3efdb57f46ed2164770dad99c66c08925a2258a0c3440f2e1fa0b9eee3209c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 46abf9b5942f67930ec91ac9b5c3d926ddf82f721d22c2314f0cc7663f78c7c929996b206348b8a182fd3afb0c4be91a23c947acaedbcb01cbf5c1c23161446e
|
|
7
|
+
data.tar.gz: '0886bc56896897d0e33c9467934cbfd9e622955f3ea8606b4df7635196f8ec98dafe9a1a48c420026b15962636e48783324aa089da725a5a4f70c4b0a66657c1'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
* Loading the gem makes `rspec` run the suite in forked worker processes, one
|
|
6
|
+
per physical CPU core (`config.parallel_workers = 1` runs it sequentially).
|
|
7
|
+
* `rspec-parallel` command, which also takes `--parallel [COUNT]` (`-j`) and
|
|
8
|
+
`--no-parallel`.
|
|
9
|
+
* Extracted from a backport of `rspec --parallel` to rspec-core 3.13. Unlike
|
|
10
|
+
there, run times are saved in `tmp/rspec_parallel_run_times.txt` even when
|
|
11
|
+
`tmp` does not exist yet.
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
=====================
|
|
3
|
+
|
|
4
|
+
* Copyright © 2026 Alessandro Rodi, Renuo AG
|
|
5
|
+
* Copyright © 2012 Chad Humphries, David Chelimsky, Myron Marston
|
|
6
|
+
* Copyright © 2009 Chad Humphries, David Chelimsky
|
|
7
|
+
* Copyright © 2006 David Chelimsky, The RSpec Development Team
|
|
8
|
+
* Copyright © 2005 Steven Baker
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
11
|
+
a copy of this software and associated documentation files (the
|
|
12
|
+
"Software"), to deal in the Software without restriction, including
|
|
13
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
14
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
15
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
16
|
+
the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be
|
|
19
|
+
included in all copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
22
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
23
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
24
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
25
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
26
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
27
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# rspec-core-parallel
|
|
2
|
+
|
|
3
|
+
Runs an RSpec suite on all the cores of your machine. Add the gem, and `rspec`
|
|
4
|
+
runs in parallel, with one worker process per physical CPU core.
|
|
5
|
+
|
|
6
|
+
RSpec loads the suite once and forks worker processes, which share the loaded
|
|
7
|
+
code. Groups are handed out to idle workers, longest first, and every result is
|
|
8
|
+
sent back to the main process, which reports them like a sequential run: all
|
|
9
|
+
formatters, `--only-failures`, `--profile`, `--fail-fast`, `--bisect` and exit
|
|
10
|
+
codes work as usual.
|
|
11
|
+
|
|
12
|
+
This gem brings `--parallel` to rspec-core 3.13 until it is part of rspec-core
|
|
13
|
+
itself.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
group :test do
|
|
19
|
+
gem "rspec-core-parallel"
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Rails apps load it with the other gems of the `test` group. Elsewhere, require
|
|
24
|
+
it at the top of `spec_helper.rb`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "rspec/core/parallel"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's it: `bundle exec rspec` now runs in parallel.
|
|
31
|
+
|
|
32
|
+
## Number of workers
|
|
33
|
+
|
|
34
|
+
The default is one worker per physical CPU core. Set it in `spec_helper.rb`
|
|
35
|
+
(`1` runs the suite sequentially):
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
RSpec.configure do |config|
|
|
39
|
+
config.parallel_workers = 4
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
To choose it on the command line, run `rspec-parallel`, which is `rspec` with
|
|
44
|
+
the gem loaded before the options are read, so that it also takes
|
|
45
|
+
`--parallel [COUNT]` (`-j`) and `--no-parallel`:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
$ bundle exec rspec-parallel --parallel 4
|
|
49
|
+
$ bundle exec rspec-parallel --no-parallel spec/models/user_spec.rb
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Options given on the command line win over `config.parallel_workers`.
|
|
53
|
+
|
|
54
|
+
## How it compares
|
|
55
|
+
|
|
56
|
+
Unlike [parallel_tests](https://github.com/grosser/parallel_tests) and similar
|
|
57
|
+
runners, which start one rspec process per CPU and split the spec files between
|
|
58
|
+
them:
|
|
59
|
+
|
|
60
|
+
* the app boots once, then workers are forked from it;
|
|
61
|
+
* groups are dispatched dynamically, longest first (using the run times of the
|
|
62
|
+
previous run), and long files are split, so all the workers finish together;
|
|
63
|
+
* there is one combined report, and one exit code;
|
|
64
|
+
* in Rails apps, the worker databases are created and loaded by the runner.
|
|
65
|
+
|
|
66
|
+
## Isolation
|
|
67
|
+
|
|
68
|
+
Because every worker is a separate process, examples running at the same time
|
|
69
|
+
never share global state (constants, stubs, `RSpec.configuration`...), so
|
|
70
|
+
existing suites run unchanged, provided that they don't share external
|
|
71
|
+
resources such as databases or files between processes.
|
|
72
|
+
|
|
73
|
+
Rails apps need no setup: as with `rails test` in parallel, every worker gets
|
|
74
|
+
its own test database (`app_test_0`, `app_test_1`...), created and loaded from
|
|
75
|
+
the schema when needed. Set `ActiveSupport.parallelize_test_databases = false`
|
|
76
|
+
to opt out.
|
|
77
|
+
|
|
78
|
+
Each worker gets a `TEST_ENV_NUMBER` environment variable (`""`, `"2"`, `"3"`...,
|
|
79
|
+
as with parallel_tests) and runs `parallel_worker_setup` hooks, which you can
|
|
80
|
+
use to give every worker its own resources. Workers are forked once the suite
|
|
81
|
+
is loaded, so values computed while loading it, such as a constant holding a
|
|
82
|
+
directory or a Redis client, are the same in all of them. This includes
|
|
83
|
+
configuration reading `TEST_ENV_NUMBER` while the app boots (as parallel_tests
|
|
84
|
+
setups often do for Redis URLs): the variable is only set in the workers, so
|
|
85
|
+
such resources must be pointed at a per-worker instance in
|
|
86
|
+
`parallel_worker_setup`. For example, to give every worker its own directory
|
|
87
|
+
for uploaded files:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
RSpec.configure do |config|
|
|
91
|
+
config.parallel_worker_setup do |worker_number|
|
|
92
|
+
CarrierWave.configure { |c| c.root = Rails.root.join("tmp/uploads/#{worker_number}") }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`before(:suite)`, `after(:suite)` and `at_exit` hooks run in every worker.
|
|
98
|
+
|
|
99
|
+
## Code coverage
|
|
100
|
+
|
|
101
|
+
To collect code coverage from all the workers with SimpleCov, enable its
|
|
102
|
+
support for forked processes:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
SimpleCov.enable_for_subprocesses true
|
|
106
|
+
SimpleCov.start
|
|
107
|
+
|
|
108
|
+
RSpec.configure do |config|
|
|
109
|
+
# A stable name per worker, so that each run replaces the results of the
|
|
110
|
+
# previous one instead of adding to them (SimpleCov names subprocesses after
|
|
111
|
+
# their pid by default).
|
|
112
|
+
config.parallel_worker_setup do |worker_number|
|
|
113
|
+
SimpleCov.command_name "RSpec (worker #{worker_number})"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Workers only save their coverage: the main process, which exits last, merges
|
|
119
|
+
it, writes the report and checks the minimum coverage.
|
|
120
|
+
|
|
121
|
+
## Splitting long groups
|
|
122
|
+
|
|
123
|
+
To spread long groups over several workers, a group expected to take longer
|
|
124
|
+
than a worker's share of the suite is split into its child groups and parts of
|
|
125
|
+
its examples, unless it has `before(:context)`/`after(:context)` hooks of its
|
|
126
|
+
own (which can be allowed with
|
|
127
|
+
`config.parallel_split_groups_with_context_hooks = true`) or is tagged with
|
|
128
|
+
`:parallel_split => false`. Examples of the same file can therefore run at the
|
|
129
|
+
same time in different workers: tag groups whose examples share a file or
|
|
130
|
+
directory at a fixed path (such as a browser download directory) with
|
|
131
|
+
`:parallel_split => false`, or use a path per worker.
|
|
132
|
+
|
|
133
|
+
## Run times
|
|
134
|
+
|
|
135
|
+
The run time of every example is saved in `tmp/rspec_parallel_run_times.txt`,
|
|
136
|
+
or in `config.example_status_persistence_file_path` when it is set, so that the
|
|
137
|
+
next runs hand out the longest groups first (see
|
|
138
|
+
`config.parallel_run_times_file_path`). On CI, cache this file between builds.
|
|
139
|
+
|
|
140
|
+
## Requirements
|
|
141
|
+
|
|
142
|
+
* rspec-core 3.13
|
|
143
|
+
* Ruby 3.3 or later with `Process.fork`: on JRuby and Windows the suite runs
|
|
144
|
+
sequentially.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT. Most of the code comes from rspec-core, by the RSpec team.
|
data/exe/rspec-parallel
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# A one-directional stream of Marshal-encoded, length-prefixed messages.
|
|
5
|
+
#
|
|
6
|
+
# @private
|
|
7
|
+
class Channel
|
|
8
|
+
HEADER_SIZE = 4
|
|
9
|
+
|
|
10
|
+
def self.pipe
|
|
11
|
+
reader, writer = IO.pipe
|
|
12
|
+
reader.binmode
|
|
13
|
+
writer.binmode
|
|
14
|
+
writer.sync = true
|
|
15
|
+
[new(reader), new(writer)]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :io
|
|
19
|
+
|
|
20
|
+
def initialize(io)
|
|
21
|
+
@io = io
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write(message)
|
|
25
|
+
data = Marshal.dump(message)
|
|
26
|
+
@io.write([data.bytesize].pack('N') << data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Object, nil] the next message, or nil once the other end is closed.
|
|
30
|
+
def read
|
|
31
|
+
header = read_exactly(HEADER_SIZE)
|
|
32
|
+
return nil unless header
|
|
33
|
+
|
|
34
|
+
data = read_exactly(header.unpack1('N'))
|
|
35
|
+
data && Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def readable_now?
|
|
39
|
+
# rubocop:disable-next Lint/IncompatibleIoSelectWithFiberScheduler
|
|
40
|
+
!IO.select([@io], nil, nil, 0).nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
@io.close unless @io.closed?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read_exactly(size)
|
|
50
|
+
buffer = @io.read(size)
|
|
51
|
+
buffer if buffer && buffer.bytesize == size
|
|
52
|
+
rescue IOError, SystemCallError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Core
|
|
3
|
+
module Parallel
|
|
4
|
+
# Runs in the main process: forks the workers, hands out units of work
|
|
5
|
+
# and replays the events workers send back onto the reporter.
|
|
6
|
+
#
|
|
7
|
+
# Events of one unit are replayed live while events of units running
|
|
8
|
+
# concurrently are buffered and replayed once the live unit finishes,
|
|
9
|
+
# so formatters always see each group as a contiguous whole. The start
|
|
10
|
+
# and finish of groups split into several units are replayed around
|
|
11
|
+
# the events of their units.
|
|
12
|
+
#
|
|
13
|
+
# @private
|
|
14
|
+
class Coordinator
|
|
15
|
+
SELECT_TIMEOUT = 0.5
|
|
16
|
+
|
|
17
|
+
def initialize(configuration, world, worker_count)
|
|
18
|
+
@configuration = configuration
|
|
19
|
+
@world = world
|
|
20
|
+
@worker_count = worker_count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(units, reporter)
|
|
24
|
+
@reporter = reporter
|
|
25
|
+
@registry = Registry.new(@world.example_groups)
|
|
26
|
+
@units = units
|
|
27
|
+
@previous_in_family = previous_in_family(units)
|
|
28
|
+
@queue = (0...units.size).to_a
|
|
29
|
+
@results = []
|
|
30
|
+
@run_times = Hash.new(0.0)
|
|
31
|
+
@buffers = {}
|
|
32
|
+
@dispatched_units = {}
|
|
33
|
+
@finished_units = {}
|
|
34
|
+
@replayed_units = {}
|
|
35
|
+
@live_unit = nil
|
|
36
|
+
@open_groups = []
|
|
37
|
+
@orphan_events = []
|
|
38
|
+
@failure_count = 0
|
|
39
|
+
@quitting = false
|
|
40
|
+
@exit_status = nil
|
|
41
|
+
@open_groups_by_unit = Hash.new { |hash, unit| hash[unit] = [] }
|
|
42
|
+
@running_examples = {}
|
|
43
|
+
|
|
44
|
+
start_workers
|
|
45
|
+
process_events while @workers.any?(&:alive?)
|
|
46
|
+
finish
|
|
47
|
+
exit(@exit_status) if @exit_status
|
|
48
|
+
|
|
49
|
+
@results.all?
|
|
50
|
+
ensure
|
|
51
|
+
terminate_workers
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @return [Array<Integer, nil>] for each unit, the unit of the same
|
|
57
|
+
# top-level group running right before it when running sequentially
|
|
58
|
+
def previous_in_family(units)
|
|
59
|
+
previous = []
|
|
60
|
+
families = units.each_index.group_by { |unit| units[unit].split_ancestors.first }
|
|
61
|
+
families.delete(nil)
|
|
62
|
+
families.each_value do |family|
|
|
63
|
+
family.sort_by { |unit| units[unit].sequence }.each_cons(2) { |before, unit| previous[unit] = before }
|
|
64
|
+
end
|
|
65
|
+
previous
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def start_workers
|
|
69
|
+
flush_output_streams
|
|
70
|
+
RailsIntegration.before_fork
|
|
71
|
+
Process.warmup if Process.respond_to?(:warmup)
|
|
72
|
+
|
|
73
|
+
@workers = []
|
|
74
|
+
1.upto(@worker_count) do |number|
|
|
75
|
+
worker = Worker.new(number).start(@configuration, @registry, @workers)
|
|
76
|
+
@workers << worker
|
|
77
|
+
dispatch(worker)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Output buffered before forking would otherwise be written again by
|
|
82
|
+
# every worker when it exits.
|
|
83
|
+
def flush_output_streams
|
|
84
|
+
ObjectSpace.each_object(IO) do |io|
|
|
85
|
+
begin
|
|
86
|
+
io.flush unless io.closed?
|
|
87
|
+
rescue IOError, SystemCallError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dispatch(worker)
|
|
94
|
+
unit = (@quitting || @world.wants_to_quit) ? nil : @queue.shift
|
|
95
|
+
|
|
96
|
+
if unit
|
|
97
|
+
worker.current_unit = unit
|
|
98
|
+
@dispatched_units[unit] = true
|
|
99
|
+
worker.send_command(:run, unit, *unit_location(@units[unit]))
|
|
100
|
+
else
|
|
101
|
+
worker.send_command(:shutdown)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unit_location(unit)
|
|
106
|
+
[
|
|
107
|
+
@registry.group_index(unit.group),
|
|
108
|
+
unit.examples_only,
|
|
109
|
+
unit.split_ancestors.map { |group| @registry.group_index(group) },
|
|
110
|
+
unit.examples&.map { |example| @registry.example_index(example) }
|
|
111
|
+
]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_events
|
|
115
|
+
stop_dispatching if @world.wants_to_quit && !@quitting
|
|
116
|
+
|
|
117
|
+
alive = @workers.select(&:alive?)
|
|
118
|
+
ready, = IO.select(alive.map { |worker| worker.events.io }, nil, nil, SELECT_TIMEOUT)
|
|
119
|
+
ready ||= []
|
|
120
|
+
|
|
121
|
+
alive.each do |worker|
|
|
122
|
+
if ready.include?(worker.events.io)
|
|
123
|
+
batch = worker.events.read
|
|
124
|
+
batch ? batch.each { |message| handle(worker, message) if worker.alive? } : worker_exited(worker)
|
|
125
|
+
elsif worker.exited?
|
|
126
|
+
# Processes forked by the worker can keep its end of the pipe
|
|
127
|
+
# open after it exited.
|
|
128
|
+
worker_exited(worker)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle(worker, message)
|
|
134
|
+
case message.first
|
|
135
|
+
when :unit_done
|
|
136
|
+
_, unit, succeeded, run_time = message
|
|
137
|
+
worker.current_unit = nil
|
|
138
|
+
@results << succeeded
|
|
139
|
+
@run_times[@units[unit].split_ancestors.first || @units[unit].group] += run_time
|
|
140
|
+
unit_finished(unit)
|
|
141
|
+
dispatch(worker)
|
|
142
|
+
when :exit
|
|
143
|
+
@exit_status ||= message[2]
|
|
144
|
+
stop_dispatching
|
|
145
|
+
when :bye
|
|
146
|
+
worker.close
|
|
147
|
+
else
|
|
148
|
+
track(message)
|
|
149
|
+
count_failure(message)
|
|
150
|
+
route(message)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Keeps track of what each unit is running, to clean up after a crash.
|
|
155
|
+
def track(message)
|
|
156
|
+
kind, unit, event, index = message
|
|
157
|
+
return unless unit
|
|
158
|
+
|
|
159
|
+
if kind == :group
|
|
160
|
+
event == :example_group_started ? @open_groups_by_unit[unit] << index : @open_groups_by_unit[unit].pop
|
|
161
|
+
elsif kind == :example
|
|
162
|
+
event == :example_started ? @running_examples[unit] = index : @running_examples.delete(unit)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def worker_exited(worker)
|
|
167
|
+
worker.close
|
|
168
|
+
unit = worker.current_unit
|
|
169
|
+
return unless unit
|
|
170
|
+
|
|
171
|
+
worker.current_unit = nil
|
|
172
|
+
@results << false
|
|
173
|
+
report_crash(worker, unit)
|
|
174
|
+
unit_finished(unit)
|
|
175
|
+
stop_dispatching if @exit_status
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fails the example that was running, or reports an error outside of
|
|
179
|
+
# examples, and closes the groups the unit left open.
|
|
180
|
+
def report_crash(worker, unit)
|
|
181
|
+
error = ExceptionSerializer.dump(worker_crash_error(worker, unit))
|
|
182
|
+
|
|
183
|
+
if (example = @running_examples.delete(unit))
|
|
184
|
+
route([:example, unit, :example_finished, example, [nil, :failed, error, nil, 0.0, nil, nil, nil, nil]])
|
|
185
|
+
route([:example, unit, :example_failed, example, nil])
|
|
186
|
+
@failure_count += 1
|
|
187
|
+
else
|
|
188
|
+
route([:non_example_exception, unit, error, "An error occurred in parallel worker #{worker.number}."])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
(@open_groups_by_unit.delete(unit) || []).reverse_each do |group|
|
|
192
|
+
route([:group, unit, :example_group_finished, group])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def worker_crash_error(worker, unit)
|
|
197
|
+
group = @units[unit].group
|
|
198
|
+
RuntimeError.new(
|
|
199
|
+
"Parallel worker #{worker.number} (pid #{worker.pid}) exited unexpectedly while running " \
|
|
200
|
+
"`#{group.metadata[:full_description]}` (#{group.location})."
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def count_failure(message)
|
|
205
|
+
return unless message[0] == :example && message[2] == :example_failed
|
|
206
|
+
|
|
207
|
+
@failure_count += 1
|
|
208
|
+
stop_dispatching if fail_fast_limit_met?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fail_fast_limit_met?
|
|
212
|
+
fail_fast = @configuration.fail_fast
|
|
213
|
+
return false unless fail_fast
|
|
214
|
+
|
|
215
|
+
@failure_count >= (fail_fast == true ? 1 : fail_fast)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def stop_dispatching
|
|
219
|
+
@quitting = true
|
|
220
|
+
@workers.each do |worker|
|
|
221
|
+
next if worker.quit_sent || !worker.alive? || !worker.current_unit
|
|
222
|
+
|
|
223
|
+
worker.quit_sent = true
|
|
224
|
+
worker.send_command(:quit)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def route(message)
|
|
229
|
+
unit = message[1]
|
|
230
|
+
|
|
231
|
+
if unit.nil?
|
|
232
|
+
@live_unit || @open_groups.any? ? @orphan_events << message : apply(message)
|
|
233
|
+
else
|
|
234
|
+
make_live(unit) if @live_unit.nil? && replayable_now?(unit)
|
|
235
|
+
unit == @live_unit ? apply(message) : (@buffers[unit] ||= []) << message
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def replayable_now?(unit)
|
|
240
|
+
(@open_groups.empty? || @units[unit].split_ancestors.include?(@open_groups.last)) && next_of_family?(unit)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The units of a split group are replayed in their order, as they
|
|
244
|
+
# would run sequentially.
|
|
245
|
+
def next_of_family?(unit)
|
|
246
|
+
previous = @previous_in_family[unit]
|
|
247
|
+
previous.nil? || @replayed_units[previous]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def started?(unit)
|
|
251
|
+
@finished_units[unit] || @buffers.key?(unit)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def unit_finished(unit)
|
|
255
|
+
@finished_units[unit] = true
|
|
256
|
+
return unless unit == @live_unit
|
|
257
|
+
|
|
258
|
+
@replayed_units[unit] = true
|
|
259
|
+
@live_unit = nil
|
|
260
|
+
replay_next_units
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Replays buffered units until reaching one that is still running.
|
|
264
|
+
# Units of the innermost open split group come first, so that the
|
|
265
|
+
# group is replayed as a whole before it is closed.
|
|
266
|
+
def replay_next_units
|
|
267
|
+
while @live_unit.nil?
|
|
268
|
+
replay_orphan_events if @open_groups.empty?
|
|
269
|
+
unit = @open_groups.empty? ? next_top_level_unit : unreplayed_units_in(@open_groups.last).first
|
|
270
|
+
|
|
271
|
+
if unit.nil?
|
|
272
|
+
break if @open_groups.empty?
|
|
273
|
+
|
|
274
|
+
close_group
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
break unless started?(unit)
|
|
278
|
+
|
|
279
|
+
make_live(unit)
|
|
280
|
+
next unless @finished_units[unit]
|
|
281
|
+
|
|
282
|
+
@replayed_units[unit] = true
|
|
283
|
+
@live_unit = nil
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def next_top_level_unit
|
|
288
|
+
candidates = unreplayed_units.select { |unit| next_of_family?(unit) }
|
|
289
|
+
candidates.find { |unit| @finished_units[unit] } || candidates.first
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def replay_orphan_events
|
|
293
|
+
@orphan_events.shift(@orphan_events.size).each { |message| apply(message) }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def unreplayed_units
|
|
297
|
+
@buffers.keys.reject { |unit| @replayed_units[unit] } +
|
|
298
|
+
@finished_units.keys.reject { |unit| @replayed_units[unit] || @buffers.key?(unit) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def unreplayed_units_in(group)
|
|
302
|
+
units = @units.each_index.select do |unit|
|
|
303
|
+
!@replayed_units[unit] && @units[unit].split_ancestors.include?(group)
|
|
304
|
+
end
|
|
305
|
+
units.sort_by { |unit| @units[unit].sequence }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def make_live(unit)
|
|
309
|
+
ancestors = @units[unit].split_ancestors
|
|
310
|
+
close_group until ancestors[0, @open_groups.size] == @open_groups
|
|
311
|
+
ancestors.drop(@open_groups.size).each do |group|
|
|
312
|
+
@open_groups << group
|
|
313
|
+
@reporter.example_group_started(group)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
@live_unit = unit
|
|
317
|
+
(@buffers.delete(unit) || []).each { |message| apply(message) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def close_group
|
|
321
|
+
@reporter.example_group_finished(@open_groups.pop)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def finish
|
|
325
|
+
# Units never dispatched (after `--fail-fast` or an interrupt) have
|
|
326
|
+
# nothing to replay; replay whatever the others sent.
|
|
327
|
+
@units.each_index do |unit|
|
|
328
|
+
next @finished_units[unit] = true if @dispatched_units[unit]
|
|
329
|
+
|
|
330
|
+
@replayed_units[unit] = true
|
|
331
|
+
@results << false
|
|
332
|
+
end
|
|
333
|
+
@live_unit = nil
|
|
334
|
+
replay_next_units
|
|
335
|
+
close_group until @open_groups.empty?
|
|
336
|
+
replay_orphan_events
|
|
337
|
+
|
|
338
|
+
@run_times.each { |group, run_time| @reporter.record_group_run_time(group, run_time) }
|
|
339
|
+
@workers.each(&:wait)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def terminate_workers
|
|
343
|
+
(@workers || []).each do |worker|
|
|
344
|
+
next unless worker.alive?
|
|
345
|
+
|
|
346
|
+
worker.close
|
|
347
|
+
begin
|
|
348
|
+
Process.kill(:TERM, worker.pid)
|
|
349
|
+
rescue SystemCallError
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
worker.wait
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def apply(message)
|
|
357
|
+
case message.first
|
|
358
|
+
when :example
|
|
359
|
+
_, _, event, index, payload = message
|
|
360
|
+
example = @registry.examples[index]
|
|
361
|
+
restore(example, payload) if payload
|
|
362
|
+
@reporter.__send__(event, example)
|
|
363
|
+
when :group
|
|
364
|
+
_, _, event, index = message
|
|
365
|
+
@reporter.__send__(event, @registry.groups[index])
|
|
366
|
+
when :message
|
|
367
|
+
@reporter.message(message[2])
|
|
368
|
+
when :notify
|
|
369
|
+
_, _, event, notification = message
|
|
370
|
+
notification = WorkerReporter.unpack(notification, nil)
|
|
371
|
+
@reporter.notify(event, notification) if notification
|
|
372
|
+
when :publish
|
|
373
|
+
_, _, event, options = message
|
|
374
|
+
@reporter.publish(event, options.transform_values { |value| WorkerReporter.unpack(value) })
|
|
375
|
+
when :non_example_exception
|
|
376
|
+
_, _, exception, description = message
|
|
377
|
+
@reporter.notify_non_example_exception(ExceptionSerializer.load(exception), description)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def time_at(nanoseconds)
|
|
382
|
+
::Time.at(nanoseconds / 1_000_000_000, nanoseconds % 1_000_000_000, :nsec)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Hashes and arrays are updated in place, so that the metadata of groups
|
|
386
|
+
# sharing them with their examples sees the changes, as in the worker.
|
|
387
|
+
# Hashes are merged, to collect the changes of the parts of a group
|
|
388
|
+
# which ran in different workers.
|
|
389
|
+
def restore_metadata(metadata, key, value)
|
|
390
|
+
current = metadata[key]
|
|
391
|
+
if current.frozen? || current.class != value.class || !(Hash === value || Array === value)
|
|
392
|
+
metadata[key] = value
|
|
393
|
+
elsif Hash === value
|
|
394
|
+
merge_in_place(current, value)
|
|
395
|
+
else
|
|
396
|
+
current.replace(value)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def merge_in_place(target, source)
|
|
401
|
+
source.each do |key, value|
|
|
402
|
+
nested = Hash === target[key] && Hash === value && !target[key].frozen?
|
|
403
|
+
nested ? merge_in_place(target[key], value) : target[key] = value
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def restore(example, payload)
|
|
408
|
+
started_at, status, exception, finished_at, run_time,
|
|
409
|
+
pending_message, pending_exception, pending_fixed, metadata = payload
|
|
410
|
+
|
|
411
|
+
result = example.execution_result
|
|
412
|
+
result.started_at = time_at(started_at) if started_at
|
|
413
|
+
return if payload.size == 1
|
|
414
|
+
|
|
415
|
+
(metadata || {}).each { |key, value| restore_metadata(example.metadata, key, WorkerReporter.unpack(value)) }
|
|
416
|
+
result.status = status
|
|
417
|
+
result.exception = ExceptionSerializer.load(exception)
|
|
418
|
+
result.finished_at = finished_at && time_at(finished_at)
|
|
419
|
+
result.run_time = run_time
|
|
420
|
+
result.pending_message = pending_message
|
|
421
|
+
result.pending_exception = ExceptionSerializer.load(pending_exception)
|
|
422
|
+
result.pending_fixed = pending_fixed
|
|
423
|
+
example.restore_exception(result.exception)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|