summaryrefslogtreecommitdiff
path: root/lib/irb
diff options
context:
space:
mode:
authorStan Lo <stan001212@gmail.com>2023-08-13 19:30:30 +0100
committergit <svn-admin@ruby-lang.org>2023-08-13 18:30:34 +0000
commit7f8f62c93bf3d11a0321fa91823065a2ff36f6d0 (patch)
tree2dffe13305f50883f33644f9d701ebb832ec0ab4 /lib/irb
parent9099d62ac77cdca548bc4110e2cb03057ef0ac8f (diff)
[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/irb')
-rw-r--r--lib/irb/cmd/debug.rb128
-rw-r--r--lib/irb/cmd/subirb.rb36
-rw-r--r--lib/irb/context.rb2
-rw-r--r--lib/irb/debug.rb127
-rw-r--r--lib/irb/debug/ui.rb104
-rw-r--r--lib/irb/history.rb4
-rw-r--r--lib/irb/ruby-lex.rb6
-rw-r--r--lib/irb/workspace.rb4
8 files changed, 316 insertions, 95 deletions
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)