diff --git a/bin/gdb_wrapper b/bin/gdb_wrapper index 4fdf002..905e741 100755 --- a/bin/gdb_wrapper +++ b/bin/gdb_wrapper @@ -74,343 +74,9 @@ end require 'ruby-debug-ide/greeter' Debugger::print_greeting_msg(nil, nil) -class NativeDebugger - - attr_reader :pid, :main_thread, :process_threads, :pipe - - # @param executable -- path to ruby interpreter - # @param pid -- pid of process you want to debug - # @param flags -- flags you want to specify to your debugger as a string (e.g. "-nx -nh" for gdb to disable .gdbinit) - def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) - @pid = pid - @delimiter = '__OUTPUT_FINISHED__' # for getting response - @tbreak = '__func_to_set_breakpoint_at' - @main_thread = nil - @process_threads = nil - debase_path = gems_to_include.select {|gem_path| gem_path =~ /debase/} - if debase_path.size == 0 - raise 'No debase gem found.' - end - @path_to_attach = find_attach_lib(debase_path[0]) - - @gems_to_include = '["' + gems_to_include * '", "' + '"]' - @debugger_loader_path = debugger_loader_path - @argv = argv - - @eval_string = "rb_eval_string_protect(\"require '#{@debugger_loader_path}'; load_debugger(#{@gems_to_include.gsub("\"", "'")}, #{@argv.gsub("\"", "'")})\", (int *)0)" - - launch_string = "#{self} #{executable} #{flags}" - @pipe = IO.popen(launch_string, 'r+') - $stdout.puts "executed '#{launch_string}'" - end - - def find_attach_lib(debase_path) - attach_lib = debase_path + '/attach' - known_extensions = %w(.so .bundle .dll) - known_extensions.each do |ext| - if File.file?(attach_lib + ext) - return attach_lib + ext - end - end - - raise 'Could not find attach library' - end - - def attach_to_process - execute "attach #{@pid}" - end - - def execute(command) - @pipe.puts command - $stdout.puts "executed `#{command}` command inside #{self}." - if command == 'q' - return '' - end - get_response - end - - def get_response - # we need this hack to understand that debugger gave us all output from last executed command - print_delimiter - - content = '' - loop do - line = @pipe.readline - break if check_delimiter(line) - DebugPrinter.print_debug('respond line: ' + line) - next if line =~ /\(lldb\)/ # lldb repeats your input to its output - content += line - end - - content - end - - def update_threads - - end - - def check_already_under_debug - - end - - def print_delimiter - - end - - def check_delimiter(line) - - end - - def switch_to_thread - - end - - def set_tbreak(str) - execute "tbreak #{str}" - end - - def continue - $stdout.puts 'continuing' - @pipe.puts 'c' - loop do - line = @pipe.readline - break if line =~ /#{Regexp.escape(@tbreak)}/ - end - get_response - end - - def call_start_attach - raise 'No main thread found. Did you forget to call `update_threads`?' if @main_thread == nil - @main_thread.switch - end - - def wait_line_event - call_start_attach - continue - end - - def load_debugger - - end - - def exit - execute 'q' - @pipe.close - end - - def to_s - 'native_debugger' - end - -end - -class LLDB < NativeDebugger - - def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) - super(executable, pid, flags, gems_to_include, debugger_loader_path, argv) - end - - def set_flags - - end - - def update_threads - @process_threads = [] - info_threads = (execute 'thread list').split("\n") - info_threads.each do |thread_info| - next unless thread_info =~ /[\s*]*thread\s#\d+.*/ - is_main = thread_info[0] == '*' - thread_num = thread_info.sub(/[\s*]*thread\s#/, '').sub(/:\s.*$/, '').to_i - thread = ProcessThread.new(thread_num, is_main, thread_info, self) - if thread.is_main - @main_thread = thread - end - @process_threads << thread - end - @process_threads - end - - def check_already_under_debug - threads = execute 'thread list' - threads =~ /ruby-debug-ide/ - end - - def switch_to_thread(thread_num) - execute "thread select #{thread_num}" - end - - def call_start_attach - super() - execute "expr (void *) dlopen(\"#{@path_to_attach}\", 2)" - execute 'expr (int) start_attach()' - set_tbreak(@tbreak) - end - - def print_delimiter - @pipe.puts "script print \"#{@delimiter}\"" - end - - def check_delimiter(line) - line =~ /#{@delimiter}$/ - end - - def load_debugger - execute "expr (VALUE) #{@eval_string}" - end - - def to_s - 'lldb' - end - -end - -class GDB < NativeDebugger - - def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) - super(executable, pid, flags, gems_to_include, debugger_loader_path, argv) - end - - def set_flags - execute 'set scheduler-locking off' # we will deadlock with it - execute 'set unwindonsignal on' # in case of some signal we will exit gdb - end - - def update_threads - @process_threads = [] - info_threads = (execute 'info threads').split("\n") - info_threads.each do |thread_info| - next unless thread_info =~ /[\s*]*\d+\s+Thread.*/ - $stdout.puts "thread_info: #{thread_info}" - is_main = thread_info[0] == '*' - thread_num = thread_info.sub(/[\s*]*/, '').sub(/\s.*$/, '').to_i - thread = ProcessThread.new(thread_num, is_main, thread_info, self) - if thread.is_main - @main_thread = thread - end - @process_threads << thread - end - @process_threads - end - - def check_already_under_debug - threads = execute 'info threads' - threads =~ /ruby-debug-ide/ - end - - def switch_to_thread(thread_num) - execute "thread #{thread_num}" - end - - def call_start_attach - super() - execute "call dlopen(\"#{@path_to_attach}\", 2)" - execute 'call start_attach()' - set_tbreak(@tbreak) - end - - def print_delimiter - @pipe.puts "print \"#{@delimiter}\"" - end - - def check_delimiter(line) - line =~ /\$\d+\s=\s"#{@delimiter}"/ - end - - def load_debugger - execute "call #{@eval_string}" - end - - def to_s - 'gdb' - end - -end - -class ProcessThread - - attr_reader :thread_num, :is_main, :thread_info, :last_bt - - def initialize(thread_num, is_main, thread_info, native_debugger) - @thread_num = thread_num - @is_main = is_main - @native_debugger = native_debugger - @thread_info = thread_info - @last_bt = nil - end - - def switch - @native_debugger.switch_to_thread(thread_num) - end - - def finish - @native_debugger.execute 'finish' - end - - def get_bt - @last_bt = @native_debugger.execute 'bt' - end - - def any_caller_match(bt, pattern) - bt =~ /#{pattern}/ - end - - def is_inside_malloc(bt = get_bt) - if any_caller_match(bt, '(malloc\.c)') - $stderr.puts "process #{@native_debugger.pid} is currently inside malloc." - true - else - false - end - end - - def is_inside_gc(bt = get_bt) - if any_caller_match(bt, '(gc\.c)') - $stderr.puts "process #{@native_debugger.pid} is currently in garbage collection phase." - true - else - false - end - end - - def need_finish_frame - bt = get_bt - is_inside_malloc(bt) || is_inside_gc(bt) - end - -end - -def command_exists(command) - checking_command = "checking command #{command} for existence\n" - `command -v #{command} >/dev/null 2>&1 || { exit 1; }` - if $?.exitstatus != 0 - DebugPrinter.print_debug("#{checking_command}command does not exist.") - else - DebugPrinter.print_debug("#{checking_command}command does exist.") - end - $?.exitstatus == 0 -end - -def choose_debugger(ruby_path, pid, gems_to_include, debugger_loader_path, argv) - if command_exists('lldb') - debugger = LLDB.new(ruby_path, pid, '--no-lldbinit', gems_to_include, debugger_loader_path, argv) - elsif command_exists('gdb') - debugger = GDB.new(ruby_path, pid, '-nh -nx', gems_to_include, debugger_loader_path, argv) - else - raise 'Neither gdb nor lldb was found. Aborting.' - end - - trap('INT') do - unless debugger.pipe.closed? - $stderr.puts "backtraces for threads:\n\n" - debugger.process_threads.each do |thread| - $stderr.puts "#{thread.thread_info}\n#{thread.last_bt}\n\n" - end - end - exit! - end - - debugger -end +require 'ruby-debug-ide/attach/util' +require 'ruby-debug-ide/attach/native_debugger' +require 'ruby-debug-ide/attach/process_thread' debugger = choose_debugger(options.ruby_path, options.pid, options.gems_to_include, debugger_loader_path, argv) debugger.attach_to_process diff --git a/lib/ruby-debug-ide/attach/gdb.rb b/lib/ruby-debug-ide/attach/gdb.rb new file mode 100644 index 0000000..a507880 --- /dev/null +++ b/lib/ruby-debug-ide/attach/gdb.rb @@ -0,0 +1,69 @@ +require 'ruby-debug-ide/attach/native_debugger' + +class GDB < NativeDebugger + + def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) + super(executable, pid, flags, gems_to_include, debugger_loader_path, argv) + end + + def set_flags + execute 'set scheduler-locking off' # we will deadlock with it + execute 'set unwindonsignal on' # in case of some signal we will exit gdb + end + + def update_threads + @process_threads = [] + info_threads = (execute 'info threads').split("\n") + info_threads.each do |thread_info| + next unless thread_info =~ /[\s*]*\d+\s+Thread.*/ + $stdout.puts "thread_info: #{thread_info}" + is_main = thread_info[0] == '*' + thread_num = thread_info.sub(/[\s*]*/, '').sub(/\s.*$/, '').to_i + thread = ProcessThread.new(thread_num, is_main, thread_info, self) + if thread.is_main + @main_thread = thread + end + @process_threads << thread + end + @process_threads + end + + def check_already_under_debug + threads = execute 'info threads' + threads =~ /ruby-debug-ide/ + end + + def switch_to_thread(thread_num) + execute "thread #{thread_num}" + end + + def call_start_attach + super() + execute "call dlopen(\"#{@path_to_attach}\", 2)" + execute 'call start_attach()' + set_tbreak(@tbreak) + end + + def print_delimiter + @pipe.puts "print \"#{@delimiter}\"" + end + + def check_delimiter(line) + line =~ /\$\d+\s=\s"#{@delimiter}"/ + end + + def load_debugger + execute "call #{@eval_string}" + end + + def to_s + GDB.to_s + end + + class << self + def to_s + 'gdb' + end + end + +end diff --git a/lib/ruby-debug-ide/attach/lldb.rb b/lib/ruby-debug-ide/attach/lldb.rb new file mode 100644 index 0000000..87245df --- /dev/null +++ b/lib/ruby-debug-ide/attach/lldb.rb @@ -0,0 +1,67 @@ +require 'ruby-debug-ide/attach/native_debugger' + +class LLDB < NativeDebugger + + def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) + super(executable, pid, flags, gems_to_include, debugger_loader_path, argv) + end + + def set_flags + + end + + def update_threads + @process_threads = [] + info_threads = (execute 'thread list').split("\n") + info_threads.each do |thread_info| + next unless thread_info =~ /[\s*]*thread\s#\d+.*/ + is_main = thread_info[0] == '*' + thread_num = thread_info.sub(/[\s*]*thread\s#/, '').sub(/:\s.*$/, '').to_i + thread = ProcessThread.new(thread_num, is_main, thread_info, self) + if thread.is_main + @main_thread = thread + end + @process_threads << thread + end + @process_threads + end + + def check_already_under_debug + threads = execute 'thread list' + threads =~ /ruby-debug-ide/ + end + + def switch_to_thread(thread_num) + execute "thread select #{thread_num}" + end + + def call_start_attach + super() + execute "expr (void *) dlopen(\"#{@path_to_attach}\", 2)" + execute 'expr (int) start_attach()' + set_tbreak(@tbreak) + end + + def print_delimiter + @pipe.puts "script print \"#{@delimiter}\"" + end + + def check_delimiter(line) + line =~ /#{@delimiter}$/ + end + + def load_debugger + execute "expr (VALUE) #{@eval_string}" + end + + def to_s + LLDB.to_s + end + + class << self + def to_s + 'lldb' + end + end + +end diff --git a/lib/ruby-debug-ide/attach/native_debugger.rb b/lib/ruby-debug-ide/attach/native_debugger.rb new file mode 100644 index 0000000..d48ed1e --- /dev/null +++ b/lib/ruby-debug-ide/attach/native_debugger.rb @@ -0,0 +1,129 @@ +class NativeDebugger + + attr_reader :pid, :main_thread, :process_threads, :pipe + + # @param executable -- path to ruby interpreter + # @param pid -- pid of process you want to debug + # @param flags -- flags you want to specify to your debugger as a string (e.g. "-nx -nh" for gdb to disable .gdbinit) + def initialize(executable, pid, flags, gems_to_include, debugger_loader_path, argv) + @pid = pid + @delimiter = '__OUTPUT_FINISHED__' # for getting response + @tbreak = '__func_to_set_breakpoint_at' + @main_thread = nil + @process_threads = nil + debase_path = gems_to_include.select {|gem_path| gem_path =~ /debase/} + if debase_path.size == 0 + raise 'No debase gem found.' + end + @path_to_attach = find_attach_lib(debase_path[0]) + + @gems_to_include = '["' + gems_to_include * '", "' + '"]' + @debugger_loader_path = debugger_loader_path + @argv = argv + + @eval_string = "rb_eval_string_protect(\"require '#{@debugger_loader_path}'; load_debugger(#{@gems_to_include.gsub("\"", "'")}, #{@argv.gsub("\"", "'")})\", (int *)0)" + + launch_string = "#{self} #{executable} #{flags}" + @pipe = IO.popen(launch_string, 'r+') + $stdout.puts "executed '#{launch_string}'" + end + + def find_attach_lib(debase_path) + attach_lib = debase_path + '/attach' + known_extensions = %w(.so .bundle .dll) + known_extensions.each do |ext| + if File.file?(attach_lib + ext) + return attach_lib + ext + end + end + + raise 'Could not find attach library' + end + + def attach_to_process + execute "attach #{@pid}" + end + + def execute(command) + @pipe.puts command + $stdout.puts "executed `#{command}` command inside #{self}." + if command == 'q' + return '' + end + get_response + end + + def get_response + # we need this hack to understand that debugger gave us all output from last executed command + print_delimiter + + content = '' + loop do + line = @pipe.readline + break if check_delimiter(line) + DebugPrinter.print_debug('respond line: ' + line) + next if line =~ /\(lldb\)/ # lldb repeats your input to its output + content += line + end + + content + end + + def update_threads + + end + + def check_already_under_debug + + end + + def print_delimiter + + end + + def check_delimiter(line) + + end + + def switch_to_thread + + end + + def set_tbreak(str) + execute "tbreak #{str}" + end + + def continue + $stdout.puts 'continuing' + @pipe.puts 'c' + loop do + line = @pipe.readline + break if line =~ /#{Regexp.escape(@tbreak)}/ + end + get_response + end + + def call_start_attach + raise 'No main thread found. Did you forget to call `update_threads`?' if @main_thread == nil + @main_thread.switch + end + + def wait_line_event + call_start_attach + continue + end + + def load_debugger + + end + + def exit + execute 'q' + @pipe.close + end + + def to_s + 'native_debugger' + end + +end diff --git a/lib/ruby-debug-ide/attach/process_thread.rb b/lib/ruby-debug-ide/attach/process_thread.rb new file mode 100644 index 0000000..cee7ea7 --- /dev/null +++ b/lib/ruby-debug-ide/attach/process_thread.rb @@ -0,0 +1,54 @@ +require 'ruby-debug-ide/attach/native_debugger' + +class ProcessThread + + attr_reader :thread_num, :is_main, :thread_info, :last_bt + + def initialize(thread_num, is_main, thread_info, native_debugger) + @thread_num = thread_num + @is_main = is_main + @native_debugger = native_debugger + @thread_info = thread_info + @last_bt = nil + end + + def switch + @native_debugger.switch_to_thread(thread_num) + end + + def finish + @native_debugger.execute 'finish' + end + + def get_bt + @last_bt = @native_debugger.execute 'bt' + end + + def any_caller_match(bt, pattern) + bt =~ /#{pattern}/ + end + + def is_inside_malloc(bt = get_bt) + if any_caller_match(bt, '(malloc)') + $stderr.puts "process #{@native_debugger.pid} is currently inside malloc." + true + else + false + end + end + + def is_inside_gc(bt = get_bt) + if any_caller_match(bt, '(gc\.c)') + $stderr.puts "process #{@native_debugger.pid} is currently in garbage collection phase." + true + else + false + end + end + + def need_finish_frame + bt = get_bt + is_inside_malloc(bt) || is_inside_gc(bt) + end + +end diff --git a/lib/ruby-debug-ide/attach/util.rb b/lib/ruby-debug-ide/attach/util.rb new file mode 100644 index 0000000..035933c --- /dev/null +++ b/lib/ruby-debug-ide/attach/util.rb @@ -0,0 +1,35 @@ +require 'ruby-debug-ide/attach/lldb' +require 'ruby-debug-ide/attach/gdb' + +def command_exists(command) + checking_command = "checking command #{command} for existence\n" + `command -v #{command} >/dev/null 2>&1 || { exit 1; }` + if $?.exitstatus != 0 + DebugPrinter.print_debug("#{checking_command}command does not exist.") + else + DebugPrinter.print_debug("#{checking_command}command does exist.") + end + $?.exitstatus == 0 +end + +def choose_debugger(ruby_path, pid, gems_to_include, debugger_loader_path, argv) + if command_exists(LLDB.to_s) + debugger = LLDB.new(ruby_path, pid, '--no-lldbinit', gems_to_include, debugger_loader_path, argv) + elsif command_exists(GDB.to_s) + debugger = GDB.new(ruby_path, pid, '-nh -nx', gems_to_include, debugger_loader_path, argv) + else + raise 'Neither gdb nor lldb was found. Aborting.' + end + + trap('INT') do + unless debugger.pipe.closed? + $stderr.puts "backtraces for threads:\n\n" + debugger.process_threads.each do |thread| + $stderr.puts "#{thread.thread_info}\n#{thread.last_bt}\n\n" + end + end + exit! + end + + debugger +end