rspec-conductor 1.0.9 → 1.0.11

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: 57c9a96bb487176dd0c40d962ef210ca3caf316fd5135d032cccded1a9726cae
4
- data.tar.gz: 976c271c89bb8847a0e1cbcd2b80f544054fd8467e701225f1f146f14c081d2f
3
+ metadata.gz: 4a4f284fb6887972bbd325f6a5d6ce77b3c384d3cc7b8933c19bd04af2aa3ada
4
+ data.tar.gz: 733f1d8de0935949b77aa54374f37f97976a007c7dc53381b1dc8d8bb3e6922d
5
5
  SHA512:
6
- metadata.gz: d4efec9193e711ff163176ef03edcd0a4566eb75f77b528d8a931d1e93c6718810c455d79c595c1c8c55fcddca00a60c40e65104439203605655ec5ae25e9026
7
- data.tar.gz: 9ed9595a493bd54fe6d51d029a5bf693f166dad3d5a6144d0b2bd34f26824173056bae85b0a78ee04e12f4883640b007700e5d3a45e0e7946450c69c21b6c1d4
6
+ metadata.gz: e23c66e05afe4064b4a6062d6f7880429e641c5e2a3eac73f60a50ba5bd4c75ecb2b36a8484405200e9e8102f0cfd24b20fb99a0fbdf959be376e0087cf4006d
7
+ data.tar.gz: ebc3ad89f178da346ce4a5aee153e280b8b10b98f2c1d4e55894035237ee9f990e34206f272f35128d0e94da7c01847acd15225ab7a8db0516ceb674618f1887
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [1.0.11] - 2026-08-03
2
+
3
+ - Fix a bug in 2.x that would cause an exception because of the 3.x syntax usage (`_1`) in the formatter base
4
+ - Remove a potential bottleneck when spec files run under 10ms on average
5
+ - Fix performance issue with recalculating the file/location list over and over (which would coincidentally also take around 10ms for large directories)
6
+ - In other words, if you had a lot of super fast spec files, it will run them fast as well
7
+
8
+ ## [1.0.10] - 2026-03-08
9
+
10
+ - Add --print-slowest cli param to display the slowest specs in the suite
11
+
1
12
  ## [1.0.9] - 2026-03-01
2
13
 
3
14
  - 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
@@ -15,6 +15,7 @@ module RSpec
15
15
  display_retry_backtraces: false,
16
16
  prefork_require: 'config/application.rb',
17
17
  postfork_require: :spec_helper,
18
+ print_slowest_count: nil,
18
19
  }.freeze
19
20
 
20
21
  def self.run(argv)
@@ -97,6 +98,10 @@ module RSpec
97
98
  @conductor_options[:display_retry_backtraces] = true
98
99
  end
99
100
 
101
+ opts.on("--print-slowest COUNT", Integer, "Print slowest specs with their execution times") do |n|
102
+ @conductor_options[:print_slowest_count] = n
103
+ end
104
+
100
105
  opts.on("--verbose", "Enable debug output") do
101
106
  @conductor_options[:verbose] = true
102
107
  end
@@ -120,6 +125,7 @@ module RSpec
120
125
  rspec_args: @rspec_args,
121
126
  formatter: @conductor_options[:formatter],
122
127
  display_retry_backtraces: @conductor_options[:display_retry_backtraces],
128
+ print_slowest_count: @conductor_options[:print_slowest_count],
123
129
  verbose: @conductor_options[:verbose],
124
130
  ).run
125
131
  end
@@ -9,7 +9,7 @@ module RSpec
9
9
  def initialize(**_kwargs)
10
10
  end
11
11
 
12
- def handle_worker_message(_worker_process, _message, _results)
12
+ def handle_worker_message(_worker_process, _message, _suite_run)
13
13
  end
14
14
 
15
15
  def print_startup_banner(worker_count:, seed:, spec_files_count:)
@@ -17,15 +17,15 @@ module RSpec
17
17
  puts "Running #{spec_files_count} spec files\n\n"
18
18
  end
19
19
 
