#!/usr/bin/env ruby
#  Phusion Passenger - http://www.modrails.com/
#  Copyright (c) 2008, 2009 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.

$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib")
require 'phusion_passenger/platform_info'

# ANSI color codes
RESET   = "\e[0m"
BOLD    = "\e[1m"
WHITE   = "\e[37m"
YELLOW  = "\e[33m"
BLUE_BG = "\e[44m"

# Container for tabular data.
class Table
	def initialize(column_names)
		@column_names = column_names
		@rows = []
	end
	
	def add_row(values)
		@rows << values.to_a
	end
	
	def add_rows(list_of_rows)
		list_of_rows.each do |row|
			add_row(row)
		end
	end
	
	def remove_column(name)
		i = @column_names.index(name)
		@column_names.delete_at(i)
		@rows.each do |row|
			row.delete_at(i)
		end
	end
	
	def to_s(title = nil)
		max_column_widths = [1] * @column_names.size
		(@rows + [@column_names]).each do |row|
			row.each_with_index do |value, i|
				max_column_widths[i] = [value.to_s.size, max_column_widths[i]].max
			end
		end
		
		format_string = max_column_widths.map{ |i| "%#{-i}s" }.join("  ")
		header = sprintf(format_string, *@column_names).rstrip << "\n"
		if title
			free_space = header.size - title.size - 2
			if free_space <= 0
				left_bar_size = 3
				right_bar_size = 3
			else
				left_bar_size = free_space / 2
				right_bar_size = free_space - left_bar_size
			end
			result = "#{BLUE_BG}#{BOLD}#{YELLOW}\n"
			result << "#{"-" * left_bar_size} #{title} #{"-" * right_bar_size}\n"
			if !@rows.empty?
				result << WHITE
				result << header
			end
		else
			result = header.dup
		end
		if @rows.empty?
			result << RESET
		else
			result << ("-" * header.size) << "#{RESET}\n"
			@rows.each do |row|
				result << sprintf(format_string, *row).rstrip << "\n"
			end
		end
		result
	end
end

