rspec-multicore 0.2.0.pre1

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: c5b4ba855f92f2fc6b231d2d57cb81a89c0f8ffc8c127a83b4d2f6d5470f0027
4
+ data.tar.gz: 55258d03db9e19e53d77ab967bc2a0f7c803f535abff99af1aa5a58f40ae7175
5
+ SHA512:
6
+ metadata.gz: 76afdaeefbeb2b42a0f646c3b578fb002e54541fcd91d5170e6589f00ffb85cd6af5b986d7699681b64c7d70d88f09f76f7ec71fbcceb1024b2cf5add78523e7
7
+ data.tar.gz: 5255c15ae7b1adca839a178aaf18b87365d1d1507a112edb0eb7e7164cd251168791fdd93aa24a57b27e6169bac4c3186069f4995444b64e65737b02c002bfa2
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0.pre1
4
+
5
+ - Uses one narrow `Runner#run_specs` patch and delegates suite ownership to RSpec.
6
+ - Adds a bounded plain-value Channel, persistent process Pool, parent object registry, and standard-event snapshot bridge.
7
+ - Adds `RSPEC_MULTICORE`, configurable workers, fork hooks, and reverse-order shutdown hooks.
8
+ - Supports Ruby 3.2+ and RSpec 3.13.x.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025-2026 Sveta Markovic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # rspec-multicore
2
+
3
+ `rspec-multicore` runs RSpec 3.13 top-level example groups in persistent forked workers while the parent retains the real RSpec objects and reporter.
4
+
5
+ ```ruby
6
+ require "rspec/multicore"
7
+
8
+ RSpec::Multicore.configure { _1.workers = 8 }
9
+ RSpec::Multicore.on_worker_fork { |slot| MyResource.connect(slot) }
10
+ RSpec::Multicore.on_worker_shutdown { |slot| MyResults.finish(slot) }
11
+ ```
12
+
13
+ Use `RSPEC_MULTICORE=8` for an explicit count, or `RSPEC_MULTICORE=0`, `false`, or `off` for native serial RSpec. Unset, `auto`, `true`, or `on` selects the configured count or CPU count.
14
+
15
+ The gem patches only `RSpec::Core::Runner#run_specs`. It sends stable IDs and bounded result/exception snapshots between processes, supports only standard RSpec 3.13 reporter events, and replays events in seeded group order. Fail-fast and dry-run remain serial. Workers use `exit!`; inherited `at_exit` callbacks do not run.
16
+
17
+ Application resources and coverage tools are configured explicitly through the lifecycle hooks. Serial coverage is the safe default.
18
+
19
+ See the [project documentation](https://github.com/svetam/rspec-multicore) for details.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module RSpec
6
+ module Multicore
7
+ # Transfers bounded plain-value frames over a local socket pair.
8
+ class Channel
9
+ HEADER_SIZE = 4
10
+ MAX_FRAME_SIZE = 10 * 1024 * 1024
11
+ PLAIN_VALUES = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol].freeze
12
+
13
+ attr_reader :socket
14
+
15
+ def self.pair = UNIXSocket.pair.map { new(_1) }
16
+
17
+ def initialize(socket)
18
+ @socket = socket
19
+ @write_lock = Mutex.new
20
+ end
21
+
22
+ def write(value)
23
+ validate!(value)
24
+ payload = Marshal.dump(value)
25
+ raise Error, "Channel frame exceeds #{MAX_FRAME_SIZE} bytes" if payload.bytesize > MAX_FRAME_SIZE
26
+
27
+ @write_lock.synchronize { write_all([payload.bytesize].pack("N") + payload) }
28
+ nil
29
+ rescue IOError, SystemCallError => e
30
+ raise Error, "Channel write failed: #{e.message}"
31
+ end
32
+
33
+ def read
34
+ header = read_exactly(HEADER_SIZE)
35
+ return if header.nil?
36
+
37
+ length = header.unpack1("N")
38
+ raise Error, "Invalid Channel frame size: #{length}" unless length.between?(1, MAX_FRAME_SIZE)
39
+
40
+ payload = read_exactly(length, "frame")
41
+
42
+ value = Marshal.load(payload)
43
+ validate!(value)
44
+ value
45
+ rescue TypeError, ArgumentError => e
46
+ raise Error, "Invalid Channel frame: #{e.message}"
47
+ end
48
+
49
+ def close
50
+ socket.close unless socket.closed?
51
+ rescue IOError, SystemCallError
52
+ nil
53
+ end
54
+
55
+ def closed? = socket.closed?
56
+
57
+ private
58
+
59
+ def read_exactly(length, part = "header")
60
+ data = +""
61
+ while data.bytesize < length
62
+ chunk = socket.read(length - data.bytesize)
63
+ if chunk.nil? || chunk.empty?
64
+ return if data.empty? && part == "header"
65
+
66
+ raise Error, "Channel closed during #{part}"
67
+ end
68
+
69
+ data << chunk
70
+ end
71
+ data
72
+ rescue IOError, SystemCallError => e
73
+ raise Error, "Channel read failed: #{e.message}"
74
+ end
75
+
76
+ def write_all(data)
77
+ offset = 0
78
+ offset += socket.write(data.byteslice(offset..)) while offset < data.bytesize
79
+ end
80
+
81
+ def validate!(value)
82
+ return if PLAIN_VALUES.any? { value.is_a?(_1) }
83
+ return value.each { validate!(_1) } if value.is_a?(Array)
84
+
85
+ if value.is_a?(Hash)
86
+ return value.each do |key, item|
87
+ validate!(key)
88
+ validate!(item)
89
+ end
90
+ end
91
+
92
+ raise Error, "Channel cannot transfer #{value.class}"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module RSpec
6
+ # Process configuration and lifecycle hooks for multicore RSpec execution.
7
+ module Multicore
8
+ # Resolves the worker count from the Ruby API and RSPEC_MULTICORE.
9
+ class Configuration
10
+ ENABLED_VALUES = %w[true on auto].freeze
11
+ DISABLED_VALUES = %w[0 false off].freeze
12
+
13
+ def workers
14
+ setting = env_value
15
+ case setting
16
+ when :disabled then 0
17
+ when Integer then setting
18
+ else @workers || Etc.nprocessors
19
+ end
20
+ end
21
+
22
+ def workers=(value)
23
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
24
+ raise ArgumentError, "workers must be a positive integer or nil"
25
+ end
26
+
27
+ @workers = value
28
+ end
29
+
30
+ private
31
+
32
+ def env_value
33
+ value = ENV.fetch("RSPEC_MULTICORE", nil)
34
+ return :default if value.nil? || ENABLED_VALUES.include?(value.downcase)
35
+ return :disabled if DISABLED_VALUES.include?(value.downcase)
36
+
37
+ workers = Integer(value, 10)
38
+ return workers if workers.positive?
39
+
40
+ raise ArgumentError
41
+ rescue ArgumentError
42
+ raise ArgumentError,
43
+ "RSPEC_MULTICORE must be auto, true, on, false, off, 0, or a positive integer"
44
+ end
45
+ end
46
+
47
+ # Stores process lifecycle hooks independently of RSpec configuration.
48
+ module Hooks
49
+ class << self
50
+ def on_fork(&block) = (fork_hooks << block if block)
51
+
52
+ def on_shutdown(&block) = (shutdown_hooks << block if block)
53
+
54
+ def run_fork(slot) = fork_hooks.each { _1.call(slot) }
55
+
56
+ def run_shutdown(slot)
57
+ shutdown_hooks.reverse_each.filter_map do |hook|
58
+ hook.call(slot)
59
+ nil
60
+ rescue StandardError => e
61
+ e
62
+ end
63
+ end
64
+
65
+ def clear!
66
+ fork_hooks.clear
67
+ shutdown_hooks.clear
68
+ end
69
+
70
+ private
71
+
72
+ def fork_hooks = @fork_hooks ||= []
73
+ def shutdown_hooks = @shutdown_hooks ||= []
74
+ end
75
+ end
76
+
77
+ class << self
78
+ def configuration = @configuration ||= Configuration.new
79
+
80
+ def configure = yield configuration
81
+
82
+ def workers = configuration.workers
83
+ def enabled? = workers.positive?
84
+ def on_worker_fork(&) = Hooks.on_fork(&)
85
+ def on_worker_shutdown(&) = Hooks.on_shutdown(&)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Multicore
5
+ # Schedules groups across persistent workers and replays ordered results.
6
+ class Pool
7
+ Worker = Struct.new(:pid, :slot, :channel, :assigned, :completed, :status, keyword_init: true)
8
+
9
+ attr_reader :workers
10
+
11
+ def initialize(reporter:, configuration:, workers: RSpec::Multicore.workers)
12
+ @reporter = reporter
13
+ @configuration = configuration
14
+ @worker_count = workers
15
+ @workers = []
16
+ @failures = []
17
+ end
18
+
19
+ def run(groups)
20
+ return [] if groups.empty?
21
+
22
+ prepare(groups)
23
+ spawn_workers
24
+ event_loop
25
+ reap_workers
26
+ report_failures
27
+ @results
28
+ rescue StandardError => e
29
+ @failures << e
30
+ report_failures
31
+ Array.new(groups.size, false)
32
+ ensure
33
+ cleanup
34
+ end
35
+
36
+ private
37
+
38
+ def prepare(groups)
39
+ @groups = groups
40
+ @queue = (0...groups.size).to_a
41
+ @results = Array.new(groups.size)
42
+ @buffers = Hash.new { |hash, key| hash[key] = [] }
43
+ @completed = {}
44
+ @next_replay = 0
45
+ registry = ObjectRegistry.new(groups)
46
+ @bridge = ReporterBridge.new(registry, @reporter)
47
+ end
48
+
49
+ def spawn_workers
50
+ [@worker_count, @groups.size].min.times do |index|
51
+ parent_channel, child_channel = Channel.pair
52
+ inherited_channels = workers.map(&:channel)
53
+ pid = fork_worker(index, parent_channel, child_channel, inherited_channels)
54
+ child_channel.close
55
+ workers << Worker.new(pid:, slot: index + 1, channel: parent_channel)
56
+ rescue StandardError
57
+ parent_channel&.close
58
+ child_channel&.close
59
+ raise
60
+ end
61
+ end
62
+
63
+ def fork_worker(index, parent_channel, child_channel, inherited_channels)
64
+ Process.fork do
65
+ parent_channel.close
66
+ inherited_channels.each(&:close)
67
+ worker_main(index + 1, child_channel)
68
+ end
69
+ end
70
+
71
+ def worker_main(slot, channel)
72
+ ENV["RSPEC_MULTICORE_WORKER"] = slot.to_s
73
+ writer = EventWriter.new(channel)
74
+ reporter = ReporterProxy.new(writer)
75
+ originals = install_worker_runtime(reporter, writer)
76
+ failed = false
77
+
78
+ begin
79
+ Hooks.run_fork(slot)
80
+ run_groups(channel, writer, reporter)
81
+ rescue Exception => e # rubocop:disable Lint/RescueException
82
+ failed = true
83
+ safely_write(channel, [:worker_error, writer.group_index, Snapshot.failure(e)])
84
+ ensure
85
+ failed = shutdown_failed?(channel, slot) || failed
86
+ restore_worker_runtime(originals)
87
+ channel.close
88
+ Process.exit!(failed ? 1 : 0)
89
+ end
90
+ end
91
+
92
+ def install_worker_runtime(reporter, writer)
93
+ originals = [$stdout, $stderr, @configuration.instance_variable_get(:@reporter)]
94
+ $stdout = ProxyIO.new(writer, :stdout)
95
+ $stderr = ProxyIO.new(writer, :stderr)
96
+ @configuration.instance_variable_set(:@reporter, reporter)
97
+ originals
98
+ end
99
+
100
+ def restore_worker_runtime(originals)
101
+ $stdout, $stderr, reporter = originals
102
+ @configuration.instance_variable_set(:@reporter, reporter)
103
+ end
104
+
105
+ def run_groups(channel, writer, reporter)
106
+ loop do
107
+ channel.write([:request_group])
108
+ response = channel.read
109
+ break if response.nil? || response.first == :no_more_groups
110
+
111
+ index = response.fetch(1)
112
+ writer.group_index = index
113
+ result = @groups.fetch(index).run(reporter)
114
+ channel.write([:group_done, index, !!result])
115
+ end
116
+ end
117
+
118
+ def shutdown_failed?(channel, slot)
119
+ errors = Hooks.run_shutdown(slot)
120
+ errors.each { safely_write(channel, [:worker_error, nil, Snapshot.failure(_1)]) }
121
+ errors.any?
122
+ end
123
+
124
+ def safely_write(channel, frame)
125
+ channel.write(frame)
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def event_loop
131
+ active = workers.dup
132
+ until active.empty?
133
+ readable, = IO.select(active.map { _1.channel.socket })
134
+ readable.each do |socket|
135
+ worker = active.find { _1.channel.socket.equal?(socket) }
136
+ frame = worker.channel.read
137
+ frame ? process_frame(worker, frame) : disconnect(worker, active)
138
+ rescue StandardError => e
139
+ @failures << e
140
+ disconnect(worker, active)
141
+ end
142
+ end
143
+ flush_completed
144
+ end
145
+
146
+ def process_frame(worker, frame)
147
+ case frame.first
148
+ when :request_group then assign_group(worker)
149
+ when :event then buffer_event(frame)
150
+ when :group_done then complete_group(worker, frame)
151
+ when :worker_error then record_worker_error(worker, frame)
152
+ else raise Error, "Unknown worker frame: #{frame.first.inspect}"
153
+ end
154
+ end
155
+
156
+ def assign_group(worker)
157
+ index = @queue.shift
158
+ if index
159
+ worker.assigned = index
160
+ worker.channel.write([:run_group, index])
161
+ else
162
+ worker.assigned = nil
163
+ worker.channel.write([:no_more_groups])
164
+ end
165
+ end
166
+
167
+ def buffer_event(frame)
168
+ _, group_index, sequence, event, payload = frame
169
+ @buffers[group_index] << [sequence, event, payload]
170
+ end
171
+
172
+ def complete_group(worker, frame)
173
+ index = frame.fetch(1)
174
+ @results[index] = frame.fetch(2)
175
+ @completed[index] = true
176
+ worker.assigned = nil
177
+ flush_completed
178
+ end
179
+
180
+ def record_worker_error(worker, frame)
181
+ @failures << Snapshot.restore_failure(frame.fetch(2))
182
+ fail_group(worker.assigned || frame[1])
183
+ worker.assigned = nil
184
+ end
185
+
186
+ def disconnect(worker, active)
187
+ if worker.assigned
188
+ @failures << Error.new("Worker #{worker.slot} disconnected while running group #{worker.assigned}")
189
+ end
190
+ fail_group(worker.assigned)
191
+ worker.assigned = nil
192
+ worker.channel.close
193
+ active.delete(worker)
194
+ end
195
+
196
+ def fail_group(index)
197
+ return if index.nil?
198
+
199
+ @results[index] = false
200
+ @completed[index] = true
201
+ flush_completed
202
+ end
203
+
204
+ def flush_completed
205
+ while @completed[@next_replay]
206
+ Array(@buffers.delete(@next_replay)).sort_by(&:first).each do |_, event, payload|
207
+ @bridge.replay(event, payload)
208
+ end
209
+ @next_replay += 1
210
+ end
211
+ end
212
+
213
+ def reap_workers
214
+ workers.each do |worker|
215
+ _, status = Process.waitpid2(worker.pid)
216
+ worker.status = status
217
+ worker.completed = true
218
+ next if status.success?
219
+ next if @results.compact.include?(false) || @failures.any?
220
+
221
+ @failures << Error.new("Worker #{worker.slot} exited with status #{status.exitstatus}")
222
+ rescue Errno::ECHILD
223
+ nil
224
+ end
225
+ @results.map! { _1.nil? ? false : _1 }
226
+ end
227
+
228
+ def report_failures
229
+ @failures.each do |failure|
230
+ @reporter.notify_non_example_exception(failure, "An error occurred in RSpec::Multicore.")
231
+ end
232
+ end
233
+
234
+ def cleanup
235
+ workers.each do |worker|
236
+ next if worker.completed
237
+
238
+ worker.channel.close
239
+ Process.kill("TERM", worker.pid)
240
+ rescue Errno::ESRCH
241
+ nil
242
+ end
243
+ workers.each do |worker|
244
+ next if worker.completed
245
+
246
+ Process.waitpid(worker.pid)
247
+ worker.completed = true
248
+ rescue Errno::ECHILD
249
+ worker.completed = true
250
+ nil
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Multicore
5
+ # Retains the parent's original RSpec groups and examples by stable ID.
6
+ class ObjectRegistry
7
+ def initialize(top_groups)
8
+ groups = top_groups.flat_map { [_1, *_1.descendants] }.uniq(&:id)
9
+ @groups = groups.to_h { [_1.id, _1] }
10
+ @examples = groups.flat_map(&:examples).to_h { [_1.id, _1] }
11
+ end
12
+
13
+ def group(id) = @groups.fetch(id)
14
+ def example(id) = @examples.fetch(id)
15
+ end
16
+
17
+ # Represents a worker exception whose original class cannot be reconstructed.
18
+ class RemoteFailure < StandardError
19
+ attr_reader :original_class_name, :cause, :all_exceptions
20
+
21
+ def initialize(data, cause: nil, all_exceptions: [])
22
+ @original_class_name = data.fetch(:class)
23
+ @cause = cause
24
+ @all_exceptions = all_exceptions
25
+ super(data.fetch(:message))
26
+ set_backtrace(data[:backtrace])
27
+ end
28
+ end
29
+
30
+ # Converts execution results and exceptions to bounded plain-value state.
31
+ module Snapshot
32
+ RESULT_FIELDS = %i[status started_at finished_at run_time pending_message pending_fixed].freeze
33
+
34
+ module_function
35
+
36
+ def example(example, description: nil)
37
+ result = example.execution_result
38
+ data = RESULT_FIELDS.to_h { [_1, encode_value(result.public_send(_1))] }
39
+ data[:exception] = failure(result.exception)
40
+ data[:pending_exception] = failure(result.pending_exception)
41
+
42
+ { id: example.id, description:, result: data }
43
+ end
44
+
45
+ def apply_example(example, data)
46
+ description = data[:description]
47
+ apply_description(example, description) if description && !description.empty?
48
+
49
+ result = example.execution_result
50
+ RESULT_FIELDS.each do |field|
51
+ result.public_send("#{field}=", decode_value(data.fetch(:result)[field]))
52
+ end
53
+ result.exception = restore_failure(data.dig(:result, :exception))
54
+ result.pending_exception = restore_failure(data.dig(:result, :pending_exception))
55
+ example
56
+ end
57
+
58
+ def failure(exception, seen = {}.compare_by_identity)
59
+ return unless exception
60
+ return cycle_failure(exception) if seen[exception]
61
+
62
+ seen = seen.dup
63
+ seen[exception] = true
64
+ children = exception.respond_to?(:all_exceptions) ? exception.all_exceptions : []
65
+ {
66
+ class: exception.class.name,
67
+ message: exception.message,
68
+ backtrace: exception.backtrace,
69
+ cause: failure(exception.cause, seen),
70
+ children: children.map { failure(_1, seen) }
71
+ }
72
+ end
73
+
74
+ def restore_failure(data)
75
+ return unless data
76
+
77
+ cause = restore_failure(data[:cause])
78
+ children = Array(data[:children]).map { restore_failure(_1) }
79
+ exception = build_exception(data, children)
80
+ exception.set_backtrace(data[:backtrace])
81
+ attach_failure_details(exception, cause, children)
82
+ rescue StandardError
83
+ RemoteFailure.new(data, cause:, all_exceptions: children)
84
+ end
85
+
86
+ def encode_value(value) = value.is_a?(Time) ? { time: value.to_f } : value
87
+
88
+ def decode_value(value) = value.is_a?(Hash) && value.key?(:time) ? Time.at(value[:time]) : value
89
+
90
+ def runtime_description(example)
91
+ description = example.metadata[:description].to_s
92
+ return description unless description.empty?
93
+
94
+ RSpec::Matchers.generated_description.to_s if generated_description?
95
+ end
96
+
97
+ def apply_description(example, description)
98
+ metadata = example.metadata
99
+ if metadata[:description].to_s.empty?
100
+ metadata[:full_description] = "#{metadata[:full_description]}#{description}"
101
+ end
102
+ metadata[:description] = description
103
+ end
104
+
105
+ def generated_description?
106
+ defined?(RSpec::Matchers) && RSpec::Matchers.respond_to?(:generated_description) &&
107
+ !RSpec::Matchers.generated_description.to_s.empty?
108
+ end
109
+
110
+ def build_exception(data, children)
111
+ klass = Object.const_get(data.fetch(:class))
112
+ raise TypeError unless klass <= Exception
113
+
114
+ children.empty? ? klass.new(data.fetch(:message)) : klass.new(children)
115
+ rescue NameError, TypeError, ArgumentError
116
+ RemoteFailure.new(data, all_exceptions: children)
117
+ end
118
+
119
+ def attach_failure_details(exception, cause, children)
120
+ exception.define_singleton_method(:cause) { cause } if cause
121
+ if children.any? && !exception.respond_to?(:all_exceptions)
122
+ exception.define_singleton_method(:all_exceptions) { children }
123
+ end
124
+ exception
125
+ end
126
+
127
+ def cycle_failure(exception)
128
+ { class: exception.class.name, message: exception.message, backtrace: exception.backtrace,
129
+ cause: nil, children: [] }
130
+ end
131
+ end
132
+
133
+ # Serializes worker reporter events with a shared monotonic sequence.
134
+ class EventWriter
135
+ attr_accessor :group_index
136
+
137
+ def initialize(channel)
138
+ @channel = channel
139
+ @sequence = 0
140
+ @lock = Mutex.new
141
+ end
142
+
143
+ def emit(event, payload)
144
+ @lock.synchronize do
145
+ @sequence += 1
146
+ @channel.write([:event, group_index, @sequence, event, payload])
147
+ end
148
+ end
149
+ end
150
+
151
+ # Exposes only the reporter calls made by RSpec 3.13 example execution.
152
+ class ReporterProxy
153
+ GROUP_EVENTS = %i[example_group_started example_group_finished].freeze
154
+ EXAMPLE_EVENTS = %i[example_started example_finished example_passed example_failed example_pending].freeze
155
+
156
+ def initialize(writer)
157
+ @writer = writer
158
+ @descriptions = {}
159
+ end
160
+
161
+ GROUP_EVENTS.each do |event|
162
+ define_method(event) { |group| @writer.emit(event, group.id) }
163
+ end
164
+
165
+ EXAMPLE_EVENTS.each do |event|
166
+ define_method(event) { |example| @writer.emit(event, snapshot(example)) }
167
+ end
168
+
169
+ def message(message) = @writer.emit(:message, message.to_s)
170
+ def deprecation(*args) = @writer.emit(:deprecation, args)
171
+
172
+ def notify_non_example_exception(exception, context)
173
+ @writer.emit(:notify_non_example_exception, [Snapshot.failure(exception), context.to_s])
174
+ end
175
+
176
+ def fail_fast_limit_met? = false
177
+
178
+ def method_missing(name, *) = raise UnsupportedReporterEvent, "Unsupported RSpec reporter event: #{name}"
179
+
180
+ def respond_to_missing?(*, **) = false
181
+
182
+ private
183
+
184
+ def snapshot(example)
185
+ original = @descriptions.fetch(example.id) do
186
+ @descriptions[example.id] = example.metadata[:description].to_s
187
+ end
188
+ description = Snapshot.runtime_description(example)
189
+ description = nil if description == original
190
+ Snapshot.example(example, description:)
191
+ end
192
+ end
193
+
194
+ # Sends worker stdout and stderr through the same ordered event writer.
195
+ class ProxyIO
196
+ def initialize(writer, stream)
197
+ @writer = writer
198
+ @stream = stream
199
+ end
200
+
201
+ def write(data)
202
+ value = data.to_s
203
+ return 0 if value.empty?
204
+
205
+ @writer.emit(@stream, value)
206
+ value.bytesize
207
+ end
208
+
209
+ def <<(data) = write(data).then { self }
210
+ def flush = self
211
+ def print(*args) = args.each { write(_1) }.then { nil }
212
+ def printf(format, *args) = write(format % args).then { nil }
213
+ def puts(*args) = (args.empty? ? write("\n") : args.each { write("#{_1}\n") }).then { nil }
214
+ def tty? = false
215
+ alias isatty tty?
216
+ def sync = true
217
+
218
+ def sync=(_value)
219
+ true
220
+ end
221
+
222
+ def close = nil
223
+ def closed? = false
224
+ end
225
+
226
+ # Restores parent objects and invokes the real RSpec reporter.
227
+ class ReporterBridge
228
+ GROUP_EVENTS = ReporterProxy::GROUP_EVENTS
229
+ EXAMPLE_EVENTS = ReporterProxy::EXAMPLE_EVENTS
230
+
231
+ def initialize(registry, reporter, stdout: $stdout, stderr: $stderr)
232
+ @registry = registry
233
+ @reporter = reporter
234
+ @stdout = stdout
235
+ @stderr = stderr
236
+ end
237
+
238
+ def replay(event, payload)
239
+ case event
240
+ when *GROUP_EVENTS then @reporter.public_send(event, @registry.group(payload))
241
+ when *EXAMPLE_EVENTS then replay_example(event, payload)
242
+ when :message then @reporter.message(payload)
243
+ when :deprecation then @reporter.deprecation(*payload)
244
+ when :notify_non_example_exception then replay_error(payload)
245
+ when :stdout then @stdout.write(payload)
246
+ when :stderr then @stderr.write(payload)
247
+ else raise UnsupportedReporterEvent, "Unsupported RSpec reporter event: #{event}"
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ def replay_example(event, payload)
254
+ example = @registry.example(payload.fetch(:id))
255
+ @reporter.public_send(event, Snapshot.apply_example(example, payload))
256
+ end
257
+
258
+ def replay_error(payload)
259
+ @reporter.notify_non_example_exception(Snapshot.restore_failure(payload[0]),
260
+ payload[1])
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Multicore
5
+ # Enumerable wrapper that substitutes parallel work only for RSpec's map call.
6
+ class ParallelGroups
7
+ include Enumerable
8
+
9
+ def initialize(groups, configuration)
10
+ @groups = groups
11
+ @configuration = configuration
12
+ end
13
+
14
+ def each(&block) = block ? @groups.each(&block) : enum_for(:each)
15
+
16
+ def map
17
+ return enum_for(:map) unless block_given?
18
+
19
+ Pool.new(reporter: @configuration.reporter, configuration: @configuration).run(@groups)
20
+ end
21
+ end
22
+
23
+ # The only RSpec method override used by rspec-multicore.
24
+ module RunnerPatch
25
+ def run_specs(example_groups)
26
+ return super unless parallelize?(example_groups)
27
+
28
+ super(ParallelGroups.new(example_groups, @configuration))
29
+ end
30
+
31
+ private
32
+
33
+ def parallelize?(groups)
34
+ RSpec::Multicore.workers > 1 && groups.size > 1 && Process.respond_to?(:fork) &&
35
+ !@configuration.fail_fast && !@configuration.dry_run?
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ RSpec::Core::Runner.prepend(RSpec::Multicore::RunnerPatch) unless RSpec::Core::Runner < RSpec::Multicore::RunnerPatch
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Multicore
5
+ VERSION = "0.2.0.pre1"
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core/runner"
4
+
5
+ module RSpec
6
+ module Multicore
7
+ class Error < StandardError; end
8
+ class UnsupportedReporterEvent < Error; end
9
+ end
10
+ end
11
+
12
+ require_relative "multicore/version"
13
+ require_relative "multicore/configuration"
14
+ require_relative "multicore/channel"
15
+ require_relative "multicore/reporter_bridge"
16
+ require_relative "multicore/pool"
17
+ require_relative "multicore/runner_patch"
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspec-multicore
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0.pre1
5
+ platform: ruby
6
+ authors:
7
+ - Sveta Markovic
8
+ bindir: bin
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'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.13'
26
+ description: Single-boot, multi-process execution for RSpec on POSIX systems. The
27
+ parent loads RSpec once, then runs top-level groups in persistent forked workers.
28
+ email:
29
+ - svetam.sd@pm.me
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - lib/rspec/multicore.rb
38
+ - lib/rspec/multicore/channel.rb
39
+ - lib/rspec/multicore/configuration.rb
40
+ - lib/rspec/multicore/pool.rb
41
+ - lib/rspec/multicore/reporter_bridge.rb
42
+ - lib/rspec/multicore/runner_patch.rb
43
+ - lib/rspec/multicore/version.rb
44
+ homepage: https://github.com/svetam/rspec-multicore
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ homepage_uri: https://github.com/svetam/rspec-multicore
49
+ source_code_uri: https://github.com/svetam/rspec-multicore/tree/master/rspec-multicore
50
+ changelog_uri: https://github.com/svetam/rspec-multicore/blob/master/rspec-multicore/CHANGELOG.md
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.2.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.9
67
+ specification_version: 4
68
+ summary: Multi-process parallelization for RSpec
69
+ test_files: []