Fix deadlock in `Hbc::SystemCommand` (#21665)

* Write failing tests for issue #18638

* Fix `Hbc::SystemCommand` to avoid deadlocks; ref #18638

The current implementation of `Hbc::SystemCommand` uses the `Open3` library in a way that causes a deadlock in cases where `STDERR` grows beyond a certain size.

This commit fixes the issue by following the practice suggested in the Ruby documentation, i. e. choosing a stream selection method that avoids deadlocks.
This commit is contained in:
Claudia 2016-06-04 03:01:34 +02:00 committed by Josh Hagins
parent de311428b7
commit d10d2c8f7e
6 changed files with 203 additions and 71 deletions

View File

@ -16,9 +16,11 @@ group :release do
end
group :test do
gem 'coveralls', :require => false
gem 'coveralls', require: false
gem 'minitest', '5.4.1'
gem 'minitest-reporters'
gem 'mocha', '1.1.0', :require => false
gem 'mocha', '1.1.0', require: false
gem 'rspec', '~> 3.0.0'
gem 'rspec-its', require: false
gem 'rspec-wait', require: false
end

View File

@ -64,9 +64,14 @@ GEM
rspec-expectations (3.0.4)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.0.0)
rspec-its (1.2.0)
rspec-core (>= 3.0.0)
rspec-expectations (>= 3.0.0)
rspec-mocks (3.0.4)
rspec-support (~> 3.0.0)
rspec-support (3.0.4)
rspec-wait (0.0.8)
rspec (>= 2.11, < 3.5)
rubocop (0.40.0)
parser (>= 2.3.1.0, < 3.0)
powerpack (~> 0.1)
@ -104,6 +109,8 @@ DEPENDENCIES
rake
ronn (= 0.7.3)
rspec (~> 3.0.0)
rspec-its
rspec-wait
rubocop-cask (~> 0.6.1)
BUNDLED WITH

View File

@ -2,65 +2,113 @@ require 'open3'
require 'shellwords'
class Hbc::SystemCommand
attr_reader :command
def self.run(executable, options={})
command = _process_options(executable, options)
processed_stdout = ''
processed_stderr = ''
command[0] = Shellwords.shellescape(command[0]) if command.size == 1
to_exec = command.map { |arg| arg.respond_to?(:to_path) ? File.absolute_path(arg) : String(arg) }
odebug "Executing: #{to_exec.utf8_inspect}"
raw_stdin, raw_stdout, raw_stderr, raw_wait_thr = Open3.popen3(*to_exec)
if options[:input]
Array(options[:input]).each { |line| raw_stdin.puts line }
end
raw_stdin.close_write
while line = raw_stdout.gets
processed_stdout << line
ohai line.chomp if options[:print_stdout]
end
while line = raw_stderr.gets
processed_stderr << line
ohai line.chomp if options[:print_stderr]
end
raw_stdout.close_read
raw_stderr.close_read
processed_status = raw_wait_thr.value
_assert_success(processed_status, command, processed_stdout) if options[:must_succeed]
Hbc::SystemCommand::Result.new(command, processed_stdout, processed_stderr, processed_status.exitstatus)
self.new(executable, options).run!
end
def self.run!(command, options={})
run(command, options.merge(:must_succeed => true))
end
def self._process_options(executable, options)
def run!
@processed_output = { stdout: '', stderr: '' }
odebug "Executing: #{ expanded_command.utf8_inspect }"
each_output_line do |type, line|
case type
when :stdout
processed_output[:stdout] << line
ohai line.chomp if options[:print_stdout]
when :stderr
processed_output[:stderr] << line
ohai line.chomp if options[:print_stderr]
end
end
assert_success if options[:must_succeed]
result
end
def initialize(executable, options)
@executable = executable
@options = options
process_options!
end
private
attr_reader :executable, :options,
:processed_output, :processed_status
def process_options!
options.assert_valid_keys :input, :print_stdout, :print_stderr, :args, :must_succeed, :sudo, :bsexec
sudo_prefix = %w{/usr/bin/sudo -E --}
bsexec_prefix = [ '/bin/launchctl', 'bsexec', options[:bsexec] == :startup ? '/' : options[:bsexec] ]
command = [executable]
@command = [executable]
options[:print_stderr] = true if !options.key?(:print_stderr)
command.unshift(*bsexec_prefix) if options[:bsexec]
command.unshift(*sudo_prefix) if options[:sudo]
command.concat(options[:args]) if options.key?(:args) and !options[:args].empty?
command
@command.unshift(*bsexec_prefix) if options[:bsexec]
@command.unshift(*sudo_prefix) if options[:sudo]
@command.concat(options[:args]) if options.key?(:args) and !options[:args].empty?
if @command.size == 1
@command[0] = Shellwords.shellescape(@command[0])
end
nil
end
def self._assert_success(status, command, output)
unless status and status.success?
raise Hbc::CaskCommandFailedError.new(command.utf8_inspect, output, status)
def assert_success
unless processed_status && processed_status.success?
raise Hbc::CaskCommandFailedError.new(
command.utf8_inspect,
processed_output[:stdout],
processed_status)
end
end
def expanded_command
@expanded_command ||= command.map do |arg|
if arg.respond_to?(:to_path)
File.absolute_path(arg)
else
String(arg)
end
end
end
def each_output_line(&b)
raw_stdin, raw_stdout, raw_stderr, raw_wait_thr =
Open3.popen3(*expanded_command)
write_input_to(raw_stdin) if options[:input]
raw_stdin.close_write
each_line_from [raw_stdout, raw_stderr], &b
@processed_status = raw_wait_thr.value
end
def write_input_to(raw_stdin)
Array(options[:input]).each { |line| raw_stdin.puts line }
end
def each_line_from(sources)
begin
readable_sources = IO.select(sources)[0]
readable_sources.delete_if(&:eof?).first(1).each do |source|
type = (source == sources[0] ? :stdout : :stderr)
yield(type, source.gets || '')
end
end until readable_sources.empty?
sources.each(&:close_read)
end
def result
Hbc::SystemCommand::Result.new(command,
processed_output[:stdout],
processed_output[:stderr],
processed_status.exitstatus
)
end
end
class Hbc::SystemCommand::Result

