Skip to content

Commit 28c6734

Browse files
committed
Keep completion alive when RDoc document retrieval fails
## Summary The autocompletion documentation dialog could crash the whole IRB session when loading RDoc documentation failed. This makes RDoc document retrieval failable: the error is shown in the dialog and completion (and the session) keeps working. ## Steps to reproduce With `Ruby::Box` enabled, IRB crashes during completion (see the details below). The steps below reproduce it that way, but the crash can happen in any case where loading RDoc documentation fails, not only under `Ruby::Box`. 1. Use a Ruby build that provides `Ruby::Box` (e.g. a ruby-dev build) and start IRB with the box enabled: ``` RUBY_BOX=1 irb ``` 2. Type `1.` and press <Tab> to trigger method-name completion. Any receiver reproduces it; the trigger is rendering the documentation dialog for a completion candidate, which loads RDoc data from disk. ## Expected behavior The completion candidates and their documentation dialog are shown, and IRB keeps running even when the documentation cannot be loaded. ## Actual behavior IRB terminates with an uncaught exception: ``` .../rdoc/store.rb:1190:in 'Marshal.load': undefined class/module RDoc:: (ArgumentError) from .../rdoc/store.rb:1190:in 'block in RDoc::Store#marshal_load' from .../rdoc/store.rb:835:in 'RDoc::Store#load_method' ... from .../irb/input-method.rb:in 'IRB::RelineInputMethod#rdoc_dialog_contents' from .../reline/line_editor.rb:in 'Reline::LineEditor::Dialog#call' ... ``` ## Environment ``` irb(main):001> irb_info Ruby version: 4.0.6 IRB version: irb 1.18.0 (2026-04-21) InputMethod: RelineInputMethod with Reline 0.6.3 Completion: Autocomplete, ReplTypeCompletor: 0.1.12, Prism: 1.8.1, RBS: 3.10.0 .irbrc paths: /home/pocke/.irbrc RUBY_PLATFORM: x86_64-linux LANG env: C.UTF-8 East Asian Ambiguous Width: 1 ``` ## Root cause The dialog loads RDoc `.ri` data via `Marshal.load`, and `rdoc_dialog_contents` only rescued `RDoc::RI::Driver::NotFoundError`. Any other error raised during retrieval propagated through Reline's dialog proc and terminated the REPL. Here it is triggered by a Ruby core bug: under `Ruby::Box` (`RUBY_BOX=1`), `Marshal.load` fails to resolve class references and raises `ArgumentError: undefined class/module ...` (https://bugs.ruby-lang.org/issues/22090). That bug lives in Ruby itself and cannot be fixed here, but RDoc document retrieval is a fragile external dependency that IRB should treat as failable regardless of the specific cause. ## Fix Treat only the fragile part -- fetching the document from the RDoc driver -- as failable, and leave rendering untouched. - Extract the RDoc driver calls into `retrieve_rdoc_document`, and rescue around that call alone in `rdoc_dialog_contents`. `NotFoundError` still means "no document" and stays silent; any other `StandardError` is turned into an error document by `rdoc_error_document`. - `RDoc::Markup::ToAnsi` rendering and the dialog layout stay outside the rescue on purpose: a failure there is IRB/Reline's own bug and should surface rather than be swallowed. - The error document shows the exception class and message where the document would appear, so the user learns why documentation is unavailable while completion keeps working. The "Press Alt+d to read the full document" hint is omitted on error, since there is no full document to open. - The narrow dialog does not show the backtrace. To investigate such a failure, run IRB with `-d` (which sets `$DEBUG`): the error is then re-raised with its full backtrace instead of being swallowed. The dialog points to this with a "Restart IRB with -d to see the backtrace." hint. ## Out of scope The Alt+D full-document path (`display_document`) is left unchanged. In the error state there is no document to open, so pressing Alt+D behaves as before.
1 parent 6073bb0 commit 28c6734

2 files changed

Lines changed: 102 additions & 5 deletions

File tree

lib/irb/input-method.rb

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,29 @@ def easter_egg_dialog_contents
404404
end
405405

406406
def rdoc_dialog_contents(name, width)
407+
formatter = RDoc::Markup::ToAnsi.new
408+
formatter.width = width
409+
410+
begin
411+
document = retrieve_rdoc_document(name)
412+
rescue RDoc::RI::Driver::NotFoundError
413+
return
414+
rescue => e
415+
raise if $DEBUG
416+
return rdoc_error_document(e).accept(formatter).split("\n")
417+
end
418+
return unless document
419+
420+
[PRESS_ALT_D_TO_READ_FULL_DOC] + document.accept(formatter).split("\n")
421+
end
422+
423+
def retrieve_rdoc_document(name)
407424
driver = rdoc_ri_driver
408425
return unless driver
409426

