rspec-conductor 1.0.8 → 1.0.9
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 +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/rspec/conductor/ext/rspec.rb +2 -0
- data/lib/rspec/conductor/formatters/base.rb +62 -46
- data/lib/rspec/conductor/formatters/ci.rb +1 -1
- data/lib/rspec/conductor/formatters/fancy.rb +15 -2
- data/lib/rspec/conductor/formatters/plain.rb +2 -0
- data/lib/rspec/conductor/results.rb +10 -17
- data/lib/rspec/conductor/rspec_subscriber.rb +2 -0
- data/lib/rspec/conductor/server.rb +82 -86
- data/lib/rspec/conductor/util/child_process.rb +40 -29
- data/lib/rspec/conductor/util/terminal.rb +1 -3
- data/lib/rspec/conductor/version.rb +1 -1
- data/lib/rspec/conductor/worker.rb +9 -17
- data/lib/rspec/conductor/worker_process.rb +37 -1
- data/lib/rspec/conductor.rb +4 -0
- data/lib/tasks/rspec_conductor.rake +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57c9a96bb487176dd0c40d962ef210ca3caf316fd5135d032cccded1a9726cae
|
|
4
|
+
data.tar.gz: 976c271c89bb8847a0e1cbcd2b80f544054fd8467e701225f1f146f14c081d2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4efec9193e711ff163176ef03edcd0a4566eb75f77b528d8a931d1e93c6718810c455d79c595c1c8c55fcddca00a60c40e65104439203605655ec5ae25e9026
|
|
7
|
+
data.tar.gz: 9ed9595a493bd54fe6d51d029a5bf693f166dad3d5a6144d0b2bd34f26824173056bae85b0a78ee04e12f4883640b007700e5d3a45e0e7946450c69c21b6c1d4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [1.0.9] - 2026-03-01
|
|
2
|
+
|
|
3
|
+
- Handle workers stdout/stderr better. It is no longer necessary to use --verbose to see worker output. Verbose now only controls whether you see the debug output of the workers
|
|
4
|
+
- For the fancy formatter, allocate one line per stdout/stderr. Not ideal, but I'm not sure what layout I'm even looking for here, since allowing to freely put stuff into stdout/stderr breaks the TUI completely
|
|
5
|
+
- Disable echo when running (also breaks the TUI). The side effect of this is that you probably lose ability to tactically use binding.irb in your specs, but you might want to drop into the regular rspec to do that anyway
|
|
6
|
+
- Way better handling of SIGINT / Ctrl-C. Child processes used to just crash when terminated via signal, now they're safely terminating
|
|
7
|
+
- Support double Ctrl-C to force-kill the workers (same as rspec)
|
|
8
|
+
- When there are spec failures, include a rerun command for failed examples (e.g. `rspec spec/some_spec.rb:28 spec/other_spec.rb:42`). It should also be possible to use rspec-conductor for those, but in my personal practice, I prefer rspec because I also want to have some interactive console during the spec run, which is not going to be possible with forked children
|
|
9
|
+
|
|
1
10
|
## [1.0.8] - 2026-02-18
|
|
2
11
|
|
|
3
12
|
- When --postfork-require is provided, use current dir instead of spec/
|
|
@@ -2,65 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
module RSpec
|
|
4
4
|
module Conductor
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
module Formatters
|
|
6
|
+
class Base
|
|
7
|
+
include Util::ANSI
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
def initialize(**_kwargs)
|
|
10
|
+
end
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
def handle_worker_message(_worker_process, _message, _results)
|
|
13
|
+
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
def print_startup_banner(worker_count:, seed:, spec_files_count:)
|
|
16
|
+
puts "RSpec Conductor starting with #{worker_count} workers (seed: #{seed})"
|
|
17
|
+
puts "Running #{spec_files_count} spec files\n\n"
|
|
18
|
+
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
def print_summary(results, seed:, success:)
|
|
21
|
+
puts "\n\n"
|
|
22
|
+
puts "Randomized with seed #{seed}"
|
|
23
|
+
puts "#{colorize("#{results.examples_passed} passed", :green)}, #{colorize("#{results.examples_failed} failed", :red)}, #{colorize("#{results.examples_pending} pending", :yellow)}"
|
|
24
|
+
puts colorize("Worker crashes: #{results.worker_crashes}", :red) if results.worker_crashes.positive?
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
if results.errors.any?
|
|
27
|
+
puts "\nFailures:\n\n"
|
|
28
|
+
results.errors.each_with_index do |error, i|
|
|
29
|
+
puts " #{i + 1}) #{error[:description]}"
|
|
30
|
+
puts colorize(" #{error[:message]}", :red) if error[:message]
|
|
31
|
+
puts colorize(" #{error[:location]}", :cyan)
|
|
32
|
+
if error[:backtrace]&.any?
|
|
33
|
+
puts " Backtrace:"
|
|
34
|
+
error[:backtrace].each { |line| puts " #{line}" }
|
|
35
|
+
end
|
|
36
|
+
puts
|
|
34
37
|
end
|
|
35
|
-
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts "Specs took: #{results.specs_runtime.round(2)}s"
|
|
41
|
+
puts "Total runtime: #{results.total_runtime.round(2)}s"
|
|
42
|
+
puts "Suite: #{success ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
|
|
43
|
+
|
|
44
|
+
if results.errors.any?
|
|
45
|
+
puts ""
|
|
46
|
+
puts "To rerun failed examples:"
|
|
47
|
+
puts " rspec #{results.errors.map { |e| e[:location] }.join(" ")}"
|
|
36
48
|
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
51
|
+
def handle_worker_stdout(worker_number, string)
|
|
52
|
+
puts "[worker #{worker_number}] #{string}"
|
|
53
|
+
end
|
|
43
54
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
def handle_worker_stderr(worker_number, string)
|
|
56
|
+
$stderr.puts "[worker #{worker_number}] #{string}"
|
|
57
|
+
end
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
#{message[:location]}
|
|
52
|
-
#{message[:exception_class]}: #{message[:message]}
|
|
53
|
-
Backtrace:
|
|
54
|
-
#{message[:backtrace].map { " #{_1}" }.join("\n")}
|
|
55
|
-
EOM
|
|
56
|
-
end
|
|
59
|
+
def print_debug(string)
|
|
60
|
+
$stderr.puts string
|
|
61
|
+
end
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
def print_retry_message(message)
|
|
64
|
+
puts <<~EOM
|
|
65
|
+
\nRetried: #{message[:description]}
|
|
66
|
+
#{message[:location]}
|
|
67
|
+
#{message[:exception_class]}: #{message[:message]}
|
|
68
|
+
Backtrace:
|
|
69
|
+
#{message[:backtrace].map { " #{_1}" }.join("\n")}
|
|
70
|
+
EOM
|
|
71
|
+
end
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
def print_shutdown_banner
|
|
74
|
+
puts "Shutting down... (press ctrl-c again to force quit)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def colorize(string, colors, **kwargs)
|
|
78
|
+
$stdout.tty? ? super(string, colors, **kwargs) : string
|
|
79
|
+
end
|
|
64
80
|
end
|
|
65
81
|
end
|
|
66
82
|
end
|
|
@@ -26,7 +26,7 @@ module RSpec
|
|
|
26
26
|
puts "-" * tty_width
|
|
27
27
|
puts "Current status [#{Time.now.strftime("%H:%M:%S")}]:"
|
|
28
28
|
puts "Processed: #{results.spec_files_processed} / #{results.spec_files_total} (#{(pct * 100).floor}%)"
|
|
29
|
-
puts "#{results.
|
|
29
|
+
puts "#{results.examples_passed} passed, #{results.examples_failed} failed, #{results.examples_pending} pending"
|
|
30
30
|
if results.errors.any?
|
|
31
31
|
puts "Failures:\n"
|
|
32
32
|
results.errors.each_with_index do |error, i|
|
|
@@ -11,9 +11,7 @@ module RSpec
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def initialize(worker_count:, **kwargs)
|
|
14
|
-
@worker_processes = {}
|
|
15
14
|
@terminal = Util::Terminal.new
|
|
16
|
-
@last_rendered_lines = []
|
|
17
15
|
@dots_string = +""
|
|
18
16
|
@last_error = nil
|
|
19
17
|
|
|
@@ -25,6 +23,9 @@ module RSpec
|
|
|
25
23
|
@dots_line = @terminal.line(truncate: false)
|
|
26
24
|
@terminal.puts
|
|
27
25
|
@last_error_line = @terminal.line(truncate: false)
|
|
26
|
+
@stdout_line = @terminal.puts nil
|
|
27
|
+
@stderr_line = @terminal.puts nil
|
|
28
|
+
@shutdown_line = @terminal.puts nil
|
|
28
29
|
|
|
29
30
|
super(**kwargs)
|
|
30
31
|
end
|
|
@@ -51,6 +52,18 @@ module RSpec
|
|
|
51
52
|
dot "*", :yellow
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
def handle_worker_stdout(worker_number, string)
|
|
56
|
+
@stdout_line.update("STDOUT: [worker #{worker_number}]: #{string}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_worker_stderr(worker_number, string)
|
|
60
|
+
@stderr_line.update("STDERR: [worker #{worker_number}]: #{string}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_shutdown_banner
|
|
64
|
+
@shutdown_line.update("Shutting down... (press ctrl-c again to force quit)")
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
private
|
|
55
68
|
|
|
56
69
|
def redraw(worker_process, results)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RSpec
|
|
2
4
|
module Conductor
|
|
3
5
|
class Results
|
|
4
|
-
attr_accessor :
|
|
6
|
+
attr_accessor :examples_passed, :examples_failed, :examples_pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed
|
|
5
7
|
|
|
6
8
|
def initialize
|
|
7
|
-
@
|
|
8
|
-
@
|
|
9
|
-
@
|
|
9
|
+
@examples_passed = 0
|
|
10
|
+
@examples_failed = 0
|
|
11
|
+
@examples_pending = 0
|
|
10
12
|
@worker_crashes = 0
|
|
11
13
|
@errors = []
|
|
12
14
|
@started_at = Time.now
|
|
@@ -14,24 +16,23 @@ module RSpec
|
|
|
14
16
|
@specs_completed_at = nil
|
|
15
17
|
@spec_files_total = 0
|
|
16
18
|
@spec_files_processed = 0
|
|
17
|
-
@shutting_down = false
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def success?
|
|
21
|
-
@
|
|
22
|
+
@examples_failed.zero? && @errors.empty? && @worker_crashes.zero? && @spec_files_total == @spec_files_processed
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def example_passed
|
|
25
|
-
@
|
|
26
|
+
@examples_passed += 1
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def example_failed(message)
|
|
29
|
-
@
|
|
30
|
+
@examples_failed += 1
|
|
30
31
|
@errors << message
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def example_pending
|
|
34
|
-
@
|
|
35
|
+
@examples_pending += 1
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def spec_file_assigned
|
|
@@ -56,14 +57,6 @@ module RSpec
|
|
|
56
57
|
@worker_crashes += 1
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
def shut_down
|
|
60
|
-
@shutting_down = true
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def shutting_down?
|
|
64
|
-
@shutting_down
|
|
65
|
-
end
|
|
66
|
-
|
|
67
60
|
def suite_complete
|
|
68
61
|
@specs_completed_at ||= Time.now
|
|
69
62
|
end
|
|
@@ -33,7 +33,8 @@ module RSpec
|
|
|
33
33
|
@verbose = opts.fetch(:verbose, false)
|
|
34
34
|
|
|
35
35
|
@rspec_args = rspec_args
|
|
36
|
-
@worker_processes =
|
|
36
|
+
@worker_processes = []
|
|
37
|
+
@dead_worker_processes = []
|
|
37
38
|
@spec_queue = []
|
|
38
39
|
@formatter_class = case opts[:formatter]
|
|
39
40
|
when "ci"
|
|
@@ -48,8 +49,10 @@ module RSpec
|
|
|
48
49
|
@formatter = @formatter_class.new(worker_count: @worker_count)
|
|
49
50
|
@results = Results.new
|
|
50
51
|
|
|
52
|
+
$stdout.sync = true
|
|
53
|
+
$stdin.echo = false if $stdin.tty?
|
|
51
54
|
Dir.chdir(Conductor.root)
|
|
52
|
-
ENV[
|
|
55
|
+
ENV["PARALLEL_TEST_GROUPS"] = worker_count.to_s # parallel_tests backward-compatibility
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
def run
|
|
@@ -57,53 +60,27 @@ module RSpec
|
|
|
57
60
|
build_spec_queue
|
|
58
61
|
preload_application
|
|
59
62
|
|
|
60
|
-
$stdout.sync = true
|
|
61
63
|
@formatter.print_startup_banner(worker_count: @worker_count, seed: @seed, spec_files_count: @spec_queue.size)
|
|
62
64
|
|
|
63
65
|
start_workers
|
|
64
66
|
run_event_loop
|
|
67
|
+
wait_for_workers_to_exit
|
|
65
68
|
@results.suite_complete
|
|
66
69
|
|
|
67
|
-
@formatter.print_summary(@results, seed: @seed)
|
|
70
|
+
@formatter.print_summary(@results, seed: @seed, success: success?)
|
|
68
71
|
exit_with_status
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
private
|
|
72
75
|
|
|
73
|
-
def preload_application
|
|
74
|
-
if !@prefork_require
|
|
75
|
-
debug "Prefork require not set, skipping..."
|
|
76
|
-
return
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
preload = File.expand_path(@prefork_require)
|
|
80
|
-
|
|
81
|
-
if File.exist?(preload)
|
|
82
|
-
debug "Preloading #{@prefork_require}..."
|
|
83
|
-
require preload
|
|
84
|
-
else
|
|
85
|
-
debug "#{@prefork_require} not found, skipping..."
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
debug "Application preloaded, autoload paths configured"
|
|
89
|
-
end
|
|
90
|
-
|
|
91
76
|
def setup_signal_handlers
|
|
92
77
|
%w[INT TERM].each do |signal|
|
|
93
78
|
Signal.trap(signal) do
|
|
94
|
-
@worker_processes.any? ? initiate_shutdown : Kernel.exit(1)
|
|
79
|
+
@worker_processes.any?(&:running?) ? initiate_shutdown : Kernel.exit(1)
|
|
95
80
|
end
|
|
96
81
|
end
|
|
97
82
|
end
|
|
98
83
|
|
|
99
|
-
def initiate_shutdown
|
|
100
|
-
return if @results.shutting_down?
|
|
101
|
-
|
|
102
|
-
@results.shut_down
|
|
103
|
-
@formatter.print_shut_down_banner
|
|
104
|
-
@worker_processes.each_value { |w| w.socket&.send_message({ type: :shutdown }) }
|
|
105
|
-
end
|
|
106
|
-
|
|
107
84
|
def build_spec_queue
|
|
108
85
|
config_options = RSpec::Core::ConfigurationOptions.new(@rspec_args)
|
|
109
86
|
# a bit of a hack, but if they want to require something explicitly, they should use either --prefork-require or --postfork-require,
|
|
@@ -121,57 +98,68 @@ module RSpec
|
|
|
121
98
|
@results.spec_files_total = @spec_queue.size
|
|
122
99
|
end
|
|
123
100
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
101
|
+
def preload_application
|
|
102
|
+
if !@prefork_require
|
|
103
|
+
debug "Prefork require not set, skipping..."
|
|
104
|
+
return
|
|
127
105
|
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def spawn_worker(worker_number)
|
|
131
|
-
parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0)
|
|
132
106
|
|
|
133
|
-
|
|
107
|
+
preload = File.expand_path(@prefork_require)
|
|
134
108
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else
|
|
141
|
-
""
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
Worker.new(
|
|
145
|
-
worker_number: worker_number,
|
|
146
|
-
socket: Protocol::Socket.new(child_socket),
|
|
147
|
-
rspec_args: @rspec_args,
|
|
148
|
-
verbose: @verbose,
|
|
149
|
-
postfork_require: @postfork_require,
|
|
150
|
-
).run
|
|
109
|
+
if File.exist?(preload)
|
|
110
|
+
debug "Preloading #{@prefork_require}..."
|
|
111
|
+
require preload
|
|
112
|
+
else
|
|
113
|
+
debug "#{@prefork_require} not found, skipping..."
|
|
151
114
|
end
|
|
152
115
|
|
|
153
|
-
|
|
154
|
-
|
|
116
|
+
debug "Application preloaded, autoload paths configured"
|
|
117
|
+
end
|
|
155
118
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
status: :running,
|
|
160
|
-
socket: Protocol::Socket.new(parent_socket),
|
|
161
|
-
current_spec: nil,
|
|
162
|
-
)
|
|
163
|
-
assign_work(@worker_processes[pid])
|
|
119
|
+
def start_workers
|
|
120
|
+
@worker_processes = @worker_count.times.map { |i| spawn_worker(@worker_number_offset + i + 1) }
|
|
121
|
+
@worker_processes.each { |wp| assign_work(wp) }
|
|
164
122
|
end
|
|
165
123
|
|
|
166
124
|
def run_event_loop
|
|
167
|
-
until @worker_processes.empty?
|
|
168
|
-
|
|
169
|
-
|
|
125
|
+
until @worker_processes.select(&:running?).empty?
|
|
126
|
+
if @shutdown_status == :initiated_graceful
|
|
127
|
+
@shutdown_status = :shutdown_messages_sent
|
|
128
|
+
@formatter.print_shutdown_banner
|
|
129
|
+
@worker_processes.select(&:running?).each do |worker_process|
|
|
130
|
+
worker_process.socket.send_message({ type: :shutdown })
|
|
131
|
+
cleanup_worker_process(worker_process)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
worker_processes_by_io = @worker_processes.select(&:running?).to_h { |w| [w.socket.io, w] }
|
|
136
|
+
readable_ios, = IO.select(worker_processes_by_io.keys, nil, nil, 0)
|
|
170
137
|
readable_ios&.each { |io| handle_worker_message(worker_processes_by_io.fetch(io)) }
|
|
138
|
+
Util::ChildProcess.tick_all(@worker_processes.map(&:child_process))
|
|
171
139
|
reap_workers
|
|
172
140
|
end
|
|
173
141
|
end
|
|
174
142
|
|
|
143
|
+
def wait_for_workers_to_exit
|
|
144
|
+
Util::ChildProcess.wait_all(@worker_processes.map(&:child_process))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def spawn_worker(worker_number)
|
|
148
|
+
debug "Spawning worker #{worker_number}"
|
|
149
|
+
|
|
150
|
+
worker_process = WorkerProcess.spawn(
|
|
151
|
+
number: worker_number,
|
|
152
|
+
test_env_number: (@first_is_1 || worker_number != 1) ? worker_number.to_s : "",
|
|
153
|
+
on_stdout: ->(string) { @formatter.handle_worker_stdout(worker_number, string) },
|
|
154
|
+
on_stderr: ->(string) { @formatter.handle_worker_stderr(worker_number, string) },
|
|
155
|
+
debug_io: @verbose ? $stderr : nil,
|
|
156
|
+
rspec_args: @rspec_args,
|
|
157
|
+
postfork_require: @postfork_require,
|
|
158
|
+
)
|
|
159
|
+
debug "Worker #{worker_number} started with pid #{worker_process.pid}"
|
|
160
|
+
worker_process
|
|
161
|
+
end
|
|
162
|
+
|
|
175
163
|
def handle_worker_message(worker_process)
|
|
176
164
|
message = worker_process.socket.receive_message
|
|
177
165
|
return unless message
|
|
@@ -184,8 +172,8 @@ module RSpec
|
|
|
184
172
|
when :example_failed
|
|
185
173
|
@results.example_failed(message)
|
|
186
174
|
|
|
187
|
-
if @fail_fast_after && @results.
|
|
188
|
-
debug "Shutting down after #{@results.
|
|
175
|
+
if @fail_fast_after && @results.examples_failed >= @fail_fast_after
|
|
176
|
+
debug "Shutting down after #{@results.examples_failed} failures"
|
|
189
177
|
initiate_shutdown
|
|
190
178
|
end
|
|
191
179
|
when :example_pending
|
|
@@ -201,9 +189,6 @@ module RSpec
|
|
|
201
189
|
debug "Spec error details: #{message[:error]}"
|
|
202
190
|
worker_process.current_spec = nil
|
|
203
191
|
assign_work(worker_process)
|
|
204
|
-
when :spec_interrupted
|
|
205
|
-
debug "Spec interrupted: #{message[:file]}"
|
|
206
|
-
worker_process.current_spec = nil
|
|
207
192
|
end
|
|
208
193
|
@formatter.handle_worker_message(worker_process, message, @results)
|
|
209
194
|
end
|
|
@@ -211,7 +196,7 @@ module RSpec
|
|
|
211
196
|
def assign_work(worker_process)
|
|
212
197
|
spec_file = @spec_queue.shift
|
|
213
198
|
|
|
214
|
-
if
|
|
199
|
+
if shutting_down? || !spec_file
|
|
215
200
|
debug "No more work for worker #{worker_process.number}, sending shutdown"
|
|
216
201
|
worker_process.socket.send_message({ type: :shutdown })
|
|
217
202
|
cleanup_worker_process(worker_process)
|
|
@@ -226,19 +211,15 @@ module RSpec
|
|
|
226
211
|
end
|
|
227
212
|
|
|
228
213
|
def cleanup_worker_process(worker_process, status: :shut_down)
|
|
229
|
-
|
|
230
|
-
worker_process.socket.close
|
|
231
|
-
worker_process.status = status
|
|
214
|
+
worker_process.shut_down(status)
|
|
232
215
|
@formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @results)
|
|
233
|
-
Process.wait(worker_process.pid)
|
|
234
|
-
rescue Errno::ECHILD
|
|
235
|
-
nil
|
|
236
216
|
end
|
|
237
217
|
|
|
238
218
|
def reap_workers
|
|
239
|
-
dead_worker_processes = @worker_processes.each_with_object([]) do |
|
|
240
|
-
result = Process.
|
|
241
|
-
memo << [
|
|
219
|
+
dead_worker_processes = @worker_processes.select(&:running?).each_with_object([]) do |worker_process, memo|
|
|
220
|
+
result, status = Process.waitpid2(worker_process.pid, Process::WNOHANG)
|
|
221
|
+
memo << [worker_process, status] if result
|
|
222
|
+
rescue Errno::ECHILD
|
|
242
223
|
end
|
|
243
224
|
|
|
244
225
|
dead_worker_processes.each do |worker_process, exitstatus|
|
|
@@ -246,12 +227,27 @@ module RSpec
|
|
|
246
227
|
@results.worker_crashed
|
|
247
228
|
debug "Worker #{worker_process.number} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
|
|
248
229
|
end
|
|
249
|
-
|
|
250
|
-
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def shutting_down?
|
|
233
|
+
!@shutdown_status.nil?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def initiate_shutdown
|
|
237
|
+
if @shutdown_status.nil?
|
|
238
|
+
@shutdown_status = :initiated_graceful
|
|
239
|
+
elsif @shutdown_status != :initiated_forced && @worker_processes.any?(&:running?)
|
|
240
|
+
@shutdown_status = :initiated_forced
|
|
241
|
+
Process.kill(:TERM, *@worker_processes.select(&:running?).map(&:pid))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def success?
|
|
246
|
+
@results.success? && !shutting_down?
|
|
251
247
|
end
|
|
252
248
|
|
|
253
249
|
def exit_with_status
|
|
254
|
-
Kernel.exit(
|
|
250
|
+
Kernel.exit(success? ? 0 : 1)
|
|
255
251
|
end
|
|
256
252
|
|
|
257
253
|
def debug(message)
|
|
@@ -4,6 +4,8 @@ module RSpec
|
|
|
4
4
|
module Conductor
|
|
5
5
|
module Util
|
|
6
6
|
class ChildProcess
|
|
7
|
+
POLL_INTERVAL = 0.01
|
|
8
|
+
|
|
7
9
|
attr_reader :pid, :exit_status
|
|
8
10
|
|
|
9
11
|
def self.fork(**args, &block)
|
|
@@ -12,18 +14,24 @@ module RSpec
|
|
|
12
14
|
|
|
13
15
|
def self.wait_all(processes)
|
|
14
16
|
until processes.all?(&:done?)
|
|
15
|
-
|
|
16
|
-
process.pipes.reject(&:closed?).each { |pipe| memo[pipe] = process }
|
|
17
|
-
end
|
|
18
|
-
break if pipe_to_process.empty?
|
|
19
|
-
|
|
20
|
-
ready, = IO.select(pipe_to_process.keys, nil, nil, 0.1)
|
|
21
|
-
ready&.each { |pipe| pipe_to_process[pipe].read_available(pipe) }
|
|
17
|
+
break unless tick_all(processes)
|
|
22
18
|
end
|
|
23
19
|
|
|
24
20
|
processes.each(&:finalize)
|
|
25
21
|
end
|
|
26
22
|
|
|
23
|
+
def self.tick_all(processes, poll_interval: POLL_INTERVAL)
|
|
24
|
+
processes_by_io = processes.each_with_object({}) do |process, memo|
|
|
25
|
+
process.pipes.reject(&:closed?).each { |pipe| memo[pipe] = process }
|
|
26
|
+
end
|
|
27
|
+
return false if processes_by_io.empty?
|
|
28
|
+
|
|
29
|
+
ready, = IO.select(processes_by_io.keys, nil, nil, poll_interval)
|
|
30
|
+
ready&.each { |pipe| processes_by_io[pipe].read_available(pipe) }
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
def initialize(on_stdout: nil, on_stderr: nil)
|
|
28
36
|
@on_stdout = on_stdout
|
|
29
37
|
@on_stderr = on_stderr
|
|
@@ -55,11 +63,13 @@ module RSpec
|
|
|
55
63
|
|
|
56
64
|
$stdout = stdout_write
|
|
57
65
|
$stderr = stderr_write
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
$stdin = File.open("/dev/null")
|
|
67
|
+
STDOUT.reopen($stdout)
|
|
68
|
+
STDERR.reopen($stderr)
|
|
69
|
+
STDIN.reopen($stdin)
|
|
60
70
|
|
|
61
71
|
begin
|
|
62
|
-
yield
|
|
72
|
+
yield self
|
|
63
73
|
rescue => e
|
|
64
74
|
stderr_write.puts "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
65
75
|
exit 1
|
|
@@ -111,12 +121,17 @@ module RSpec
|
|
|
111
121
|
def finalize
|
|
112
122
|
return if done?
|
|
113
123
|
|
|
114
|
-
process_buffer(@stdout_buffer, @on_stdout, partial: true)
|
|
115
|
-
process_buffer(@stderr_buffer, @on_stderr, partial: true)
|
|
116
|
-
|
|
117
|
-
_, status = Process.wait2(@pid)
|
|
118
|
-
@exit_status = status.exitstatus
|
|
119
124
|
@done = true
|
|
125
|
+
|
|
126
|
+
process_buffer(@stdout_buffer, @on_stdout, drain_remaining: true)
|
|
127
|
+
process_buffer(@stderr_buffer, @on_stderr, drain_remaining: true)
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
_, status = Process.waitpid2(@pid)
|
|
131
|
+
@exit_status = status.exitstatus
|
|
132
|
+
rescue Errno::ECHILD
|
|
133
|
+
end
|
|
134
|
+
|
|
120
135
|
self
|
|
121
136
|
end
|
|
122
137
|
|
|
@@ -130,21 +145,17 @@ module RSpec
|
|
|
130
145
|
|
|
131
146
|
private
|
|
132
147
|
|
|
133
|
-
def process_buffer(buffer, callback,
|
|
134
|
-
|
|
148
|
+
def process_buffer(buffer, callback, drain_remaining: false)
|
|
149
|
+
while (newline_pos = buffer.index("\n"))
|
|
150
|
+
# String#slice! seems like it was invented specifically for this scenario,
|
|
151
|
+
# when you need to cut out a string fragment destructively
|
|
152
|
+
line = buffer.slice!(0..newline_pos).chomp
|
|
153
|
+
callback&.call(line)
|
|
154
|
+
end
|
|
135
155
|
|
|
136
|
-
if
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
buffer.clear
|
|
140
|
-
end
|
|
141
|
-
else
|
|
142
|
-
while (newline_pos = buffer.index("\n"))
|
|
143
|
-
# String#slice! seems like it was invented specifically for this scenario,
|
|
144
|
-
# when you need to cut out a string fragment destructively
|
|
145
|
-
line = buffer.slice!(0..newline_pos).chomp
|
|
146
|
-
callback.call(line)
|
|
147
|
-
end
|
|
156
|
+
if drain_remaining && !buffer.empty?
|
|
157
|
+
callback&.call(buffer.chomp)
|
|
158
|
+
buffer.clear
|
|
148
159
|
end
|
|
149
160
|
end
|
|
150
161
|
end
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative 'ext/rspec'
|
|
4
|
-
|
|
5
3
|
module RSpec
|
|
6
4
|
module Conductor
|
|
7
5
|
class Worker
|
|
8
|
-
def initialize(worker_number:, socket:, rspec_args: [],
|
|
6
|
+
def initialize(worker_number:, socket:, rspec_args: [], postfork_require: :spec_helper, debug_io: nil)
|
|
9
7
|
@worker_number = worker_number
|
|
10
8
|
@socket = socket
|
|
11
9
|
@rspec_args = rspec_args
|
|
12
|
-
@verbose = verbose
|
|
13
10
|
@postfork_require = postfork_require
|
|
14
|
-
|
|
11
|
+
@debug_io = debug_io
|
|
15
12
|
@message_queue = []
|
|
16
13
|
end
|
|
17
14
|
|
|
18
15
|
def run
|
|
19
|
-
suppress_output unless @verbose
|
|
20
16
|
debug "Worker #{@worker_number} starting"
|
|
17
|
+
setup_signal_handlers
|
|
21
18
|
setup_load_path
|
|
22
19
|
require_postfork_preloads
|
|
23
20
|
|
|
@@ -57,6 +54,11 @@ module RSpec
|
|
|
57
54
|
|
|
58
55
|
private
|
|
59
56
|
|
|
57
|
+
def setup_signal_handlers
|
|
58
|
+
Signal.trap(:INT, :IGNORE)
|
|
59
|
+
Signal.trap(:TERM, :EXIT)
|
|
60
|
+
end
|
|
61
|
+
|
|
60
62
|
def setup_load_path
|
|
61
63
|
parsed_options.configure(RSpec.configuration)
|
|
62
64
|
@default_path = RSpec.configuration.default_path || "spec"
|
|
@@ -75,12 +77,6 @@ module RSpec
|
|
|
75
77
|
$LOAD_PATH.unshift(path)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
|
-
def suppress_output
|
|
79
|
-
$stdout.reopen(null_io_out)
|
|
80
|
-
$stderr.reopen(null_io_out)
|
|
81
|
-
$stdin.reopen(null_io_in)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
80
|
def require_postfork_preloads
|
|
85
81
|
if @postfork_require == :spec_helper
|
|
86
82
|
rails_helper = File.expand_path("rails_helper.rb", @default_full_path)
|
|
@@ -172,16 +168,12 @@ module RSpec
|
|
|
172
168
|
end
|
|
173
169
|
|
|
174
170
|
def debug(message)
|
|
175
|
-
|
|
171
|
+
@debug_io.puts message if @debug_io
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
def null_io_out
|
|
179
175
|
@null_io_out ||= File.open(File::NULL, "w")
|
|
180
176
|
end
|
|
181
|
-
|
|
182
|
-
def null_io_in
|
|
183
|
-
@null_io_in ||= File.open(File::NULL, "r")
|
|
184
|
-
end
|
|
185
177
|
end
|
|
186
178
|
end
|
|
187
179
|
end
|
|
@@ -1,6 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RSpec
|
|
2
4
|
module Conductor
|
|
3
|
-
WorkerProcess = Struct.new(:pid, :number, :status, :socket, :current_spec, keyword_init: true) do
|
|
5
|
+
WorkerProcess = Struct.new(:pid, :child_process, :number, :status, :socket, :current_spec, keyword_init: true) do
|
|
6
|
+
def self.spawn(number:, test_env_number:, on_stdout: nil, on_stderr: nil, **worker_init_args)
|
|
7
|
+
parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0)
|
|
8
|
+
child_process = Util::ChildProcess.fork(on_stdout: on_stdout, on_stderr: on_stderr) do
|
|
9
|
+
ENV["TEST_ENV_NUMBER"] = test_env_number
|
|
10
|
+
parent_socket.close
|
|
11
|
+
Worker.new(
|
|
12
|
+
worker_number: number,
|
|
13
|
+
socket: Protocol::Socket.new(child_socket),
|
|
14
|
+
**worker_init_args
|
|
15
|
+
).run
|
|
16
|
+
end
|
|
17
|
+
child_socket.close
|
|
18
|
+
|
|
19
|
+
new(
|
|
20
|
+
pid: child_process.pid,
|
|
21
|
+
child_process: child_process,
|
|
22
|
+
number: number,
|
|
23
|
+
status: :running,
|
|
24
|
+
socket: Protocol::Socket.new(parent_socket),
|
|
25
|
+
current_spec: nil
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def shut_down(status)
|
|
30
|
+
return unless running?
|
|
31
|
+
|
|
32
|
+
self.status = status
|
|
33
|
+
socket.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def running?
|
|
37
|
+
status == :running
|
|
38
|
+
end
|
|
39
|
+
|
|
4
40
|
def hash
|
|
5
41
|
[number].hash
|
|
6
42
|
end
|
data/lib/rspec/conductor.rb
CHANGED
|
@@ -28,7 +28,9 @@ module RSpec
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
require_relative "conductor/util/ansi"
|
|
31
|
+
require_relative "conductor/util/screen_buffer"
|
|
31
32
|
require_relative "conductor/util/terminal"
|
|
33
|
+
require_relative "conductor/util/child_process"
|
|
32
34
|
require_relative "conductor/version"
|
|
33
35
|
require_relative "conductor/protocol"
|
|
34
36
|
require_relative "conductor/server"
|
|
@@ -42,6 +44,8 @@ require_relative "conductor/formatters/plain"
|
|
|
42
44
|
require_relative "conductor/formatters/ci"
|
|
43
45
|
require_relative "conductor/formatters/fancy"
|
|
44
46
|
|
|
47
|
+
require_relative "conductor/ext/rspec"
|
|
48
|
+
|
|
45
49
|
if defined?(Rails)
|
|
46
50
|
require_relative "conductor/railtie"
|
|
47
51
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-conductor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mark Abramov
|
|
@@ -82,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
82
82
|
- !ruby/object:Gem::Version
|
|
83
83
|
version: '0'
|
|
84
84
|
requirements: []
|
|
85
|
-
rubygems_version: 4.0.
|
|
85
|
+
rubygems_version: 4.0.6
|
|
86
86
|
specification_version: 4
|
|
87
87
|
summary: Queue-based parallel test runner for rspec
|
|
88
88
|
test_files: []
|