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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +19 -0
- data/lib/rspec/multicore/channel.rb +96 -0
- data/lib/rspec/multicore/configuration.rb +88 -0
- data/lib/rspec/multicore/pool.rb +255 -0
- data/lib/rspec/multicore/reporter_bridge.rb +264 -0
- data/lib/rspec/multicore/runner_patch.rb +41 -0
- data/lib/rspec/multicore/version.rb +7 -0
- data/lib/rspec/multicore.rb +17 -0
- metadata +69 -0
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,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: []
|