class Vagrant::Util::Subprocess

Execute a command in a subprocess, gathering the results and exit status.

This class also allows you to read the data as it is outputted from the subprocess in real time, by simply passing a block to the execute method.

Public Class Methods

execute(*command, &block) click to toggle source

Convenience method for executing a method.

# File lib/vagrant/util/subprocess.rb, line 21
def self.execute(*command, &block)
  new(*command).execute(&block)
end
new(*command) click to toggle source
# File lib/vagrant/util/subprocess.rb, line 25
def initialize(*command)
  @options = command.last.is_a?(Hash) ? command.pop : {}
  @command = command.dup
  @command[0] = Which.which(@command[0]) if !File.file?(@command[0])
  if !@command[0]
    raise Errors::CommandUnavailableWindows, file: command[0] if Platform.windows?
    raise Errors::CommandUnavailable, file: command[0]
  end

  @logger  = Log4r::Logger.new("vagrant::util::subprocess")
end

Public Instance Methods

execute() { |io_name, data| ... } click to toggle source

Start the process

@return [Result]

# File lib/vagrant/util/subprocess.rb, line 57
def execute
  # Get the timeout, if we have one
  timeout = @options[:timeout]

  # Get the working directory
  workdir = @options[:workdir] || Dir.pwd

  # Get what we're interested in being notified about
  notify  = @options[:notify] || []
  notify  = [notify] if !notify.is_a?(Array)
  if notify.empty? && block_given?
    # If a block is given, subscribers must be given, otherwise the
    # block is never called. This is usually NOT what you want, so this
    # is an error.
    message = "A list of notify subscriptions must be given if a block is given"
    raise ArgumentError, message
  end

  # Let's get some more useful booleans that we access a lot so
  # we're not constantly calling an `include` check
  notify_table = {}
  notify_table[:stderr] = notify.include?(:stderr)
  notify_table[:stdout] = notify.include?(:stdout)
  notify_stdin  = notify.include?(:stdin)

  # Build the ChildProcess
  @logger.info("Starting process: #{@command.inspect}")
  @process = process = ChildProcess.build(*@command)

  # Create the pipes so we can read the output in real time as
  # we execute the command.
  stdout, stdout_writer = ::IO.pipe
  stderr, stderr_writer = ::IO.pipe
  process.io.stdout = stdout_writer
  process.io.stderr = stderr_writer
  process.duplex = true
  process.leader = true if @options[:detach]
  process.detach = true if @options[:detach]

  # Special installer-related things
  if Vagrant.in_installer?
    installer_dir = Vagrant.installer_embedded_dir.to_s.downcase

    # If we're in an installer on Mac and we're executing a command
    # in the installer context, then force DYLD_LIBRARY_PATH to look
    # at our libs first.
    if Platform.darwin?
      if @command[0].downcase.include?(installer_dir)
        @logger.info("Command in the installer. Specifying DYLD_LIBRARY_PATH...")
        process.environment["DYLD_LIBRARY_PATH"] =
          "#{installer_dir}/lib:#{ENV["DYLD_LIBRARY_PATH"]}"
      else
        @logger.debug("Command not in installer, not touching env vars.")
      end

      if File.setuid?(@command[0]) || File.setgid?(@command[0])
        @logger.info("Command is setuid/setgid, clearing DYLD_LIBRARY_PATH")
        process.environment["DYLD_LIBRARY_PATH"] = ""
      end
    end

    # If the command that is being run is not inside the installer, reset
    # the original environment - this is required for shelling out to
    # other subprocesses that depend on environment variables (like Ruby
    # and $GEM_PATH for example)
    internal = [installer_dir, Vagrant.user_data_path.to_s.downcase].
      any? { |path| @command[0].downcase.include?(path) }
    if !internal
      @logger.info("Command not in installer, restoring original environment...")
      jailbreak(process.environment)
    end

    # If running within an AppImage and calling external executable. When
    # executable is external set the LD_LIBRARY_PATH to host values.
    if ENV["VAGRANT_APPIMAGE"]
      embed_path = Pathname.new(Vagrant.installer_embedded_dir).expand_path.to_s
      exec_path = Pathname.new(@command[0]).expand_path.to_s
      if !exec_path.start_with?(embed_path) && ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"]
        @logger.info("Detected AppImage environment and request to external binary. Updating library path.")
        @logger.debug("Setting LD_LIBRARY_PATH to #{ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"]}")
        process.environment["LD_LIBRARY_PATH"] = ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"].to_s
      end
    end
  else
    @logger.info("Vagrant not running in installer, restoring original environment...")
    jailbreak(process.environment)
  end

  # Set the environment on the process if we must
  if @options[:env]
    @options[:env].each do |k, v|
      process.environment[k] = v
    end
  end

  # Start the process
  begin
    SafeChdir.safe_chdir(workdir) do
      process.start
    end
  rescue ChildProcess::LaunchError => ex
    # Raise our own version of the error so that users of the class
    # don't need to be aware of ChildProcess
    raise LaunchError.new(ex.message)
  end

  # If running with the detach option, no need to capture IO or
  # ensure program exists.
  if @options[:detach]
    return
  end

  # Make sure the stdin does not buffer
  process.io.stdin.sync = true

  if RUBY_PLATFORM != "java"
    # On Java, we have to close after. See down the method...
    # Otherwise, we close the writers right here, since we're
    # not on the writing side.
    stdout_writer.close
    stderr_writer.close
  end

  # Create a dictionary to store all the output we see.
  io_data = { stdout: "", stderr: "" }

  # Record the start time for timeout purposes
  start_time = Time.now.to_i

  open_readers = [stdout, stderr]
  open_writers = notify_stdin ? [process.io.stdin] : []
  @logger.debug("Selecting on IO")
  while true
    results = ::IO.select(open_readers, open_writers, nil, 0.1)
    results ||= []
    readers = results[0]
    writers = results[1]

    # Check if we have exceeded our timeout
    raise TimeoutExceeded, process.pid if timeout && (Time.now.to_i - start_time) > timeout

    # Check the readers to see if they're ready
    if readers && !readers.empty?
      readers.each do |r|
        # Read from the IO object
        data = IO.read_until_block(r)

        # We don't need to do anything if the data is empty
        next if data.empty?

        io_name = r == stdout ? :stdout : :stderr
        @logger.trace("#{io_name}: #{data.chomp}")

        io_data[io_name] += data
        yield io_name, data if block_given? && notify_table[io_name]
      end
    end

    # Break out if the process exited. We have to do this before
    # attempting to write to stdin otherwise we'll get a broken pipe
    # error.
    break if process.exited?

    # Check the writers to see if they're ready, and notify any listeners
    if writers && !writers.empty? && block_given?
      yield :stdin, process.io.stdin

      # if the callback closed stdin, we should remove it, because
      # IO.select() will throw if called with a closed io.
      if process.io.stdin.closed?
        open_writers = []
      end
    end
  end

  # Wait for the process to end.
  begin
    remaining = (timeout || 32000) - (Time.now.to_i - start_time)
    remaining = 0 if remaining < 0
    @logger.debug("Waiting for process to exit. Remaining to timeout: #{remaining}")

    process.poll_for_exit(remaining)
  rescue ChildProcess::TimeoutError
    raise TimeoutExceeded, process.pid
  end

  @logger.debug("Exit status: #{process.exit_code}")

  # Read the final output data, since it is possible we missed a small
  # amount of text between the time we last read data and when the
  # process exited.
  [stdout, stderr].each do |io|
    # Read the extra data, ignoring if there isn't any
    extra_data = IO.read_until_block(io)
    next if extra_data == ""

    # Log it out and accumulate
    io_name = io == stdout ? :stdout : :stderr
    io_data[io_name] += extra_data
    @logger.trace("#{io_name}: #{extra_data.chomp}")

    # Yield to any listeners any remaining data
    yield io_name, extra_data if block_given? && notify_table[io_name]
  end

  if RUBY_PLATFORM == "java"
    # On JRuby, we need to close the writers after the process,
    # for some reason. See GH-711.
    stdout_writer.close
    stderr_writer.close
  end

  # Return an exit status container
  return Result.new(process.exit_code, io_data[:stdout], io_data[:stderr])
