Switch posix-spawn to childprocess for better cross-OS support

This commit is contained in:
Mitchell Hashimoto 2011-11-21 21:16:51 -08:00
parent 2174d02439
commit 203056a0db
3 changed files with 79 additions and 49 deletions

View File

@ -2,7 +2,7 @@ require 'digest/sha1'
require 'pathname' require 'pathname'
require 'yaml' require 'yaml'
require 'posix-spawn' require 'childprocess'
require 'vagrant/util/file_checksum' require 'vagrant/util/file_checksum'
@ -39,9 +39,11 @@ namespace :acceptance do
# TODO: This isn't Windows friendly yet. Move to a OS-independent # TODO: This isn't Windows friendly yet. Move to a OS-independent
# download. # download.
pid = POSIX::Spawn.spawn("wget", box["url"], "-O", box_file.to_s) process = ChildProcess.build("wget", box["url"], "-O", box_file.to_s)
pid, status = Process.waitpid2(pid) process.io.inherit!
if status.exitstatus != 0 process.start
process.poll_for_exit(64000)
if process.exit_code != 0
puts "Download failed!" puts "Download failed!"
abort abort
end end

View File

@ -1,8 +1,9 @@
require "fileutils" require "fileutils"
require "pathname" require "pathname"
require "tempfile"
require "log4r" require "log4r"
require "posix-spawn" require "childprocess"
require File.expand_path("../tempdir", __FILE__) require File.expand_path("../tempdir", __FILE__)
require File.expand_path("../virtualbox", __FILE__) require File.expand_path("../virtualbox", __FILE__)
@ -12,8 +13,6 @@ module Acceptance
# run in. It creates a temporary directory to act as the # run in. It creates a temporary directory to act as the
# working directory as well as sets a custom home directory. # working directory as well as sets a custom home directory.
class IsolatedEnvironment class IsolatedEnvironment
include POSIX::Spawn
attr_reader :homedir attr_reader :homedir
attr_reader :workdir attr_reader :workdir
@ -54,31 +53,46 @@ module Acceptance
def execute(command, *argN) def execute(command, *argN)
command = replace_command(command) command = replace_command(command)
# Setup the options that will be passed to the ``popen4`` # Get the hash options passed to this method
# method. options = argN.last.is_a?(Hash) ? argN.pop : {}
argN << {} if !argN.last.is_a?(Hash)
options = argN.last
options[:chdir] ||= @workdir.to_s
# Determine the timeout for the process
timeout = options.delete(:timeout) timeout = options.delete(:timeout)
# Execute in a separate process, wait for it to complete, and # Build a child process to run this command. For the stdout/stderr
# return the IO streams. # we use pipes so that we can select() on it and block and stream
# data in as it comes.
@logger.info("Executing: #{command} #{argN.inspect}. Output will stream in...") @logger.info("Executing: #{command} #{argN.inspect}. Output will stream in...")
pid, stdin, stdout, stderr = popen4(@env, command, *argN) process = ChildProcess.build(command, *argN)
status = nil stdout, stdout_writer = IO.pipe
process.io.stdout = stdout_writer
io_data = { stderr, stderr_writer = IO.pipe
stdout => "", process.io.stderr = stderr_writer
stderr => "" process.duplex = true
}
Dir.chdir(@workdir.to_s) do
with_env_changes do
process.start
process.io.stdin.sync = true
end
end
# Close our side of the pipes, since we're just reading
stdout_writer.close
stderr_writer.close
# Create a hash to store all the data we see.
io_data = { stdout => "", stderr => "" }
# Record the start time for timeout purposes # Record the start time for timeout purposes
start_time = Time.now.to_i start_time = Time.now.to_i
while results = IO.select([stdout, stderr], [stdin], nil, timeout || 5) @logger.debug("Selecting on IO...")
raise TimeoutExceeded, pid if timeout && (Time.now.to_i - start_time) > timeout while true
results = IO.select([stdout, stderr],
[process.io.stdin], nil, timeout || 5)
# Check if we have exceeded our timeout from waiting on a select()
raise TimeoutExceeded, process.pid if timeout && (Time.now.to_i - start_time) > timeout
# Check the readers first to see if they're ready # Check the readers first to see if they're ready
readers = results[0] readers = results[0]
@ -87,7 +101,6 @@ module Acceptance
readers.each do |r| readers.each do |r|
data = r.readline data = r.readline
io_data[r] += data io_data[r] += data
io_name = r == stdout ? "stdout" : "stderr" io_name = r == stdout ? "stdout" : "stderr"
@logger.debug("[#{io_name}] #{data.chomp}") @logger.debug("[#{io_name}] #{data.chomp}")
yield io_name.to_sym, data if block_given? yield io_name.to_sym, data if block_given?
@ -98,38 +111,31 @@ module Acceptance
end end
end end
# Check here if the process has exited, and if so, exit the # Check if the process exited in order to break the loop before
# loop. # we try to see if any stdin is ready.
exit_pid, status = Process.waitpid2(pid, Process::WNOHANG) break if process.exited?
break if exit_pid
# Check the writers to see if they're ready, and notify any # Check the writers to see if they're ready, and notify any listeners
# listeners...
if !results[1].empty? if !results[1].empty?
yield :stdin, stdin if block_given? yield :stdin, process.io.stdin if block_given?
end end
end end
# Continually try to wait for the process to end, but do so asynchronously # Continually try to wait for the process to end, but do so asynchronously
# so that we can also check to see if we have exceeded a timeout. # so that we can also check to see if we have exceeded a timeout.
while true begin
# Break if status because it was already obtained above # If a timeout is not set, we set a very large timeout to
break if status # simulate "forever"
@logger.debug("Waiting for process to exit...")
# Try to wait for the PID to exit, and exit this loop if it does remaining = (timeout || 32000) - (Time.now.to_i - start_time)
exitpid, status = Process.waitpid2(pid, Process::WNOHANG) remaining = 0 if remaining < 0
break if exitpid process.poll_for_exit(remaining)
rescue ChildProcess::TimeoutError
# Check to see if we exceeded our process timeout while waiting for raise TimeoutExceeded, process.pid
# it to end.
raise TimeoutExceeded, pid if timeout && (Time.now.to_i - start_time) > timeout
# Sleep between checks so that we're not constantly hitting the syscall
sleep 0.5
end end
@logger.debug("Exit status: #{status.exitstatus}")
return ExecuteProcess.new(status.exitstatus, io_data[stdout], io_data[stderr]) @logger.debug("Exit status: #{process.exit_code}")
return ExecuteProcess.new(process.exit_code, io_data[stdout], io_data[stderr])
end end
# Closes the environment, cleans up the temporary directories, etc. # Closes the environment, cleans up the temporary directories, etc.
@ -182,6 +188,28 @@ module Acceptance
return @apps[command] if @apps.has_key?(command) return @apps[command] if @apps.has_key?(command)
return command return command
end end
# This method changes the environmental variables of the process to
# that of this environment, yields, and then resets them. This allows
# us to change the environment temporarily.
#
# NOTE: NOT threadsafe.
def with_env_changes
stashed = {}
begin
@env.each do |key, value|
stashed[key] = ENV[key]
ENV[key] = value
end
yield
ensure
stashed.each do |key, value|
ENV[key] = stashed[key]
end
end
end
end end
# This class represents a process which has run via the IsolatedEnvironment. # This class represents a process which has run via the IsolatedEnvironment.

View File

@ -28,7 +28,7 @@ Gem::Specification.new do |s|
s.add_development_dependency "log4r", "~> 1.1.9" s.add_development_dependency "log4r", "~> 1.1.9"
s.add_development_dependency "minitest", "~> 2.5.1" s.add_development_dependency "minitest", "~> 2.5.1"
s.add_development_dependency "mocha" s.add_development_dependency "mocha"
s.add_development_dependency "posix-spawn", "~> 0.3.6" s.add_development_dependency "childprocess", "~> 0.2.2"
s.add_development_dependency "sys-proctable", "~> 0.9.1" s.add_development_dependency "sys-proctable", "~> 0.9.1"
s.add_development_dependency "rspec-core", "~> 2.7.1" s.add_development_dependency "rspec-core", "~> 2.7.1"
s.add_development_dependency "rspec-expectations", "~> 2.7.0" s.add_development_dependency "rspec-expectations", "~> 2.7.0"