20
- def print_summary(results, seed:, success:)
20
+ def print_summary(suite_run, seed:, success:)
21
21
  puts "\n\n"
22
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?
23
+ puts "#{colorize("#{suite_run.examples_passed} passed", :green)}, #{colorize("#{suite_run.examples_failed} failed", :red)}, #{colorize("#{suite_run.examples_pending} pending", :yellow)}"
24
+ puts colorize("Worker crashes: #{suite_run.worker_crashes}", :red) if suite_run.worker_crashes.positive?
25
25
 
26
- if results.errors.any?
26
+ if suite_run.errors.any?
27
27
  puts "\nFailures:\n\n"
28
- results.errors.each_with_index do |error, i|
28
+ suite_run.errors.each_with_index do |error, i|
29
29
  puts " #{i + 1}) #{error[:description]}"
30
30
  puts colorize(" #{error[:message]}", :red) if error[:message]
31
31
  puts colorize(" #{error[:location]}", :cyan)
@@ -37,14 +37,22 @@ module RSpec
37
37
  end
38
38
  end
39
39
 
40
- puts "Specs took: #{results.specs_runtime.round(2)}s"
41
- puts "Total runtime: #{results.total_runtime.round(2)}s"
40
+ puts "Specs took: #{suite_run.specs_runtime.round(2)}s"
41
+ puts "Total runtime: #{suite_run.total_runtime.round(2)}s"
42
42
  puts "Suite: #{success ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
43
43
 
44
- if results.errors.any?
44
+ if suite_run.errors.any?
45
45
  puts ""
46
46
  puts "To rerun failed examples:"
47
- puts " rspec #{results.errors.map { |e| e[:location] }.join(" ")}"
47
+ puts " rspec #{suite_run.errors.map { |e| e[:location] }.join(" ")}"
48
+ end
49
+ end
50
+
51
+ def print_slowest(suite_run, n)
52
+ puts "\n\n"
53
+ puts "Slowest #{n} specs:"
54
+ suite_run.example_stats.sort_by { |e| -e[:run_time] }.take(n).each_with_index do |e, i|
55
+ puts "%3d. (%8.2fms) %s @ %s" % [i + 1, e[:run_time] * 1000, colorize(e[:description], :dim), colorize(e[:location], :cyan)]
48
56
  end
49
57
  end
50
58
 
@@ -66,7 +74,7 @@ module RSpec
66
74
  #{message[:location]}
67
75
  #{message[:exception_class]}: #{message[:message]}
68
76
  Backtrace:
69
- #{message[:backtrace].map { " #{_1}" }.join("\n")}
77
+ #{message[:backtrace].map { |line| " #{line}" }.join("\n")}
70
78
  EOM
71
79
  end
72
80
 
@@ -14,22 +14,22 @@ module RSpec
14
14
  super(**kwargs)
15
15
  end
16
16
 
17
- def handle_worker_message(_worker_process, message, results)
17
+ def handle_worker_message(_worker_process, message, suite_run)
18
18
  public_send(message[:type], message) if respond_to?(message[:type])
19
- print_status(results) if @last_printout + @printout_interval < Time.now
19
+ print_status(suite_run) if @last_printout + @printout_interval < Time.now
20
20
  end
21
21
 
22
- def print_status(results)
22
+ def print_status(suite_run)
23
23
  @last_printout = Time.now
24
- pct = results.spec_file_processed_percentage
24
+ pct = suite_run.spec_file_processed_percentage
25
25
 
26
26
  puts "-" * tty_width
27
27
  puts "Current status [#{Time.now.strftime("%H:%M:%S")}]:"
28
- puts "Processed: #{results.spec_files_processed} / #{results.spec_files_total} (#{(pct * 100).floor}%)"
29
- puts "#{results.examples_passed} passed, #{results.examples_failed} failed, #{results.examples_pending} pending"
30
- if results.errors.any?
28
+ puts "Processed: #{suite_run.spec_files_processed} / #{suite_run.spec_files_total} (#{(pct * 100).floor}%)"
29
+ puts "#{suite_run.examples_passed} passed, #{suite_run.examples_failed} failed, #{suite_run.examples_pending} pending"
30
+ if suite_run.errors.any?
31
31
  puts "Failures:\n"
