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 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.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # `rspec`, with this gem loaded before the options are read, so that
4
+ # `--parallel COUNT` and `--no-parallel` can be passed on the command line.
5
+ require 'rspec/core'
6
+ require 'rspec/core/parallel'
7
+
8
+ RSpec::Core::Runner.invoke
@@ -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