class MemoryStats
	class Process
		attr_accessor :pid
		attr_accessor :ppid
		attr_accessor :threads
		attr_accessor :vm_size              # in KB
		attr_accessor :rss                  # in KB
		attr_accessor :name
		attr_accessor :private_dirty_rss    # in KB
		
		def vm_size_in_mb
			return sprintf("%.1f MB", vm_size / 1024.0)
		end

		def rss_in_mb
			return sprintf("%.1f MB", rss / 1024.0)
		end
		
		def private_dirty_rss_in_mb
			if private_dirty_rss.is_a?(Numeric)
				return sprintf("%.1f MB", private_dirty_rss / 1024.0)
			else
				return "?"
			end
		end
		
		def to_a
			return [pid, ppid, vm_size_in_mb, private_dirty_rss_in_mb, rss_in_mb, name]
		end
	end
	
	def start
		if PlatformInfo.httpd
			apache_processes = list_processes(:exe => PlatformInfo.httpd)
			if apache_processes.empty?
				# On some Linux distros, the Apache worker processes
				# are called "httpd.worker"
				apache_processes = list_processes(:exe => "#{PlatformInfo.httpd}.worker")
			end
			print_process_list("Apache processes", apache_processes)
		else
			apache_processes = []
			puts "#{BLUE_BG}#{BOLD}#{YELLOW}------------- Apache processes -------------#{RESET}\n"
			STDERR.puts "*** WARNING: The Apache executable cannot be found."
			STDERR.puts "Please set the APXS2 environment variable to your 'apxs2' " <<
				"executable's filename, or set the HTTPD environment variable " <<
				"to your 'httpd' or 'apache2' executable's filename."
		end
		
		puts
		nginx_processes = list_processes(:exe => "nginx")
		print_process_list("Nginx processes", nginx_processes)
		
		puts
		passenger_processes = list_processes(:match =>
			/((^| )Passenger |(^| )Rails:|(^| )Rack:|ApplicationPoolServerExecutable|PassengerNginxHelperServer)/)
		print_process_list("Passenger processes", passenger_processes, :show_ppid => false)
		
		if RUBY_PLATFORM !~ /linux/
			puts
			puts "*** WARNING: The private dirty RSS can only be displayed " <<
				"on Linux. You're currently using '#{RUBY_PLATFORM}'."
		elsif ::Process.uid != 0 && (apache_processes + passenger_processes).any?{ |p| p.private_dirty_rss.nil? }
			puts
			puts "*** WARNING: Please run this tool as root. Otherwise the " <<
				"private dirty RSS of processes cannot be determined."
		end
	end
	
	# Returns a list of Process objects that match the given search criteria.
	#
	#  # Search by executable path.
	#  list_processes(:exe => '/usr/sbin/apache2')
	#  
	#  # Search by executable name.
	#  list_processes(:name => 'ruby1.8')
	#  
	#  # Search by process name.
	#  list_processes(:match => 'Passenger FrameworkSpawner')
	def list_processes(options)
		if options[:exe]
			name = options[:exe].sub(/.*\/(.*)/, '\1')
			if RUBY_PLATFORM =~ /linux/
				ps = "ps -C '#{name}'"
			else
				ps = "ps -A"
				options[:match] = Regexp.new(Regexp.escape(name))
			end
		elsif options[:name]
			if RUBY_PLATFORM =~ /linux/
				ps = "ps -C '#{options[:name]}'"
			else
				ps = "ps -A"
				options[:match] = Regexp.new(" #{Regexp.escape(options[:name])}")
			end
		elsif options[:match]
			ps = "ps -A"
		else
			raise ArgumentError, "Invalid options."
		end
		
		processes = []
		case RUBY_PLATFORM
		when /solaris/
			list = `#{ps} -o pid,ppid,nlwp,vsz,rss,comm`.split("\n")
			threads_known = true
		when /darwin/
			list = `#{ps} -w -o pid,ppid,vsz,rss,command`.split("\n")
			threads_known = false
		else
			list = `#{ps} -w -o pid,ppid,nlwp,vsz,rss,command`.split("\n")
			threads_known = true
		end
		list.shift
		list.each do |line|
			line.gsub!(/^ */, '')
			line.gsub!(/ *$/, '')
			
			p = Process.new
			if threads_known
				p.pid, p.ppid, p.threads, p.vm_size, p.rss, p.name = line.split(/ +/, 6)
			else
				p.pid, p.ppid, p.vm_size, p.rss, p.name = line.split(/ +/, 5)
				p.threads = "?"
			end
			p.name.sub!(/\Aruby: /, '')
			p.name.sub!(/ \(ruby\)\Z/, '')
			if p.name !~ /^ps/ && (!options[:match] || p.name.match(options[:match]))
				# Convert some values to integer.
				[:pid, :ppid, :vm_size, :rss].each do |attr|
					p.send("#{attr}=", p.send(attr).to_i)
				end
				p.threads = p.threads.to_i if threads_known

				if platform_provides_private_dirty_rss_information?
					p.private_dirty_rss = determine_private_dirty_rss(p.pid)
				end
				processes << p
			end
		end
		return processes
	end

private
	def platform_provides_private_dirty_rss_information?
		return RUBY_PLATFORM =~ /linux/
	end

	# Returns the private dirty RSS for the given process, in KB.
	def determine_private_dirty_rss(pid)
		total = 0
		File.read("/proc/#{pid}/smaps").split("\n").each do |line|
			line =~ /^(Private)_Dirty: +(\d+)/
			if $2
				total += $2.to_i
			end
		end
		if total == 0
			return nil
		else
			return total
		end
	rescue Errno::EACCES, Errno::ENOENT
		return nil
	end
	
	def print_process_list(title, processes, options = {})
		table = Table.new(%w{PID PPID VMSize Private Resident Name})
		table.add_rows(processes)
		if options.has_key?(:show_ppid) && !options[:show_ppid]
			table.remove_column('PPID')
		end
		if platform_provides_private_dirty_rss_information?
			table.remove_column('Resident')
		else
			table.remove_column('Private')
		end
		puts table.to_s(title)
		
		if platform_provides_private_dirty_rss_information?
			total_private_dirty_rss = 0
			some_private_dirty_rss_cannot_be_determined = false
			processes.each do |p|
				if p.private_dirty_rss.is_a?(Numeric)
					total_private_dirty_rss += p.private_dirty_rss
				else
					some_private_dirty_rss_cannot_be_determined = true
				end
			end
			puts   "### Processes: #{processes.size}"
			printf "### Total private dirty RSS: %.2f MB", total_private_dirty_rss / 1024.0
			if some_private_dirty_rss_cannot_be_determined
				puts " (?)"
			else
				puts
			end
		end
	end
end

MemoryStats.new.start

