Skip to content

Commit d8fb324

Browse files
authored
Support seamless integration with ruby/debug (#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.
1 parent daff750 commit d8fb324

File tree

11 files changed

+642
-114
lines changed

11 files changed

+642
-114
lines changed

‎lib/irb.rb‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
require_relative "irb/version"
2020
require_relative "irb/easter-egg"
21+
require_relative "irb/debug"
2122

2223
# IRB stands for "interactive Ruby" and is a tool to interactively execute Ruby
2324
# expressions read from the standard input.
@@ -373,8 +374,6 @@ module IRB
373374
class Abort < Exception;end
374375

375376
@CONF = {}
376-
377-
378377
# Displays current configuration.
379378
#
380379
# Modifying the configuration is achieved by sending a message to IRB.conf.
@@ -441,7 +440,7 @@ class Irb
441440
# Creates a new irb session
442441
def initialize(workspace = nil, input_method = nil)
443442
@context = Context.new(self, workspace, input_method)
444-
@context.main.extend ExtendCommandBundle
443+
@context.workspace.load_commands_to_main
445444
@signal_status = :IN_IRB
446445
@scanner = RubyLex.new(@context)
447446
end
@@ -457,6 +456,38 @@ def debug_break
457456
end
458457
end
459458

459+
def debug_readline(binding)
460+
workspace = IRB::WorkSpace.new(binding)
461+
context.workspace = workspace
462+
context.workspace.load_commands_to_main
463+
scanner.increase_line_no(1)
464+
465+
# When users run:
466+
# 1. Debugging commands, like `step 2`
467+
# 2. Any input that's not irb-command, like `foo = 123`
468+
#
469+
# Irb#eval_input will simply return the input, and we need to pass it to the debugger.
470+
input = if IRB.conf[:SAVE_HISTORY] && context.io.support_history_saving?
471+
# Previous IRB session's history has been saved when `Irb#run` is exited
472+
# We need to make sure the saved history is not saved again by reseting the counter
473+
context.io.reset_history_counter
474+
475+
begin
476+
eval_input
477+
ensure
478+
context.io.save_history
479+
end
480+
else
481+
eval_input
482+
end
483+
484+
if input&.include?("\n")
485+
scanner.increase_line_no(input.count("\n") - 1)
486+
end
487+
488+
input
489+
end
490+
460491
def run(conf = IRB.conf)
461492
in_nested_session = !!conf[:MAIN_CONTEXT]
462493
conf[:IRB_RC].call(context) if conf[:IRB_RC]
@@ -542,6 +573,18 @@ def eval_input
542573
@scanner.each_top_level_statement do |line, line_no, is_assignment|
543574
signal_status(:IN_EVAL) do
544575
begin
576+
# If the integration with debugger is activated, we need to handle certain input differently
577+
if @context.with_debugger
578+
command_class = load_command_class(line)
579+
# First, let's pass debugging command's input to debugger
580+
# Secondly, we need to let debugger evaluate non-command input
581+
# Otherwise, the expression will be evaluated in the debugger's main session thread
582+
# This is the only way to run the user's program in the expected thread
583+
if !command_class || ExtendCommand::DebugCommand > command_class
584+
return line
585+
end
586+
end
587+
545588
evaluate_line(line, line_no)
546589

547590
# Don't echo if the line ends with a semicolon
@@ -633,6 +676,12 @@ def evaluate_line(line, line_no)
633676
@context.evaluate(line, line_no)
634677
end
635678

679+
def load_command_class(line)
680+
command, _ = line.split(/\s/, 2)
681+
command_name = @context.command_aliases[command.to_sym]
682+
ExtendCommandBundle.load_command(command_name || command)
683+
end
684+
636685
def convert_invalid_byte_sequence(str, enc)
637686
str.force_encoding(enc)
638687
str.scrub { |c|
@@ -986,12 +1035,32 @@ class Binding
9861035
#
9871036
# See IRB@Usage for more information.
9881037
def irb(show_code: true)
1038+
# Setup IRB with the current file's path and no command line arguments
9891039
IRB.setup(source_location[0], argv: [])
1040+
# Create a new workspace using the current binding
9901041
workspace = IRB::WorkSpace.new(self)
1042+
# Print the code around the binding if show_code is true
9911043
STDOUT.print(workspace.code_around_binding) if show_code
992-
binding_irb = IRB::Irb.new(workspace)
993-
binding_irb.context.irb_path = File.expand_path(source_location[0])
994-
binding_irb.run(IRB.conf)
995-
binding_irb.debug_break
1044+
# Get the original IRB instance
1045+
debugger_irb = IRB.instance_variable_get(:@debugger_irb)
1046+
1047+
irb_path = File.expand_path(source_location[0])
1048+
1049+
if debugger_irb
1050+
# If we're already in a debugger session, set the workspace and irb_path for the original IRB instance
1051+
debugger_irb.context.workspace = workspace
1052+
debugger_irb.context.irb_path = irb_path
1053+
# If we've started a debugger session and hit another binding.irb, we don't want to start an IRB session
1054+
# instead, we want to resume the irb:rdbg session.
1055+
IRB::Debug.setup(debugger_irb)
1056+
IRB::Debug.insert_debug_break
1057+
debugger_irb.debug_break
1058+
else
1059+
# If we're not in a debugger session, create a new IRB instance with the current workspace
1060+
binding_irb = IRB::Irb.new(workspace)
1061+
binding_irb.context.irb_path = irb_path
1062+
binding_irb.run(IRB.conf)
1063+
binding_irb.debug_break
1064+
end
9961065
end
9971066
end

‎lib/irb/cmd/debug.rb‎

Lines changed: 36 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require_relative "nop"
2+
require_relative "../debug"
23

34
module IRB
45
# :stopdoc:
@@ -12,37 +13,46 @@ class Debug < Nop
1213
'<internal:prelude>',
1314
binding.method(:irb).source_location.first,
1415
].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ }
15-
IRB_DIR = File.expand_path('..', __dir__)
1616

