diff options
-rw-r--r-- | Rakefile | 5 | ||||
-rwxr-xr-x | bin/unicorn | 182 | ||||
-rwxr-xr-x | bin/unicorn-hello-world | 27 | ||||
-rw-r--r-- | test/exec/README | 5 | ||||
-rw-r--r-- | test/exec/test_exec.rb | 430 |
5 files changed, 601 insertions, 48 deletions
@@ -10,13 +10,14 @@ Echoe.new("unicorn") do |p| p.url = "http://unicorn.bogomips.org" p.rdoc_pattern = ['README', 'LICENSE', 'CONTRIBUTORS', 'CHANGELOG', 'COPYING', 'lib/**/*.rb', 'doc/**/*.rdoc'] p.ignore_pattern = /^(pkg|site|projects|doc|log)|CVS|\.log/ - p.extension_pattern = nil - p.need_tar_gz = false p.need_tgz = true p.extension_pattern = ["ext/**/extconf.rb"] + # Eric hasn't bothered to figure out running exec tests properly + # from Rake, but Eric prefers GNU make to Rake for tests anyways... + p.test_pattern = [ 'test/unit/test*.rb' ] end #### Ragel builder diff --git a/bin/unicorn b/bin/unicorn index 93441ae..f682311 100755 --- a/bin/unicorn +++ b/bin/unicorn @@ -1,27 +1,171 @@ #!/home/ew/bin/ruby STDIN.sync = STDOUT.sync = STDERR.sync = true -usage = "Usage: #{File.basename($0)} <config_file>" -require 'unicorn' -exit 0 if ARGV.size == 2 && ARGV[-1] == 'check' # used for reexec_check -ARGV.size == 1 or abort usage -case ARGV[0] -when 'check' then exit -when '-h' then puts usage -when '-v' then puts "unicorn v#{Unicorn::Const::UNICORN_VERSION}" +require 'unicorn' # require this first to populate Unicorn::DEFAULT_START_CTX +require 'rack' +require 'optparse' + +env = "development" +daemonize = false +listeners = [] +options = { :listeners => listeners } +host = Unicorn::Const::DEFAULT_HOST +port = Unicorn::Const::DEFAULT_PORT + +opts = OptionParser.new("", 24, ' ') do |opts| + opts.banner = "Usage: #{File.basename($0)} " \ + "[ruby options] [unicorn options] [rackup config file]" + + opts.separator "" + opts.separator "Ruby options:" + + lineno = 1 + opts.on("-e", "--eval LINE", "evaluate a LINE of code") do |line| + eval line, TOPLEVEL_BINDING, "-e", lineno + lineno += 1 + end + + opts.on("-d", "--debug", "set debugging flags (set $DEBUG to true)") do + $DEBUG = true + end + + opts.on("-w", "--warn", "turn warnings on for your script") do + $-w = true + end + + opts.on("-I", "--include PATH", + "specify $LOAD_PATH (may be used more than once)") do |path| + $LOAD_PATH.unshift(*path.split(/:/)) + end + + opts.on("-r", "--require LIBRARY", + "require the library, before executing your script") do |library| + require library + end + + opts.separator "" + opts.separator "Unicorn options:" + + # some of these switches exist for rackup command-line compatibility, + + opts.on("-o", "--host HOST", "listen on HOST (default: 0.0.0.0)") do |h| + warn "The --host/-o option is not recommended, see --listen/-l" + host = h + end + + opts.on("-p", "--port PORT", "use PORT (default: 8080)") do |p| + warn "The --port/-p option is not recommended, see --listen/-l" + port = p.to_i + end + + opts.on("-E", "--env ENVIRONMENT", + "use ENVIRONMENT for defaults (default: development)") do |e| + env = e + end + + opts.on("-D", "--daemonize", "run daemonized in the background") do |d| + daemonize = d ? true : false + end + + opts.on("-P", "--pid FILE", "file to store PID (default: none)") do |f| + options[:pid] = File.expand_path(f) + end + + # Unicorn-specific stuff + opts.on("-l", "--listen <address:port|/path/to/socket>", + "listen on address:port or UNIX socket " \ + "(default: 0.0.0.0:8080)" \ + " this may be specified multiple times") do |address| + listeners << address + end + + opts.on("-c", "--config-file FILE", "Unicorn-specific config file") do |f| + options[:config_file] = File.expand_path(f) + end + + # I'm avoiding Unicorn-specific config options on the command-line. + # IMNSHO, config options on the command-line are redundant given a + # config files and make things unnecessarily complicated with multiple + # places to look for a config option. + + opts.separator "" + opts.separator "Common options:" + + opts.on_tail("-h", "--help", "Show this message") do + puts opts + exit + end + + opts.on_tail("--version", "Show version") do + puts "unicorn v#{Unicorn::Const::UNICORN_VERSION}" + exit + end + + opts.parse! ARGV +end + +require 'pp' if $DEBUG + +config = ARGV[0] || "config.ru" +abort "configuration file #{config} not found" unless File.exist?(config) + +if config =~ /\.ru$/ + cfgfile = File.read(config) + # parse embedded command-line options in config.ru comments + if cfgfile[/^#\\(.*)/] + opts.parse! $1.split(/\s+/) + end + inner_app = eval "Rack::Builder.new {(#{cfgfile}\n)}.to_app", nil, config +else + require config + inner_app = Object.const_get(File.basename(config, '.rb').capitalize) +end + +app = case env +when "development" + Rack::Builder.new do + use Rack::CommonLogger, STDERR + use Rack::ShowExceptions + use Rack::Lint + run inner_app + end.to_app +when "deployment" + Rack::Builder.new do + use Rack::CommonLogger, STDERR + run inner_app + end.to_app else - File.readable?(ARGV[0]) && File.file?(ARGV[0]) or abort usage - config = eval(File.read(ARGV[0])) - config.kind_of?(Hash) or abort "config is not a hash: #{config.class}" - app = config.delete(:app) or abort "Missing :app key in config!" - - # only daemonize if we're not inheriting file descriptors from our parent - if ENV['UNICORN_DAEMONIZE'] && ! ENV['UNICORN_FD'] - # don't set umask(0000), chdir("/") or redirect STDOUT/STDERR since - # it's more flexible to handle that in the config (which is just Ruby) + inner_app +end + +if listeners.empty? + listener = "#{host}:#{port}" + listeners << listener if listener != Unicorn::Const::DEFAULT_LISTEN +end + +if $DEBUG + pp({ + :unicorn_options => options, + :app => app, + :inner_app => inner_app, + :daemonize => daemonize, + }) +end + +# only daemonize if we're not inheriting file descriptors from our parent +if daemonize + unless ENV['UNICORN_FD'] exit if fork Process.setsid exit if fork - STDIN.reopen("/dev/null") end - Unicorn.run(app, config) + + Dir.chdir("/") + File.umask(0000) + STDIN.reopen("/dev/null") + + # we can redirect these again in the Unicorn after_fork hook + STDOUT.reopen("/dev/null", "a") + STDERR.reopen("/dev/null", "a") end + +Unicorn.run(app, options) diff --git a/bin/unicorn-hello-world b/bin/unicorn-hello-world deleted file mode 100755 index e368344..0000000 --- a/bin/unicorn-hello-world +++ /dev/null @@ -1,27 +0,0 @@ -#!/home/ew/bin/ruby -# Simple "Hello World" application for Unicorn - -# Exec ourselves with unicorn. A shebang (e.g. "#!/usr/bin/unicorn") -# won't work since unicorn itself is a Ruby script with a shebang, but -# this does: -exec('unicorn', $0) if $0 == __FILE__ - -# Rack-compatible "Hello World" application -class HelloWorld - MSG = "Hello world!\n" - - def call(env) - [ 200, - { "Content-Type" => "text/plain", - "Content-Length" => MSG.size}, - [ MSG ] - ] - end -end - -# make sure this hash is the last statement, as this is eval-ed by unicorn -{ - # :listeners => %w(0.0.0.0:8080 127.0.0.1:7701 /tmp/test.sock), - # :hot_config_file => "/tmp/hot_config", - :app => HelloWorld.new, -} diff --git a/test/exec/README b/test/exec/README new file mode 100644 index 0000000..a1341f5 --- /dev/null +++ b/test/exec/README @@ -0,0 +1,5 @@ +These tests require the "unicorn" executable script to be installed in +PATH and rack being directly "require"-able ("rubygems" will not be +loaded for you). The tester is responsible for setting up RUBYLIB and +PATH environment variables (or running tests via GNU Make instead of +Rake). diff --git a/test/exec/test_exec.rb b/test/exec/test_exec.rb new file mode 100644 index 0000000..4e460e2 --- /dev/null +++ b/test/exec/test_exec.rb @@ -0,0 +1,430 @@ +# Copyright (c) 2009 Eric Wong +STDIN.sync = STDOUT.sync = STDERR.sync = true +require 'test/test_helper' +require 'pathname' +require 'tempfile' +require 'fileutils' + +do_test = true + +$unicorn_bin = ENV['UNICORN_TEST_BIN'] || "unicorn" +pid = fork { redirect_test_io { exec($unicorn_bin, '-v'); exit!(1) } } +Process.waitpid(pid) +unless $?.success? + STDERR.puts "#{$unicorn_bin} not found in PATH=#{ENV['PATH']}, "\ + "skipping this test" + do_test = false +end + +begin + require 'rack' +rescue LoadError + STDERR.puts "Unable to load Rack, skipping this test" + do_test = false +end + +class ExecTest < Test::Unit::TestCase + trap('QUIT', 'IGNORE') + + HI = <<-EOS +use Rack::ContentLength +run proc { |env| [ 200, { 'Content-Type' => 'text/plain' }, "HI\\n" ] } + EOS + + HELLO = <<-EOS +class Hello + def call(env) + [ 200, { 'Content-Type' => 'text/plain' }, "HI\\n" ] + end +end + EOS + + COMMON_TMP = Tempfile.new('unicorn_tmp') unless defined?(COMMON_TMP) + + HEAVY_CFG = <<-EOS +require 'fcntl' +worker_processes 4 +timeout 30 +backlog 1 +logger Logger.new('#{COMMON_TMP.path}') +before_fork do |server, worker_nr| + server.logger.info "before_fork: worker=\#{worker_nr}" +end +after_fork do |server, worker_nr| + trap('USR1') do # log rotation + server.logger.info "after_fork: worker=\#{worker_nr} rotating logs..." + ObjectSpace.each_object(File) do |fp| + next if fp.closed? || ! fp.sync + next unless (fp.fcntl(Fcntl::F_GETFL) & File::APPEND) == File::APPEND + begin + fp.stat.ino == File.stat(fp.path).ino + rescue Errno::ENOENT + end + fp.reopen(fp.path, "a") + fp.sync = true + end + server.logger.info "after_fork: worker=\#{worker_nr} done rotating logs" + end # trap('USR1') +end # after_fork + EOS + + def setup + @pwd = Dir.pwd + @tmpfile = Tempfile.new('unicorn_exec_test') + @tmpdir = @tmpfile.path + @tmpfile.close! + Dir.mkdir(@tmpdir) + Dir.chdir(@tmpdir) + @addr = ENV['UNICORN_TEST_ADDR'] || '127.0.0.1' + @port = unused_port(@addr) + @sockets = [] + end + + def teardown + Dir.chdir(@pwd) + FileUtils.rmtree(@tmpdir) + @sockets.each { |path| File.unlink(path) rescue nil } + loop do + Process.kill('-QUIT', 0) + begin + Process.waitpid(-1, Process::WNOHANG) or break + rescue Errno::ECHILD + break + end + end + end + + def test_basic + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + pid = fork do + redirect_test_io { exec($unicorn_bin, "-l", "#{@addr}:#{@port}") } + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + assert_shutdown(pid) + end + + def test_unicorn_config_listeners_overrides_cli + port2 = unused_port(@addr) + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + # listeners = [ ... ] => should _override_ command-line options + ucfg = Tempfile.new('unicorn_test_config') + ucfg.syswrite("listeners %w(#{@addr}:#{@port})\n") + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-c#{ucfg.path}", "-l#{@addr}:#{port2}") + end + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_raises(Errno::ECONNREFUSED) { TCPSocket.new(@addr, port2) } + assert_equal String, results[0].class + assert_shutdown(pid) + end + + def test_unicorn_config_listen_augments_cli + port2 = unused_port(@addr) + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + ucfg = Tempfile.new('unicorn_test_config') + ucfg.syswrite("listen '#{@addr}:#{@port}'\n") + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-c#{ucfg.path}", "-l#{@addr}:#{port2}") + end + end + uris = [@port, port2].map { |i| "http://#{@addr}:#{i}/" } + results = retry_hit(uris) + assert_equal results.size, uris.size + assert_equal String, results[0].class + assert_equal String, results[1].class + assert_shutdown(pid) + end + + def test_weird_config_settings + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + ucfg = Tempfile.new('unicorn_test_config') + ucfg.syswrite(HEAVY_CFG) + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-c#{ucfg.path}", "-l#{@addr}:#{@port}") + end + end + + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + wait_master_ready(COMMON_TMP.path) + bf = File.readlines(COMMON_TMP.path).grep(/\bbefore_fork: worker=/) + assert_equal 4, bf.size + rotate = Tempfile.new('unicorn_rotate') + assert_nothing_raised do + File.rename(COMMON_TMP.path, rotate.path) + Process.kill('USR1', pid) + end + wait_for_file(COMMON_TMP.path) + assert File.exist?(COMMON_TMP.path), "#{COMMON_TMP.path} exists" + # USR1 should've been passed to all workers + tries = 100 + log = File.readlines(rotate.path) + while (tries -= 1) > 0 && log.grep(/rotating logs\.\.\./).size < 4 + sleep 0.1 + log = File.readlines(rotate.path) + end + assert_equal 4, log.grep(/rotating logs\.\.\./).size + assert_equal 0, log.grep(/done rotating logs/).size + + tries = 100 + log = File.readlines(COMMON_TMP.path) + while (tries -= 1) > 0 && log.grep(/done rotating logs/).size < 4 + sleep 0.1 + log = File.readlines(COMMON_TMP.path) + end + assert_equal 4, log.grep(/done rotating logs/).size + assert_equal 0, log.grep(/rotating logs\.\.\./).size + assert_nothing_raised { Process.kill('QUIT', pid) } + status = nil + assert_nothing_raised { pid, status = Process.waitpid2(pid) } + assert status.success?, "exited successfully" + end + + def test_read_embedded_cli_switches + File.open("config.ru", "wb") do |fp| + fp.syswrite("#\\ -p #{@port} -o #{@addr}\n") + fp.syswrite(HI) + end + pid = fork { redirect_test_io { exec($unicorn_bin) } } + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + assert_shutdown(pid) + end + + def test_config_ru_alt_path + config_path = "#{@tmpdir}/foo.ru" + File.open(config_path, "wb") { |fp| fp.syswrite(HI) } + pid = fork do + redirect_test_io do + Dir.chdir("/") + exec($unicorn_bin, "-l#{@addr}:#{@port}", config_path) + end + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + assert_shutdown(pid) + end + + def test_load_module + libdir = "#{@tmpdir}/lib" + FileUtils.mkpath([ libdir ]) + config_path = "#{libdir}/hello.rb" + File.open(config_path, "wb") { |fp| fp.syswrite(HELLO) } + pid = fork do + redirect_test_io do + Dir.chdir("/") + exec($unicorn_bin, "-l#{@addr}:#{@port}", config_path) + end + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + assert_shutdown(pid) + end + + def test_reexec + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + pid_file = "#{@tmpdir}/test.pid" + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-l#{@addr}:#{@port}", "-P#{pid_file}") + end + end + reexec_basic_test(pid, pid_file) + end + + def test_reexec_alt_config + config_file = "#{@tmpdir}/foo.ru" + File.open(config_file, "wb") { |fp| fp.syswrite(HI) } + pid_file = "#{@tmpdir}/test.pid" + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-l#{@addr}:#{@port}", "-P#{pid_file}", config_file) + end + end + reexec_basic_test(pid, pid_file) + end + + def test_unicorn_config_file + pid_file = "#{@tmpdir}/test.pid" + sock = Tempfile.new('unicorn_test_sock') + sock_path = sock.path + sock.close! + @sockets << sock_path + + log = Tempfile.new('unicorn_test_log') + ucfg = Tempfile.new('unicorn_test_config') + ucfg.syswrite("listen \"#{sock_path}\"\n") + ucfg.syswrite("pid \"#{pid_file}\"\n") + ucfg.syswrite("logger Logger.new('#{log.path}')\n") + ucfg.close + + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-l#{@addr}:#{@port}", + "-P#{pid_file}", "-c#{ucfg.path}") + end + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + wait_master_ready(log.path) + assert File.exist?(pid_file), "pid_file created" + assert_equal pid, File.read(pid_file).to_i + assert File.socket?(sock_path), "socket created" + assert_nothing_raised do + sock = UNIXSocket.new(sock_path) + sock.syswrite("GET / HTTP/1.0\r\n\r\n") + results = sock.sysread(4096) + end + assert_equal String, results.class + + # try reloading the config + sock = Tempfile.new('unicorn_test_sock') + new_sock_path = sock.path + @sockets << new_sock_path + sock.close! + new_log = Tempfile.new('unicorn_test_log') + new_log.sync = true + assert_equal 0, new_log.size + + assert_nothing_raised do + ucfg = File.open(ucfg.path, "wb") + ucfg.syswrite("listen \"#{new_sock_path}\"\n") + ucfg.syswrite("pid \"#{pid_file}\"\n") + ucfg.syswrite("logger Logger.new('#{new_log.path}')\n") + ucfg.close + Process.kill('HUP', pid) + end + + wait_for_file(new_sock_path) + assert File.socket?(new_sock_path), "socket exists" + @sockets.each do |path| + assert_nothing_raised do + sock = UNIXSocket.new(path) + sock.syswrite("GET / HTTP/1.0\r\n\r\n") + results = sock.sysread(4096) + end + assert_equal String, results.class + end + + assert_not_equal 0, new_log.size + reexec_usr2_quit_test(pid, pid_file) + end + + def test_daemonize_reexec + pid_file = "#{@tmpdir}/test.pid" + log = Tempfile.new('unicorn_test_log') + ucfg = Tempfile.new('unicorn_test_config') + ucfg.syswrite("pid \"#{pid_file}\"\n") + ucfg.syswrite("logger Logger.new('#{log.path}')\n") + ucfg.close + + File.open("config.ru", "wb") { |fp| fp.syswrite(HI) } + pid = fork do + redirect_test_io do + exec($unicorn_bin, "-D", "-l#{@addr}:#{@port}", "-c#{ucfg.path}") + end + end + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + wait_for_file(pid_file) + new_pid = File.read(pid_file).to_i + assert_not_equal pid, new_pid + pid, status = Process.waitpid2(pid) + assert status.success?, "original process exited successfully" + assert_nothing_raised { Process.kill(0, new_pid) } + reexec_usr2_quit_test(new_pid, pid_file) + end + + private + + # sometimes the server may not come up right away + def retry_hit(uris = []) + tries = 100 + begin + hit(uris) + rescue Errno::ECONNREFUSED => err + if (tries -= 1) > 0 + sleep 0.1 + retry + end + raise err + end + end + + def assert_shutdown(pid) + wait_master_ready("#{@tmpdir}/test_stderr.#{pid}.log") + assert_nothing_raised { Process.kill('QUIT', pid) } + status = nil + assert_nothing_raised { pid, status = Process.waitpid2(pid) } + assert status.success?, "exited successfully" + end + + def wait_master_ready(master_log) + tries = 100 + while (tries -= 1) > 0 + begin + File.readlines(master_log).grep(/master process ready/)[0] and return + rescue Errno::ENOENT + end + sleep 0.1 + end + raise "master process never became ready" + end + + def reexec_usr2_quit_test(pid, pid_file) + assert File.exist?(pid_file), "pid file OK" + assert ! File.exist?("#{pid_file}.oldbin"), "oldbin pid file" + assert_nothing_raised { Process.kill('USR2', pid) } + assert_nothing_raised { retry_hit(["http://#{@addr}:#{@port}/"]) } + wait_for_file("#{pid_file}.oldbin") + wait_for_file(pid_file) + + # kill old master process + assert_not_equal pid, File.read(pid_file).to_i + assert_equal pid, File.read("#{pid_file}.oldbin").to_i + assert_nothing_raised { Process.kill('QUIT', pid) } + assert_not_equal pid, File.read(pid_file).to_i + assert_nothing_raised { retry_hit(["http://#{@addr}:#{@port}/"]) } + wait_for_file(pid_file) + assert_nothing_raised { retry_hit(["http://#{@addr}:#{@port}/"]) } + assert_nothing_raised { Process.kill('QUIT', File.read(pid_file).to_i) } + end + + def reexec_basic_test(pid, pid_file) + results = retry_hit(["http://#{@addr}:#{@port}/"]) + assert_equal String, results[0].class + assert_nothing_raised { Process.kill(0, pid) } + master_log = "#{@tmpdir}/test_stderr.#{pid}.log" + wait_master_ready(master_log) + File.truncate(master_log, 0) + nr = 50 + kill_point = 2 + assert_nothing_raised do + nr.times do |i| + hit(["http://#{@addr}:#{@port}/#{i}"]) + i == kill_point and Process.kill('HUP', pid) + end + end + wait_master_ready(master_log) + assert File.exist?(pid_file), "pid=#{pid_file} exists" + new_pid = File.read(pid_file).to_i + assert_not_equal pid, new_pid + assert_nothing_raised { Process.kill(0, new_pid) } + assert_nothing_raised { Process.kill('QUIT', new_pid) } + end + + def wait_for_file(path) + tries = 1000 + while (tries -= 1) > 0 && ! File.exist?(path) + sleep 0.1 + end + assert File.exist?(path), "path=#{path} exists" + end + +end if do_test |