diff options
author | Stan Lo <stan001212@gmail.com> | 2023-08-13 19:30:30 +0100 |
---|---|---|
committer | git <svn-admin@ruby-lang.org> | 2023-08-13 18:30:34 +0000 |
commit | 7f8f62c93bf3d11a0321fa91823065a2ff36f6d0 (patch) | |
tree | 2dffe13305f50883f33644f9d701ebb832ec0ab4 /lib | |
parent | 9099d62ac77cdca548bc4110e2cb03057ef0ac8f (diff) | |
download | ruby-7f8f62c93bf3d11a0321fa91823065a2ff36f6d0.tar.gz |
[ruby/irb] Support seamless integration with ruby/debug
(https://github.com/ruby/irb/pull/575)
* Support native integration with ruby/debug
* Prevent using multi-irb and activating debugger at the same time
Multi-irb makes a few assumptions:
- IRB will manage all threads that host sub-irb sessions
- All IRB sessions will be run on the threads created by IRB itself
However, when using the debugger these assumptions are broken:
- `debug` will freeze ALL threads when it suspends the session (e.g. when
hitting a breakpoint, or performing step-debugging).
- Since the irb-debug integration runs IRB as the debugger's interface,
it will be run on the debugger's thread, which is not managed by IRB.
So we should prevent the 2 features from being used at the same time.
To do that, we check if the other feature is already activated when
executing the commands that would activate the other feature.
https://github.com/ruby/irb/commit/d8fb3246be
Diffstat (limited to 'lib')
-rw-r--r-- | lib/irb.rb | 83 | ||||
-rw-r--r-- | lib/irb/cmd/debug.rb | 128 | ||||
-rw-r--r-- | lib/irb/cmd/subirb.rb | 36 | ||||
-rw-r--r-- | lib/irb/context.rb | 2 | ||||
-rw-r--r-- | lib/irb/debug.rb | 127 | ||||
-rw-r--r-- | lib/irb/debug/ui.rb | 104 | ||||
-rw-r--r-- | lib/irb/history.rb | 4 | ||||
-rw-r--r-- | lib/irb/ruby-lex.rb | 6 | ||||
-rw-r--r-- | lib/irb/workspace.rb | 4 |
9 files changed, 392 insertions, 102 deletions
diff --git a/lib/irb.rb b/lib/irb.rb index c3631715da..c884d70a67 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -18,6 +18,7 @@ require_relative "irb/color" 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. @@ -373,8 +374,6 @@ module IRB class Abort < Exception;end @CONF = {} - - # Displays current configuration. # # Modifying the configuration is achieved by sending a message to IRB.conf. @@ -441,7 +440,7 @@ module IRB # Creates a new irb session def initialize(workspace = nil, input_method = nil) @context = Context.new(self, workspace, input_method) - @context.main.extend ExtendCommandBundle + @context.workspace.load_commands_to_main @signal_status = :IN_IRB @scanner = RubyLex.new(@context) end @@ -457,6 +456,38 @@ module IRB end end + def debug_readline(binding) + workspace = IRB::WorkSpace.new(binding) + context.workspace = workspace + context.workspace.load_commands_to_main + scanner.increase_line_no(1) + + # When users run: + # 1. Debugging commands, like `step 2` + # 2. Any input that's not irb-command, like `foo = 123` + # + # Irb#eval_input will simply return the input, and we need to pass it to the debugger. + input = if IRB.conf[:SAVE_HISTORY] && context.io.support_history_saving? + # Previous IRB session's history has been saved when `Irb#run` is exited + # We need to make sure the saved history is not saved again by reseting the counter + context.io.reset_history_counter + + begin + eval_input + ensure + context.io.save_history + end + else + eval_input + end + + if input&.include?("\n") + scanner.increase_line_no(input.count("\n") - 1) + end + + input + end + def run(conf = IRB.conf) in_nested_session = !!conf[:MAIN_CONTEXT] conf[:IRB_RC].call(context) if conf[:IRB_RC] @@ -542,6 +573,18 @@ module IRB @scanner.each_top_level_statement do |line, line_no, is_assignment| signal_status(:IN_EVAL) do begin + # If the integration with debugger is activated, we need to handle certain input differently + if @context.with_debugger + command_class = load_command_class(line) + # First, let's pass debugging command's input to debugger + # Secondly, we need to let debugger 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 !command_class || ExtendCommand::DebugCommand > command_class + return line + end + end + evaluate_line(line, line_no) # Don't echo if the line ends with a semicolon @@ -633,6 +676,12 @@ module IRB @context.evaluate(line, line_no) end + def load_command_class(line) + command, _ = line.split(/\s/, 2) + command_name = @context.command_aliases[command.to_sym] + ExtendCommandBundle.load_command(command_name || command) + end + def convert_invalid_byte_sequence(str, enc) str.force_encoding(enc) str.scrub { |c| @@ -986,12 +1035,32 @@ class Binding # # See IRB@Usage for more information. def irb(show_code: true) + # Setup IRB with the current file's path and no command line arguments IRB.setup(source_location[0], argv: []) + # Create a new workspace using the current binding workspace = IRB::WorkSpace.new(self) + # Print the code around the binding if show_code is 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) - binding_irb.debug_break + # Get the original IRB instance + debugger_irb = IRB.instance_variable_get(:@debugger_irb) + + irb_path = File.expand_path(source_location[0]) + + if debugger_irb + # If we're already in a debugger session, set the workspace and irb_path for the original IRB instance + debugger_irb.context.workspace = workspace + debugger_irb.context.irb_path = irb_path + # 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 irb:rdbg session. + IRB::Debug.setup(debugger_irb) + IRB::Debug.insert_debug_break + debugger_irb.debug_break + else + # If we're not in a debugger session, create a new IRB instance with the current workspace + binding_irb = IRB::Irb.new(workspace) + binding_irb.context.irb_path = irb_path + binding_irb.run(IRB.conf) + binding_irb.debug_break + end end end diff --git a/lib/irb/cmd/debug.rb b/lib/irb/cmd/debug.rb index 7d39b9fa27..9eca964218 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,37 +13,46 @@ module IRB '<internal:prelude>', 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) - unless binding_irb? - puts "`debug` command is only available when IRB is started with binding.irb" - return - end + if irb_context.with_debugger + # If IRB is already running with a debug session, throw the command and IRB.debug_readline will pass it to the debugger. + if cmd = pre_cmds || do_cmds + throw :IRB_EXIT, cmd + else + puts "IRB is already running with a debug session." + return + end + else + # If IRB is not running with a debug session yet, then: + # 1. Check if the debugging command is run from a `binding.irb` call. + # 2. If so, try setting up the debug gem. + # 3. Insert a debug breakpoint at `Irb#debug_break` with the intended command. + # 4. Exit the current Irb#run call via `throw :IRB_EXIT`. + # 5. `Irb#debug_break` will be called and trigger the breakpoint, which will run the intended command. + unless binding_irb? + puts "`debug` command is only available when IRB is started with binding.irb" + return + end - unless setup_debugger - 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. - MSG - return - end + if IRB.respond_to?(:JobManager) + warn "Can't start the debugger when IRB is running in a multi-IRB session." + 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 + unless IRB::Debug.setup(irb_context.irb) + 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. + MSG + return + 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) - # exit current Irb#run call - throw :IRB_EXIT + IRB::Debug.insert_debug_break(pre_cmds: pre_cmds, do_cmds: do_cmds) + + # exit current Irb#run call + throw :IRB_EXIT + end end private @@ -54,72 +64,6 @@ module 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?('<internal:prelude>') - 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 == "<internal:prelude>" - 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/cmd/subirb.rb b/lib/irb/cmd/subirb.rb index 3018ab277b..5ffd646416 100644 --- a/lib/irb/cmd/subirb.rb +++ b/lib/irb/cmd/subirb.rb @@ -11,8 +11,7 @@ module IRB module ExtendCommand class MultiIRBCommand < Nop - def initialize(conf) - super + def execute(*args) extend_irb_context end @@ -29,6 +28,10 @@ module IRB # this extension patches IRB context like IRB.CurrentContext require_relative "../ext/multi-irb" end + + def print_debugger_warning + warn "Multi-IRB commands are not available when the debugger is enabled." + end end class IrbCommand < MultiIRBCommand @@ -37,6 +40,13 @@ module IRB def execute(*obj) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.irb(nil, *obj) end end @@ -47,6 +57,13 @@ module IRB def execute print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.JobManager end end @@ -57,6 +74,14 @@ module IRB def execute(key = nil) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super + raise CommandArgumentError.new("Please specify the id of target IRB job (listed in the `jobs` command).") unless key IRB.JobManager.switch(key) end @@ -68,6 +93,13 @@ module IRB def execute(*keys) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.JobManager.kill(*keys) end end diff --git a/lib/irb/context.rb b/lib/irb/context.rb index 18125ff6fb..43d9b53435 100644 --- a/lib/irb/context.rb +++ b/lib/irb/context.rb @@ -345,6 +345,8 @@ module IRB # 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 diff --git a/lib/irb/debug.rb b/lib/irb/debug.rb new file mode 100644 index 0000000000..dab9d1846a --- /dev/null +++ b/lib/irb/debug.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module IRB + module Debug + BINDING_IRB_FRAME_REGEXPS = [ + '<internal:prelude>', + 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(irb) + # When debug session is not started at all + 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__::CONFIG.set_config + configure_irb_for_debugger(irb) + thread = Thread.current + + DEBUGGER__.initialize_session{ IRB::Debug::UI.new(thread, irb) } + end + + # When debug session was previously started but not by IRB + if defined?(DEBUGGER__::SESSION) && !irb.context.with_debugger + configure_irb_for_debugger(irb) + thread = Thread.current + + DEBUGGER__::SESSION.reset_ui(IRB::Debug::UI.new(thread, irb)) + end + + # Apply patches to debug gem so it skips IRB frames + 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 == "<internal:prelude>" + end + frames + end + + DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB) + end + + true + end + + private + + def configure_irb_for_debugger(irb) + require 'irb/debug/ui' + IRB.instance_variable_set(:@debugger_irb, irb) + irb.context.with_debugger = true + irb.context.irb_name = "irb:rdbg" + end + + 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?('<internal:prelude>') + 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 0000000000..a4ca4fdf0f --- /dev/null +++ b/lib/irb/debug/ui.rb @@ -0,0 +1,104 @@ +require 'io/console/size' +require 'debug/console' + +module IRB + module Debug + class UI < DEBUGGER__::UI_Base + def initialize(thread, irb) + @thread = thread + @irb = irb + 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(tc.current_frame.binding || TOPLEVEL_BINDING) + + case cmd + when nil # when user types C-d + "continue" + 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/history.rb b/lib/irb/history.rb index 516890ac05..ae924d152b 100644 --- a/lib/irb/history.rb +++ b/lib/irb/history.rb @@ -4,6 +4,10 @@ module IRB true end + def reset_history_counter + @loaded_history_lines = self.class::HISTORY.size if defined? @loaded_history_lines + end + def load_history history = self.class::HISTORY if history_file = IRB.conf[:HISTORY_FILE] diff --git a/lib/irb/ruby-lex.rb b/lib/irb/ruby-lex.rb index 7d4f8a514c..282e6ef05f 100644 --- a/lib/irb/ruby-lex.rb +++ b/lib/irb/ruby-lex.rb @@ -183,6 +183,10 @@ class RubyLex prompt(opens, continue, line_num_offset) end + def increase_line_no(addition) + @line_no += addition + end + def readmultiline save_prompt_to_context_io([], false, 0) @@ -220,7 +224,7 @@ class RubyLex code.force_encoding(@context.io.encoding) yield code, @line_no, assignment_expression?(code) end - @line_no += code.count("\n") + increase_line_no(code.count("\n")) rescue TerminateLineInput end end diff --git a/lib/irb/workspace.rb b/lib/irb/workspace.rb index d6fa67053d..2bf3d5e0f1 100644 --- a/lib/irb/workspace.rb +++ b/lib/irb/workspace.rb @@ -108,6 +108,10 @@ EOF # <code>IRB.conf[:__MAIN__]</code> attr_reader :main + def load_commands_to_main + main.extend ExtendCommandBundle + end + # Evaluate the given +statements+ within the context of this workspace. def evaluate(statements, file = __FILE__, line = __LINE__) eval(statements, @binding, file, line) |