1717
def execute(pre_cmds: nil, do_cmds: nil)
18-
unless binding_irb?
19-
puts "`debug` command is only available when IRB is started with binding.irb"
20-
return
21-
end
18+
if irb_context.with_debugger
19+
# If IRB is already running with a debug session, throw the command and IRB.debug_readline will pass it to the debugger.
20+
if cmd = pre_cmds || do_cmds
21+
throw :IRB_EXIT, cmd
22+
else
23+
puts "IRB is already running with a debug session."
24+
return
25+
end
26+
else
27+
# If IRB is not running with a debug session yet, then:
28+
# 1. Check if the debugging command is run from a `binding.irb` call.
29+
# 2. If so, try setting up the debug gem.
30+
# 3. Insert a debug breakpoint at `Irb#debug_break` with the intended command.
31+
# 4. Exit the current Irb#run call via `throw :IRB_EXIT`.
32+
# 5. `Irb#debug_break` will be called and trigger the breakpoint, which will run the intended command.
33+
unless binding_irb?
34+
puts "`debug` command is only available when IRB is started with binding.irb"
35+
return
36+
end
2237

23-
unless setup_debugger
24-
puts <<~MSG
25-
You need to install the debug gem before using this command.
26-
If you use `bundle exec`, please add `gem "debug"` into your Gemfile.
27-
MSG
28-
return
29-
end
38+
if IRB.respond_to?(:JobManager)
39+
warn "Can't start the debugger when IRB is running in a multi-IRB session."
40+
return
41+
end
3042

31-
options = { oneshot: true, hook_call: false }
32-
if pre_cmds || do_cmds
33-
options[:command] = ['irb', pre_cmds, do_cmds]
34-
end
35-
if DEBUGGER__::LineBreakpoint.instance_method(:initialize).parameters.include?([:key, :skip_src])
36-
options[:skip_src] = true
37-
end
43+
unless IRB::Debug.setup(irb_context.irb)
44+
puts <<~MSG
45+
You need to install the debug gem before using this command.
46+
If you use `bundle exec`, please add `gem "debug"` into your Gemfile.
47+
MSG
48+
return
49+
end
3850

39-
# To make debugger commands like `next` or `continue` work without asking
40-
# the user to quit IRB after that, we need to exit IRB first and then hit
41-
# a TracePoint on #debug_break.
42-
file, lineno = IRB::Irb.instance_method(:debug_break).source_location
43-
DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options)
44-
# exit current Irb#run call
45-
throw :IRB_EXIT
51+
IRB::Debug.insert_debug_break(pre_cmds: pre_cmds, do_cmds: do_cmds)
52+
53+
# exit current Irb#run call
54+
throw :IRB_EXIT
55+
end
4656
end
4757

