#!/usr/bin/env ruby
#  Phusion Passenger - http://www.modrails.com/
#  Copyright (c) 2010 Phusion
#
#  "Phusion Passenger" is a trademark of Hongli Lai & Ninh Bui.
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in
#  all copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
#  THE SOFTWARE.

source_root = File.expand_path(File.dirname(__FILE__) + "/..")
$LOAD_PATH.unshift("#{source_root}/lib")
require 'phusion_passenger'
require 'rubygems'
require 'optparse'
require 'socket'
require 'thread'
require 'phusion_passenger/platform_info'
require 'phusion_passenger/message_channel'
require 'phusion_passenger/utils'

include PhusionPassenger
include PhusionPassenger::Utils
include PhusionPassenger::PlatformInfo

# A thread or a process, depending on the Ruby VM implementation.
class Subprocess
	attr_accessor :channel
	
	def initialize(name, &block)
		if RUBY_PLATFORM == "java"
			a, b = UNIXSocket.pair
			@thread = Thread.new do
				block.call(true, MessageChannel.new(b))
			end
			@channel = MessageChannel.new(a)
			@thread_channel = b
		else
			a, b = UNIXSocket.pair
			@pid = safe_fork(name) do
				a.close
				$0 = name
				Process.setsid
				block.call(false, MessageChannel.new(b))
			end
			b.close
			@channel = MessageChannel.new(a)
		end
	end
	
	def stop
		if RUBY_PLATFORM == "java"
			@thread.terminate
			@channel.close
			@thread_channel.close
		else
			Process.kill('SIGKILL', @pid) rescue nil
			Process.waitpid(@pid) rescue nil
			@channel.close
		end
	end
end

