lib/buildbox/command.rb in buildbox-0.2.3 vs lib/buildbox/command.rb in buildbox-0.3
- old
+ new
@@ -1,58 +1,148 @@
require 'childprocess'
require 'pty'
+# Inspiration from:
+# https://github.com/mitchellh/vagrant/blob/master/lib/vagrant/util/subprocess.rb
+
module Buildbox
class Command
- class Result < Struct.new(:output, :exit_status)
- end
+ # The chunk size for reading from subprocess IO.
+ READ_CHUNK_SIZE = 4096
- def self.command(command, options = {}, &block)
- new(command, options).start(&block)
- end
+ # An error which occurs when the process doesn't end within
+ # the given timeout.
+ class TimeoutExceeded < StandardError; end
- def self.script(script, options = {}, &block)
- new(script, options).start(&block)
+ attr_reader :output, :exit_status
+
+ def self.run(*args, &block)
+ options = args.last.is_a?(Hash) ? args.pop : {}
+ arguments = args.dup
+
+ # Run the command
+ command = new(arguments, options, &block)
+ command.start(&block)
+ command
end
def initialize(arguments, options = {})
- @arguments = arguments
- @environment = options[:environment] || {}
- @directory = options[:directory] || "."
+ @arguments = arguments
+ @options = options
+ @logger = Buildbox.logger
end
def start(&block)
- read_io, write_io = IO.pipe
+ # Get the timeout, if we have one
+ timeout = @options[:timeout]
- arguments = [ *runner, *@arguments ].compact.map(&:to_s) # all arguments must be a string
- process = ChildProcess.build(*arguments)
- process.cwd = expanded_directory
- process.io.stdout = process.io.stderr = write_io
+ # Build the command we're going to run
+ arguments = [ *runner, *@arguments ].compact.map(&:to_s) # all arguments must be a string
- @environment.each_pair do |key, value|
- process.environment[key] = value
+ # Build the ChildProcess
+ @logger.info("Starting process: #{arguments}")
+
+ process = ChildProcess.build(*arguments)
+ process.cwd = File.expand_path(@options[:directory] || Dir.pwd)
+
+ # Create the pipes so we can read the output in real tim
+ read_pipe, write_pipe = IO.pipe
+ process.io.stdout = write_pipe
+ process.io.stderr = write_pipe
+ process.duplex = true
+
+ # Set the environment on the process
+ if @options[:environment]
+ @options[:environment].each_pair do |key, value|
+ process.environment[key] = value
+ end
end
+ # Start the process
process.start
- write_io.close
+ # 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 writer right here, since we're
+ # not on the writing side.
+ write_pipe.close
+ end
+
+ # Record the start time for timeout purposes
+ start_time = Time.now.to_i
+
+ # Track the output as it goes
output = ""
- begin
- loop do
- chunk = read_io.readpartial(10240)
- cleaned_chunk = UTF8.clean(chunk)
- output << chunk
- yield cleaned_chunk if block_given?
+ @logger.debug("Selecting on IO")
+ while true
+ results = IO.select([read_pipe], nil, nil, timeout || 0.1) || []
+ readers = results[0]
+
+ # Check if we have exceeded our timeout
+ raise TimeoutExceeded if timeout && (Time.now.to_i - start_time) > timeout
+ # Kill the process and wait a bit for it to disappear
+ # Process.kill('KILL', process.pid)
+ # Process.waitpid2(process.pid)
+
+ # Check the readers to see if they're ready
+ if readers && !readers.empty?
+ readers.each do |r|
+ # Read from the IO object
+ data = read_io(r)
+
+ # We don't need to do anything if the data is empty
+ next if data.empty?
+
+ output << cleaned_data = UTF8.clean(data)
+ yield cleaned_data if block_given?
+ end
end
- rescue EOFError
+
+ # 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?
end
- process.wait
+ # 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}")
- # the final result!
- Result.new(output.chomp, process.exit_code)
+ process.poll_for_exit(remaining)
+ rescue ChildProcess::TimeoutError
+ raise TimeoutExceeded
+ 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.
+
+ # Read the extra data
+ extra_data = read_io(read_pipe)
+
+ # If there's some that we missed
+ if extra_data != ""
+ output << cleaned_data = UTF8.clean(extra_data)
+ yield cleaned_data if block_given?
+ end
+
+ if RUBY_PLATFORM == "java"
+ # On JRuby, we need to close the writers after the process,
+ # for some reason. See https://github.com/mitchellh/vagrant/pull/711
+ write_pipe.close
+ end
+
+ @output = output.chomp
+ @exit_status = process.exit_code
end
private
# on heroku, tty isn't avaiable. so we result to just running command through
@@ -65,10 +155,65 @@
[ File.join(Buildbox.gem_root, "bin", "buildbox-pty") ]
rescue
[ "bash", "-c" ]
end
- def expanded_directory
- File.expand_path(@directory)
+ # Reads data from an IO object while it can, returning the data it reads.
+ # When it encounters a case when it can't read anymore, it returns the
+ # data.
+ #
+ # @return [String]
+ def read_io(io)
+ data = ""
+
+ while true
+ begin
+ if Platform.windows?
+ # Windows doesn't support non-blocking reads on
+ # file descriptors or pipes so we have to get
+ # a bit more creative.
+
+ # Check if data is actually ready on this IO device.
+ # We have to do this since `readpartial` will actually block
+ # until data is available, which can cause blocking forever
+ # in some cases.
+ results = IO.select([io], nil, nil, 0.1)
+ break if !results || results[0].empty?
+
+ # Read!
+ data << io.readpartial(READ_CHUNK_SIZE)
+ else
+ # Do a simple non-blocking read on the IO object
+ data << io.read_nonblock(READ_CHUNK_SIZE)
+ end
+ rescue Exception => e
+ # The catch-all rescue here is to support multiple Ruby versions,
+ # since we use some Ruby 1.9 specific exceptions.
+
+ breakable = false
+ if e.is_a?(EOFError)
+ # An `EOFError` means this IO object is done!
+ breakable = true
+ elsif defined?(IO::WaitReadable) && e.is_a?(IO::WaitReadable)
+ # IO::WaitReadable is only available on Ruby 1.9+
+
+ # An IO::WaitReadable means there may be more IO but this
+ # IO object is not ready to be read from yet. No problem,
+ # we read as much as we can, so we break.
+ breakable = true
+ elsif e.is_a?(Errno::EAGAIN)
+ # Otherwise, we just look for the EAGAIN error which should be
+ # all that IO::WaitReadable does in Ruby 1.9.
+ breakable = true
+ end
+
+ # Break out if we're supposed to. Otherwise re-raise the error
+ # because it is a real problem.
+ break if breakable
+ raise
+ end
+ end
+
+ data
end
end
end