about summary refs log tree commit homepage
path: root/test
diff options
context:
space:
mode:
authorEric Wong <normalperson@yhbt.net>2013-10-18 10:28:18 +0000
committerEric Wong <normalperson@yhbt.net>2013-10-18 10:28:18 +0000
commitab067831e707b191d6dfdcd01de1f1d85fc90d05 (patch)
treeb02861eb1521fb325ee4e1d91e1a194ca73e7a9e /test
downloadyahns-ab067831e707b191d6dfdcd01de1f1d85fc90d05.tar.gz
Diffstat (limited to 'test')
-rw-r--r--test/covshow.rb29
-rw-r--r--test/helper.rb115
-rw-r--r--test/server_helper.rb64
-rw-r--r--test/test_bin.rb98
-rw-r--r--test/test_config.rb56
-rw-r--r--test/test_output_buffering.rb288
-rw-r--r--test/test_queue.rb59
-rw-r--r--test/test_rack.rb26
-rw-r--r--test/test_serve_static.rb42
-rw-r--r--test/test_server.rb382
-rw-r--r--test/test_stream_file.rb30
-rw-r--r--test/test_wbuf.rb136
12 files changed, 1325 insertions, 0 deletions
diff --git a/test/covshow.rb b/test/covshow.rb
new file mode 100644
index 0000000..2fd48c6
--- /dev/null
+++ b/test/covshow.rb
@@ -0,0 +1,29 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# this works with the __covmerge method in test/helper.rb
+# run this file after all tests are run
+
+# load the merged dump data
+res = Marshal.load(IO.binread("coverage.dump"))
+
+# Dirty little text formatter.  I tried simplecov but the default
+# HTML+JS is unusable without a GUI (I hate GUIs :P) and it would've
+# taken me longer to search the Internets to find a plain-text
+# formatter I like...
+res.keys.sort.each do |filename|
+  cov = res[filename]
+  puts "==> #{filename} <=="
+  File.readlines(filename).each_with_index do |line, i|
+    n = cov[i]
+    if n == 0 # BAD
+      print("  *** 0 #{line}")
+    elsif n
+      printf("% 7u %s", n, line)
+    elsif line =~ /\S/ # probably a line with just "end" in it
+      print("        #{line}")
+    else # blank line
+      print "\n" # don't output trailing whitespace on blank lines
+    end
+  end
+end
diff --git a/test/helper.rb b/test/helper.rb
new file mode 100644
index 0000000..ab9a04f
--- /dev/null
+++ b/test/helper.rb
@@ -0,0 +1,115 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+$stdout.sync = $stderr.sync = Thread.abort_on_exception = true
+require 'thread'
+
+# Global Test Lock, to protect:
+#   Process.wait*, Dir.chdir, ENV, trap, require, etc...
+GTL = Mutex.new
+
+# fork-aware coverage data gatherer, see also test/covshow.rb
+if ENV["COVERAGE"]
+  require "coverage"
+  COVMATCH = %r{/lib/yahns\b.*rb\z}
+  COVTMP = File.open("coverage.dump", IO::CREAT|IO::RDWR)
+  COVTMP.binmode
+  COVTMP.sync = true
+
+  def __covmerge
+    res = Coverage.result
+
+    # we own this file (at least until somebody tries to use NFS :x)
+    COVTMP.flock(File::LOCK_EX)
+
+    COVTMP.rewind
+    prev = COVTMP.read
+    prev = prev.empty? ? {} : Marshal.load(prev)
+    res.each do |filename, counts|
+      # filter out stuff that's not in our project
+      COVMATCH =~ filename or next
+
+      merge = prev[filename] || []
+      merge = merge
+      counts.each_with_index do |count, i|
+        count or next
+        merge[i] = (merge[i] || 0) + count
+      end
+      prev[filename] = merge
+    end
+    COVTMP.rewind
+    COVTMP.truncate(0)
+    COVTMP.write(Marshal.dump(prev))
+    COVTMP.flock(File::LOCK_UN)
+  end
+
+  Coverage.start
+  at_exit { at_exit { __covmerge } }
+end
+
+gem 'minitest'
+require 'minitest/autorun'
+require "tempfile"
+
+Testcase = begin
+  Minitest::Test # minitest 5
+rescue NameError
+  Minitest::Unit::TestCase # minitest 4
+end
+
+FIFOS = []
+def tmpfifo
+  tmp = Tempfile.new(%w(yahns-test .fifo))
+  path = tmp.path
+  tmp.close!
+  assert system(*%W(mkfifo #{path})), "mkfifo #{path}"
+
+  GTL.synchronize do
+    if FIFOS.empty?
+      at_exit do
+        FIFOS.each { |(pid,_path)| File.unlink(_path) if $$ == pid }
+      end
+    end
+    FIFOS << [ $$, path ]
+  end
+  path
+end
+
+require 'tmpdir'
+class Dir
+  require 'fileutils'
+  def Dir.mktmpdir
+    begin
+      d = "#{Dir.tmpdir}/#$$.#{rand}"
+      Dir.mkdir(d)
+    rescue Errno::EEXIST
+    end while true
+    begin
+      yield d
+    ensure
+      FileUtils.remove_entry(d)
+    end
+  end
+end unless Dir.respond_to?(:mktmpdir)
+
+def tmpfile(*args)
+  tmp = Tempfile.new(*args)
+  tmp.sync = true
+  tmp.binmode
+  tmp
+end
+
+require 'io/wait'
+# needed for Rubinius 2.0.0, we only use IO#nread in tests
+class IO
+  # this ignores buffers
+  def nread
+    buf = "\0" * 8
+    ioctl(0x541B, buf)
+    buf.unpack("l_")[0]
+  end
+end if ! IO.method_defined?(:nread) && RUBY_PLATFORM =~ /linux/
+
+require 'yahns'
+
+# needed for parallel (MT) tests)
+require 'yahns/rack'
diff --git a/test/server_helper.rb b/test/server_helper.rb
new file mode 100644
index 0000000..78d2f94
--- /dev/null
+++ b/test/server_helper.rb
@@ -0,0 +1,64 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'timeout'
+require 'socket'
+require 'net/http'
+
+module ServerHelper
+  def check_err(err = @err)
+    err = File.open(err.path, "r") if err.respond_to?(:path)
+    err.rewind
+    lines = err.readlines.delete_if { |l| l =~ /INFO/ }
+    assert lines.empty?, lines.join("\n")
+    err.close
+  end
+
+  def poke_until_dead(pid)
+    Timeout.timeout(10) do
+      begin
+        Process.kill(0, pid)
+        sleep(0.01)
+      rescue Errno::ESRCH
+        break
+      end while true
+    end
+    assert_raises(Errno::ESRCH) { Process.kill(0, pid) }
+  end
+
+  def quit_wait(pid)
+    pid or return
+    Process.kill(:QUIT, pid)
+    _, status = Timeout.timeout(10) { Process.waitpid2(pid) }
+    assert status.success?, status.inspect
+  rescue Timeout::Error
+    if RUBY_PLATFORM =~ /linux/
+      system("lsof -p #{pid}")
+      warn "#{pid} failed to die, waiting for user to inspect"
+      sleep
+    end
+    raise
+  end
+
+  def get_tcp_client(host, port, tries = 500)
+    begin
+      c = TCPSocket.new(host, port)
+      return c
+    rescue Errno::ECONNREFUSED
+      raise if tries < 0
+      tries -= 1
+    end while sleep(0.01)
+  end
+
+  def server_helper_teardown
+    @srv.close unless @srv.closed?
+    @ru.close! if @ru
+    check_err
+  end
+
+  def server_helper_setup
+    @srv = TCPServer.new(ENV["TEST_HOST"] || "127.0.0.1", 0)
+    @err = tmpfile(%w(srv .err))
+    @ru = nil
+  end
+end
diff --git a/test/test_bin.rb b/test/test_bin.rb
new file mode 100644
index 0000000..4a47a93
--- /dev/null
+++ b/test/test_bin.rb
@@ -0,0 +1,98 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'server_helper'
+class TestBin < Testcase
+  parallelize_me!
+  include ServerHelper
+  alias teardown server_helper_teardown
+
+  def setup
+    server_helper_setup
+    @cmd = %W(ruby -I lib bin/yahns)
+  end
+
+  def test_bin_daemon_noworker_inherit
+    bin_daemon(false, true)
+  end
+
+  def test_bin_daemon_worker_inherit
+    bin_daemon(true, true)
+  end
+
+  def test_bin_daemon_noworker_bind
+    bin_daemon(false, false)
+  end
+
+  def test_bin_daemon_worker_bind
+    bin_daemon(true, false)
+  end
+
+  def bin_daemon(worker, inherit)
+    @srv.close unless inherit
+    @pid = tmpfile(%w(test_bin_daemon .pid))
+    @ru = tmpfile(%w(test_bin_daemon .ru))
+    @ru.write("require 'rack/lobster'; run Rack::Lobster.new\n")
+    cfg = tmpfile(%w(test_bin_daemon_conf .rb))
+    pid = tmpfile(%w(daemon .pid))
+    cfg.puts "pid '#{@pid.path}'"
+    cfg.puts "stderr_path '#{@err.path}'"
+    cfg.puts "worker_processes 1" if worker
+    cfg.puts "app(:rack, '#{@ru.path}', preload: false) do"
+    cfg.puts "  listen ENV['YAHNS_TEST_LISTEN']"
+    cfg.puts "end"
+    @cmd.concat(%W(-D -c #{cfg.path}))
+    addr = IO.pipe
+    pid = fork do
+      if inherit
+        @cmd << { @srv.fileno => @srv }
+        ENV["YAHNS_FD"] = @srv.fileno.to_s
+      else
+        @srv = TCPServer.new(ENV["TEST_HOST"] || "127.0.0.1", 0)
+      end
+      host, port = @srv.addr[3], @srv.addr[1]
+      listen = ENV["YAHNS_TEST_LISTEN"] = "#{host}:#{port}"
+      addr[1].write(listen)
+      addr[1].close
+      addr[0].close
+      exec(*@cmd)
+    end
+    addr[1].close
+    listen = Timeout.timeout(10) { addr[0].read }
+    addr[0].close
+    host, port = listen.split(/:/, 2)
+    port = port.to_i
+    assert_operator port, :>, 0
+
+    unless inherit
+      # daemon_pipe guarantees socket will be usable after this:
+      Timeout.timeout(10) do # Ruby startup is slow!
+        _, status = Process.waitpid2(pid)
+        assert status.success?, status.inspect
+      end
+    end
+
+    Net::HTTP.start(host, port) do |http|
+      req = Net::HTTP::Get.new("/")
+      res = http.request(req)
+      assert_equal 200, res.code.to_i
+      assert_equal "keep-alive", res["Connection"]
+    end
+  rescue => e
+    warn "#{e.message} (#{e.class})"
+    e.backtrace.each { |l| warn "#{l}" }
+    raise
+  ensure
+    cfg.close! if cfg
+    pid = File.read(@pid.path)
+    pid = pid.to_i
+    assert_operator pid, :>, 0
+    Process.kill(:QUIT, pid)
+    if inherit
+      _, status = Timeout.timeout(10) { Process.waitpid2(pid) }
+      assert status.success?, status.inspect
+    else
+      poke_until_dead pid
+    end
+    @pid.close! if @pid
+  end
+end
diff --git a/test/test_config.rb b/test/test_config.rb
new file mode 100644
index 0000000..2afcecb
--- /dev/null
+++ b/test/test_config.rb
@@ -0,0 +1,56 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'rack/lobster'
+
+class TestConfig < Testcase
+  parallelize_me!
+
+  def test_initialize
+    cfg = Yahns::Config.new
+    assert_instance_of Yahns::Config, cfg
+  end
+
+  def test_multi_conf_example
+    tmpdir = Dir.mktmpdir
+
+    # modify the example config file for testing
+    path = "examples/yahns_multi.conf.rb"
+    cfgs = File.read(path)
+    cfgs.gsub!(%r{/path/to/}, "#{tmpdir}/")
+    conf = File.open("#{tmpdir}/yahns_multi.conf.rb", "w")
+    conf.sync = true
+    conf.write(cfgs)
+    File.open("#{tmpdir}/another.ru", "w") do |fp|
+      fp.puts("run Rack::Lobster.new\n")
+    end
+    FileUtils.mkpath("#{tmpdir}/another")
+
+    cfg = GTL.synchronize { Yahns::Config.new(conf.path) }
+    assert_instance_of Yahns::Config, cfg
+  ensure
+    FileUtils.rm_rf(tmpdir) if tmpdir
+  end
+
+  def test_rack_basic_conf_example
+    tmpdir = Dir.mktmpdir
+
+    # modify the example config file for testing
+    path = "examples/yahns_rack_basic.conf.rb"
+    cfgs = File.read(path)
+    cfgs.gsub!(%r{/path/to/}, "#{tmpdir}/")
+    Dir.mkdir("#{tmpdir}/my_app")
+    Dir.mkdir("#{tmpdir}/my_logs")
+    Dir.mkdir("#{tmpdir}/my_pids")
+    conf = File.open("#{tmpdir}/yahns_rack_basic.conf.rb", "w")
+    conf.sync = true
+    conf.write(cfgs)
+    File.open("#{tmpdir}/my_app/config.ru", "w") do |fp|
+      fp.puts("run Rack::Lobster.new\n")
+    end
+    cfg = GTL.synchronize { Yahns::Config.new(conf.path) }
+    assert_instance_of Yahns::Config, cfg
+  ensure
+    FileUtils.rm_rf(tmpdir) if tmpdir
+  end
+end
diff --git a/test/test_output_buffering.rb b/test/test_output_buffering.rb
new file mode 100644
index 0000000..6fe22ba
--- /dev/null
+++ b/test/test_output_buffering.rb
@@ -0,0 +1,288 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'server_helper'
+require 'digest/md5'
+require 'rack/file'
+
+class TestOutputBuffering < Testcase
+  parallelize_me!
+  include ServerHelper
+  alias setup server_helper_setup
+  alias teardown server_helper_teardown
+
+  GPLv3 = File.read("COPYING")
+  RAND = IO.binread("/dev/urandom", 666) * 119
+  dig = Digest::MD5.new
+  NR = 1337
+  MD5 = Thread.new do
+    NR.times { dig << RAND }
+    dig.hexdigest
+  end
+
+  class BigBody
+    def each
+      NR.times { yield RAND }
+    end
+  end
+
+  def test_output_buffer_false_curl
+    output_buffer(false, :curl)
+  end
+
+  def test_output_buffer_false_http09
+    output_buffer(false, :http09)
+  end
+
+  def test_output_buffer_true_curl
+    output_buffer(true, :curl)
+  end
+
+  def test_output_buffer_true_http09
+    output_buffer(true, :http09)
+  end
+
+  def output_buffer(btype, check_type, delay = 4)
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    len = (RAND.size * NR).to_s
+    cfg.instance_eval do
+      ru = lambda do |e|
+        [ 200, {'Content-Length'=>len}, BigBody.new ]
+      end
+      GTL.synchronize do
+        app(:rack, ru) do
+          listen "#{host}:#{port}"
+          output_buffering btype
+        end
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+
+    case check_type
+    when :curl
+      # curl is faster for piping gigantic wads of data than Net::HTTP
+      sh_sleep = delay ? "sleep #{delay} && " : ""
+      md5 = `curl -sSf http://#{host}:#{port}/ | (#{sh_sleep} md5sum)`
+      assert $?.success?, $?.inspect
+      (md5 =~ /([a-f0-9]{32})/i) or raise "bad md5: #{md5.inspect}"
+      md5 = $1
+      assert_equal MD5.value, md5
+    when :http09
+      # HTTP/0.9
+      c = TCPSocket.new(host, port)
+      c.write("GET /\r\n\r\n")
+      md5in = IO.pipe
+      md5out = IO.pipe
+      sleep(delay) if delay
+      md5pid = Process.spawn("md5sum", :in => md5in[0], :out => md5out[1])
+      md5in[0].close
+      md5out[1].close
+      assert_equal(NR * RAND.size, IO.copy_stream(c, md5in[1]))
+      c.close
+      md5in[1].close
+      _, status = Timeout.timeout(10) { Process.waitpid2(md5pid) }
+      assert status.success?, status.inspect
+      md5 = md5out[0].read
+      (md5 =~ /([a-f0-9]{32})/i) or raise "bad md5: #{md5.inspect}"
+      md5 = $1
+      assert_equal MD5.value, md5
+      md5out[0].close
+    else
+      raise "TESTBUG"
+    end
+  rescue => e
+    Yahns::Log.exception(Logger.new($stderr), "test", e)
+    raise
+  ensure
+    quit_wait(pid)
+  end
+
+  class BigHeader
+    A = "A" * 65536
+    def initialize(h)
+      @h = h
+    end
+    def each
+      NR.times do |n|
+        yield("X-#{n}", A)
+      end
+      @h.each { |k,v| yield(k,v) }
+    end
+  end
+
+  def test_big_header
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda do |e|
+        case e["PATH_INFO"]
+        when "/COPYING"
+          Rack::File.new(Dir.pwd).call(e)
+          gplv3 = File.open("COPYING")
+          def gplv3.each
+            raise "SHOULD NOT BE CALLED"
+          end
+          size = gplv3.stat.size
+          len = size.to_s
+          ranges = Rack::Utils.byte_ranges(e, size)
+          status = 200
+          h = { "Content-Type" => "text/plain", "Content-Length" => len }
+          if ranges && ranges.size == 1
+            status = 206
+            range = ranges[0]
+            h["Content-Range"] = "bytes #{range.begin}-#{range.end}/#{size}"
+            size = range.end - range.begin + 1
+            len.replace(size.to_s)
+          end
+          [ status , BigHeader.new(h), gplv3 ]
+        when "/"
+          h = { "Content-Type" => "text/plain", "Content-Length" => "4" }
+          [ 200, BigHeader.new(h), ["BIG\n"] ]
+        else
+          raise "WTF"
+        end
+      end
+      GTL.synchronize do
+        app(:rack, ru) do
+          listen "#{host}:#{port}"
+        end
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    threads = []
+
+    # start with just a big header
+    threads << Thread.new do
+      c = TCPSocket.new(host, port)
+      c.write "GET / HTTP/1.0\r\n\r\n"
+      begin
+        sleep 1
+      end while c.nread == 0
+      nr = 0
+      last = nil
+      c.each_line do |line|
+        case line
+        when %r{\AX-} then nr += 1
+        else
+          last = line
+        end
+      end
+      assert_equal NR, nr
+      assert_equal "BIG\n", last
+      c.close
+    end
+
+    threads << Thread.new do
+      c = TCPSocket.new(host, port)
+      c.write "GET /COPYING HTTP/1.0\r\n\r\n"
+      begin
+        sleep 1
+      end while c.nread == 0
+      nr = 0
+      c.each_line do |line|
+        case line
+        when %r{\AX-} then nr += 1
+        else
+          break if line == "\r\n"
+        end
+      end
+      assert_equal NR, nr
+      assert_equal GPLv3, c.read
+      c.close
+    end
+
+    threads << Thread.new do
+      c = TCPSocket.new(host, port)
+      c.write "GET /COPYING HTTP/1.0\r\nRange: bytes=5-46\r\n\r\n"
+      begin
+        sleep 1
+      end while c.nread == 0
+      nr = 0
+      c.each_line do |line|
+        case line
+        when %r{\AX-} then nr += 1
+        else
+          break if line == "\r\n"
+        end
+      end
+      assert_equal NR, nr
+      assert_equal GPLv3[5..46], c.read
+      c.close
+    end
+    threads.each do |t|
+      assert_equal t, t.join(30)
+      assert_nil t.value
+    end
+  ensure
+    quit_wait(pid)
+  end
+
+  def test_client_timeout
+    err = @err
+    apperr = tmpfile(%w(app .err))
+    cfg = Yahns::Config.new
+    size = RAND.size * NR
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda do |e|
+        if e["PATH_INFO"] == "/bh"
+          h = { "Content-Type" => "text/plain", "Content-Length" => "4" }
+          [ 200, BigHeader.new(h), ["BIG\n"] ]
+        else
+          [ 200, {'Content-Length' => size.to_s }, BigBody.new ]
+        end
+      end
+      GTL.synchronize do
+        app(:rack, ru) do
+          listen "#{host}:#{port}"
+          output_buffering false
+          client_timeout 3
+          logger(Logger.new(apperr.path))
+        end
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    threads = []
+    threads << Thread.new do
+      c = get_tcp_client(host, port)
+      c.write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
+      sleep(5) # wait for timeout
+      assert_operator c.nread, :>, 0
+      c
+    end
+
+    threads << Thread.new do
+      c = get_tcp_client(host, port)
+      c.write("GET /bh HTTP/1.1\r\nHost: example.com\r\n\r\n")
+      sleep(5) # wait for timeout
+      assert_operator c.nread, :>, 0
+      c
+    end
+    threads.each { |t| t.join(10) }
+    assert_operator size, :>, threads[0].value.read.size
+    assert_operator size, :>, threads[1].value.read.size
+    msg = File.readlines(apperr.path)
+    msg = msg.grep(/timeout on :wait_writable after 3s$/)
+    assert_equal 2, msg.size
+  ensure
+    quit_wait(pid)
+  end
+end if `which curl 2>/dev/null`.strip =~ /curl/ &&
+       `which md5sum 2>/dev/null`.strip =~ /md5sum/
diff --git a/test/test_queue.rb b/test/test_queue.rb
new file mode 100644
index 0000000..6d61aef
--- /dev/null
+++ b/test/test_queue.rb
@@ -0,0 +1,59 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'timeout'
+require 'stringio'
+
+class TestQueue < Testcase
+  parallelize_me!
+
+  def setup
+    @q = Yahns::Queue.new
+    @err = StringIO.new
+    @logger = Logger.new(@err)
+    @q.fdmap = @fdmap = Yahns::Fdmap.new(@logger, 0.5)
+    assert @q.close_on_exec?
+  end
+
+  def test_queue
+    r, w = IO.pipe
+    assert_equal 0, @fdmap.size
+    @q.queue_add(r, Yahns::Queue::QEV_RD)
+    assert_equal 1, @fdmap.size
+    def r.yahns_step
+      begin
+        case read_nonblock(11)
+        when "delete"
+          return :delete
+        end
+      rescue Errno::EAGAIN
+        return :wait_readable
+      rescue EOFError
+        return nil
+      end while true
+    end
+    w.write('.')
+    Timeout.timeout(10) do
+      Thread.pass until r.nread > 0
+      @q.spawn_worker_threads(@logger, 1, 1)
+      Thread.pass until r.nread == 0
+
+      w.write("delete")
+      Thread.pass until r.nread == 0
+      Thread.pass until @fdmap.size == 0
+
+      # should not raise
+      @q.queue_add(r, Yahns::Queue::QEV_RD)
+      assert_equal 1, @fdmap.size
+      w.close
+      Thread.pass until @fdmap.size == 0
+    end
+    assert r.closed?
+  ensure
+    [ r, w ].each { |io| io.close unless io.closed? }
+  end
+
+  def teardown
+    @q.close
+  end
+end
diff --git a/test/test_rack.rb b/test/test_rack.rb
new file mode 100644
index 0000000..bd0d5b5
--- /dev/null
+++ b/test/test_rack.rb
@@ -0,0 +1,26 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'rack/lobster'
+require 'yahns/rack'
+class TestRack < Testcase
+  parallelize_me!
+
+  def test_rack
+    tmp = tmpfile(%W(config .ru))
+    tmp.write "run Rack::Lobster.new\n"
+    rapp = GTL.synchronize { Yahns::Rack.new(tmp.path) }
+    assert_kind_of Rack::Lobster, GTL.synchronize { rapp.app_after_fork }
+    defaults = rapp.app_defaults
+    assert_kind_of Hash, defaults
+  end
+
+  def test_rack_preload
+    tmp = tmpfile(%W(config .ru))
+    tmp.write "run Rack::Lobster.new\n"
+    rapp = GTL.synchronize { Yahns::Rack.new(tmp.path, preload: true) }
+    assert_kind_of Rack::Lobster, rapp.instance_variable_get(:@app)
+    defaults = rapp.app_defaults
+    assert_kind_of Hash, defaults
+  end
+end
diff --git a/test/test_serve_static.rb b/test/test_serve_static.rb
new file mode 100644
index 0000000..b9856e9
--- /dev/null
+++ b/test/test_serve_static.rb
@@ -0,0 +1,42 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'server_helper'
+require 'rack/file'
+
+class TestServeStatic < Testcase
+  parallelize_me!
+  include ServerHelper
+  alias setup server_helper_setup
+  alias teardown server_helper_teardown
+
+  def test_serve_static
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      GTL.synchronize do
+        app(:rack, Rack::File.new(Dir.pwd)) { listen "#{host}:#{port}" }
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    gplv3 = File.read("COPYING")
+    Net::HTTP.start(host, port) do |http|
+      res = http.request(Net::HTTP::Get.new("/COPYING"))
+      assert_equal gplv3, res.body
+
+      req = Net::HTTP::Get.new("/COPYING", "Range" => "bytes=5-46")
+      res = http.request(req)
+      assert_equal gplv3[5..46], res.body
+    end
+  rescue => e
+    Yahns::Log.exception(Logger.new($stderr), "test", e)
+    raise
+  ensure
+    quit_wait(pid)
+  end
+end
diff --git a/test/test_server.rb b/test/test_server.rb
new file mode 100644
index 0000000..5c86268
--- /dev/null
+++ b/test/test_server.rb
@@ -0,0 +1,382 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'server_helper'
+
+class TestServer < Testcase
+  parallelize_me!
+  include ServerHelper
+
+  alias setup server_helper_setup
+  alias teardown server_helper_teardown
+
+  def test_single_process
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda { |_| [ 200, {'Content-Length'=>'2'}, ['HI'] ] }
+      GTL.synchronize { app(:rack, ru) { listen "#{host}:#{port}" } }
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    run_client(host, port) { |res| assert_equal "HI", res.body }
+    c = get_tcp_client(host, port)
+
+    # test pipelining
+    r = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
+    c.write(r + r)
+    buf = ""
+    Timeout.timeout(10) do
+      until buf =~ /HI.+HI/m
+        buf << c.readpartial(4096)
+      end
+    end
+
+    # trickle pipelining
+    c.write(r + "GET ")
+    buf = ""
+    Timeout.timeout(10) do
+      until buf =~ /HI\z/
+        buf << c.readpartial(4096)
+      end
+    end
+    c.write("/ HTTP/1.1\r\nHost: example.com\r\n\r\n")
+    Timeout.timeout(10) do
+      until buf =~ /HI.+HI/m
+        buf << c.readpartial(4096)
+      end
+    end
+  rescue => e
+    Yahns::Log.exception(Logger.new($stderr), "test", e)
+    raise
+  ensure
+    c.close if c
+    quit_wait(pid)
+  end
+
+  def test_input_body_true; input_body(true); end
+  def test_input_body_false; input_body(false); end
+  def test_input_body_lazy; input_body(:lazy); end
+
+  def input_body(btype)
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda {|e|[ 200, {'Content-Length'=>'2'},[e["rack.input"].read]]}
+      GTL.synchronize do
+        app(:rack, ru) do
+          listen "#{host}:#{port}"
+          input_buffering btype
+        end
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    c = get_tcp_client(host, port)
+    buf = "PUT / HTTP/1.0\r\nContent-Length: 2\r\n\r\nHI"
+    c.write(buf)
+    IO.select([c], nil, nil, 5)
+    rv = c.read(666)
+    head, body = rv.split(/\r\n\r\n/)
+    assert_match(%r{^Content-Length: 2\r\n}, head)
+    assert_equal "HI", body, "#{rv.inspect} - #{btype.inspect}"
+    c.close
+
+    # pipelined oneshot
+    buf = "PUT / HTTP/1.1\r\nContent-Length: 2\r\n\r\nHI"
+    c = get_tcp_client(host, port)
+    c.write(buf + buf)
+    buf = ""
+    Timeout.timeout(10) do
+      until buf =~ /HI.+HI/m
+        buf << c.readpartial(4096)
+      end
+    end
+    assert buf.gsub!(/Date:[^\r\n]+\r\n/, ""), "kill differing Date"
+    rv = buf.sub!(/\A(HTTP.+?\r\n\r\nHI)/m, "")
+    first = $1
+    assert rv
+    assert_equal first, buf
+
+    # pipelined trickle
+    buf = "PUT / HTTP/1.1\r\nContent-Length: 5\r\n\r\nHIBYE"
+    (buf + buf).each_byte do |b|
+      c.write(b.chr)
+      sleep(0.01) if b.chr == ":"
+      Thread.pass
+    end
+    buf = ""
+    Timeout.timeout(10) do
+      until buf =~ /HIBYE.+HIBYE/m
+        buf << c.readpartial(4096)
+      end
+    end
+    assert buf.gsub!(/Date:[^\r\n]+\r\n/, ""), "kill differing Date"
+    rv = buf.sub!(/\A(HTTP.+?\r\n\r\nHIBYE)/m, "")
+    first = $1
+    assert rv
+    assert_equal first, buf
+  rescue => e
+    Yahns::Log.exception(Logger.new($stderr), "test", e)
+    raise
+  ensure
+    c.close if c
+    quit_wait(pid)
+  end
+
+  def test_trailer_true; trailer(true); end
+  def test_trailer_false; trailer(false); end
+  def test_trailer_lazy; trailer(:lazy); end
+  def test_slow_trailer_true; trailer(true, 0.02); end
+  def test_slow_trailer_false; trailer(false, 0.02); end
+  def test_slow_trailer_lazy; trailer(:lazy, 0.02); end
+
+  def trailer(btype, delay = false)
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda do |e|
+        body = e["rack.input"].read
+        s = e["HTTP_XBT"] + "\n" + body
+        [ 200, {'Content-Length'=>s.size.to_s}, [ s ] ]
+      end
+      GTL.synchronize do
+        app(:rack, ru) do
+          listen "#{host}:#{port}"
+          input_buffering btype
+        end
+      end
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    c = get_tcp_client(host, port)
+    buf = "PUT / HTTP/1.0\r\nTrailer:xbt\r\nTransfer-Encoding: chunked\r\n\r\n"
+    c.write(buf)
+    xbt = btype.to_s
+    sleep(delay) if delay
+    c.write(sprintf("%x\r\n", xbt.size))
+    sleep(delay) if delay
+    c.write(xbt)
+    sleep(delay) if delay
+    c.write("\r\n")
+    sleep(delay) if delay
+    c.write("0\r\nXBT: ")
+    sleep(delay) if delay
+    c.write("#{xbt}\r\n\r\n")
+    IO.select([c], nil, nil, 5000) or raise "timed out"
+    rv = c.read(666)
+    _, body = rv.split(/\r\n\r\n/)
+    a, b = body.split(/\n/)
+    assert_equal xbt, a
+    assert_equal xbt, b
+  ensure
+    c.close if c
+    quit_wait(pid)
+  end
+
+  def test_check_client_connection
+    msgs = %w(ZZ zz)
+    err = @err
+    cfg = Yahns::Config.new
+    bpipe = IO.pipe
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      ru = lambda { |e|
+        case e['PATH_INFO']
+        when '/sleep'
+          a = Object.new
+          a.instance_variable_set(:@bpipe, bpipe)
+          a.instance_variable_set(:@msgs, msgs)
+          def a.each
+            @msgs.each do |msg|
+              yield @bpipe[0].read(msg.size)
+            end
+          end
+        when '/cccfail'
+          # we should not get here if check_client_connection worked
+          abort "CCCFAIL"
+        else
+          a = %w(HI)
+        end
+        [ 200, {'Content-Length'=>'2'}, a ]
+      }
+      GTL.synchronize {
+        app(:rack, ru) {
+          listen "#{host}:#{port}"
+          check_client_connection true
+          # needed to avoid concurrency with check_client_connection
+          queue { worker_threads 1 }
+          output_buffering false
+        }
+      }
+      logger(Logger.new(err.path))
+    end
+    srv = Yahns::Server.new(cfg)
+
+    # ensure we set worker_threads correctly
+    eggs = srv.instance_variable_get(:@config).qeggs
+    assert_equal 1, eggs.size
+    assert_equal 1, eggs[:default].instance_variable_get(:@worker_threads)
+
+    pid = fork do
+      bpipe[1].close
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    bpipe[0].close
+    a = get_tcp_client(host, port)
+    b = get_tcp_client(host, port)
+    a.write("GET /sleep HTTP/1.0\r\n\r\n")
+    r = IO.select([a], nil, nil, 4)
+    assert r, "nothing ready"
+    assert_equal a, r[0][0]
+    buf = a.read(8)
+    assert_equal "HTTP/1.1", buf
+
+    # hope the kernel sees this before it sees the bpipe ping-ponging below
+    b.write("GET /cccfail HTTP/1.0\r\n\r\n")
+    b.shutdown
+    b.close
+
+    # ping-pong a bit to stall the server
+    msgs.each do |msg|
+      bpipe[1].write(msg)
+      Timeout.timeout(10) { buf << a.readpartial(10) until buf =~ /#{msg}/ }
+    end
+    bpipe[1].close
+    assert_equal msgs.join, buf.split(/\r\n\r\n/)[1]
+
+    # do things still work?
+    run_client(host, port) { |res| assert_equal "HI", res.body }
+  ensure
+    quit_wait(pid)
+  end
+
+  def test_mp
+    pid, host, port = new_mp_server
+    wpid = nil
+    run_client(host, port) do |res|
+      wpid ||= res.body.to_i
+    end
+  ensure
+    quit_wait(pid)
+    if wpid
+      assert_raises(Errno::ESRCH) { Process.kill(:KILL, wpid) }
+      assert_raises(Errno::ECHILD) { Process.waitpid2(wpid) }
+    end
+  end
+
+  # Linux blocking accept() has fair behavior between multiple tasks
+  def test_mp_balance
+    skip("linux-only test") unless RUBY_PLATFORM =~ /linux/
+    pid, host, port = new_mp_server(2)
+    seen = {}
+
+    # wait for both processes to spin up
+    Timeout.timeout(10) do
+      run_client(host, port) { |res| seen[res.body] = 1 } until seen.size == 2
+    end
+
+    prev = nil
+    req = Net::HTTP::Get.new("/")
+    # we should bounce new connections between 2 processes
+    4.times do
+      Net::HTTP.start(host, port) do |http|
+        res = http.request(req)
+        assert_equal 200, res.code.to_i
+        assert_equal "keep-alive", res["Connection"]
+        refute_equal prev, res.body, "same PID accepted twice"
+        prev = res.body.dup
+        seen[prev] += 1
+        666.times { Thread.pass } # have the other acceptor to wake up
+      end
+    end
+    assert_equal 2, seen.size
+  ensure
+    quit_wait(pid)
+  end
+
+  def test_mp_worker_die
+    pid, host, port = new_mp_server
+    wpid1 = wpid2 = nil
+    run_client(host, port) do |res|
+      wpid1 ||= res.body.to_i
+    end
+    Process.kill(:QUIT, wpid1)
+    poke_until_dead(wpid1)
+    run_client(host, port) do |res|
+      wpid2 ||= res.body.to_i
+    end
+    refute_equal wpid2, wpid1
+  ensure
+    quit_wait(pid)
+    assert_raises(Errno::ESRCH) { Process.kill(:KILL, wpid2) } if wpid2
+  end
+
+  def test_mp_dead_parent
+    pid, host, port = new_mp_server
+    wpid = nil
+    run_client(host, port) do |res|
+      wpid ||= res.body.to_i
+    end
+    Process.kill(:KILL, pid)
+    _, status = Process.waitpid2(pid)
+    assert status.signaled?, status.inspect
+    poke_until_dead(wpid)
+  end
+
+  def run_client(host, port)
+    c = get_tcp_client(host, port)
+    Net::HTTP.start(host, port) do |http|
+      res = http.request(Net::HTTP::Get.new("/"))
+      assert_equal 200, res.code.to_i
+      assert_equal "keep-alive", res["Connection"]
+      yield res
+      res = http.request(Net::HTTP::Get.new("/"))
+      assert_equal 200, res.code.to_i
+      assert_equal "keep-alive", res["Connection"]
+      yield res
+    end
+    c.write "GET / HTTP/1.0\r\n\r\n"
+    res = Timeout.timeout(10) { c.read }
+    head, _ = res.split(/\r\n\r\n/)
+    head = head.split(/\r\n/)
+    assert_equal "HTTP/1.1 200 OK", head[0]
+    assert_equal "Connection: close", head[-1]
+    c.close
+  end
+
+  def new_mp_server(nr = 1)
+    ru = @ru = tmpfile(%w(config .ru))
+    @ru.puts('a = $$.to_s')
+    @ru.puts('run lambda { |_| [ 200, {"Content-Length"=>a.size.to_s},[a]]}')
+    err = @err
+    cfg = Yahns::Config.new
+    host, port = @srv.addr[3], @srv.addr[1]
+    cfg.instance_eval do
+      worker_processes 2
+      GTL.synchronize { app(:rack, ru.path) { listen "#{host}:#{port}" } }
+      logger(Logger.new(File.open(err.path, "a")))
+    end
+    srv = Yahns::Server.new(cfg)
+    pid = fork do
+      ENV["YAHNS_FD"] = @srv.fileno.to_s
+      srv.start.join
+    end
+    [ pid, host, port ]
+  end
+end
diff --git a/test/test_stream_file.rb b/test/test_stream_file.rb
new file mode 100644
index 0000000..6574a97
--- /dev/null
+++ b/test/test_stream_file.rb
@@ -0,0 +1,30 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'timeout'
+
+class TestStreamFile < Testcase
+  parallelize_me!
+  DevFD = Struct.new(:to_path)
+
+  def test_stream_file
+    fp = File.open("COPYING")
+    sf = Yahns::StreamFile.new(fp, true, 0, fp.stat.size)
+    refute sf.respond_to?(:close)
+    sf.wbuf_close(nil)
+    assert fp.closed?
+  end
+
+  def test_fd
+    fp = File.open("COPYING")
+    obj = DevFD.new("/dev/fd/#{fp.fileno}")
+    sf = Yahns::StreamFile.new(obj, true, 0, fp.stat.size)
+    io = sf.instance_variable_get :@tmpio
+    assert_instance_of IO, io.to_io
+    assert_equal fp.fileno, io.fileno
+    refute sf.respond_to?(:close)
+    sf.wbuf_close(nil)
+    refute fp.closed?
+    refute io.closed?
+  end
+end
diff --git a/test/test_wbuf.rb b/test/test_wbuf.rb
new file mode 100644
index 0000000..dc6bc24
--- /dev/null
+++ b/test/test_wbuf.rb
@@ -0,0 +1,136 @@
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+require_relative 'helper'
+require 'timeout'
+
+class TestWbuf < Testcase
+  parallelize_me!
+
+  def test_wbuf
+    buf = "*" * (16384 * 2)
+    nr = 1000
+    [ true, false ].each do |persist|
+      wbuf = Yahns::Wbuf.new([], persist)
+      a, b = UNIXSocket.pair
+      assert_nil wbuf.wbuf_write(a, "HIHI")
+      assert_equal "HIHI", b.read(4)
+      nr.times { wbuf.wbuf_write(a, buf) }
+      assert_equal :wait_writable, wbuf.wbuf_flush(a)
+      done = IO.pipe
+      thr = Thread.new do
+        rv = []
+        until rv[-1] == persist
+          IO.select(nil, [a])
+          tmp = wbuf.wbuf_flush(a)
+          rv << tmp
+        end
+        done[1].syswrite '.'
+        rv
+      end
+
+      wait = true
+      begin
+        if wait
+          r = IO.select([b,done[0]], nil, nil, 5)
+        end
+        b.read_nonblock((rand * 1024) + 666, buf)
+        wait = (r[0] & done).empty?
+      rescue Errno::EAGAIN
+        break
+      end while true
+
+      assert_equal thr, thr.join(5)
+      rv = thr.value
+      assert_equal persist, rv.pop
+      assert(rv.all? { |x| x == :wait_writable })
+      a.close
+      b.close
+      done.each { |io| io.close }
+    end
+  end
+
+  def test_wbuf_blocked
+    a, b = UNIXSocket.pair
+    buf = "." * 4096
+    4.times do
+      begin
+        a.write_nonblock(buf)
+      rescue Errno::EAGAIN
+        break
+      end while true
+    end
+    wbuf = Yahns::Wbuf.new([], true)
+    assert_equal :wait_writable, wbuf.wbuf_write(a, buf)
+    assert_equal :wait_writable, wbuf.wbuf_flush(a)
+
+    # drain the buffer
+    Timeout.timeout(10) { b.read(b.nread) until b.nread == 0 }
+
+    # b.nread will increase after this
+    assert_nil wbuf.wbuf_write(a, "HI")
+    nr = b.nread
+    assert_operator nr, :>, 0
+    assert_equal b, IO.select([b], nil, nil, 5)[0][0]
+    b.read(nr - 2) if nr > 2
+    assert_equal b, IO.select([b], nil, nil, 5)[0][0]
+    assert_equal "HI", b.read(2)
+    begin
+      wbuf.wbuf_flush(a)
+      assert false
+    rescue => e
+    end
+    assert_match(%r{BUG: EOF on tmpio}, e.message)
+  ensure
+    a.close
+    b.close
+  end
+
+  def test_wbuf_flush_close
+    pipe = IO.pipe
+    persist = true
+    wbuf = Yahns::Wbuf.new(pipe[0], persist)
+    refute wbuf.respond_to?(:close) # we don't want this for HttpResponse body
+    sp = UNIXSocket.pair
+    rv = nil
+
+    buf = ("*" * 16384) << "\n"
+    thr = Thread.new do
+      1000.times { pipe[1].write(buf) }
+      pipe[1].close
+    end
+
+    pipe[0].each { |chunk| rv = wbuf.wbuf_write(sp[1], chunk) }
+    assert_equal thr, thr.join(5)
+    assert_equal :wait_writable, rv
+
+    done = IO.pipe
+    thr = Thread.new do
+      rv = []
+      until rv[-1] == persist
+        IO.select(nil, [sp[1]])
+        rv << wbuf.wbuf_flush(sp[1])
+      end
+      done[1].syswrite '.'
+      rv
+    end
+
+    wait = true
+    begin
+      if wait
+        r = IO.select([sp[0],done[0]], nil, nil, 5)
+      end
+      sp[0].read_nonblock(16384, buf)
+      wait = (r[0] & done).empty?
+    rescue Errno::EAGAIN
+      break
+    end while true
+
+    assert_equal thr, thr.join(5)
+    rv = thr.value
+    assert_equal true, rv.pop
+    assert rv.all? { |x| x == :wait_writable }
+    assert pipe[0].closed?
+    sp.each(&:close)
+    done.each(&:close)
+  end
+end