#!/usr/bin/env ruby
# A simple pure-Ruby HTTP server, meant as a helper tool in benchmarks.
# It supports HTTP keep-alive and it supports forwarding the request to
# another server.

require 'thread'
require 'socket'
require 'optparse'

class TestServer
  REQUEST =
    "GET / HTTP/1.1\r\n" <<
    "Connection: Keep-Alive\r\n" <<
    "Host: 127.0.0.1:3001\r\n" <<
    "User-Agent: ApacheBench/2.3\r\n" <<
    "Accept: */*\r\n\r\n"

  RESPONSE =
    "HTTP/1.1 200 OK\r\n" <<
    "Status: 200 OK\r\n" <<
    "Content-Type: text/plain\r\n" <<
    "Content-Length: 3\r\n" <<
    "Connection: keep-alive\r\n" <<
    "\r\n" <<
    "ok\n"

  def initialize(options = {})
    @options = options
    @options[:transport] ||= :tcp
    @options[:protocol] ||= :http
    @options[:port] ||= 3000
    @options[:file] ||= './socket'
    @options[:threads] ||= 2
    @options[:processes] ||= 2
    @forward = @options[:forward]
    @forward_transport = @options[:forward_transport]
    @forward_file = @options[:forward_file]
    @forward_port = @options[:forward_port]
    @forward_keep_alive = @options[:forward_keep_alive]
  end

  def run
    case @options[:transport]
    when :tcp
      @server = TCPServer.new('127.0.0.1', @options[:port])
      @server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
      puts "Listening on http://127.0.0.1:#{@options[:port]}/"
    when :unix
      File.unlink(@options[:file]) rescue nil
      @server = UNIXServer.new(@options[:file])
      puts "Listening on Unix domain socket: #{@options[:file]}"
    else
      abort "Unknown transport #{@options[:transport]}"
    end
    @server.listen(100)

    case @options[:protocol]
    when :http
      puts "Using HTTP protocol"
      @protocol = :http
    when :session
      puts "Using session protocol"
      @protocol = :session
    else
      abort "Unknown protocol #{@options[:protocol]}"
    end

    if @forward
      case @forward_transport
      when :tcp
        puts "Forwarding to http://127.0.0.1:#{@forward_port}/"
      when :unix
        puts "Forwarding to Unix domain socket: #{@forward_file}"
      end
    end

    puts "Using #{@options[:processes]} processes"
    puts "Using #{@options[:threads]} threads per process"
    fork_children
    threads = []
    @options[:threads].times { threads << start_thread }
    begin
      threads.each { |t| t.join }
    rescue Interrupt
    end
  end

private
  def fork_children
    if @options[:processes] == 1
      return
    end

    children = []
    @options[:processes].times do
      pid = fork
      if pid
        # Parent
        puts "Spawned child process: #{pid}"
        children << pid
      else
        return
      end
    end
    if !children.empty?
      # Parent
      begin
        sleep 999999
      rescue Interrupt
        exit
      ensure
        children.each do |pid|
          puts "Reaping child process: #{pid}"
          Process.kill('INT', pid)
        end
        children.each do |pid|
          Process.waitpid(pid)
        end
      end
    end
  end

  def start_thread
    Thread.new do
      Thread.current.abort_on_exception = true
      if @forward && @forward_keep_alive
        forward_connection = connect_to_forwarding_target
      end
      while true
        handle_next_client(forward_connection)
      end
    end
  end

  def handle_next_client(forward_connection)
    client = @server.accept
    begin
      if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.3.0')
        buffer = String.new(encoding: Encoding::BINARY)
      else
        buffer = ""
        buffer.force_encoding('binary') if buffer.respond_to?(:force_encoding)
      end
      while true
        begin
          read_header(client, buffer)

          if @forward
            forward(forward_connection)
          end

          # Write response
          client.write(RESPONSE)
        rescue EOFError, Errno::ECONNRESET
          break
        end
      end
    ensure
      client.close
    end
  end

  def read_header(client, buffer)
    if @protocol == :http
      while client.readline != "\r\n"
        # Do nothing.
      end
    else
      temp = client.read(4, buffer)
      raise EOFError if temp.nil?
      size = temp.unpack('N')[0]
      temp = client.read(size, buffer)
      raise EOFError if temp.nil?
    end
  end

  def forward(target_connection)
    if target_connection
      io = target_connection
    else
      io = connect_to_forwarding_target
    end
    begin
      io.write(REQUEST)
      while io.readline != "ok\n"
        # Do nothing
      end
    ensure
      if !target_connection
        io.close
      end
    end
  end

  def connect_to_forwarding_target
    if @forward_transport == :unix
      UNIXSocket.new(@forward_file)
    else
      TCPSocket.new('127.0.0.1', @forward_port)
    end
  end
end

options = {}
parser = OptionParser.new do |opts|
  opts.banner = "Usage: ./ruby.rb [options]"
  opts.separator ""

  opts.separator "Options:"
  opts.on("--port PORT", Integer, "Listen on the given TCP port. Default: 3000") do |val|
    options[:transport] = :tcp
    options[:port] = val
  end
  opts.on("--file PATH", String, "Listen on the given Unix domain socket file") do |val|
    options[:transport] = :unix
    options[:file] = val
  end
  opts.on("--session-protocol", "Accept session protocol instead of HTTP") do
    options[:protocol] = :session
  end
  opts.on("--threads N", Integer, "Number of threads to use. Default: 2") do |val|
    options[:threads] = val
  end
  opts.on("--processes N", Integer, "Number of processes to use. Default: 2") do |val|
    options[:processes] = val
  end
  opts.on("--forward-tcp PORT", Integer, "Forward request to another TCP server") do |val|
    options[:forward] = true
    options[:forward_transport] = :tcp
    options[:forward_port] = val
  end
  opts.on("--forward-file PATH", String, "Forward request to another Unix domain socket server") do |val|
    options[:forward] = true
    options[:forward_transport] = :unix
    options[:forward_file] = val
  end
  opts.on("--forward-keep-alive", "Use keep-alive when forwarding") do
    options[:forward_keep_alive] = true
  end
end
begin
  parser.parse!
rescue OptionParser::ParseError => e
  puts e
  puts
  puts "Please see '--help' for valid options."
  exit 1
end
TestServer.new(options).run