32
- results.errors.each_with_index do |error, i|
32
+ suite_run.errors.each_with_index do |error, i|
33
33
  puts " #{i + 1}) #{error[:description]}"
34
34
  puts " #{error[:location]}"
35
35
  puts " #{error[:message]}" if error[:message]
@@ -30,9 +30,9 @@ module RSpec
30
30
  super(**kwargs)
31
31
  end
32
32
 
33
- def handle_worker_message(worker_process, message, results)
33
+ def handle_worker_message(worker_process, message, suite_run)
34
34
  public_send(message[:type], worker_process, message) if respond_to?(message[:type])
35
- redraw(worker_process, results)
35
+ redraw(worker_process, suite_run)
36
36
  end
37
37
 
38
38
  def example_passed(_worker_process, _message)
@@ -66,9 +66,9 @@ module RSpec
66
66
 
67
67
  private
68
68
 
69
- def redraw(worker_process, results)
69
+ def redraw(worker_process, suite_run)
70
70
  update_worker_status_line(worker_process)
71
- update_results_line(results)
71
+ update_suite_run_line(suite_run)
72
72
  update_errors_line
73
73
  @terminal.redraw
74
74
  @terminal.scroll_to_bottom
@@ -94,13 +94,13 @@ module RSpec
94
94
  @worker_lines[worker_process.number].update(status, redraw: false)
95
95
  end
96
96
 
97
- def update_results_line(results)
98
- pct = results.spec_file_processed_percentage
97
+ def update_suite_run_line(suite_run)
98
+ pct = suite_run.spec_file_processed_percentage
99
99
  bar_width = [tty_width - 20, 20].max
100
100
  filled = (pct * bar_width).floor
101
101
  empty = bar_width - filled
102
102
 
103
- percentage = " %3d%% (%d/%d)" % [(pct * 100).floor, results.spec_files_processed, results.spec_files_total]
103
+ percentage = " %3d%% (%d/%d)" % [(pct * 100).floor, suite_run.spec_files_processed, suite_run.spec_files_total]
104
104
  bar = colorize("[", :reset) + colorize("▓", :green) * filled + colorize(" ", :reset) * empty + colorize("]", :reset)
105
105
 
106
106
  @progress_bar_line.update(bar + percentage, redraw: false)
@@ -4,7 +4,7 @@ module RSpec
4
4
  module Conductor
5
5
  module Formatters
6
6
  class Plain < Base
7
- def handle_worker_message(_worker_process, message, _results)
7
+ def handle_worker_message(_worker_process, message, _suite_run)
8
8
  public_send(message[:type], message) if respond_to?(message[:type])
9
9
  end
10
10
 
@@ -1,14 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
- require "socket"
5
- require "json"
6
-
7
3
  module RSpec
8
4
  module Conductor
9
5
  class Server
10
6
  MAX_SEED = 2**16
11
- WORKER_POLL_INTERVAL = 0.01
12
7
 
13
8
  # @option worker_count [Integer] How many workers to spin
14
9
  # @option rspec_args [Array<String>] A list of rspec options
@@ -21,6 +16,7 @@ module RSpec
21
16
  # @option formatter [String] Use a certain formatter
22
17
  # @option verbose [Boolean] Use especially verbose output
23
18
  # @option display_retry_backtraces [Boolean] Display backtraces for specs retried via rspec-retry
19
+ # @option print_slowest_count [Integer] Print slowest specs in the end of the suite
24
20
  def initialize(worker_count:, rspec_args:, **opts)
25
21
  @worker_count = worker_count
26
22
  @worker_number_offset = opts.fetch(:worker_number_offset, 0)
@@ -30,11 +26,11 @@ module RSpec
30
26
  @seed = opts[:seed] || (Random.new_seed % MAX_SEED)
31
27
  @fail_fast_after = opts[:fail_fast_after]
32
28
  @display_retry_backtraces = opts.fetch(:display_retry_backtraces, false)
