|
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' |
| 8 | +require_relative 'lib/test/jobserver' |
7 | 9 |
|
8 | 10 | ENV.delete("GNUMAKEFLAGS") |
9 | 11 |
|
|
28 | 30 | exit_code = 0 |
29 | 31 | ruby = ENV['RUBY'] || RbConfig.ruby |
30 | 32 | 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 = [] |
31 | 47 | File.foreach("#{gem_dir}/bundled_gems") do |line| |
32 | 48 | next unless gem = line[/^[^\s\#]+/] |
33 | 49 | next if bundled_gems&.none? {|pat| File.fnmatch?(pat, gem)} |
34 | 50 | next unless File.directory?("#{gem_dir}/src/#{gem}/test") |
35 | 51 |
|
36 | 52 | test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", rake, "test"] |
37 | 53 | first_timeout = 600 # 10min |
| 54 | + env_rubylib = rubylib |
38 | 55 |
|
39 | 56 | toplib = gem |
40 | 57 | unless File.exist?("#{gem_dir}/src/#{gem}/lib/#{toplib}.rb") |
|
68 | 85 | # Since debug gem requires debug.so in child processes without |
69 | 86 | # activating the gem, we preset necessary paths in RUBYLIB |
70 | 87 | # 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) |
72 | 91 |
|
73 | 92 | when "test-unit" |
74 | 93 | test_command = [ruby, *run_opts, "-C", "#{gem_dir}/src/#{gem}", "test/run.rb"] |
|
81 | 100 |
|
82 | 101 | end |
83 | 102 |
|
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 |
91 | 113 |
|
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 |
105 | 118 | 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 |
115 | 162 | 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 |
121 | 165 | end |
| 166 | +end |
122 | 167 |
|
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 |
141 | 222 | end |
142 | 223 | end |
143 | 224 | end |
144 | 225 |
|
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 |
146 | 243 | exit exit_code |
0 commit comments