class CrashWatch::GdbController
def all_threads_backtraces
def all_threads_backtraces execute("thread apply all bt full").strip end
def attach(pid)
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)
def call(code) result = execute("call #{code}") result =~ /= (.*)$/ $1 end
def close
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!
def close! if !closed? @in.close @out.close Process.kill('KILL', @pid) Process.waitpid(@pid) @pid = nil end end
def closed?
def closed? !@pid end
def current_thread
def current_thread execute("thread") =~ /Current thread is (.+?) / $1 end
def current_thread_backtrace
def current_thread_backtrace execute("bt full").strip end
def execute(command_string, timeout = nil)
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 find_gdb
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' isn't installed. Please install it first.\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
def initialize
def initialize @pid, @in, @out = popen_command(find_gdb, "-n", "-q") execute("set prompt ") end
def popen_command(*command)
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 program_counter
def program_counter execute("p/x $pc").gsub(/.* = /, '') end
def ruby_backtrace
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
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