From 2ea619c087ad71151796d4dca3c8f5bd7ec31d37 Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Fri, 24 Mar 2023 23:50:07 +0800 Subject: [PATCH] Support native integration with ruby/debug --- lib/irb.rb | 48 +++++++++++++++- lib/irb/cmd/debug.rb | 92 ++++-------------------------- lib/irb/context.rb | 19 ++++++- lib/irb/debug.rb | 109 ++++++++++++++++++++++++++++++++++++ lib/irb/debug/ui.rb | 106 +++++++++++++++++++++++++++++++++++ lib/irb/ruby-lex.rb | 6 +- test/irb/helper.rb | 1 + test/irb/test_debug_cmd.rb | 111 ++++++++++++++++++++++++++++++++----- 8 files changed, 395 insertions(+), 97 deletions(-) create mode 100644 lib/irb/debug.rb create mode 100644 lib/irb/debug/ui.rb diff --git a/lib/irb.rb b/lib/irb.rb index 26432a0fa..326ad9a38 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -18,6 +18,7 @@ require_relative "irb/version" require_relative "irb/easter-egg" +require_relative "irb/debug" # IRB stands for "interactive Ruby" and is a tool to interactively execute Ruby # expressions read from the standard input. @@ -374,6 +375,41 @@ class Abort < Exception;end @CONF = {} + def self.debug_readline(binding:, scanner:, prompt_name:) + workspace = IRB::WorkSpace.new(binding) + + if @irb + @irb.context.workspace = workspace + else + @irb = IRB::Irb.new(workspace) + conf[:IRB_RC].call(@irb.context) if conf[:IRB_RC] + conf[:MAIN_CONTEXT] = @irb.context + @irb.scanner = scanner + # When users run debugging commands, the original IRB session would be interrupted in the middle, which will cause + # that command line increase not being recorded. + # So we need to compensate that from here. + @irb.scanner.increase_line_no(1) + end + + @irb.context.irb_name = prompt_name + @irb.context.with_debugger = true + + # When users run: + # 1. Debugging commands + # 2. Any input that's not irb-command, like `foo = 123` + # + # :IRB_EXIT will be thrown with the input and we will just pass it to the debugger + input = catch(:IRB_EXIT) do + @irb.eval_input + end + + if input + line_addition = input.include?("\n") ? input.count("\n") : 1 + @irb.scanner.increase_line_no(line_addition) + end + + input + end # Displays current configuration. # @@ -915,7 +951,6 @@ def @CONF.inspect array.join("\n") end end - class Binding # Opens an IRB session where +binding.irb+ is called which allows for # interactive debugging. You can call any methods or variables available in @@ -980,7 +1015,16 @@ def irb(show_code: true) STDOUT.print(workspace.code_around_binding) if show_code binding_irb = IRB::Irb.new(workspace) binding_irb.context.irb_path = File.expand_path(source_location[0]) - binding_irb.run(IRB.conf) + + if defined?(DEBUGGER__::SESSION) + # If we've started a debugger session and hit another binding.irb, we don't want to start an IRB session + # instead, we want to resume the debugger session. + IRB::Debug.setup + IRB::Debug.insert_debug_break + else + binding_irb.run(IRB.conf) + end + binding_irb.debug_break end end diff --git a/lib/irb/cmd/debug.rb b/lib/irb/cmd/debug.rb index 7d39b9fa2..2af0ecbd1 100644 --- a/lib/irb/cmd/debug.rb +++ b/lib/irb/cmd/debug.rb @@ -1,4 +1,5 @@ require_relative "nop" +require_relative "../debug" module IRB # :stopdoc: @@ -12,15 +13,23 @@ class Debug < Nop '', binding.method(:irb).source_location.first, ].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ } - IRB_DIR = File.expand_path('..', __dir__) def execute(pre_cmds: nil, do_cmds: nil) + if defined?(DEBUGGER__::SESSION) + if cmd = pre_cmds || do_cmds + throw :IRB_EXIT, cmd + else + puts "IRB is already running with a debug session." + return + end + end + unless binding_irb? puts "`debug` command is only available when IRB is started with binding.irb" return end - unless setup_debugger + unless IRB::Debug.setup puts <<~MSG You need to install the debug gem before using this command. If you use `bundle exec`, please add `gem "debug"` into your Gemfile. @@ -28,19 +37,8 @@ def execute(pre_cmds: nil, do_cmds: nil) return end - options = { oneshot: true, hook_call: false } - if pre_cmds || do_cmds - options[:command] = ['irb', pre_cmds, do_cmds] - end - if DEBUGGER__::LineBreakpoint.instance_method(:initialize).parameters.include?([:key, :skip_src]) - options[:skip_src] = true - end + IRB::Debug.insert_debug_break(pre_cmds: pre_cmds, do_cmds: do_cmds) - # To make debugger commands like `next` or `continue` work without asking - # the user to quit IRB after that, we need to exit IRB first and then hit - # a TracePoint on #debug_break. - file, lineno = IRB::Irb.instance_method(:debug_break).source_location - DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options) # exit current Irb#run call throw :IRB_EXIT end @@ -54,72 +52,6 @@ def binding_irb? end end end - - module SkipPathHelperForIRB - def skip_internal_path?(path) - # The latter can be removed once https://github.com/ruby/debug/issues/866 is resolved - super || path.match?(IRB_DIR) || path.match?('') - end - end - - def setup_debugger - unless defined?(DEBUGGER__::SESSION) - begin - require "debug/session" - rescue LoadError # debug.gem is not written in Gemfile - return false unless load_bundled_debug_gem - end - DEBUGGER__.start(nonstop: true) - end - - unless DEBUGGER__.respond_to?(:capture_frames_without_irb) - DEBUGGER__.singleton_class.send(:alias_method, :capture_frames_without_irb, :capture_frames) - - def DEBUGGER__.capture_frames(*args) - frames = capture_frames_without_irb(*args) - frames.reject! do |frame| - frame.realpath&.start_with?(IRB_DIR) || frame.path == "" - end - frames - end - - DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB) - end - - true - end - - # This is used when debug.gem is not written in Gemfile. Even if it's not - # installed by `bundle install`, debug.gem is installed by default because - # it's a bundled gem. This method tries to activate and load that. - def load_bundled_debug_gem - # Discover latest debug.gem under GEM_PATH - debug_gem = Gem.paths.path.flat_map { |path| Dir.glob("#{path}/gems/debug-*") }.select do |path| - File.basename(path).match?(/\Adebug-\d+\.\d+\.\d+(\w+)?\z/) - end.sort_by do |path| - Gem::Version.new(File.basename(path).delete_prefix('debug-')) - end.last - return false unless debug_gem - - # Discover debug/debug.so under extensions for Ruby 3.2+ - ext_name = "/debug/debug.#{RbConfig::CONFIG['DLEXT']}" - ext_path = Gem.paths.path.flat_map do |path| - Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}#{ext_name}") - end.first - - # Attempt to forcibly load the bundled gem - if ext_path - $LOAD_PATH << ext_path.delete_suffix(ext_name) - end - $LOAD_PATH << "#{debug_gem}/lib" - begin - require "debug/session" - puts "Loaded #{File.basename(debug_gem)}" - true - rescue LoadError - false - end - end end class DebugCommand < Debug diff --git a/lib/irb/context.rb b/lib/irb/context.rb index f398c6f75..992e7346e 100644 --- a/lib/irb/context.rb +++ b/lib/irb/context.rb @@ -329,6 +329,8 @@ def main # User-defined IRB command aliases attr_accessor :command_aliases + attr_accessor :with_debugger + # Alias for #use_multiline alias use_multiline? use_multiline # Alias for #use_singleline @@ -474,6 +476,8 @@ def inspect_mode=(opt) end def evaluate(line, line_no, exception: nil) # :nodoc: + original_line = line + @line_no = line_no if exception line_no -= 1 @@ -490,11 +494,24 @@ def evaluate(line, line_no, exception: nil) # :nodoc: # Hook command-specific transformation command_class = ExtendCommandBundle.load_command(command) + + # If the debugger is activated, we can directly pass debugging command's input + if with_debugger && command_class&.ancestors&.include?(ExtendCommand::DebugCommand) + throw :IRB_EXIT, original_line + end + if command_class&.respond_to?(:transform_args) line = "#{command} #{command_class.transform_args(args)}" end - set_last_value(@workspace.evaluate(line, irb_path, line_no)) + # Use debugger to evaluate non-command input + # Otherwise, the expression will be evaluated in the debugger's main session thread + # This is the only way to run the user's program in the expected thread + if with_debugger && !command_class + throw :IRB_EXIT, line + else + set_last_value(@workspace.evaluate(line, irb_path, line_no)) + end end def inspect_last_value # :nodoc: diff --git a/lib/irb/debug.rb b/lib/irb/debug.rb new file mode 100644 index 000000000..030cf32aa --- /dev/null +++ b/lib/irb/debug.rb @@ -0,0 +1,109 @@ +module IRB + module Debug + BINDING_IRB_FRAME_REGEXPS = [ + '', + binding.method(:irb).source_location.first, + ].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ } + IRB_DIR = File.expand_path('..', __dir__) + + class << self + def insert_debug_break(pre_cmds: nil, do_cmds: nil) + options = { oneshot: true, hook_call: false } + + if pre_cmds || do_cmds + options[:command] = ['irb', pre_cmds, do_cmds] + end + if DEBUGGER__::LineBreakpoint.instance_method(:initialize).parameters.include?([:key, :skip_src]) + options[:skip_src] = true + end + + # To make debugger commands like `next` or `continue` work without asking + # the user to quit IRB after that, we need to exit IRB first and then hit + # a TracePoint on #debug_break. + file, lineno = IRB::Irb.instance_method(:debug_break).source_location + DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options) + end + + def setup + unless defined?(DEBUGGER__::SESSION) + begin + require "debug/session" + rescue LoadError # debug.gem is not written in Gemfile + return false unless load_bundled_debug_gem + end + require 'irb/debug/ui' + DEBUGGER__::CONFIG.set_config + # We want to remember the program's main thread, so we can use it to get its status/context through debugger later + thread = Thread.current + scanner = IRB.conf[:MAIN_CONTEXT].irb.scanner + DEBUGGER__.initialize_session{ IRB::Debug::UI.new(thread, scanner) } + end + + unless DEBUGGER__.respond_to?(:capture_frames_without_irb) + DEBUGGER__.singleton_class.send(:alias_method, :capture_frames_without_irb, :capture_frames) + + def DEBUGGER__.capture_frames(*args) + frames = capture_frames_without_irb(*args) + frames.reject! do |frame| + frame.realpath&.start_with?(IRB_DIR) || frame.path == "" + end + frames + end + + DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB) + end + + true + end + + private + + def binding_irb? + caller.any? do |frame| + BINDING_IRB_FRAME_REGEXPS.any? do |regexp| + frame.match?(regexp) + end + end + end + + module SkipPathHelperForIRB + def skip_internal_path?(path) + # The latter can be removed once https://github.com/ruby/debug/issues/866 is resolved + super || path.match?(IRB_DIR) || path.match?('') + end + end + + # This is used when debug.gem is not written in Gemfile. Even if it's not + # installed by `bundle install`, debug.gem is installed by default because + # it's a bundled gem. This method tries to activate and load that. + def load_bundled_debug_gem + # Discover latest debug.gem under GEM_PATH + debug_gem = Gem.paths.path.flat_map { |path| Dir.glob("#{path}/gems/debug-*") }.select do |path| + File.basename(path).match?(/\Adebug-\d+\.\d+\.\d+(\w+)?\z/) + end.sort_by do |path| + Gem::Version.new(File.basename(path).delete_prefix('debug-')) + end.last + return false unless debug_gem + + # Discover debug/debug.so under extensions for Ruby 3.2+ + ext_name = "/debug/debug.#{RbConfig::CONFIG['DLEXT']}" + ext_path = Gem.paths.path.flat_map do |path| + Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}#{ext_name}") + end.first + + # Attempt to forcibly load the bundled gem + if ext_path + $LOAD_PATH << ext_path.delete_suffix(ext_name) + end + $LOAD_PATH << "#{debug_gem}/lib" + begin + require "debug/session" + puts "Loaded #{File.basename(debug_gem)}" + true + rescue LoadError + false + end + end + end + end +end diff --git a/lib/irb/debug/ui.rb b/lib/irb/debug/ui.rb new file mode 100644 index 000000000..732124059 --- /dev/null +++ b/lib/irb/debug/ui.rb @@ -0,0 +1,106 @@ +require 'io/console/size' +require 'debug/console' + +module IRB + module Debug + class UI < DEBUGGER__::UI_Base + PROMPT_NAME = "irb:rdbg" + + def initialize(thread, scanner) + @thread = thread + @scanner = scanner + end + + def remote? + false + end + + def activate session, on_fork: false + end + + def deactivate + end + + def width + if (w = IO.console_size[1]) == 0 # for tests PTY + 80 + else + w + end + end + + def quit n + yield + exit n + end + + def ask prompt + setup_interrupt do + print prompt + ($stdin.gets || '').strip + end + end + + def puts str = nil + case str + when Array + str.each{|line| + $stdout.puts line.chomp + } + when String + str.each_line{|line| + $stdout.puts line.chomp + } + when nil + $stdout.puts + end + end + + def readline _ + setup_interrupt do + tc = DEBUGGER__::SESSION.get_thread_client(@thread) + cmd = IRB.debug_readline(binding: tc.current_frame.binding || TOPLEVEL_BINDING, scanner: @scanner, prompt_name: PROMPT_NAME) + + case cmd + when nil # when user types C-d + "exit!" + else + cmd + end + end + end + + def setup_interrupt + DEBUGGER__::SESSION.intercept_trap_sigint false do + current_thread = Thread.current # should be session_server thread + + prev_handler = trap(:INT){ + current_thread.raise Interrupt + } + + yield + ensure + trap(:INT, prev_handler) + end + end + + def after_fork_parent + parent_pid = Process.pid + + at_exit{ + DEBUGGER__::SESSION.intercept_trap_sigint_end + trap(:SIGINT, :IGNORE) + + if Process.pid == parent_pid + # only check child process from its parent + begin + # wait for all child processes to keep terminal + Process.waitpid + rescue Errno::ESRCH, Errno::ECHILD + end + end + } + end + end + end +end diff --git a/lib/irb/ruby-lex.rb b/lib/irb/ruby-lex.rb index e29d52e47..f2778c9b0 100644 --- a/lib/irb/ruby-lex.rb +++ b/lib/irb/ruby-lex.rb @@ -218,6 +218,10 @@ def save_prompt_to_context_io(ltype, indent, continue, line_num_offset) @prompt.call(ltype, indent, continue, @line_no + line_num_offset) end + def increase_line_no(addition) + @line_no += addition + end + def readmultiline save_prompt_to_context_io(nil, 0, false, 0) @@ -254,7 +258,7 @@ def each_top_level_statement code.force_encoding(@io.encoding) yield code, @line_no end - @line_no += code.count("\n") + increase_line_no(code.count("\n")) rescue TerminateLineInput end end diff --git a/test/irb/helper.rb b/test/irb/helper.rb index f4f969e88..2124f3ec9 100644 --- a/test/irb/helper.rb +++ b/test/irb/helper.rb @@ -1,5 +1,6 @@ require "test/unit" require "pathname" +require_relative "../lib/envutil" begin require_relative "../lib/helper" diff --git a/test/irb/test_debug_cmd.rb b/test/irb/test_debug_cmd.rb index 80331ea0d..f505f9a6e 100644 --- a/test/irb/test_debug_cmd.rb +++ b/test/irb/test_debug_cmd.rb @@ -8,14 +8,13 @@ require "tempfile" require "tmpdir" -require "envutil" require_relative "helper" module TestIRB LIB = File.expand_path("../../lib", __dir__) - class DebugCommandTestCase < TestCase + class DebugCommandTest < TestCase IRB_AND_DEBUGGER_OPTIONS = { "NO_COLOR" => "true", "RUBY_DEBUG_HISTORY_FILE" => '' } @@ -40,10 +39,10 @@ def foo output = run_ruby_file do type "backtrace" - type "q!" + type "exit!" end - assert_match(/\(rdbg:irb\) backtrace/, output) + assert_match(/irb\(main\):001:0> backtrace/, output) assert_match(/Object#foo at #{@ruby_file.to_path}/, output) end @@ -59,7 +58,8 @@ def test_debug type "continue" end - assert_match(/\(rdbg\) next/, output) + assert_match(/irb\(main\):001:0> debug/, output) + assert_match(/irb:rdbg\(main\):002:0> next/, output) assert_match(/=> 2\| puts "hello"/, output) end @@ -74,7 +74,7 @@ def test_next type "continue" end - assert_match(/\(rdbg:irb\) next/, output) + assert_match(/irb\(main\):001:0> next/, output) assert_match(/=> 2\| puts "hello"/, output) end @@ -90,7 +90,7 @@ def test_break type "continue" end - assert_match(/\(rdbg:irb\) break/, output) + assert_match(/irb\(main\):001:0> break/, output) assert_match(/=> 2\| puts "Hello"/, output) end @@ -109,7 +109,7 @@ def test_delete type "continue" end - assert_match(/\(rdbg:irb\) delete/, output) + assert_match(/irb:rdbg\(main\):003:0> delete/, output) assert_match(/deleted: #0 BP - Line/, output) end @@ -128,11 +128,44 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) step/, output) + assert_match(/irb\(main\):001:0> step/, output) assert_match(/=> 5\| foo/, output) assert_match(/=> 2\| puts "Hello"/, output) end + def test_long_stepping + write_ruby <<~'RUBY' + class Foo + def foo(num) + bar(num + 10) + end + + def bar(num) + num + end + end + + binding.irb + Foo.new.foo(100) + RUBY + + output = run_ruby_file do + type "step" + type "step" + type "step" + type "step" + type "num" + type "continue" + end + + assert_match(/irb\(main\):001:0> step/, output) + assert_match(/irb:rdbg\(main\):002:0> step/, output) + assert_match(/irb:rdbg\(#\):003:0> step/, output) + assert_match(/irb:rdbg\(#\):004:0> step/, output) + assert_match(/irb:rdbg\(#\):005:0> num/, output) + assert_match(/=> 110/, output) + end + def test_continue write_ruby <<~'RUBY' binding.irb @@ -146,8 +179,9 @@ def test_continue type "continue" end - assert_match(/\(rdbg:irb\) continue/, output) + assert_match(/irb\(main\):001:0> continue/, output) assert_match(/=> 3: binding.irb/, output) + assert_match(/irb:rdbg\(main\):002:0> continue/, output) end def test_finish @@ -164,7 +198,7 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) finish/, output) + assert_match(/irb\(main\):001:0> finish/, output) assert_match(/=> 4\| end/, output) end @@ -182,7 +216,7 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) info/, output) + assert_match(/irb\(main\):001:0> info/, output) assert_match(/%self = main/, output) assert_match(/a = "Hello"/, output) end @@ -199,10 +233,61 @@ def test_catch type "continue" end - assert_match(/\(rdbg:irb\) catch/, output) + assert_match(/irb\(main\):001:0> catch/, output) assert_match(/Stop by #0 BP - Catch "ZeroDivisionError"/, output) end + def test_exit + write_ruby <<~'RUBY' + binding.irb + puts "hello" + RUBY + + output = run_ruby_file do + type "next" + type "exit" + end + + assert_match(/irb\(main\):001:0> next/, output) + end + + def test_prompt_line_number_continues + write_ruby <<~'ruby' + binding.irb + puts "Hello" + puts "World" + ruby + + output = run_ruby_file do + type "123" + type "456" + type "next" + type "info" + type "next" + type "continue" + end + + assert_match(/irb\(main\):003:0> next/, output) + assert_match(/irb:rdbg\(main\):004:0> info/, output) + assert_match(/irb:rdbg\(main\):005:0> next/, output) + end + + def test_thread_control + write_ruby <<~'ruby' + current_thread = Thread.current + binding.irb + ruby + + output = run_ruby_file do + type "debug" + type '"Threads match: #{current_thread == Thread.current}"' + type "continue" + end + + assert_match(/irb\(main\):001:0> debug/, output) + assert_match(/Threads match: true/, output) + end + private TIMEOUT_SEC = 3