Skip to content

Commit

Permalink
Support seamless integration with ruby/debug (#575)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
st0012 authored Aug 13, 2023
1 parent daff750 commit d8fb324
Show file tree
Hide file tree
Showing 11 changed files with 642 additions and 114 deletions.
83 changes: 76 additions & 7 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -441,7 +440,7 @@ class 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
Expand All @@ -457,6 +456,38 @@ def debug_break
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]
Expand Down Expand Up @@ -542,6 +573,18 @@ def eval_input
@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
Expand Down Expand Up @@ -633,6 +676,12 @@ def evaluate_line(line, line_no)
@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|
Expand Down Expand Up @@ -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
128 changes: 36 additions & 92 deletions lib/irb/cmd/debug.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require_relative "nop"
require_relative "../debug"

module IRB
# :stopdoc:
Expand All @@ -12,37 +13,46 @@ class Debug < Nop
'<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
Expand All @@ -54,72 +64,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?('<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
Expand Down
36 changes: 34 additions & 2 deletions lib/irb/cmd/subirb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ module IRB

module ExtendCommand
class MultiIRBCommand < Nop
def initialize(conf)
super
def execute(*args)
extend_irb_context
end

Expand All @@ -29,6 +28,10 @@ def extend_irb_context
# 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
Expand All @@ -37,6 +40,13 @@ class IrbCommand < MultiIRBCommand

def execute(*obj)
print_deprecated_warning

if irb_context.with_debugger
print_debugger_warning
return
end

super
IRB.irb(nil, *obj)
end
end
Expand All @@ -47,6 +57,13 @@ class Jobs < MultiIRBCommand

def execute
print_deprecated_warning

if irb_context.with_debugger
print_debugger_warning
return
end

super
IRB.JobManager
end
end
Expand All @@ -57,6 +74,14 @@ class Foreground < MultiIRBCommand

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
Expand All @@ -68,6 +93,13 @@ class Kill < MultiIRBCommand

def execute(*keys)
print_deprecated_warning

if irb_context.with_debugger
print_debugger_warning
return
end

super
IRB.JobManager.kill(*keys)
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/irb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,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
Expand Down
Loading

0 comments on commit d8fb324

Please sign in to comment.