ActiveSupport::Testing::Isolation: gracefully handle the subprocess dying

Right now if the subprocess exit uncleanly, it straight out bring the
parent down with it because it will fail to parse the (likely empty)
Marshal payload:

```
<internal:marshal>:34:in `load': marshal data too short (ArgumentError)
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:23:in `run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:1094:in `run_one_method'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:179:in `block in run'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:168:in `with_timestamps'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:178:in `run'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:229:in `block in run_from_queue'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/ci/queue/redis/worker.rb:55:in `poll'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:228:in `run_from_queue'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:213:in `__run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:162:in `run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:86:in `block in autorun'
 - XXXX::XXXXTest#test_xxxxx - /tmp/bundle/ruby/3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:52:in `write': closed stream (IOError)
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:52:in `puts'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:52:in `block (2 levels) in run_in_isolation'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:32:in `fork'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:32:in `block in run_in_isolation'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:28:in `pipe'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:28:in `run_in_isolation'
	from 3.3.0+0/bundler/gems/rails-488a7ce18880/activesupport/lib/active_support/testing/isolation.rb:19:in `run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:1094:in `run_one_method'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:179:in `block in run'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:168:in `with_timestamps'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:178:in `run'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:229:in `block in run_from_queue'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/ci/queue/redis/worker.rb:55:in `poll'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:228:in `run_from_queue'
	from 3.3.0+0/gems/ci-queue-0.38.0/lib/minitest/queue.rb:213:in `__run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:162:in `run'
	from 3.3.0+0/gems/minitest-5.20.0/lib/minitest.rb:86:in `block in autorun'
```

This breaks the Minitest contract that `run_one_method` shouldn't raise
ever, and return a `Minitest::Result`.

By properly checking the sub process status, we can turn this crash into
a test failure, allowing the original test process to go on.
This commit is contained in:
Jean Boussier 2023-11-28 13:21:25 +01:00
parent 139c5678aa
commit b07362cffa

@ -5,6 +5,8 @@ module Testing
module Isolation
require "thread"
SubprocessCrashed = Class.new(StandardError)
def self.included(klass) # :nodoc:
klass.class_eval do
parallelize_me!
@ -16,10 +18,17 @@ def self.forking_env?
end
def run
serialized = run_in_isolation do
status, serialized = run_in_isolation do
super
end
unless status&.success?
error = SubprocessCrashed.new("Subprocess exited with an error: #{status.inspect}\noutput: #{serialized.inspect}")
error.set_backtrace(caller)
self.failures << Minitest::UnexpectedError.new(error)
return defined?(Minitest::Result) ? Minitest::Result.from(self) : dup
end
Marshal.load(serialized)
end
@ -50,13 +59,13 @@ def run_in_isolation(&blk)
end
write.puts [result].pack("m")
exit!
exit!(0)
end
write.close
result = read.read
Process.wait2(pid)
result.unpack1("m")
_, status = Process.wait2(pid)
return status, result.unpack1("m")
end
end
end
@ -75,7 +84,7 @@ def run_in_isolation(&blk)
File.open(ENV["ISOLATION_OUTPUT"], "w") do |file|
file.puts [Marshal.dump(test_result)].pack("m")
end
exit!
exit!(0)
else
Tempfile.open("isolation") do |tmpfile|
env = {
@ -93,13 +102,14 @@ def run_in_isolation(&blk)
child = IO.popen([env, Gem.ruby, *load_path_args, $0, *ORIG_ARGV, test_opts])
status = nil
begin
Process.wait(child.pid)
_, status = Process.wait2(child.pid)
rescue Errno::ECHILD # The child process may exit before we wait
nil
end
return tmpfile.read.unpack1("m")
return status, tmpfile.read.unpack1("m")
end
end
end