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.
@@ -0,0 +1,187 @@
1
+ module RSpec
2
+ module Core
3
+ module Parallel
4
+ # A forked process running the units of work it is sent.
5
+ #
6
+ # @private
7
+ class Worker
8
+ attr_reader :number, :pid, :events
9
+ attr_accessor :current_unit, :quit_sent
10
+
11
+ def initialize(number)
12
+ @number = number
13
+ @current_unit = nil
14
+ @quit_sent = false
15
+ @alive = true
16
+ @reaped = false
17
+ end
18
+
19
+ def alive?
20
+ @alive
21
+ end
22
+
23
+ def start(configuration, registry, other_workers)
24
+ @events, events_writer = Channel.pipe
25
+ commands_reader, @commands = Channel.pipe
26
+
27
+ # The process exits when the block returns, running `at_exit` hooks
28
+ # (so that tools such as SimpleCov can save their results) but not
29
+ # the `ensure` clauses of the main process's call stack.
30
+ @pid = Process.fork do
31
+ @events.close
32
+ @commands.close
33
+ other_workers.each(&:close)
34
+ run(configuration, registry, commands_reader, events_writer)
35
+ end
36
+
37
+ events_writer.close
38
+ commands_reader.close
39
+ self
40
+ end
41
+
42
+ def send_command(*command)
43
+ @commands.write(command)
44
+ true
45
+ rescue IOError, SystemCallError
46
+ false
47
+ end
48
+
49
+ def close
50
+ @alive = false
51
+ @events.close
52
+ @commands.close
53
+ end
54
+
55
+ def exited?
56
+ @reaped ||= !Process.waitpid(@pid, Process::WNOHANG).nil?
57
+ rescue SystemCallError
58
+ @reaped = true
59
+ end
60
+
61
+ def wait
62
+ return if @reaped
63
+
64
+ Process.waitpid(@pid)
65
+ @reaped = true
66
+ rescue SystemCallError
67
+ @reaped = true
68
+ end
69
+
70
+ private
71
+
72
+ def run(configuration, registry, commands, events)
73
+ # :nocov: - executed in forked processes, covered by integration specs
74
+ prepare_environment(configuration)
75
+ reporter = WorkerReporter.new(configuration, registry, events, commands)
76
+ configuration.reporter = reporter
77
+
78
+ begin
79
+ RailsIntegration.after_fork(@number)
80
+ SimpleCovIntegration.after_fork
81
+ configuration.parallel_worker_hooks(:setup).each { |hook| hook.call(@number) }
82
+ configuration.with_suite_hooks { process_commands(registry, reporter, commands) }
83
+ configuration.parallel_worker_hooks(:teardown).each { |hook| hook.call(@number) }
84
+ RailsIntegration.run_cleanup(@number)
85
+ rescue SystemExit => e
86
+ finish_interrupted_unit(reporter)
87
+ reporter.exit_requested(e.status)
88
+ rescue Exception => e # rubocop:disable Lint/RescueException
89
+ finish_interrupted_unit(reporter)
90
+ reporter.notify_non_example_exception(e, "An error occurred in parallel worker #{@number}.")
91
+ end
92
+ reporter.worker_finished
93
+ rescue IOError, SystemCallError
94
+ # The main process is gone: there is no one left to report to.
95
+ nil
96
+ # :nocov:
97
+ end
98
+
99
+ # `PARALLEL_TEST_GROUPS` is not set: with it, SimpleCov assumes it runs
100
+ # under parallel_tests and makes the last worker wait for a parallel_tests
101
+ # pid file. The main process collects the results of all the workers.
102
+ def prepare_environment(configuration)
103
+ ENV['TEST_ENV_NUMBER'] = @number == 1 ? '' : @number.to_s
104
+ configuration.parallel_worker_number = @number
105
+ configuration.world.wants_to_quit = false
106
+ trap_interrupt(configuration.world)
107
+ end
108
+
109
+ # The main process tells the user it is shutting down: workers
110
+ # quietly stop after their current example.
111
+ def trap_interrupt(world)
112
+ trap('INT') { world.wants_to_quit ? exit!(1) : world.wants_to_quit = true }
113
+ end
114
+
115
+ def process_commands(registry, reporter, commands)
116
+ # Once quitting, groups return without running any example.
117
+ while (command = commands.read) && command.first == :run
118
+ _, unit, group_index, examples_only, ancestor_indices, example_indices = command
119
+ group = registry.groups[group_index]
120
+ ancestors = ancestor_indices.map { |index| registry.groups[index] }
121
+ restrict_examples(group, example_indices.map { |index| registry.examples[index] }) if example_indices
122
+ reporter.unit = unit
123
+ @unit_started_at = RSpec::Core::Time.now
124
+ succeeded = run_unit(group, examples_only, ancestors, reporter)
125
+ reporter.unit = nil
126
+ reporter.unit_finished(unit, succeeded ? true : false, (RSpec::Core::Time.now - @unit_started_at).to_f)
127
+ end
128
+ end
129
+
130
+ # Workers are forked processes: the examples of a group can be
131
+ # restricted without affecting the other workers.
132
+ def restrict_examples(group, examples)
133
+ RSpec.world.filtered_examples[group] = examples
134
+ end
135
+
136
+ def finish_interrupted_unit(reporter)
137
+ return unless (unit = reporter.unit)
138
+
139
+ reporter.unit_finished(unit, false, (RSpec::Core::Time.now - @unit_started_at).to_f)
140
+ reporter.unit = nil
141
+ end
142
+
143
+ def run_unit(group, examples_only, ancestors, reporter)
144
+ return run_part(group, examples_only, reporter) if ancestors.empty?
145
+
146
+ with_context_hooks_of(ancestors.first, group, examples_only, reporter) do
147
+ run_unit(group, examples_only, ancestors.drop(1), reporter)
148
+ end
149
+ end
150
+
151
+ def run_part(group, examples_only, reporter)
152
+ examples_only ? group.run_examples(reporter) : group.run(reporter)
153
+ end
154
+
155
+ # Runs the context hooks of a split group around part of it, as
156
+ # `ExampleGroup.run` does around the whole group.
157
+ def with_context_hooks_of(split_group, group, examples_only, reporter)
158
+ return if RSpec.world.wants_to_quit
159
+
160
+ begin
161
+ RSpec.current_scope = :before_context_hook
162
+ split_group.run_before_context_hooks(split_group.new('before(:context) hook'))
163
+ yield
164
+ rescue Pending::SkipDeclaredInExample => e
165
+ for_part_examples(group, examples_only, reporter) { |example| example.skip_with_exception(reporter, e) }
166
+ true
167
+ rescue Support::AllExceptionsExceptOnesWeMustNotRescue => e
168
+ for_part_examples(group, examples_only, reporter) { |example| example.fail_with_exception(reporter, e) }
169
+ RSpec.world.wants_to_quit = true if reporter.fail_fast_limit_met?
170
+ false
171
+ ensure
172
+ RSpec.current_scope = :after_context_hook
173
+ split_group.run_after_context_hooks(split_group.new('after(:context) hook'))
174
+ end
175
+ end
176
+
177
+ def for_part_examples(group, examples_only, reporter, &block)
178
+ return group.filtered_examples.each(&block) if examples_only
179
+
180
+ reporter.example_group_started(group)
181
+ group.for_filtered_examples(reporter, &block)
182
+ reporter.example_group_finished(group)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,212 @@
1
+ module RSpec
2
+ module Core
3
+ module Parallel
4
+ # Stands in for the reporter inside a worker process: rather than
5
+ # notifying formatters, it forwards every event to the main process.
6
+ #
7
+ # Events are sent in batches, once per example, to keep the main
8
+ # process, which replays the events of every worker, from becoming a
9
+ # bottleneck.
10
+ #
11
+ # Values provided by user code (metadata, notifications) are marshaled
12
+ # on their own, so that one the main process cannot load (for instance
13
+ # of a class defined while running an example) only affects itself.
14
+ #
15
+ # @private
16
+ class WorkerReporter < Reporter
17
+ UNFORWARDED_METADATA_KEYS = [:execution_result, :example_group, :block, :last_run_status].freeze
18
+ MAX_BATCH_SIZE = 200
19
+
20
+ def initialize(configuration, registry, channel, control)
21
+ super(configuration)
22
+ @registry = registry
23
+ @channel = channel
24
+ @control = control
25
+ @metadata_baselines = {}.compare_by_identity
26
+ @unit = nil
27
+ @batch = []
28
+ end
29
+
30
+ attr_accessor :unit
31
+
32
+ def notify(event, notification)
33
+ case notification
34
+ when Notifications::ExampleNotification
35
+ forward_example_event(event, notification.example)
36
+ when Notifications::GroupNotification
37
+ forward_group_event(event, notification.group)
38
+ else
39
+ forward_notification(event, notification)
40
+ end
41
+ end
42
+
43
+ # Custom notifications are instances of anonymous classes, which
44
+ # cannot be sent to the main process: their options are sent instead.
45
+ def publish(event, options={})
46
+ return super if RSPEC_NOTIFICATIONS.include?(event)
47
+
48
+ post([:publish, @unit, event, options.to_h { |key, value| [key, pack(value)] }])
49
+ end
50
+
51
+ def notify_non_example_exception(exception, context_description)
52
+ @configuration.world.non_example_failure = true
53
+ post([:non_example_exception, @unit, ExceptionSerializer.dump(exception), context_description])
54
+ end
55
+
56
+ # Sending the start of each example lets the main process report the
57
+ # example that was running if the worker crashes.
58
+ def example_started(example)
59
+ super
60
+ flush
61
+ end
62
+
63
+ def example_finished(example)
64
+ super
65
+ poll_control
66
+ end
67
+
68
+ def unit_finished(unit, succeeded, run_time)
69
+ post([:unit_done, unit, succeeded, run_time])
70
+ flush
71
+ end
72
+
73
+ # Asks the main process to exit with the given status once the run is
74
+ # over, as when `exit` is called while running the suite sequentially.
75
+ def exit_requested(status)
76
+ post([:exit, @unit, status])
77
+ end
78
+
79
+ def worker_finished
80
+ post([:bye, nil])
81
+ flush
82
+ end
83
+
84
+ # Marshals a value on its own, along with a description of it to
85
+ # report in its place if the main process cannot load it.
86
+ def self.pack(value)
87
+ [Marshal.dump(value), value.inspect]
88
+ rescue Exception # rubocop:disable Lint/RescueException
89
+ [nil, (value.inspect rescue value.class.name)] # rubocop:disable Style/RescueModifier
90
+ end
91
+
92
+ # @return the packed value, or `fallback` (by default, the description
93
+ # of the value) if it cannot be loaded.
94
+ def self.unpack(packed, fallback=packed[1])
95
+ data = packed[0]
96
+ data ? Marshal.load(data) : fallback # rubocop:disable Security/MarshalLoad
97
+ rescue Exception # rubocop:disable Lint/RescueException
98
+ fallback
99
+ end
100
+
101
+ def flush
102
+ return if @batch.empty?
103
+
104
+ batch = @batch
105
+ @batch = []
106
+ @channel.write(batch)
107
+ end
108
+
109
+ private
110
+
111
+ def post(message)
112
+ @batch << message
113
+ flush if @batch.size >= MAX_BATCH_SIZE
114
+ end
115
+
116
+ def pack(value)
117
+ self.class.pack(value)
118
+ end
119
+
120
+ def forward_example_event(event, example)
121
+ index = @registry.example_index(example)
122
+ return forward_unknown_example(event, example) unless index
123
+
124
+ post([:example, @unit, event, index, example_payload(event, example)])
125
+ end
126
+
127
+ def example_payload(event, example)
128
+ case event
129
+ when :example_started
130
+ @metadata_baselines[example] = metadata_snapshot(example.metadata)
131
+ [nanoseconds(example.execution_result.started_at)]
132
+ when :example_finished
133
+ execution_result_payload(example.execution_result) << metadata_changes(example)
134
+ end
135
+ end
136
+
137
+ def execution_result_payload(result)
138
+ [
139
+ nanoseconds(result.started_at),
140
+ result.status,
141
+ ExceptionSerializer.dump(result.exception),
142
+ nanoseconds(result.finished_at),
143
+ result.run_time,
144
+ result.pending_message,
145
+ ExceptionSerializer.dump(result.pending_exception),
146
+ result.pending_fixed
147
+ ]
148
+ end
149
+
150
+ def nanoseconds(time)
151
+ time && ((time.to_i * 1_000_000_000) + time.nsec)
152
+ end
153
+
154
+ # Hashes and arrays can be changed in place (as rswag does with
155
+ # `example.metadata[:response]`), so their contents are compared too.
156
+ def metadata_snapshot(metadata)
157
+ metadata.each_with_object({}) do |(key, value), snapshot|
158
+ snapshot[key] = [value, contents_of(value)] unless UNFORWARDED_METADATA_KEYS.include?(key)
159
+ end
160
+ end
161
+
162
+ def contents_of(value)
163
+ Marshal.dump(value) if value.is_a?(Hash) || value.is_a?(Array)
164
+ rescue TypeError
165
+ nil
166
+ end
167
+
168
+ def metadata_changes(example)
169
+ baseline = @metadata_baselines.delete(example) || {}
170
+ changes = nil
171
+ example.metadata.each do |key, value|
172
+ next if UNFORWARDED_METADATA_KEYS.include?(key)
173
+
174
+ before, contents = baseline[key]
175
+ next if baseline.key?(key) && before.equal?(value) && contents_of(value) == contents
176
+
177
+ (changes ||= {})[key] = pack(value)
178
+ end
179
+ changes
180
+ end
181
+
182
+ def forward_unknown_example(event, example)
183
+ return unless event == :example_failed
184
+
185
+ message = "Failure in an example unknown to the main process: #{example.full_description}\n" +
186
+ ExceptionSerializer.safe_message(example.exception || StandardError.new)
187
+ forward_notification(:message, Notifications::MessageNotification.new(message))
188
+ end
189
+
190
+ def forward_group_event(event, group)
191
+ index = @registry.group_index(group)
192
+ post([:group, @unit, event, index]) if index
193
+ end
194
+
195
+ def forward_notification(event, notification)
196
+ if event == :message
197
+ post([:message, @unit, notification.message.to_s])
198
+ else
199
+ post([:notify, @unit, event, pack(notification)])
200
+ end
201
+ end
202
+
203
+ def poll_control
204
+ return unless @control.readable_now?
205
+
206
+ command = @control.read
207
+ @configuration.world.wants_to_quit = true if command.nil? || command.first == :quit
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,95 @@
1
+ require 'rspec/core'
2
+ require_relative 'parallel/version'
3
+
4
+ module RSpec
5
+ module Core
6
+ # Multi-process execution of a spec suite.
7
+ #
8
+ # The suite is loaded once in the main process, then forked into a pool
9
+ # of workers which share the loaded code copy-on-write. Top-level example
10
+ # groups (or parts of long ones) are dispatched dynamically to idle
11
+ # workers, longest first. Workers stream every
12
+ # reporter event back to the main process, which replays it against its
13
+ # own copies of the examples, so formatters, `--only-failures`, profiling
14
+ # and exit codes behave exactly as in a sequential run.
15
+ #
16
+ # Because every worker is a separate process, global state (constants,
17
+ # stubs, `RSpec.configuration`, database connections...) is never shared
18
+ # between concurrently running examples.
19
+ #
20
+ # @private
21
+ module Parallel
22
+ # @return [Array<Scheduler::Unit>, nil] the units of work to run in
23
+ # parallel, or nil when the groups must run sequentially.
24
+ def self.units_for(configuration, world, example_groups)
25
+ return nil unless configuration.parallel_workers.to_i > 1 && !configuration.dry_run?
26
+
27
+ unless supported?
28
+ RSpec.warning "Running specs in parallel requires `Process.fork`, which is not " \
29
+ "supported here: running them sequentially", :call_site => nil
30
+ return nil
31
+ end
32
+
33
+ require_components
34
+ units = Scheduler.new(configuration, world).units(example_groups, configuration.parallel_workers.to_i)
35
+ units if units.size > 1
36
+ end
37
+
38
+ # Loaded only for parallel runs.
39
+ def self.require_components
40
+ %w[
41
+ channel exception_serializer rails_integration simplecov_integration registry scheduler worker_reporter
42
+ worker coordinator
43
+ ].each do |file|
44
+ require_relative "parallel/#{file}"
45
+ end
46
+ end
47
+
48
+ # Runs the units in forked worker processes.
49
+ #
50
+ # @return [Boolean] whether all examples passed
51
+ def self.run(configuration, world, units, reporter)
52
+ worker_count = [configuration.parallel_workers.to_i, units.size].min
53
+ passed = Coordinator.new(configuration, world, worker_count).run(units, reporter)
54
+ persist_run_times(configuration, world)
55
+ passed
56
+ end
57
+
58
+ def self.persist_run_times(configuration, world)
59
+ return unless (path = configuration.parallel_run_times_file_path)
60
+
61
+ ExampleStatusPersister.persist(world.all_examples, path)
62
+ rescue SystemCallError => e
63
+ RSpec.warning "Could not write example run times to #{path} (configured as " \
64
+ "`config.parallel_run_times_file_path`) due to a system error: " \
65
+ "#{e.inspect}.", :call_site => nil
66
+ end
67
+
68
+ def self.supported?
69
+ Process.respond_to?(:fork) && RUBY_ENGINE == 'ruby' && !RSpec::Support::OS.windows?
70
+ end
71
+
72
+ # One worker per physical core: workers sharing a core through
73
+ # simultaneous multithreading (hyper-threading) mostly slow each other
74
+ # down, using more CPU time and memory for the same wall time.
75
+ def self.default_worker_count
76
+ require 'etc'
77
+ [Etc.nprocessors, physical_core_count].compact.min
78
+ end
79
+
80
+ def self.physical_core_count
81
+ siblings = Dir['/sys/devices/system/cpu/cpu[0-9]*/topology/thread_siblings_list']
82
+ return siblings.map { |path| File.read(path).strip }.uniq.size unless siblings.empty?
83
+
84
+ return nil unless RbConfig::CONFIG['host_os'] =~ /darwin/
85
+
86
+ count = IO.popen(%w[sysctl -n hw.physicalcpu], :err => File::NULL, &:read).to_i
87
+ count if count > 0
88
+ rescue SystemCallError, IOError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ require_relative 'parallel/core_ext'
@@ -0,0 +1 @@
1
+ require 'rspec/core/parallel'
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-core-parallel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alessandro Rodi
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rspec-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.13.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.14'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 3.13.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.14'
32
+ description: 'Adds `rspec --parallel` to rspec-core 3.13: the suite is loaded once,
33
+ forked into one worker per CPU core, and groups are handed out longest first using
34
+ the run times of the previous run. Formatters, --only-failures, --bisect and exit
35
+ codes work as in a sequential run. Rails apps get a test database per worker.'
36
+ email:
37
+ - alessandro.rodi@renuo.ch
38
+ executables:
39
+ - rspec-parallel
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - CHANGELOG.md
44
+ - LICENSE.md
45
+ - README.md
46
+ - exe/rspec-parallel
47
+ - lib/rspec-core-parallel.rb
48
+ - lib/rspec/core/parallel.rb
49
+ - lib/rspec/core/parallel/channel.rb
50
+ - lib/rspec/core/parallel/coordinator.rb
51
+ - lib/rspec/core/parallel/core_ext.rb
52
+ - lib/rspec/core/parallel/exception_serializer.rb
53
+ - lib/rspec/core/parallel/rails_integration.rb
54
+ - lib/rspec/core/parallel/registry.rb
55
+ - lib/rspec/core/parallel/scheduler.rb
56
+ - lib/rspec/core/parallel/simplecov_integration.rb
57
+ - lib/rspec/core/parallel/version.rb
58
+ - lib/rspec/core/parallel/worker.rb
59
+ - lib/rspec/core/parallel/worker_reporter.rb
60
+ homepage: https://github.com/renuo/rspec-core-parallel
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/renuo/rspec-core-parallel
65
+ source_code_uri: https://github.com/renuo/rspec-core-parallel
66
+ changelog_uri: https://github.com/renuo/rspec-core-parallel/blob/main/CHANGELOG.md
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.3'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.9
83
+ specification_version: 4
84
+ summary: 'Run an RSpec suite in forked worker processes: rspec --parallel'
85
+ test_files: []