Skip to content

Commit 239ebfa

Browse files
committed
Parallelize bundled gems test execution
Run gem tests concurrently using a thread pool instead of sequentially. Each gem test runs in its own process group with output captured via pipes and printed in original order after all tests complete. Concurrency is controlled by `TEST_BUNDLED_GEMS_NPROCS` env var, defaulting to `[Etc.nprocessors, 8].min`. Per-process `RUBYLIB` is passed via `Process.spawn` env hash to avoid shared `ENV` mutation across threads. Full suite on 10-core machine: 268s → ~107s (2.5x speedup).
1 parent 768a6cf commit 239ebfa

1 file changed

Lines changed: 151 additions & 54 deletions

File tree

tool/test-bundled-gems.rb

Lines changed: 151 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
require 'timeout'
33
require 'fileutils'
44
require 'shellwords'
5+
require 'etc'
56
require_relative 'lib/colorize'
67
require_relative 'lib/gem_env'
8+
require_relative 'lib/test/jobserver'
79

810
ENV.delete("GNUMAKEFLAGS")
911

@@ -28,13 +30,28 @@
2830
exit_code = 0
2931
ruby = ENV['RUBY'] || RbConfig.ruby
3032
failed = []
33+
34+
max = ENV['TEST_BUNDLED_GEMS_NPROCS']&.to_i || [Etc.nprocessors, 8].min
35+
nprocs = Test::JobServer.max_jobs(max) || max
36+
nprocs = 1 if nprocs < 1
37+
38+
if /mingw|mswin/ =~ RUBY_PLATFORM
39+
spawn_group = :new_pgroup
40+
signal_prefix = ""
41+
else
42+
spawn_group = :pgroup
43+
signal_prefix = "-"
44+
end
45+
46+
jobs = []
3147
File.foreach("#{gem_dir}/bundled_gems") do |line|
3248
next unless gem = line[/^[^\s\#]+/]
3349
next if bundled_gems&.none? {|pat| File.fnmatch?(pat, gem)}
3450
next unless File.directory?("#{gem_dir}/src/#{gem}/test")
3551

3652
test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", rake, "test"]
3753
first_timeout = 600 # 10min
54+
env_rubylib = rubylib
3855

3956
toplib = gem
4057
unless File.exist?("#{gem_dir}/src/#{gem}/lib/#{toplib}.rb")
@@ -68,7 +85,9 @@
6885
# Since debug gem requires debug.so in child processes without
6986
# activating the gem, we preset necessary paths in RUBYLIB
7087
# environment variable.
71-
load_path = true
88+
libs = IO.popen([ruby, "-e", "old = $:.dup; require '#{toplib}'; puts $:-old"], &:read)
89+
next unless $?.success?
90+
env_rubylib = [libs.split("\n"), rubylib].join(File::PATH_SEPARATOR)
7291

7392
when "test-unit"
7493
test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", "test/run.rb"]
@@ -81,66 +100,144 @@
81100

82101
end
83102

84-
if load_path
85-
libs = IO.popen([ruby, "-e", "old = $:.dup; require '#{toplib}'; puts $:-old"], &:read)
86-
next unless $?.success?
87-
ENV["RUBYLIB"] = [libs.split("\n"), rubylib].join(File::PATH_SEPARATOR)
88-
else
89-
ENV["RUBYLIB"] = rubylib
90-
end
103+
jobs << {
104+
gem: gem,
105+
test_command: test_command,
106+
first_timeout: first_timeout,
107+
rubylib: env_rubylib,
108+
}
109+
end
110+
111+
running_pids = []
112+
interrupted = false
91113

92-
print (github_actions ? "::group::" : "\n")
93-
puts colorize.decorate("Testing the #{gem} gem", "note")
94-
print "[command]" if github_actions
95-
p test_command
96-
start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97-
timeouts = {nil => first_timeout, INT: 30, TERM: 10, KILL: nil}
98-
if /mingw|mswin/ =~ RUBY_PLATFORM
99-
timeouts.delete(:TERM) # Inner process signal on Windows
100-
group = :new_pgroup
101-
pg = ""
102-
else
103-
group = :pgroup
104-
pg = "-"
114+
trap(:INT) do
115+
interrupted = true
116+
running_pids.each do |pid|
117+
Process.kill("#{signal_prefix}INT", pid) rescue nil
105118
end
106-
pid = Process.spawn(*test_command, group => true)
107-
timeouts.each do |sig, sec|
108-
if sig
109-
puts "Sending #{sig} signal"
110-
Process.kill("#{pg}#{sig}", pid)
111-
end
112-
begin
113-
break Timeout.timeout(sec) {Process.wait(pid)}
114-
rescue Timeout::Error
119+
end
120+
121+
results = Array.new(jobs.size)
122+
queue = Queue.new
123+
jobs.each_with_index { |j, i| queue << [j, i] }
124+
nprocs.times { queue << nil }
125+
print_queue = Queue.new
126+
127+
puts "Running #{jobs.size} gem tests with #{nprocs} workers..."
128+
129+
printer = Thread.new do
130+
printed = 0
131+
while printed < jobs.size
132+
result = print_queue.pop
133+
break if result.nil?
134+
135+
gem = result[:gem]
136+
elapsed = result[:elapsed]
137+
status = result[:status]
138+
t = " in %.6f sec" % elapsed
139+
140+
print (github_actions ? "::group::" : "\n")
141+
puts colorize.decorate("Testing the #{gem} gem", "note")
142+
print "[command]" if github_actions
143+
p result[:test_command]
144+
result[:log_lines].each { |l| puts l }
145+
print result[:output]
146+
print "::endgroup::\n" if github_actions
147+
148+
if status&.success?
149+
puts colorize.decorate("Test passed#{t}", "pass")
150+
else
151+
mesg = "Tests failed " +
152+
(status&.signaled? ? "by SIG#{Signal.signame(status.termsig)}" :
153+
"with exit code #{status&.exitstatus}") + t
154+
puts colorize.decorate(mesg, "fail")
155+
if allowed_failures.include?(gem)
156+
mesg = "Ignoring test failures for #{gem} due to \$TEST_BUNDLED_GEMS_ALLOW_FAILURES or DEFAULT_ALLOWED_FAILURES"
157+
puts colorize.decorate(mesg, "skip")
158+
else
159+
failed << gem
160+
exit_code = 1
161+
end
115162
end
116-
rescue Interrupt
117-
exit_code = Signal.list["INT"]
118-
Process.kill("#{pg}KILL", pid)
119-
Process.wait(pid)
120-
break
163+
164+
printed += 1
121165
end
166+
end
122167

123-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_at
124-
print "::endgroup::\n" if github_actions
125-
126-
t = " in %.6f sec" % elapsed
127-
128-
if $?.success?
129-
puts colorize.decorate("Test passed#{t}", "pass")
130-
else
131-
mesg = "Tests failed " +
132-
($?.signaled? ? "by SIG#{Signal.signame($?.termsig)}" :
133-
"with exit code #{$?.exitstatus}") + t
134-
puts colorize.decorate(mesg, "fail")
135-
if allowed_failures.include?(gem)
136-
mesg = "Ignoring test failures for #{gem} due to \$TEST_BUNDLED_GEMS_ALLOW_FAILURES or DEFAULT_ALLOWED_FAILURES"
137-
puts colorize.decorate(mesg, "skip")
138-
else
139-
failed << gem
140-
exit_code = 1
168+
threads = nprocs.times.map do
169+
Thread.new do
170+
while (item = queue.pop)
171+
break if interrupted
172+
job, index = item
173+
174+
start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175+
176+
rd, wr = IO.pipe
177+
env = { "RUBYLIB" => job[:rubylib] }
178+
pid = Process.spawn(env, *job[:test_command], spawn_group => true, [:out, :err] => wr)
179+
wr.close
180+
running_pids << pid
181+
output_thread = Thread.new { rd.read }
182+
183+
timeouts = { nil => job[:first_timeout], INT: 30, TERM: 10, KILL: nil }
184+
if /mingw|mswin/ =~ RUBY_PLATFORM
185+
timeouts.delete(:TERM)
186+
end
187+
188+
log_lines = []
189+
status = nil
190+
timeouts.each do |sig, sec|
191+
if sig
192+
log_lines << "Sending #{sig} signal"
193+
begin
194+
Process.kill("#{signal_prefix}#{sig}", pid)
195+
rescue Errno::ESRCH
196+
_, status = Process.wait2(pid) unless status
197+
break
198+
end
199+
end
200+
begin
201+
break Timeout.timeout(sec) { _, status = Process.wait2(pid) }
202+
rescue Timeout::Error
203+
end
204+
end
205+
206+
captured = output_thread.value
207+
rd.close
208+
running_pids.delete(pid)
209+
210+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_at
211+
212+
result = {
213+
gem: job[:gem],
214+
test_command: job[:test_command],
215+
status: status,
216+
elapsed: elapsed,
217+
output: captured,
218+
log_lines: log_lines,
219+
}
220+
results[index] = result
221+
print_queue << result
141222
end
142223
end
143224
end
144225

145-
puts "Failed gems: #{failed.join(', ')}" unless failed.empty?
226+
threads.each(&:join)
227+
print_queue << nil
228+
printer.join
229+
230+
if interrupted
231+
exit Signal.list["INT"]
232+
end
233+
234+
unless failed.empty?
235+
puts "\n#{colorize.decorate("Failed gems: #{failed.join(', ')}", "fail")}"
236+
results.compact.each do |result|
237+
next if result[:status]&.success?
238+
next if allowed_failures.include?(result[:gem])
239+
puts colorize.decorate("\nTesting the #{result[:gem]} gem", "note")
240+
print result[:output]
241+
end
242+
end
146243
exit exit_code

0 commit comments

Comments
 (0)