From d900ff83969358a9e8df376d5c7cfe6760cf81c3 Mon Sep 17 00:00:00 2001 From: Masataka Pocke Kuwabara Date: Wed, 15 Jul 2026 18:43:09 +0900 Subject: [PATCH] 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 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. --- lib/irb/input-method.rb | 31 +++++++++++--- test/irb/test_input_method.rb | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/lib/irb/input-method.rb b/lib/irb/input-method.rb index 32bdce14f..10758cc83 100644 --- a/lib/irb/input-method.rb +++ b/lib/irb/input-method.rb @@ -404,12 +404,29 @@ def easter_egg_dialog_contents end def rdoc_dialog_contents(name, width) + formatter = RDoc::Markup::ToAnsi.new + formatter.width = width + + begin + document = retrieve_rdoc_document(name) + rescue RDoc::RI::Driver::NotFoundError + return + rescue => e + raise if $DEBUG + return rdoc_error_document(e).accept(formatter).split("\n") + end + return unless document + + [PRESS_ALT_D_TO_READ_FULL_DOC] + document.accept(formatter).split("\n") + end + + def retrieve_rdoc_document(name) driver = rdoc_ri_driver return unless driver name = driver.expand_name(name) - doc = if name =~ /#|\./ + if name =~ /#|\./ d = RDoc::Markup::Document.new driver.add_method(d, name) d @@ -423,11 +440,15 @@ def rdoc_dialog_contents(name, width) driver.class_document(name, found, klasses, includes, extends) end end + end - formatter = RDoc::Markup::ToAnsi.new - formatter.width = width - [PRESS_ALT_D_TO_READ_FULL_DOC] + doc.accept(formatter).split("\n") - rescue RDoc::RI::Driver::NotFoundError + def rdoc_error_document(error) + document = RDoc::Markup::Document.new + document << RDoc::Markup::Paragraph.new("Failed to load the document:") + document << RDoc::Markup::Paragraph.new("#{error.class}: #{error.message}") + document << RDoc::Markup::BlankLine.new + document << RDoc::Markup::Paragraph.new("Restart IRB with -d to see the backtrace.") + document end def dialog_doc_position(cursor_pos_to_render, autocomplete_dialog, screen_width) diff --git a/test/irb/test_input_method.rb b/test/irb/test_input_method.rb index c77dab1ac..d412e9e81 100644 --- a/test/irb/test_input_method.rb +++ b/test/irb/test_input_method.rb @@ -215,6 +215,82 @@ def has_rdoc_content? end end if defined?(RDoc) + class RdocDialogContentsTest < InputMethodTest + def test_shows_error_content_when_document_retrieval_raises + input_method = build_input_method(failing_driver(ArgumentError.new("undefined class/module RDoc::"))) + + contents = nil + assert_nothing_raised do + contents = input_method.rdoc_dialog_contents("1.foo", 40) + end + + assert_not_nil contents + assert(contents.any? { |line| line.include?("ArgumentError") }, + "expected the error to be shown in the dialog contents: #{contents.inspect}") + hint_index = contents.index { |line| line.include?("-d") } + assert_not_nil hint_index, "expected the hint to run with -d in the dialog contents: #{contents.inspect}" + assert_equal "", contents[hint_index - 1], + "expected a blank line before the -d hint: #{contents.inspect}" + assert(contents.none? { |line| line.include?(IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC) }, + "the full document hint must not be shown on error: #{contents.inspect}") + end + + def test_raises_the_error_when_debug_is_enabled + input_method = build_input_method(failing_driver(ArgumentError.new("undefined class/module RDoc::"))) + + original_debug = $DEBUG + $DEBUG = true + # $DEBUG makes Ruby print raised exceptions to stderr; swallow that noise. + capture_output do + assert_raise(ArgumentError) do + input_method.rdoc_dialog_contents("1.foo", 40) + end + end + ensure + $DEBUG = original_debug + end + + def test_includes_full_document_hint_when_document_is_available + input_method = build_input_method(documented_driver) + + contents = input_method.rdoc_dialog_contents("1.foo", 40) + + assert_equal IRB::RelineInputMethod::PRESS_ALT_D_TO_READ_FULL_DOC, contents.first + end + + def test_returns_nil_when_document_not_found + input_method = build_input_method(failing_driver(RDoc::RI::Driver::NotFoundError.new("1.foo"))) + + assert_nil input_method.rdoc_dialog_contents("1.foo", 40) + end + + private + + def build_input_method(driver) + input_method = IRB::RelineInputMethod.new(IRB::RegexpCompletor.new) + input_method.instance_variable_set(:@rdoc_ri_driver, driver) + input_method + end + + def failing_driver(error) + driver = Object.new + driver.define_singleton_method(:expand_name) { |name| name } + driver.define_singleton_method(:add_method) { |_document, _name| raise error } + driver.define_singleton_method(:classes_and_includes_and_extends_for) { |_name| raise error } + driver.define_singleton_method(:class_document) { |*| raise error } + driver + end + + def documented_driver + driver = Object.new + driver.define_singleton_method(:expand_name) { |name| name } + driver.define_singleton_method(:add_method) do |document, _name| + document << RDoc::Markup::Paragraph.new("some documentation") + end + driver + end + end if defined?(RDoc) + class CommandDocDialogContentTest < TestCase def setup @conf_backup = IRB.conf.dup