View File

@ -2,16 +2,11 @@ require 'spec_helper'
describe Hbc::SystemCommand do
describe "when the exit code is 0" do
let(:command) { '/usr/bin/true' }
describe "its result" do
subject { described_class.run('/usr/bin/true') }
it "says the command was successful" do
result = described_class.run(command)
expect(result.success?).to eq(true)
end
it "says the exit status is 0" do
result = described_class.run(command)
expect(result.exit_status).to eq(0)
it { is_expected.to be_a_success }
its(:exit_status) { is_expected.to eq(0) }
end
end
@ -27,32 +22,110 @@ describe Hbc::SystemCommand do
end
describe "and the command does not have to succeed" do
it "says the command failed" do
result = described_class.run(command)
expect(result.success?).to eq(false)
end
describe "its result" do
subject { described_class.run(command) }
it "says the exit status is 1" do
result = described_class.run(command)
expect(result.exit_status).to eq(1)
it { is_expected.not_to be_a_success }
its(:exit_status) { is_expected.to eq(1) }
end
end
end
describe "args" do
describe "given a pathname" do
let(:command) { '/bin/ls' }
let(:path) { Pathname(Dir.mktmpdir) }
before do
FileUtils.touch(path.join('somefile'))
before { FileUtils.touch(path.join('somefile')) }
describe "its result" do
subject { described_class.run(command, args: [path]) }
it { is_expected.to be_a_success }
its(:stdout) { is_expected.to eq("somefile\n") }
end
end
describe "with both STDOUT and STDERR output from upstream" do
let(:command) { '/bin/bash' }
let(:options) do
{ args: [
'-c',
'for i in $(seq 1 2 5); do echo $i; echo $(($i + 1)) >&2; done'
] }
end
it "can be pathnames" do
result = described_class.run(command, args: [path])
shared_examples "it returns '1 2 3 4 5 6'" do
describe "its result" do
subject { shutup { described_class.run(command, options) } }
expect(result.success?).to eq(true)
expect(result.stdout).to eq("somefile\n")
it { is_expected.to be_a_success }
its(:stdout) { is_expected.to eq([1, 3, 5, nil].join("\n")) }
its(:stderr) { is_expected.to eq([2, 4, 6, nil].join("\n")) }
end
end
describe "with default options" do
it "echoes only STDERR" do
expected = [2, 4, 6].map { |i| "==> #{ i }\n" }.join('')
expect { described_class.run(command, options)
}.to output(expected).to_stdout
end
include_examples("it returns '1 2 3 4 5 6'")
end
describe "with print_stdout" do
before { options.merge!(print_stdout: true) }
it "echoes both STDOUT and STDERR" do
(1..6).each do |i|
expect { described_class.run(command, options)
}.to output(/==> #{ i }/).to_stdout
end
end
include_examples("it returns '1 2 3 4 5 6'")
end
describe "without print_stderr" do
before { options.merge!(print_stderr: false) }
it "echoes nothing" do
expect { described_class.run(command, options)
}.to output('').to_stdout
end
include_examples("it returns '1 2 3 4 5 6'")
end
describe "with print_stdout but without print_stderr" do
before do
options.merge!(print_stdout: true, print_stderr: false)
end
it "echoes only STDOUT" do
expected = [1, 3, 5].map { |i| "==> #{ i }\n" }.join('')
expect { described_class.run(command, options)
}.to output(expected).to_stdout
end
include_examples("it returns '1 2 3 4 5 6'")
end
end
describe "with a very long STDERR output" do
let(:command) { '/bin/bash' }
let(:options) do
{ args: [
'-c',
'for i in $(seq 1 2 100000); do echo $i; echo $(($i + 1)) >&2; done'
] }
end
it "returns without deadlocking" do
wait(10).for {
shutup { described_class.run(command, options) }
}.to be_a_success
end
end
end

View File

@ -1,4 +1,6 @@
require 'pathname'
require 'rspec/its'
require 'rspec/wait'
if ENV['COVERAGE']
require 'coveralls'

View File

@ -42,7 +42,7 @@ class Hbc::FakeSystemCommand
end
def self.run(command_string, options={})
command = Hbc::SystemCommand._process_options(command_string, options)
command = Hbc::SystemCommand.new(command_string, options).command
unless responses.key?(command)
fail("no response faked for #{command.inspect}, faked responses are: #{responses.inspect}")
end