|
2 | 2 | require 'timeout' |
3 | 3 | require 'fileutils' |
4 | 4 | require 'shellwords' |
| 5 | +require 'etc' |
5 | 6 | require_relative 'lib/colorize' |
6 | 7 | require_relative 'lib/gem_env' |
7 | 8 |
|
|
28 | 29 | exit_code = 0 |
29 | 30 | ruby = ENV['RUBY'] || RbConfig.ruby |
30 | 31 | failed = [] |
| 32 | + |
| 33 | +nprocs = (ENV['TEST_BUNDLED_GEMS_NPROCS'] || [Etc.nprocessors, 8].min).to_i |
| 34 | +nprocs = 1 if nprocs < 1 |
| 35 | + |
| 36 | +if /mingw|mswin/ =~ RUBY_PLATFORM |
| 37 | + spawn_group = :new_pgroup |
| 38 | + signal_prefix = "" |
| 39 | +else |
| 40 | + spawn_group = :pgroup |
| 41 | + signal_prefix = "-" |
| 42 | +end |
| 43 | + |
| 44 | +# Build list of gem test jobs |
| 45 | +jobs = [] |
31 | 46 | File.foreach("#{gem_dir}/bundled_gems") do |line| |
32 | 47 | next unless gem = line[/^[^\s\#]+/] |
33 | 48 | next if bundled_gems&.none? {|pat| File.fnmatch?(pat, gem)} |
34 | 49 | next unless File.directory?("#{gem_dir}/src/#{gem}/test") |
35 | 50 |
|
36 | 51 | test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", rake, "test"] |
37 | 52 | first_timeout = 600 # 10min |
| 53 | + env_rubylib = rubylib |
38 | 54 |
|
39 | 55 | toplib = gem |
40 | 56 | unless File.exist?("#{gem_dir}/src/#{gem}/lib/#{toplib}.rb") |
|
68 | 84 | # Since debug gem requires debug.so in child processes without |
69 | 85 | # activating the gem, we preset necessary paths in RUBYLIB |
70 | 86 | # environment variable. |
71 | | - load_path = true |
| 87 | + libs = IO.popen([ruby, "-e", "old = $:.dup; require '#{toplib}'; puts $:-old"], &:read) |
| 88 | + next unless $?.success? |
| 89 | + env_rubylib = [libs.split("\n"), rubylib].join(File::PATH_SEPARATOR) |
72 | 90 |
|
73 | 91 | when "test-unit" |
74 | 92 | test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", "test/run.rb"] |
|
81 | 99 |
|
82 | 100 | end |
83 | 101 |
|
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 |
| 102 | + jobs << { |
| 103 | + gem: gem, |
| 104 | + test_command: test_command, |
| 105 | + first_timeout: first_timeout, |
| 106 | + rubylib: env_rubylib, |
| 107 | + } |
| 108 | +end |
91 | 109 |
|
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 = "-" |
| 110 | +# Run gem tests in parallel using a thread pool. |
| 111 | +# Avoid Mutex in the signal handler — CRuby's GVL makes plain Array |
| 112 | +# operations safe enough here. |
| 113 | +running_pids = [] |
| 114 | +interrupted = false |
| 115 | + |
| 116 | +trap(:INT) do |
| 117 | + interrupted = true |
| 118 | + running_pids.each do |pid| |
| 119 | + Process.kill("#{signal_prefix}KILL", pid) rescue nil |
105 | 120 | 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 |
| 121 | +end |
| 122 | + |
| 123 | +results = Array.new(jobs.size) |
| 124 | +queue = Queue.new |
| 125 | +jobs.each_with_index { |j, i| queue << [j, i] } |
| 126 | +nprocs.times { queue << nil } |
| 127 | +io_mutex = Mutex.new |
| 128 | + |
| 129 | +puts "Running #{jobs.size} gem tests with #{nprocs} workers...\n\n" |
| 130 | + |
| 131 | +threads = nprocs.times.map do |
| 132 | + Thread.new do |
| 133 | + while (item = queue.pop) |
| 134 | + break if interrupted |
| 135 | + job, index = item |
| 136 | + |
| 137 | + io_mutex.synchronize do |
| 138 | + puts " #{job[:gem]}" |
| 139 | + end |
| 140 | + |
| 141 | + start_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
| 142 | + |
| 143 | + rd, wr = IO.pipe |
| 144 | + env = { "RUBYLIB" => job[:rubylib] } |
| 145 | + pid = Process.spawn(env, *job[:test_command], spawn_group => true, [:out, :err] => wr) |
| 146 | + wr.close |
| 147 | + running_pids << pid |
| 148 | + output_thread = Thread.new { rd.read } |
| 149 | + |
| 150 | + timeouts = { nil => job[:first_timeout], INT: 30, TERM: 10, KILL: nil } |
| 151 | + if /mingw|mswin/ =~ RUBY_PLATFORM |
| 152 | + timeouts.delete(:TERM) |
| 153 | + end |
| 154 | + |
| 155 | + log_lines = [] |
| 156 | + status = nil |
| 157 | + timeouts.each do |sig, sec| |
| 158 | + if sig |
| 159 | + log_lines << "Sending #{sig} signal" |
| 160 | + begin |
| 161 | + Process.kill("#{signal_prefix}#{sig}", pid) |
| 162 | + rescue Errno::ESRCH |
| 163 | + _, status = Process.wait2(pid) unless status |
| 164 | + break |
| 165 | + end |
| 166 | + end |
| 167 | + begin |
| 168 | + break Timeout.timeout(sec) { _, status = Process.wait2(pid) } |
| 169 | + rescue Timeout::Error |
| 170 | + end |
| 171 | + end |
| 172 | + |
| 173 | + captured = output_thread.value |
| 174 | + rd.close |
| 175 | + running_pids.delete(pid) |
| 176 | + |
| 177 | + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_at |
| 178 | + |
| 179 | + results[index] = { |
| 180 | + gem: job[:gem], |
| 181 | + test_command: job[:test_command], |
| 182 | + status: status, |
| 183 | + elapsed: elapsed, |
| 184 | + output: captured, |
| 185 | + log_lines: log_lines, |
| 186 | + } |
115 | 187 | end |
116 | | - rescue Interrupt |
117 | | - exit_code = Signal.list["INT"] |
118 | | - Process.kill("#{pg}KILL", pid) |
119 | | - Process.wait(pid) |
120 | | - break |
121 | 188 | end |
| 189 | +end |
122 | 190 |
|
123 | | - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_at |
124 | | - print "::endgroup::\n" if github_actions |
| 191 | +threads.each(&:join) |
125 | 192 |
|
| 193 | +if interrupted |
| 194 | + exit Signal.list["INT"] |
| 195 | +end |
| 196 | + |
| 197 | +# Print results: passing gems first, then failures for visibility |
| 198 | +results.compact.sort_by { |r| r[:status]&.success? ? 0 : 1 }.each do |result| |
| 199 | + gem = result[:gem] |
| 200 | + elapsed = result[:elapsed] |
| 201 | + status = result[:status] |
126 | 202 | t = " in %.6f sec" % elapsed |
127 | 203 |
|
128 | | - if $?.success? |
| 204 | + print (github_actions ? "::group::" : "\n") |
| 205 | + puts colorize.decorate("Testing the #{gem} gem", "note") |
| 206 | + print "[command]" if github_actions |
| 207 | + p result[:test_command] |
| 208 | + result[:log_lines].each { |l| puts l } |
| 209 | + print result[:output] |
| 210 | + print "::endgroup::\n" if github_actions |
| 211 | + |
| 212 | + if status&.success? |
129 | 213 | puts colorize.decorate("Test passed#{t}", "pass") |
130 | 214 | else |
131 | 215 | mesg = "Tests failed " + |
132 | | - ($?.signaled? ? "by SIG#{Signal.signame($?.termsig)}" : |
133 | | - "with exit code #{$?.exitstatus}") + t |
| 216 | + (status&.signaled? ? "by SIG#{Signal.signame(status.termsig)}" : |
| 217 | + "with exit code #{status&.exitstatus}") + t |
134 | 218 | puts colorize.decorate(mesg, "fail") |
135 | 219 | if allowed_failures.include?(gem) |
136 | 220 | mesg = "Ignoring test failures for #{gem} due to \$TEST_BUNDLED_GEMS_ALLOW_FAILURES or DEFAULT_ALLOWED_FAILURES" |
|
0 commit comments