class StressTester
	def start
		@options = parse_options
		load_hawler
		
		Thread.abort_on_exception = true
		if GC.respond_to?(:copy_on_write_friendly=)
			GC.copy_on_write_friendly = true
		end
		@terminal_height = ENV['LINES'] ? ENV['LINES'].to_i : 24
		@terminal_width = ENV['COLUMNS'] ? ENV['COLUMNS'].to_i : 80
		
		if Process.euid != 0
			puts "*** WARNING: This program might not be able to restart " <<
				"Apache because it's not running as root. Please run " <<
				"this tool as root."
			puts
			puts "Press Enter to continue..."
			begin
				STDIN.readline
			rescue Interrupt
				exit 1
			end
		end
		
		run_crawlers
	end

	def parse_options
		options = {
			:concurrency => 20,
			:depth => 20,
			:nice => true,
			:apache_restart_interval => 24 * 60,
			:app_restart_interval => 55
		}
		parser = OptionParser.new do |opts|
			opts.banner = "Usage: passenger-stress-test <hostname> <app_root> [options]\n\n" <<
				"Stress test the given (Passenger-powered) website by:\n" <<
				"  * crawling it with multiple concurrently running crawlers.\n" <<
				"  * gracefully restarting Apache at random times (please point the 'APXS2'\n" <<
				"    variable to your Apache's 'apxs' binary).\n" <<
				"  * restarting the target (Passenger-powered) application at random time.\n" <<
				"\n" <<
				"Example:\n" <<
				"  passenger-stress-test mywebsite.com /webapps/mywebsite\n" <<
				"\n"
		
			opts.separator "Options:"
			opts.on("-c", "--concurrency N", Integer,
				"Number of crawlers to start (default = #{options[:concurrency]})") do |v|
				options[:concurrency] = v
			end
			opts.on("-p", "--apache-restart-interval N", Integer,
				"Gracefully restart Apache after N minutes\n" <<
				(" " * 37) << "(default = #{options[:apache_restart_interval]})") do |v|
				options[:apache_restart_interval] = v
			end
			opts.on("-a", "--app-restart-interval N", Integer,
				"Restart the application after N minutes\n" <<
				(" " * 37) << "(default = #{options[:app_restart_interval]})") do |v|
				options[:app_restart_interval] = v
			end
			opts.on("-h", "--help", "Show this message") do
				puts opts
				exit
			end
		end
		parser.parse!
	
		options[:host] = ARGV[0]
		options[:app_root] = ARGV[1]
		if !options[:host] || !options[:app_root]
			puts parser
			exit 1
		end
		return options
	end

	def load_hawler
		begin
			require 'hawler'
		rescue LoadError
			STDERR.puts "This tool requires Hawler (http://tinyurl.com/ywgk6x). Please install it with:"
			STDERR.puts
			STDERR.puts "  gem install --source http://spoofed.org/files/hawler/ hawler"
			exit 1
		end
	end

	def run_crawlers
		@started = false
		@crawlers = []
		
		# Start crawler processes.
		GC.start if GC.copy_on_write_friendly?
		@options[:concurrency].times do |i|
			STDOUT.write("Starting crawler #{i + 1} of #{@options[:concurrency]}...\n")
			STDOUT.flush
			process = Subprocess.new("crawler #{i + 1}") do |is_thread, channel|
				if !is_thread && @options[:nice]
					system("renice 1 #{Process.pid} >/dev/null 2>/dev/null")
				end
				while true
					crawl!(i + 1, channel)
				end
			end
			@crawlers << {
				:id => i + 1,
				:process => process,
				:channel => process.channel,
				:mutex => Mutex.new,
				:current_uri => nil,
				:crawled => 0
			}
		end
		
		puts
		if RUBY_PLATFORM != "java"
			# 'sleep' b0rks when running in JRuby?
			sleep 1
		end
		begin
			$0 = "Passenger Crawler: control process"
			io_to_crawler = {}
			ios = []
			@crawlers.each do |crawler|
				io_to_crawler[crawler[:channel].io] = crawler
				ios << crawler[:channel].io
			end
			
			# Tell each crawler to start crawling.
			@crawlers.each do |crawler|
				crawler[:channel].write("start")
			end
			
			# Show progress periodically.
			@start_time = Time.now
			progress_reporter = Thread.new(&method(:report_progress))
			@next_apache_restart = Time.now + @options[:apache_restart_interval] * 60
			apache_restarter = Thread.new(&method(:restart_apache))
			@next_app_restart = Time.now + @options[:app_restart_interval] * 60
			app_restarter = Thread.new(&method(:restart_app))
			
			while true
				note_progress(ios, io_to_crawler)
			end
		rescue Interrupt
			trap('SIGINT') {}
			puts "Shutting down..."
			@done = true
			@crawlers.each do |crawler|
				STDOUT.write("Stopping crawler #{crawler[:id]} of #{@options[:concurrency]}...\r")
				STDOUT.flush
				crawler[:process].stop
			end
			progress_reporter.join if progress_reporter
			apache_restarter.join if apache_restarter
			app_restarter.join if app_restarter
			puts
		end
	end
	
	def note_progress(ios, io_to_crawler)
		select(ios)[0].each do |io|
			crawler = io_to_crawler[io]
			uri = crawler[:channel].read[0]
			crawler[:mutex].synchronize do
				crawler[:current_uri] = uri
				crawler[:crawled] += 1
			end
		end
	end
	
	def report_progress
		while !@done
			output = "\n" * @terminal_height
			output << "### Running for #{duration(Time.now.to_i - @start_time.to_i)}\n"
			@crawlers.each do |crawler|
				crawler[:mutex].synchronize do
					line = sprintf("Crawler %-2d: %-3d -> %s",
						crawler[:id],
						crawler[:crawled],
						crawler[:current_uri])
					output << sprintf("%-#{@terminal_width}s\n", line)
				end
			end
			output << "Next Apache restart: in #{duration(@next_apache_restart.to_i - Time.now.to_i)}\n"
			output << "Next app restart   : in #{duration(@next_app_restart.to_i - Time.now.to_i)}\n"
			STDOUT.write(output)
			sleep 0.5
		end
	end
	
	def restart_apache
		while !@done
			if Time.now > @next_apache_restart
				@next_apache_restart = Time.now + @options[:apache_restart_interval] * 60
				system("#{HTTPD} -k graceful")
			end
		end
	end
	
	def restart_app
		while !@done
			if Time.now > @next_app_restart
				@next_app_restart = Time.now + @options[:app_restart_interval] * 60
				system("touch #{@options[:app_root]}/tmp/restart.txt")
			end
		end
	end
	
	def duration(seconds)
		result = ""
		if seconds >= 60
			minutes = (seconds / 60)
			if minutes >= 60
				hours = minutes / 60
				minutes = minutes % 60
				if hours == 1
					result << "#{hours} hour "
				else
					result << "#{hours} hours "
				end
			end
			
			seconds = seconds % 60
			if minutes == 1
				result << "#{minutes} minute "
			else
				result << "#{minutes} minutes "
			end
		end
		result << "#{seconds} seconds"
		return result
	end

	def crawl!(id, channel)
		progress_reporter = lambda do |uri, referer, response|
			begin
				if !@started
					# At the beginning, wait until the control process
					# tells us to start.
					@started = true
					channel.read
				end
				channel.write(uri, referer, response)
			rescue
				if RUBY_PLATFORM == "java"
					Thread.current.terminate
				else
					Process.kill('SIGKILL', Process.pid)
				end
			end
		end
		crawler = Hawler.new(@options[:host], progress_reporter)
		if RUBY_PLATFORM == "java"
			trap('SIGINT') do
				raise Interrupt, "Interrupted"
			end
		end
		crawler.recurse = true
		crawler.depth = @options[:depth]
		crawler.start
	end
end

StressTester.new.start