29
+ @print_slowest_count = opts.fetch(:print_slowest_count, nil)
33
30
  @verbose = opts.fetch(:verbose, false)
34
31
 
35
32
  @rspec_args = rspec_args
36
33
  @worker_processes = []
37
- @dead_worker_processes = []
38
34
  @spec_queue = []
39
35
  @formatter_class = case opts[:formatter]
40
36
  when "ci"
@@ -47,7 +43,7 @@ module RSpec
47
43
  (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy : Formatters::Plain
48
44
  end
49
45
  @formatter = @formatter_class.new(worker_count: @worker_count)
50
- @results = Results.new
46
+ @suite_run = SuiteRun.new
51
47
 
52
48
  $stdout.sync = true
53
49
  $stdin.echo = false if $stdin.tty?
@@ -65,9 +61,10 @@ module RSpec
65
61
  start_workers
66
62
  run_event_loop
67
63
  wait_for_workers_to_exit
68
- @results.suite_complete
64
+ @suite_run.suite_complete
69
65
 
70
- @formatter.print_summary(@results, seed: @seed, success: success?)
66
+ @formatter.print_summary(@suite_run, seed: @seed, success: success?)
67
+ @formatter.print_slowest(@suite_run, @print_slowest_count) if @print_slowest_count
71
68
  exit_with_status
72
69
  end
73
70
 
@@ -95,7 +92,7 @@ module RSpec
95
92
  debug "RSpec config: #{config.inspect}"
96
93
  debug "Files to run: #{config.files_to_run}"
97
94
  @spec_queue = config.files_to_run.shuffle(random: Random.new(@seed))
98
- @results.spec_files_total = @spec_queue.size
95
+ @suite_run.spec_files_total = @spec_queue.size
99
96
  end
100
97
 
101
98
  def preload_application
@@ -127,21 +124,18 @@ module RSpec
127
124
  @shutdown_status = :shutdown_messages_sent
128
125
  @formatter.print_shutdown_banner
129
126
  @worker_processes.select(&:running?).each do |worker_process|
130
- worker_process.socket.send_message({ type: :shutdown })
127
+ worker_process.send_message({ type: :shutdown })
131
128
  cleanup_worker_process(worker_process)
132
129
  end
133
130
  end
134
131
 
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)
137
- readable_ios&.each { |io| handle_worker_message(worker_processes_by_io.fetch(io)) }
138
- Util::ChildProcess.tick_all(@worker_processes.map(&:child_process))
132
+ WorkerProcess.tick_all(@worker_processes)
139
133
  reap_workers
140
134
  end
141
135
  end
142
136
 
143
137
  def wait_for_workers_to_exit
144
- Util::ChildProcess.wait_all(@worker_processes.map(&:child_process))
138
+ WorkerProcess.wait_all(@worker_processes)
145
139
  end
146
140
 
147
141
  def spawn_worker(worker_number)