ensure
  if process && process.alive? && !@options[:detach]
    # Make sure no matter what happens, the process exits
    process.stop(2)
  end
end
running?() click to toggle source

@return [TrueClass, FalseClass] subprocess is currently running

# File lib/vagrant/util/subprocess.rb, line 38
def running?
  !!(@process && @process.alive?)
end
stop() click to toggle source

Stop the subprocess if running

@return [TrueClass] FalseClass] true if process was running and stopped

# File lib/vagrant/util/subprocess.rb, line 45
def stop
  if @process && @process.alive?
    @process.stop
    true
  else
    false
  end
end

Private Instance Methods

jailbreak(env = {}) click to toggle source

This is, quite possibly, the saddest function in all of Vagrant.

If a user is running Vagrant via Bundler (but not via the official installer), we want to reset to the “original” environment so that when shelling out to other Ruby processes (specifically), the original environment is restored. This is super important for things like rbenv and chruby, who rely on environment variables to locate gems, but Bundler stomps on those environment variables like an angry T-Rex after watching Jurassic Park 2 and realizing they replaced you with CGI.

If a user is running in Vagrant via the official installer, BUT trying to execute a subprocess outside of the installer, we want to reset to the “original” environment. In this case, the Vagrant installer actually knows what the original environment was and replaces it completely.

Finally, we reset any Bundler-specific environment variables, since the subprocess being called could, itself, be Bundler. And Bundler does not behave very nicely in these circumstances.

This function was added in Vagrant 1.7.3, but there is a failsafe because the author doesn’t trust himself that this functionality won’t break existing assumptions, so users can specify ‘VAGRANT_SKIP_SUBPROCESS_JAILBREAK` and none of the above will happen.

This function modifies the given hash in place!

@return [nil]

# File lib/vagrant/util/subprocess.rb, line 336
def jailbreak(env = {})
  return if ENV.key?("VAGRANT_SKIP_SUBPROCESS_JAILBREAK")

  if defined?(::Bundler) && defined?(::Bundler::ORIGINAL_ENV)
    env.replace(::Bundler::ORIGINAL_ENV)
  end
  env.merge!(Vagrant.original_env)

  # Bundler does this, so I guess we should as well, since I think it
  # other subprocesses that use Bundler will reload it
  env["MANPATH"] = ENV["BUNDLE_ORIG_MANPATH"]

  # Replace all current environment BUNDLE_ variables to nil
  ENV.each do |k,_|
    env[k] = nil if k[0,7] == "BUNDLE_"
  end

  # If RUBYOPT was set, unset it with Bundler
  if ENV.key?("RUBYOPT")
    env["RUBYOPT"] = ENV["RUBYOPT"].sub("-rbundler/setup", "")
  end

  nil
end