4858
private
@@ -54,72 +64,6 @@ def binding_irb?
5464
end
5565
end
5666
end
57-
58-
module SkipPathHelperForIRB
59-
def skip_internal_path?(path)
60-
# The latter can be removed once https://github.com/ruby/debug/issues/866 is resolved
61-
super || path.match?(IRB_DIR) || path.match?('<internal:prelude>')
62-
end
63-
end
64-
65-
def setup_debugger
66-
unless defined?(DEBUGGER__::SESSION)
67-
begin
68-
require "debug/session"
69-
rescue LoadError # debug.gem is not written in Gemfile
70-
return false unless load_bundled_debug_gem
71-
end
72-
DEBUGGER__.start(nonstop: true)
73-
end
74-
75-
unless DEBUGGER__.respond_to?(:capture_frames_without_irb)
76-
DEBUGGER__.singleton_class.send(:alias_method, :capture_frames_without_irb, :capture_frames)
77-
78-
def DEBUGGER__.capture_frames(*args)
79-
frames = capture_frames_without_irb(*args)
80-
frames.reject! do |frame|
81-
frame.realpath&.start_with?(IRB_DIR) || frame.path == "<internal:prelude>"
82-
end
83-
frames
84-
end
85-
86-
DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB)
87-
end
88-
89-
true
90-
end
91-
92-
# This is used when debug.gem is not written in Gemfile. Even if it's not
93-
# installed by `bundle install`, debug.gem is installed by default because
94-
# it's a bundled gem. This method tries to activate and load that.
95-
def load_bundled_debug_gem
96-
# Discover latest debug.gem under GEM_PATH
97-
debug_gem = Gem.paths.path.flat_map { |path| Dir.glob("#{path}/gems/debug-*") }.select do |path|
98-
File.basename(path).match?(/\Adebug-\d+\.\d+\.\d+(\w+)?\z/)
99-
end.sort_by do |path|
100-
Gem::Version.new(File.basename(path).delete_prefix('debug-'))
101-
end.last
102-
return false unless debug_gem
103-
104-
# Discover debug/debug.so under extensions for Ruby 3.2+
105-
ext_name = "/debug/debug.#{RbConfig::CONFIG['DLEXT']}"
106-
ext_path = Gem.paths.path.flat_map do |path|
107-
Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}#{ext_name}")
108-
end.first
109-
110-
# Attempt to forcibly load the bundled gem
111-
if ext_path
112-
$LOAD_PATH << ext_path.delete_suffix(ext_name)
113-
end
114-
$LOAD_PATH << "#{debug_gem}/lib"
115-
begin
116-
require "debug/session"
117-
puts "Loaded #{File.basename(debug_gem)}"
118-
true
119-
rescue LoadError
120-
false
121-
end
122-
end
12367
end
12468

12569
class DebugCommand < Debug

‎lib/irb/cmd/subirb.rb‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ module IRB
1111

1212
module ExtendCommand
1313
class MultiIRBCommand < Nop
14-
def initialize(conf)
15-
super
14+
def execute(*args)
1615
extend_irb_context
1716
end
1817

@@ -29,6 +28,10 @@ def extend_irb_context
2928
# this extension patches IRB context like IRB.CurrentContext
3029
require_relative "../ext/multi-irb"
3130
end
31+
32+
def print_debugger_warning
33+
warn "Multi-IRB commands are not available when the debugger is enabled."
34+
end
3235
end
3336

3437
class IrbCommand < MultiIRBCommand
@@ -37,6 +40,13 @@ class IrbCommand < MultiIRBCommand
3740

3841
def execute(*obj)
3942
print_deprecated_warning
43+
44+
if irb_context.with_debugger
45+
print_debugger_warning
46+
return
47+
end
48+
49+
super
4050
IRB.irb(nil, *obj)
4151
end
4252
end
@@ -47,6 +57,13 @@ class Jobs < MultiIRBCommand
4757

4858
def execute
4959
print_deprecated_warning
60+
61+
if irb_context.with_debugger
62+
print_debugger_warning
63+
return
64+
end
65+
66+
super
5067
IRB.JobManager
5168
end
5269
end
@@ -57,6 +74,14 @@ class Foreground < MultiIRBCommand
5774

5875
def execute(key = nil)
5976
print_deprecated_warning
77+
78+
if irb_context.with_debugger
79+
print_debugger_warning
80+
return
81+
end
82+
83+
super
84+
6085
raise CommandArgumentError.new("Please specify the id of target IRB job (listed in the `jobs` command).") unless key
6186
IRB.JobManager.switch(key)
6287
end
@@ -68,6 +93,13 @@ class Kill < MultiIRBCommand
6893

6994
def execute(*keys)
7095
print_deprecated_warning
96+
97+
if irb_context.with_debugger
98+
print_debugger_warning
99+
return
100+
end
101+
102+
super
71103
IRB.JobManager.kill(*keys)
72104
end
73105
end

‎lib/irb/context.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ def main
345345
# User-defined IRB command aliases
346346
attr_accessor :command_aliases
347347

348+
attr_accessor :with_debugger
349+
348350
# Alias for #use_multiline
349351
alias use_multiline? use_multiline
350352
# Alias for #use_singleline

0 commit comments

Comments
 (0)