@@ -150,6 +144,7 @@ module RSpec
150
144
  worker_process = WorkerProcess.spawn(
151
145
  number: worker_number,
152
146
  test_env_number: (@first_is_1 || worker_number != 1) ? worker_number.to_s : "",
147
+ on_message: ->(worker_process, message) { handle_worker_message(worker_process, message) },
153
148
  on_stdout: ->(string) { @formatter.handle_worker_stdout(worker_number, string) },
154
149
  on_stderr: ->(string) { @formatter.handle_worker_stderr(worker_number, string) },
155
150
  debug_io: @verbose ? $stderr : nil,
@@ -160,37 +155,34 @@ module RSpec
160
155
  worker_process
161
156
  end
162
157
 
163
- def handle_worker_message(worker_process)
164
- message = worker_process.socket.receive_message
165
- return unless message
166
-
158
+ def handle_worker_message(worker_process, message)
167
159
  debug "Worker #{worker_process.number}: #{message[:type]}"
168
160
 
169
161
  case message[:type].to_sym
170
162
  when :example_passed
171
- @results.example_passed
163
+ @suite_run.example_passed(message)
172
164
  when :example_failed
173
- @results.example_failed(message)
165
+ @suite_run.example_failed(message)
174
166
 
175
- if @fail_fast_after && @results.examples_failed >= @fail_fast_after
176
- debug "Shutting down after #{@results.examples_failed} failures"
167
+ if @fail_fast_after && @suite_run.examples_failed >= @fail_fast_after
168
+ debug "Shutting down after #{@suite_run.examples_failed} failures"
177
169
  initiate_shutdown
178
170
  end
179
171
  when :example_pending
180
- @results.example_pending
172
+ @suite_run.example_pending
181
173
  when :example_retried
182
174
  @formatter.print_retry_message(message) if @display_retry_backtraces
183
175
  when :spec_complete
184
- @results.spec_file_complete
176
+ @suite_run.spec_file_complete
185
177
  worker_process.current_spec = nil
186
178
  assign_work(worker_process)
187
179
  when :spec_error
188
- @results.spec_file_error(message)
180
+ @suite_run.spec_file_error(message)
189
181
  debug "Spec error details: #{message[:error]}"
190
182
  worker_process.current_spec = nil
191
183
  assign_work(worker_process)
192
184
  end
193
- @formatter.handle_worker_message(worker_process, message, @results)
185
+ @formatter.handle_worker_message(worker_process, message, @suite_run)
194
186
  end
195
187
 
196
188
  def assign_work(worker_process)
@@ -198,21 +190,21 @@ module RSpec
198
190
 
199
191
  if shutting_down? || !spec_file
200
192
  debug "No more work for worker #{worker_process.number}, sending shutdown"
201
- worker_process.socket.send_message({ type: :shutdown })
193
+ worker_process.send_message({ type: :shutdown })
202
194
  cleanup_worker_process(worker_process)
203
195
  else
204
- @results.spec_file_assigned
196
+ @suite_run.spec_file_assigned
205
197
  worker_process.current_spec = spec_file
206
198
  debug "Assigning #{spec_file} to worker #{worker_process.number}"
207
199
  message = { type: :worker_assigned_spec, file: spec_file }
208
- worker_process.socket.send_message(message)
209
- @formatter.handle_worker_message(worker_process, message, @results)
200
+ worker_process.send_message(message)
201
+ @formatter.handle_worker_message(worker_process, message, @suite_run)
210
202
  end
211
203
  end
212
204
 
213
205
  def cleanup_worker_process(worker_process, status: :shut_down)
214
206
  worker_process.shut_down(status)
215
- @formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @results)
207
+ @formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @suite_run)
216
208
  end
217
209
 
218
210
  def reap_workers
@@ -224,7 +216,7 @@ module RSpec
224
216
 
225
217
  dead_worker_processes.each do |worker_process, exitstatus|
226
218
  cleanup_worker_process(worker_process, status: :terminated)
227
- @results.worker_crashed
219
+ @suite_run.worker_crashed
228
220
  debug "Worker #{worker_process.number} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
229
221
  end
230
222
  end
@@ -243,7 +235,7 @@ module RSpec
243
235
  end
244
236
 
245
237
  def success?
246
- @results.success? && !shutting_down?
238
+ @suite_run.success? && !shutting_down?
247
239
  end
248
240
 
249
241
  def exit_with_status
@@ -2,8 +2,8 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- class Results
6
- attr_accessor :examples_passed, :examples_failed, :examples_pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed
5
+ class SuiteRun
6
+ attr_accessor :examples_passed, :examples_failed, :examples_pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed, :example_stats
7
7
 
8
8
  def initialize
9
9
  @examples_passed = 0
@@ -16,17 +16,20 @@ module RSpec
16
16
  @specs_completed_at = nil
17
17
  @spec_files_total = 0
18
18
  @spec_files_processed = 0
19
+ @example_stats = []
19
20
  end
20
21
 
21
22
  def success?
22
23
  @examples_failed.zero? && @errors.empty? && @worker_crashes.zero? && @spec_files_total == @spec_files_processed
23
24
  end
24
25
 
