# encoding: binary
#
# Copyright (c) 2010-2015 Phusion
#
# 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.

require 'rbconfig'

module CrashWatch
  class Error < StandardError
  end

  class GdbNotFound < Error
  end

  class GdbBroken < Error
  end

  class GdbController
    class ExitInfo
      attr_reader :exit_code, :signal, :backtrace, :snapshot
      
      def initialize(exit_code, signal, backtrace, snapshot)
        @exit_code = exit_code
        @signal = signal
        @backtrace = backtrace
        @snapshot = snapshot
      end
      
      def signaled?
        !!@signal
      end
    end
    
    END_OF_RESPONSE_MARKER = '--------END_OF_RESPONSE--------'
    
    attr_accessor :debug

    def initialize
      @pid, @in, @out = popen_command(find_gdb, "-n", "-q")
      execute("set prompt ")
    end
    
    def execute(command_string, timeout = nil)
      raise "GDB session is already closed" if !@pid
      puts "gdb write #{command_string.inspect}" if @debug
      @in.puts(command_string)
      @in.puts("echo \\n#{END_OF_RESPONSE_MARKER}\\n")
      done = false
      result = ""
      while !done
        begin
          if select([@out], nil, nil, timeout)
            line = @out.readline
            puts "gdb read #{line.inspect}" if @debug
            if line == "#{END_OF_RESPONSE_MARKER}\n"
              done = true
            else
              result << line
            end
          else
            close!
            done = true
            result = nil
          end
        rescue EOFError
          done = true
        end
      end
      result
    end
    
    def closed?
      !@pid
    end
    
    def close
      if !closed?
        begin
          execute("detach", 5)
          execute("quit", 5) if !closed?
        rescue Errno::EPIPE
        end
        if !closed?
          @in.close
          @out.close
          Process.waitpid(@pid)
          @pid = nil
        end
      end
    end
    
    def close!
      if !closed?
        @in.close
        @out.close
        Process.kill('KILL', @pid)
        Process.waitpid(@pid)
        @pid = nil
      end
    end
    
    def attach(pid)
      pid = pid.to_s.strip
      raise ArgumentError if pid.empty?
      result = execute("attach #{pid}")
      result !~ /(No such process|Unable to access task|Operation not permitted)/
    end
    
    def call(code)
      result = execute("call #{code}")
      result =~ /= (.*)$/
      $1
    end
    
    def program_counter
      execute("p/x $pc").gsub(/.* = /, '')
    end
    
    def current_thread
      execute("thread") =~ /Current thread is (.+?) /
      $1
    end
    
    def current_thread_backtrace
      execute("bt full").strip
    end
    
    def all_threads_backtraces
      execute("thread apply all bt full").strip
    end
    
    def ruby_backtrace
      filename = "/tmp/gdb-capture.#{@pid}.txt"
      
      orig_stdout_fd_copy = call("(int) dup(1)")
      new_stdout = call(%Q{(void *) fopen("#{filename}", "w")})
      new_stdout_fd = call("(int) fileno(#{new_stdout})")
      call("(int) dup2(#{new_stdout_fd}, 1)")
      
      # Let's hope stdout is set to line buffered or unbuffered mode...
      call("(void) rb_backtrace()")
      
      call("(int) dup2(#{orig_stdout_fd_copy}, 1)")
      call("(int) fclose(#{new_stdout})")
      call("(int) close(#{orig_stdout_fd_copy})")
      
      if File.exist?(filename)
        result = File.read(filename)
        result.strip!
        if result.empty?
          nil
        else
          result
        end
      else
        nil
      end
    ensure
      if filename
        File.unlink(filename) rescue nil
      end
    end
    
    def wait_until_exit
      execute("break _exit")
      
      signal = nil
      backtraces = nil
      snapshot = nil
      
      while true
        result = execute("continue")
        if result =~ /^Program received signal (.+?),/
          signal = $1
          backtraces = execute("thread apply all bt full").strip
          if backtraces.empty?
            backtraces = execute("bt full").strip
          end
          snapshot = yield(self) if block_given?
          
          # This signal may or may not be immediately fatal; the
          # signal might be ignored by the process, or the process
          # has some clever signal handler that fixes the state,
          # or maybe the signal handler must run some cleanup code
          # before killing the process. Let's find out by running
          # the next machine instruction.
          old_program_counter = program_counter
          result = execute("stepi")
          if result =~ /^Program received signal .+?,/
            # Yes, it was fatal. Here we don't care whether the
            # instruction caused a different signal. The last
            # one is probably what we're interested in.
            return ExitInfo.new(nil, signal, backtraces, snapshot)
          elsif result =~ /^Program (terminated|exited)/ || result =~ /^Breakpoint .*? _exit/
            # Running the next instruction causes the program to terminate.
            # Not sure what's going on but the previous signal and
            # backtrace is probably what we're interested in.
            return ExitInfo.new(nil, signal, backtraces, snapshot)
          elsif old_program_counter == program_counter
            # The process cannot continue but we're not sure what GDB
            # is telling us.
            raise "Unexpected GDB output: #{result}"
          end
          # else:
          # The signal doesn't isn't immediately fatal, so save current
          # status, continue, and check whether the process exits later.
        elsif result =~ /^Program terminated with signal (.+?),/
          if $1 == signal
            # Looks like the signal we trapped earlier
            # caused an exit.
            return ExitInfo.new(nil, signal, backtraces, snapshot)
          else
            return ExitInfo.new(nil, signal, nil, snapshot)
          end
        elsif result =~ /^Breakpoint .*? _exit /
          backtraces = execute("thread apply all bt full").strip
          if backtraces.empty?
            backtraces = execute("bt full").strip
          end
          snapshot = yield(self) if block_given?
          # On OS X, gdb may fail to return from the 'continue' command
          # even though the process exited. Kernel bug? In any case,
          # we put a timeout here so that we don't wait indefinitely.
          result = execute("continue", 10)
          if result =~ /^Program exited with code (\d+)\.$/
            return ExitInfo.new($1.to_i, nil, backtraces, snapshot)
          elsif result =~ /^Program exited normally/
            return ExitInfo.new(0, nil, backtraces, snapshot)
          else
            return ExitInfo.new(nil, nil, backtraces, snapshot)
          end
        elsif result =~ /^Program exited with code (\d+)\.$/
          return ExitInfo.new($1.to_i, nil, nil, nil)
        elsif result =~ /^Program exited normally/
          return ExitInfo.new(0, nil, nil, nil)
        else
          return ExitInfo.new(nil, nil, nil, nil)
        end
      end
    end
    
  private
    def popen_command(*command)
      a, b = IO.pipe
      c, d = IO.pipe
      if Process.respond_to?(:spawn)
        args = command.dup
        args << {
          STDIN  => a,
          STDOUT => d,
          STDERR => d,
          :close_others => true
        }
        pid = Process.spawn(*args)
      else
        pid = fork do
          STDIN.reopen(a)
          STDOUT.reopen(d)
          STDERR.reopen(d)
          b.close
          c.close
          exec(*command)
        end
      end
      a.close
      d.close
      b.binmode
      c.binmode
      [pid, b, c]
    end

    def find_gdb
      result = nil
      if ENV['GDB'] && File.executable?(ENV['GDB'])
        result = ENV['GDB']
      else
        ENV['PATH'].to_s.split(/:+/).each do |path|
          filename = "#{path}/gdb"
          if File.file?(filename) && File.executable?(filename)
            result = filename
            break
          end
        end
      end

      puts "Found gdb at: #{result}" if result

      config = defined?(RbConfig) ? RbConfig::CONFIG : Config::CONFIG
      if config['target_os'] =~ /freebsd/ && result == "/usr/bin/gdb"
        # /usr/bin/gdb on FreeBSD is broken:
        # https://github.com/FooBarWidget/crash-watch/issues/1
        # Look for a newer one that's installed from ports.
        puts "#{result} is broken on FreeBSD. Looking for an alternative..."
        result = nil
        ["/usr/local/bin/gdb76", "/usr/local/bin/gdb66"].each do |candidate|
          if File.executable?(candidate)
            result = candidate
            break
          end
        end

        if result.nil?
          raise GdbBroken,
            "*** ERROR ***: '/usr/bin/gdb' is broken on FreeBSD. " +
            "Please install the one from the devel/gdb port instead. " +
            "If you want to use another gdb"
        else
          puts "Found gdb at: #{result}" if result
          result
        end
      elsif result.nil?
        raise GdbNotFound,
          "*** ERROR ***: 'gdb' not found. Please install it (and if using Nginx " + 
          "ensure that PATH isn't filtered out, see also its \"env\" option).\n" +
          "       Debian/Ubuntu: sudo apt-get install gdb\n" +
          "RedHat/CentOS/Fedora: sudo yum install gdb\n" +
          "            Mac OS X: please install the Developer Tools or XCode\n" +
          "             FreeBSD: use the devel/gdb port\n"
      else
        result
      end
    end
  end
end
