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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91793bc12c3384342322b331ddbc5738dd448b92b18517f3de1a5f27b45b464d
4
- data.tar.gz: 5daf14136401f25142f9d0393f8f427fd785245baff4eb6a026823549afa0e34
3
+ metadata.gz: 57c9a96bb487176dd0c40d962ef210ca3caf316fd5135d032cccded1a9726cae
4
+ data.tar.gz: 976c271c89bb8847a0e1cbcd2b80f544054fd8467e701225f1f146f14c081d2f
5
5
  SHA512:
6
- metadata.gz: 48cc6ad36c65d3686da3a03bd87261c66a00d3d77c1e7156a4504eb6c2e2e1b16b2c6df999163e02aa8f603674fdbe8c081765a0ad3983e426e9f9e40f03be9a
7
- data.tar.gz: f93a55a25e1a890d13b29216f2c454ccd8c8d2cb01dda643ff24ba514dbdcfc85331cfed134f3bd9ba9fc87491b79dbf70fab43e5037383425a522dc00af3d7e
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/
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # RSpec doesn't provide us with a good way to handle before/after suite hooks,
2
4
  # doing what we can here
3
5
  class RSpec::Core::Configuration
@@ -2,65 +2,81 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- class Base
6
- include Util::ANSI
5
+ module Formatters
6
+ class Base
7
+ include Util::ANSI
7
8
 
8
- def initialize(**_kwargs)
9
- end
9
+ def initialize(**_kwargs)
10
+ end
10
11
 
11
- def handle_worker_message(_worker_process, _message, _results)
12
- end
12
+ def handle_worker_message(_worker_process, _message, _results)
13
+ end
13
14
 
14
- def print_startup_banner(worker_count:, seed:, spec_files_count:)
15
- puts "RSpec Conductor starting with #{worker_count} workers (seed: #{seed})"
16
- puts "Running #{spec_files_count} spec files\n\n"
17
- end
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
- def print_summary(results, seed:)
20
- puts "\n\n"
21
- puts "Randomized with seed #{seed}"
22
- puts "#{colorize("#{results.passed} passed", :green)}, #{colorize("#{results.failed} failed", :red)}, #{colorize("#{results.pending} pending", :yellow)}"
23
- puts colorize("Worker crashes: #{results.worker_crashes}", :red) if results.worker_crashes.positive?
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
- if results.errors.any?
26
- puts "\nFailures:\n\n"
27
- results.errors.each_with_index do |error, i|
28
- puts " #{i + 1}) #{error[:description]}"
29
- puts colorize(" #{error[:message]}", :red) if error[:message]
30
- puts colorize(" #{error[:location]}", :cyan)
31
- if error[:backtrace]&.any?
32
- puts " Backtrace:"
33
- error[:backtrace].each { |line| puts " #{line}" }
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
- puts
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
- puts "Specs took: #{results.specs_runtime.round(2)}s"
40
- puts "Total runtime: #{results.total_runtime.round(2)}s"
41
- puts "Suite: #{results.success? ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
42
- end
51
+ def handle_worker_stdout(worker_number, string)
52
+ puts "[worker #{worker_number}] #{string}"
53
+ end
43
54
 
44
- def print_debug(string)
45
- $stderr.puts string
46
- end
55
+ def handle_worker_stderr(worker_number, string)
56
+ $stderr.puts "[worker #{worker_number}] #{string}"
57
+ end
47
58
 
48
- def print_retry_message(message)
49
- puts <<~EOM
50
- \nRetried: #{message[:description]}
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
- def print_shut_down_banner
59
- puts "Shutting down..."
60
- end
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
- def colorize(string, colors, **kwargs)
63
- $stdout.tty? ? super(string, colors, **kwargs) : string
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.passed} passed, #{results.failed} failed, #{results.pending} pending"
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,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RSpec
2
4
  module Conductor
3
5
  module Formatters
@@ -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 :passed, :failed, :pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed
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
- @passed = 0
8
- @failed = 0
9
- @pending = 0
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
- @failed.zero? && @errors.empty? && @worker_crashes.zero? && !shutting_down?
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
- @passed += 1
26
+ @examples_passed += 1
26
27
  end
27
28
 
28
29
  def example_failed(message)
29
- @failed += 1
30
+ @examples_failed += 1
30
31
  @errors << message
31
32
  end
32
33
 
33
34
  def example_pending
34
- @pending += 1
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RSpec
2
4
  module Conductor
3
5
  # Technically this is a **Formatter**, as in RSpec Formatter, but that was too confusing,
@@ -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['PARALLEL_TEST_GROUPS'] = worker_count.to_s # parallel_tests backward-compatibility
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 start_workers
125
- @worker_count.times do |i|
126
- spawn_worker(@worker_number_offset + i + 1)
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
- debug "Spawning worker #{worker_number}"
107
+ preload = File.expand_path(@prefork_require)
134
108
 
135
- pid = fork do
136
- parent_socket.close
137
-
138
- ENV["TEST_ENV_NUMBER"] = if @first_is_1 || worker_number != 1
139
- worker_number.to_s
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
- child_socket.close
154
- debug "Worker #{worker_number} started with pid #{pid}"
116
+ debug "Application preloaded, autoload paths configured"
117
+ end
155
118
 
156
- @worker_processes[pid] = WorkerProcess.new(
157
- pid: pid,
158
- number: worker_number,
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
- worker_processes_by_io = @worker_processes.values.to_h { |w| [w.socket.io, w] }
169
- readable_ios, = IO.select(worker_processes_by_io.keys, nil, nil, WORKER_POLL_INTERVAL)
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.failed >= @fail_fast_after
188
- debug "Shutting down after #{@results.failed} failures"
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 @results.shutting_down? || !spec_file
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
- @worker_processes.delete(worker_process.pid)
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 |(pid, worker), memo|
240
- result = Process.waitpid(pid, Process::WNOHANG)
241
- memo << [worker, $CHILD_STATUS] if result
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
- rescue Errno::ECHILD
250
- nil
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(@results.success? ? 0 : 1)
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
- pipe_to_process = processes.each_with_object({}) do |process, memo|
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
- STDOUT.reopen(stdout_write)
59
- STDERR.reopen(stderr_write)
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, partial: false)
134
- return unless callback
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 partial
137
- unless buffer.empty?
138
- callback.call(buffer.chomp)
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,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "screen_buffer"
4
-
5
3
  module RSpec
6
4
  module Conductor
7
5
  module Util
@@ -30,7 +28,7 @@ module RSpec
30
28
  end
31
29
 
32
30
  def lines
33
- [self]
31
+ @content ? [self] : []
34
32
  end
35
33
  end
36
34
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.8"
5
+ VERSION = "1.0.9"
6
6
  end
7
7
  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: [], verbose: false, postfork_require: :spec_helper)
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
- $stderr.puts "[worker #{@worker_number}] #{message}"
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
@@ -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
@@ -49,7 +49,7 @@ module RSpec
49
49
  configs
50
50
  end
51
51
 
52
- def run_for_each_database(count, action)
52
+ def run_for_each_database(count, action, &block)
53
53
  raise ArgumentError, "count must be positive" if count < 1
54
54
 
55
55
  puts "#{action} #{count} test databases in parallel..."
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.8
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.3
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: []