25
- def example_passed
26
+ def example_passed(message)
27
+ @example_stats << message.slice(:location, :run_time, :description)
26
28
  @examples_passed += 1
27
29
  end
28
30
 
29
31
  def example_failed(message)
32
+ @example_stats << message.slice(:location, :run_time, :description)
30
33
  @examples_failed += 1
31
34
  @errors << message
32
35
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "socket"
4
+
3
5
  module RSpec
4
6
  module Conductor
5
7
  module Util
6
8
  class ChildProcess
7
9
  POLL_INTERVAL = 0.01
8
10
 
9
- attr_reader :pid, :exit_status
11
+ attr_reader :pid, :message_socket, :exit_status, :ios
10
12
 
11
13
  def self.fork(**args, &block)
12
14
  new(**args).fork(&block)
@@ -22,44 +24,47 @@ module RSpec
22
24
 
23
25
  def self.tick_all(processes, poll_interval: POLL_INTERVAL)
24
26
  processes_by_io = processes.each_with_object({}) do |process, memo|
25
- process.pipes.reject(&:closed?).each { |pipe| memo[pipe] = process }
27
+ process.ios.reject(&:closed?).each { |io| memo[io] = process }
26
28
  end
27
29
  return false if processes_by_io.empty?
28
30
 
29
31
  ready, = IO.select(processes_by_io.keys, nil, nil, poll_interval)
30
- ready&.each { |pipe| processes_by_io[pipe].read_available(pipe) }
32
+ ready&.each { |io| processes_by_io[io].handle_available(io) }
31
33
 
32
34
  true
33
35
  end
34
36
 
35
- def initialize(on_stdout: nil, on_stderr: nil)
37
+ def initialize(on_stdout: nil, on_stderr: nil, on_message: nil)
36
38
  @on_stdout = on_stdout
37
39
  @on_stderr = on_stderr
40
+ @on_message = on_message
38
41
  @pid = nil
39
42
  @exit_status = nil
40
43
  @stdout_pipe = nil
41
44
  @stderr_pipe = nil
42
45
  @stdout_buffer = +""
43
46
  @stderr_buffer = +""
47
+ @message_socket = nil
48
+ @ios = []
44
49
  @done = false
45
50
  end
46
51
 
47
- def pipes
48
- [@stdout_pipe, @stderr_pipe].compact
49
- end
50
-
51
52
  def fork(&block)
52
53
  raise ArgumentError, '.fork should be called with a block' unless block_given?
53
54
 
54
55
  stdout_read, stdout_write = IO.pipe
55
56
  stderr_read, stderr_write = IO.pipe
57
+ parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0) if @on_message
56
58
 
57
59
  @stdout_pipe = stdout_read
58
60
  @stderr_pipe = stderr_read
61
+ @message_socket = parent_socket
62
+ @ios = [@stdout_pipe, @stderr_pipe, @message_socket].compact
59
63
 
60
64
  @pid = Kernel.fork do
61
65
  stdout_read.close
62
66
  stderr_read.close
67
+ parent_socket&.close
63
68
 
64
69
  $stdout = stdout_write
65
70
  $stderr = stderr_write
@@ -69,13 +74,14 @@ module RSpec
69
74
  STDIN.reopen($stdin)
70
75
 
71
76
  begin
72
- yield self
77
+ yield self, child_socket
73
78
  rescue => e
74
79
  stderr_write.puts "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
75
80
  exit 1
76
81
  ensure
77
82
  stdout_write.close
78
83
  stderr_write.close
84
+ child_socket&.close
79
85
  end
80
86
 
81
87
  exit 0
@@ -83,6 +89,7 @@ module RSpec
83
89
 
84
90
  stdout_write.close
85
91
  stderr_write.close
92
+ child_socket&.close
86
93
 
87
94
  self
88
95
  end
@@ -91,17 +98,27 @@ module RSpec
91
98
  @done
92
99
  end
93
100
 
94
- def read_available(pipe)
101
+ def handle_available(io)
95
102
  return if done?
