thewoolleyman/process_helper

View on GitHub
lib/process_helper.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'open3'
require 'pty'
require 'timeout'
require 'stringio'

# Makes it easy to spawn Ruby sub-processes with guaranteed exit status handling,
# capturing and/or suppressing combined STDOUT and STDERR streams,
# providing STDIN input, timeouts, and running via a pseudo terminal.
#
# Full documentation at https://github.com/thewoolleyman/process_helper
module ProcessHelper
  # Don't forget to keep version in sync with gemspec
  VERSION = '0.1.3.pre.beta.1'.freeze

  # rubocop:disable Style/ModuleFunction
  extend self

  def process(cmd, options = {})
    cmd = cmd.to_s
    fail ProcessHelper::EmptyCommandError, 'command must not be empty' if cmd.empty?
    options = options.dup
    options_processing(options)
    puts cmd if options[:log_cmd]
    output, process_status =
      if options[:pseudo_terminal]
        process_with_pseudo_terminal(cmd, options)
      else
        process_with_popen(cmd, options)
      end
    handle_exit_status(cmd, options, output, process_status)
    output
  end

  private

  def process_with_popen(cmd, options)
    Open3.popen2e(cmd) do |stdin, stdout_and_stderr, wait_thr|
      begin
        output = get_output(stdin, stdout_and_stderr, options)
      rescue TimeoutError
        # ensure the thread is killed
        wait_thr.kill
        raise
      end
      process_status = wait_thr.value
      return [output, process_status]
    end
  end

  def process_with_pseudo_terminal(cmd, options)
    max_seconds_to_wait_for_pid_to_exit = options[:timeout] || 60
    PTY.spawn(cmd) do |stdout_and_stderr, stdin, pid|
      output = get_output(stdin, stdout_and_stderr, options)
      # TODO: come up with a test that illustrates pid not exiting by the time PTY exits
      process_status = nil
      begin
        Timeout.timeout(max_seconds_to_wait_for_pid_to_exit) do
          process_status = PTY.check(pid) until process_status
          sleep 0.1 unless process_status
        end
      rescue Timeout::Error
        additional_msg = "Pid #{pid} did not exit after its pseudo-terminal (PTY) returned."
        handle_timeout_error(output, options, max_seconds_to_wait_for_pid_to_exit, additional_msg)
      end
      return [output, process_status]
    end
  end

  def warn_if_output_may_be_suppressed_on_error(options)
    return unless options[:puts_output] == :never &&
      options[:include_output_in_exception] == false

    err_msg = 'WARNING: Check your ProcessHelper options - ' \
        ':puts_output is :never, and :include_output_in_exception ' \
        'is false, so all error output will be suppressed if process fails.'
    $stderr.puts(err_msg)
  end

  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
  # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
  def get_output(stdin, stdout_and_stderr, options)
    input = options[:input]
    always_puts_output = (options[:puts_output] == :always)
    timeout = options[:timeout]
    output = ''
    begin
      begin
        until input.eof?
          Timeout.timeout(timeout) do
            in_ch = input.read_nonblock(1)
            stdin.write_nonblock(in_ch)
          end
          stdin.flush
        end
        ch = nil
        loop do
          Timeout.timeout(timeout) do
            ch = stdout_and_stderr.read_nonblock(1)
          end
          break unless ch
          printf format_char(ch) if always_puts_output
          output += ch
          stdout_and_stderr.flush
        end
      rescue EOFError
        return output
      rescue IO::WaitReadable
        result = IO.select([stdout_and_stderr], nil, nil, timeout)
        raise Timeout::Error if result.nil?
        retry
      rescue IO::WaitWritable
        result = IO.select(nil, [stdin], nil, timeout)
        raise Timeout::Error if result.nil?
        retry
      rescue Errno::EIO
        # GNU/Linux raises EIO on read operation when pty slave is closed - see pty.rb docs
        return output if options[:pseudo_terminal]
        raise
      end
    rescue Timeout::Error
      handle_timeout_error(output, options)
    ensure
      stdout_and_stderr.close
      stdin.close
    end
    # TODO: Why do we sometimes get here with no EOFError occurring, but instead
    # via IO::WaitReadable with a nil select result? (via popen, not sure if via tty)
    output
  end

  def format_char(ch)
    ch == '%' ? '%%' : ch
  end

  def handle_timeout_error(output, options, seconds = nil, additional_msg = nil)
    msg = "Timed out after #{options.fetch(:timeout, seconds)} seconds."
    msg += " #{additional_msg}" if additional_msg
    if options[:include_output_in_exception]
      msg += " Command output prior to timeout: \"#{output}\""
    end
    fail(TimeoutError, msg)
  end

  def handle_exit_status(cmd, options, output, process_status)
    expected_exit_status = options[:expected_exit_status]
    return if expected_exit_status.include?(process_status.exitstatus)

    exception_message = create_exception_message(cmd, process_status, expected_exit_status)
    if options[:include_output_in_exception]
      exception_message += " Command output: \"#{output}\""
    end
    puts_output_only_on_exception(options, output)
    fail ProcessHelper::UnexpectedExitStatusError, exception_message
  end

  def create_exception_message(cmd, exit_status, expected_exit_status)
    if expected_exit_status == [0]
      result_msg = 'failed'
      exit_status_msg = ''
    elsif !expected_exit_status.include?(0)
      result_msg = 'succeeded but was expected to fail'
      exit_status_msg = " (expected #{expected_exit_status})"
    else
      result_msg = 'did not exit with one of the expected exit statuses'
      exit_status_msg = " (expected #{expected_exit_status})"
    end

    "Command #{result_msg}, #{exit_status}#{exit_status_msg}. " \
      "Command: `#{cmd}`."
  end

  def puts_output_only_on_exception(options, output)
    return if options[:puts_output] == :always
    puts output if options[:puts_output] == :error
  end

  def options_processing(options)
    validate_long_vs_short_option_uniqueness(options)
    convert_short_options(options)
    validate_input_option(options[:input]) if options[:input]
    set_option_defaults(options)
    validate_option_values(options)
    convert_scalar_expected_exit_status_to_array(options)
    warn_if_output_may_be_suppressed_on_error(options)
  end

  def validate_input_option(input_option)
    fail(
      ProcessHelper::InvalidOptionsError,
      "#{quote_and_join_pair(%w(input in))} options must be a String or a StringIO"
    ) unless input_option.is_a?(String) || input_option.is_a?(StringIO)
  end

  # rubocop:disable Style/AccessorMethodName
  def set_option_defaults(options)
    options[:puts_output] = :always if options[:puts_output].nil?
    options[:include_output_in_exception] = true if options[:include_output_in_exception].nil?
    options[:pseudo_terminal] = false if options[:pseudo_terminal].nil?
    options[:expected_exit_status] = [0] if options[:expected_exit_status].nil?
    options[:input] = StringIO.new(options[:input].to_s) unless options[:input].is_a?(StringIO)
    options[:log_cmd] = false if options[:log_cmd].nil?
  end

  def valid_option_pairs
    pairs = [
      %w(expected_exit_status exp_st),
      %w(include_output_in_exception out_ex),
      %w(input in),
      %w(pseudo_terminal pty),
      %w(puts_output out),
      %w(timeout kill),
      %w(log_cmd log),
    ]
    pairs.each do |pair|
      pair.each_with_index do |opt, index|
        pair[index] = opt.to_sym
      end
    end
  end

  def valid_options
    valid_option_pairs.flatten
  end

  def validate_long_vs_short_option_uniqueness(options)
    invalid_options = (options.keys - valid_options)
    fail(
      ProcessHelper::InvalidOptionsError,
      "Invalid option(s) '#{invalid_options.join(', ')}' given.  " \
         "Valid options are: #{valid_options.join(', ')}") unless invalid_options.empty?
    valid_option_pairs.each do |pair|
      long_option_name, short_option_name = pair
      both_long_and_short_option_specified =
        options[long_option_name] && options[short_option_name]
      next unless both_long_and_short_option_specified
      fail(
        ProcessHelper::InvalidOptionsError,
        "Cannot specify both '#{long_option_name}' and '#{short_option_name}'")
    end
  end

  def convert_short_options(options)
    valid_option_pairs.each do |pair|
      long, short = pair
      options[long] = options.delete(short) unless options[short].nil?
    end
  end

  def validate_option_values(options)
    options.each do |option, value|
      valid_option_pairs.each do |pair|
        long_option_name, _ = pair
        next unless option == long_option_name
        validate_integer(pair, value) if option.to_s == 'expected_exit_status'
        validate_boolean(pair, value) if option.to_s == 'include_output_in_exception'
        validate_boolean(pair, value) if option.to_s == 'pseudo_terminal'
        validate_puts_output(pair, value) if option.to_s == 'puts_output'
        validate_boolean(pair, value) if option.to_s == 'log_cmd'
      end
    end
  end

  def validate_integer(pair, value)
    valid =
      case
        when value.is_a?(Integer)
          true
        when value.is_a?(Array) && value.all? { |v| v.is_a?(Integer) }
          true
        else
          false
      end

    fail(
      ProcessHelper::InvalidOptionsError,
      "#{quote_and_join_pair(pair)} options must be an Integer or an array of Integers"
    ) unless valid
  end

  def validate_boolean(pair, value)
    fail(
      ProcessHelper::InvalidOptionsError,
      "#{quote_and_join_pair(pair)} options must be a boolean"
    ) unless value == true || value == false
  end

  def validate_puts_output(pair, value)
    valid_values = [:always, :error, :never]
    fail(
      ProcessHelper::InvalidOptionsError,
      "#{quote_and_join_pair(pair)} options must be one of the following: " +
        valid_values.map { |v| ":#{v}" }.join(', ')
    ) unless valid_values.include?(value)
  end

  def quote_and_join_pair(pair)
    pair.map { |o| "'#{o}'" }.join(',')
  end

  def convert_scalar_expected_exit_status_to_array(options)
    return if options[:expected_exit_status].is_a?(Array)
    options[:expected_exit_status] =
      [options[:expected_exit_status]]
  end

  # Custom Exception Classes:

  # Error which is raised when a command is empty
  class EmptyCommandError < RuntimeError
  end

  # Error which is raised when options are invalid
  class InvalidOptionsError < RuntimeError
  end

  # Error which is raised when any read or write operation takes longer than timeout (kill) option
  class TimeoutError < RuntimeError
  end

  # Error which is raised when a command returns an unexpected exit status (return code)
  class UnexpectedExitStatusError < RuntimeError
  end

  # Error which is raised when command exists while input remains unprocessed
  class UnprocessedInputError < RuntimeError
  end
end