410427
name = driver.expand_name(name)
411428

412-
doc = if name =~ /#|\./
429+
if name =~ /#|\./
413430
d = RDoc::Markup::Document.new
414431
driver.add_method(d, name)
415432
d
@@ -423,11 +440,15 @@ def rdoc_dialog_contents(name, width)
423440
driver.class_document(name, found, klasses, includes, extends)
424441
end
425442
end
443+
end
426444

427-
formatter = RDoc::Markup::ToAnsi.new
428-
formatter.width = width
429-
[PRESS_ALT_D_TO_READ_FULL_DOC] + doc.accept(formatter).split("\n")
430-
rescue RDoc::RI::Driver::NotFoundError
445+
def rdoc_error_document(error)
446+
document = RDoc::Markup::Document.new
447+
document << RDoc::Markup::Paragraph.new("Failed to load the document:")
448+
document << RDoc::Markup::Paragraph.new("#{error.class}: #{error.message}")
449+
document << RDoc::Markup::BlankLine.new
450+
document << RDoc::Markup::Paragraph.new("Restart IRB with -d to see the backtrace.")
451+
document
431452
end
432453

433454
def dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width)

test/irb/test_input_method.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,82 @@ def has_rdoc_content?
215215
end
216216
end if defined?(RDoc)
217217

218+
class RdocDialogContentsTest < InputMethodTest
219+
def test_shows_error_content_when_document_retrieval_raises
220+
input_method = build_input_method(failing_driver(ArgumentError.new("undefined class/module RDoc::")))
221+
222+
contents = nil
223+
assert_nothing_raised do
224+
contents = input_method.rdoc_dialog_contents("1.foo", 40)
225+
end
226+
227+
assert_not_nil contents
228+
assert(contents.any? { |line| line.include?("ArgumentError") },
229+
"expected the error to be shown in the dialog contents: #{contents.inspect}")
230+
hint_index = contents.index { |line| line.include?("-d") }
231+
assert_not_nil hint_index, "expected the hint to run with -d in the dialog contents: #{contents.inspect}"
232+
assert_equal "", contents[hint_index - 1],
233+
"expected a blank line before the -d hint: #{contents.inspect}"
234+
assert(contents.none? { |line| line.include?(IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC) },
235+
"the full document hint must not be shown on error: #{contents.inspect}")
236+
end
237+
238+
def test_raises_the_error_when_debug_is_enabled
239+
input_method = build_input_method(failing_driver(ArgumentError.new("undefined class/module RDoc::")))
240+
241+
original_debug = $DEBUG
242+
$DEBUG = true
243+
# $DEBUG makes Ruby print raised exceptions to stderr; swallow that noise.
244+
capture_output do
245+
assert_raise(ArgumentError) do
246+
input_method.rdoc_dialog_contents("1.foo", 40)
247+
end
248+
end
249+
ensure
250+
$DEBUG = original_debug
251+
end
252+
253+
def test_includes_full_document_hint_when_document_is_available
254+
input_method = build_input_method(documented_driver)
255+
256+
contents = input_method.rdoc_dialog_contents("1.foo", 40)
257+
258+
assert_equal IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC, contents.first
259+
end
260+
261+
def test_returns_nil_when_document_not_found
262+
input_method = build_input_method(failing_driver(RDoc::RI::Driver::NotFoundError.new("1.foo")))
263+
264+
assert_nil input_method.rdoc_dialog_contents("1.foo", 40)
265+
end
266+
267+
private
268+
269+
def build_input_method(driver)
270+
input_method = IRB::RelineInputMethod.new(IRB::RegexpCompletor.new)
271+
input_method.instance_variable_set(:@rdoc_ri_driver, driver)
272+
input_method
273+
end
274+
275+
def failing_driver(error)
276+
driver = Object.new
277+
driver.define_singleton_method(:expand_name) { |name| name }
278+
driver.define_singleton_method(:add_method) { |_document, _name| raise error }
279+
driver.define_singleton_method(:classes_and_includes_and_extends_for) { |_name| raise error }
280+
driver.define_singleton_method(:class_document) { |*| raise error }
281+
driver
282+
end
283+
284+
def documented_driver
285+
driver = Object.new
286+
driver.define_singleton_method(:expand_name) { |name| name }
287+
driver.define_singleton_method(:add_method) do |document, _name|
288+
document << RDoc::Markup::Paragraph.new("some documentation")
289+
end
290+
driver
291+
end
292+
end if defined?(RDoc)
293+
218294
class CommandDocDialogContentTest < TestCase
219295
def setup
220296
@conf_backup = IRB.conf.dup

0 commit comments

Comments
 (0)