96
- return if pipe.closed?
97
-
98
- buffer, callback = if pipe == @stdout_pipe
99
- [@stdout_buffer, @on_stdout]
100
- elsif pipe == @stderr_pipe
101
- [@stderr_buffer, @on_stderr]
102
- else
103
- return
103
+ return if io.closed?
104
+
105
+ case io
106
+ when @stdout_pipe, @stderr_pipe
107
+ read_pipe(io)
108
+ when @message_socket
109
+ @on_message&.call
104
110
  end
111
+ end
112
+
113
+ def read_pipe(pipe)
114
+ buffer, callback = case pipe
115
+ when @stdout_pipe
116
+ [@stdout_buffer, @on_stdout]
117
+ when @stderr_pipe
118
+ [@stderr_buffer, @on_stderr]
119
+ else
120
+ return
121
+ end
105
122
 
106
123
  begin
107
124
  data = pipe.read_nonblock(4096, exception: false)
@@ -132,6 +149,8 @@ module RSpec
132
149
  rescue Errno::ECHILD
133
150
  end
134
151
 
152
+ @message_socket&.close
153
+
135
154
  self
136
155
  end
137
156
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.9"
5
+ VERSION = "1.0.11"
6
6
  end
7
7
  end
@@ -48,8 +48,6 @@ module RSpec
48
48
  debug "Worker crashed: #{e.class}: #{e.message}"
49
49
  debug e.backtrace.join("\n")
50
50
  raise
51
- ensure
52
- @socket.close
53
51
  end
54
52
 
55
53
  private
@@ -109,12 +107,12 @@ module RSpec
109
107
  def run_spec(file)
110
108
  RSpec.world.reset
111
109
  RSpec.configuration.reset
112
- RSpec.configuration.files_or_directories_to_run = [file]
113
110
  RSpec.configuration.output_stream = null_io_out
114
111
  RSpec.configuration.error_stream = null_io_out
115
112
  RSpec.configuration.formatter_loader.formatters.clear
116
113
  RSpec.configuration.add_formatter(RSpecSubscriber.new(@socket, file, -> { check_for_shutdown }))
117
114
  parsed_options.configure(RSpec.configuration)
115
+ RSpec.configuration.files_or_directories_to_run = location_entries_for(file)
118
116
  RSpec.configuration.files_to_run # this seemingly random line is necessary for rspec to set up the inclusion filters (e.g. hello_spec.rb:123 -> the :123 part is an inclusion filter)
119
117
 
120
118
  begin
@@ -133,7 +131,7 @@ module RSpec
133
131
 
134
132
  @socket.send_message(
135
133
  type: :spec_complete,
136
- file: file
134
+ file: file,
137
135
  )
138
136
  rescue StandardError => e
139
137
  debug "Spec error: #{e.class}: #{e.message}"
@@ -167,6 +165,21 @@ module RSpec
167
165
  @parsed_options ||= RSpec::Core::ConfigurationOptions.new(@rspec_args).tap { |co| co.options.delete(:requires) }
168
166
  end
169
167
 
168
+ # Returns all the location entries for a filename
169
+ # RSpec syntax for locations:
170
+ # * ./a_spec.rb - run whole file
171
+ # * ./a_spec.rb:18 - run specific spec
172
+ # * ./a_spec.rb[1:2:1] - run specific spec group
173
+ # You can specify locations more than once for a single file, so you need to find all relevant entries in the runner
174
+ def location_entries_for(filename)
175
+ filename = File.expand_path(filename)
176
+ entries = Array(parsed_options.options[:files_or_directories_to_run]).select do |entry|
177
+ entry_filename = File.expand_path(entry.sub(/(\[\d+(:\d+)*\])+\z/, "").sub(/(:\d+)+\z/, ""))
178
+ entry_filename == filename
179
+ end
180
+ entries.empty? ? filename : entries
181
+ end
182
+
170
183
  def debug(message)
171
184
  @debug_io.puts message if @debug_io
172
185
  end
