# encoding: binary

#  Phusion Passenger - https://www.phusionpassenger.com/
#  Copyright (c) 2010-2025 Asynchronous B.V.
#
#  "Passenger", "Phusion Passenger" and "Union Station" are registered
#  trademarks of Asynchronous B.V.
#
#  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.

PhusionPassenger.require_passenger_lib 'platform_info/apache'
PhusionPassenger.require_passenger_lib 'platform_info/operating_system'

module PhusionPassenger
  module AdminTools

    class MemoryStats
      # Information about a single process.
      class Process
        attr_accessor :pid
        attr_accessor :ppid
        attr_accessor :threads
        attr_accessor :vm_size              # in KB
        attr_accessor :rss                  # in KB
        attr_accessor :cpu
        attr_accessor :name
        attr_accessor :private_dirty_rss    # in KB

        def vm_size_in_mb
          sprintf("%.1f MB", vm_size / 1024.0)
        end

        def rss_in_mb
          sprintf("%.1f MB", rss / 1024.0)
        end

        def private_dirty_rss_in_mb
          if private_dirty_rss.is_a?(Numeric)
            sprintf("%.1f MB", private_dirty_rss / 1024.0)
          else
            "?"
          end
        end

        def to_a
          [ pid, ppid, vm_size_in_mb, private_dirty_rss_in_mb, rss_in_mb, name ]
        end
      end

      # Returns a list of Apache processes, which may be the empty list if Apache is
      # not running. If the Apache executable name is unknown then nil will be returned.
      def apache_processes
        @apache_processes ||= begin
          if PlatformInfo.httpd
            processes = list_processes(exe: PlatformInfo.httpd)
            if processes.empty?
              # On some Linux distros, the Apache worker processes
              # are called "httpd.worker"
              processes = list_processes(exe: "#{PlatformInfo.httpd}.worker")
            end
            processes
          else
            nil
          end
        end
      end

      # Returns a list of Nginx processes, which may be the empty list if
      # Nginx is not running.
      def nginx_processes
        @nginx_processes ||= list_processes(exe: "nginx")
      end

      # Returns a list of Phusion Passenger processes, which may be the empty list if
      # Phusion Passenger is not running.
      def passenger_processes
        @passenger_processes ||= list_processes(match:           /((^| )Passenger|(^| )Rails:|(^| )Rack:|wsgi-loader.py|(.*)PassengerAgent|rack-loader.rb)/)
      end

      # Returns the sum of the memory usages of all given processes.
      # Returns a pair [usage, accurate]. +usage+ is the summed memory usage in KB,
      # and +accurate+ indicates whether this sum is accurate. This may be false
      # if some process's memory usage cannot be determined.
      def sum_memory_usage(processes)
        total = 0
        if should_show_private_dirty_rss?
          accurate = true
          processes.each do |p|
            if p.private_dirty_rss.is_a?(Numeric)
              total += p.private_dirty_rss
            else
              accurate = true
            end
          end
          [ total, accurate ]
        else
          processes.each do |p|
            total += p.rss
          end
          [ total, true ]
        end
      end

      def platform_provides_private_dirty_rss_information?
        os_name_simple == "linux"
      end

      # Returns whether root privileges are required in order to measure private dirty RSS.
      # Only meaningful if #platform_provides_private_dirty_rss_information? returns true.
      def root_privileges_required_for_private_dirty_rss?
        all_processes = (apache_processes || []) + nginx_processes + passenger_processes
        all_processes.any? { |p| p.private_dirty_rss.nil? }
      end

      def should_show_private_dirty_rss?
        platform_provides_private_dirty_rss_information? &&
          (::Process.euid == 0 || root_privileges_required_for_private_dirty_rss?)
      end

      # Determine the system's RAM usage, not including swap.
      # Returns a tuple [total, used] where both numbers are in KB, or nil
      # if the system's RAM usage cannot be determined.
      def system_ram_usage
        @total_system_ram ||= begin
          case os_name_simple
          when "linux"
            free_text = `free -k`

            free_text =~ %r{Mem:(.+)$}
            line = $1.strip
            total = line.split(/ +/).first.to_i

            free_text =~ %r{buffers/cache:(.+)$}
            line = $1.strip
            used = line.split(/ +/).first.to_i

            [ total, used ]
          when "macosx"
            vm_stat = `vm_stat`
            vm_stat =~ /page size of (\d+) bytes/
            page_size = $1
            vm_stat =~ /Pages free: +(\d+)/
            free = $1
            vm_stat =~ /Pages active: +(\d+)/
            active = $1
            vm_stat =~ /Pages inactive: +(\d+)/
            inactive = $1
            vm_stat =~ /Pages wired down: +(\d+)/
            wired = $1

            if page_size && free && active && inactive && wired
              page_size = page_size.to_i
              free = free.to_i * page_size / 1024
              active = active.to_i * page_size / 1024
              inactive = inactive.to_i * page_size / 1024
              wired = wired.to_i * page_size / 1024

              used = active + wired
              [ free + inactive + used, used ]
            else
              nil
            end
          else
            `top` =~ /(\d+)(K|M) Active, (\d+)(K|M) Inact, (\d+)(K|M) Wired,.*?(\d+)(K|M) Free/
            if $1 && $2 && $3 && $4 && $5 && $6 && $7 && $8
              to_kb = lambda do |number, unit|
                if unit == 'K'
                  number.to_i
                else
                  number.to_i * 1024
                end
              end

              active = to_kb.call($1, $2)
              inactive = to_kb.call($3, $4)
              wired = to_kb.call($5, $6)
              free = to_kb.call($7, $8)

              used = active + wired
              [ free + inactive + used, used ]
            else
              nil
            end
          end
        end
      end

    private
      def os_name_simple
        PlatformInfo.os_name_simple
      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 os_name_simple == "linux"
            ps = "ps -C '#{name}'"
          else
            ps = "ps -A"
            options[:match] = Regexp.new(Regexp.escape(name))
          end
        elsif options[:name]
          if os_name_simple == "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 os_name_simple
        when "solaris"
          ps_output = `#{ps} -o pid,ppid,nlwp,vsz,rss,pcpu,comm`
          threads_known = true
        when "macosx"
          ps_output = `#{ps} -w -o pid,ppid,vsz,rss,%cpu,command`
          threads_known = false
        else
          ps_output = `#{ps} -w -o pid,ppid,nlwp,vsz,rss,%cpu,command`
          threads_known = true
        end
        list = force_binary(ps_output).split("\n")
        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.cpu, p.name = line.split(/ +/, 7)
          else
            p.pid, p.ppid, p.vm_size, p.rss, p.cpu, p.name = line.split(/ +/, 6)
          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
        processes
      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
          nil
        else
          total
        end
      rescue Errno::EACCES, Errno::ENOENT, Errno::ESRCH
        nil
      end

      if ''.respond_to?(:force_encoding)
        def force_binary(str)
          str.force_encoding('binary')
        end
      else
        def force_binary(str)
          str
        end
      end
    end

  end # module AdminTools
end # module PhusionPassenger