@@ -2,35 +2,70 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
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
5
+ class WorkerProcess
6
+ def self.spawn(number:, test_env_number:, on_message:, on_stdout: nil, on_stderr: nil, **worker_init_args)
7
+ worker_process = new(number: number, on_message: on_message)
8
+
9
+ child_process = Util::ChildProcess.fork(on_stdout: on_stdout, on_stderr: on_stderr, on_message: proc { worker_process.handle_message }) do |_, child_socket|
9
10
  ENV["TEST_ENV_NUMBER"] = test_env_number
10
- parent_socket.close
11
11
  Worker.new(
12
12
  worker_number: number,
13
13
  socket: Protocol::Socket.new(child_socket),
14
14
  **worker_init_args
15
15
  ).run
16
16
  end
17
- child_socket.close
18
17
 
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
- )
18
+ worker_process.child_process = child_process
19
+ worker_process
20
+ end
21
+
22
+ def self.tick_all(worker_processes)
23
+ Util::ChildProcess.tick_all(worker_processes.map(&:child_process))
24
+ end
25
+
26
+ def self.wait_all(worker_processes)
27
+ Util::ChildProcess.wait_all(worker_processes.map(&:child_process))
28
+ end
29
+
30
+ attr_reader :number
31
+ attr_accessor :current_spec, :child_process, :status
32
+
33
+ def initialize(number:, on_message: nil)
34
+ @number = number
35
+ @on_message = on_message
36
+ @status = :running
37
+ end
38
+
39
+ def handle_message
40
+ message = receive_message
41
+ unless message
42
+ socket.close
43
+ return
44
+ end
45
+
46
+ @on_message&.call(self, message)
47
+ end
48
+
49
+ def send_message(message)
50
+ socket.send_message(message)
51
+ end
52
+
53
+ def receive_message
54
+ socket.receive_message
55
+ end
56
+
57
+ def socket
58
+ @socket ||= Protocol::Socket.new(child_process.message_socket)
59
+ end
60
+
61
+ def pid
62
+ child_process.pid
27
63
  end
28
64
 
29
65
  def shut_down(status)
30
66
  return unless running?
31
67
 
32
- self.status = status
33
- socket.close
68
+ @status = status
34
69
  end
35
70
 
36
71
  def running?
@@ -35,7 +35,7 @@ require_relative "conductor/version"
35
35
  require_relative "conductor/protocol"
36
36
  require_relative "conductor/server"
37
37
  require_relative "conductor/worker"
38
- require_relative "conductor/results"
38
+ require_relative "conductor/suite_run"
39
39
  require_relative "conductor/worker_process"
40
40
  require_relative "conductor/cli"
41
41
  require_relative "conductor/rspec_subscriber"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-conductor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Abramov
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-02 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rspec-core
@@ -48,9 +49,9 @@ files:
48
49
  - lib/rspec/conductor/formatters/plain.rb
49
50
  - lib/rspec/conductor/protocol.rb
50
51
  - lib/rspec/conductor/railtie.rb
51
- - lib/rspec/conductor/results.rb
52
52
  - lib/rspec/conductor/rspec_subscriber.rb
53
53
  - lib/rspec/conductor/server.rb
54
+ - lib/rspec/conductor/suite_run.rb
54
55
  - lib/rspec/conductor/util/ansi.rb
55
56
  - lib/rspec/conductor/util/child_process.rb
56
57
  - lib/rspec/conductor/util/screen_buffer.rb
@@ -68,6 +69,7 @@ metadata:
68
69
  homepage_uri: https://github.com/markiz/rspec-conductor
69
70
  source_code_uri: https://github.com/markiz/rspec-conductor
70
71
  changelog_uri: https://github.com/markiz/rspec-conductor/blob/master/CHANGELOG.md
72
+ post_install_message:
71
73
  rdoc_options: []
72
74
  require_paths:
73
75
  - lib
@@ -82,7 +84,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
84
  - !ruby/object:Gem::Version
83
85
  version: '0'
84
86
  requirements: []
85
- rubygems_version: 4.0.6
87
+ rubygems_version: 3.3.26
88
+ signing_key:
86
89
  specification_version: 4
87
90
  summary: Queue-based parallel test runner for rspec
88
91
  test_files: []