From e21b8ff24681ab32cb7f7248cf4a64111209baf9 Mon Sep 17 00:00:00 2001 From: Nony Dutton Date: Wed, 15 Oct 2025 09:46:03 +0200 Subject: [PATCH 1/2] Set evaluation result to `_` local variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes a change to the thread client to set the `_` local variable to the result of the last evaluation similar to the behavior in irb[^1]. The reason `_` doesn't work even when using the `irb` console is because we leave the `irb` evaluation early if the input should be handled by the debugger[^2]. Otherwise, we would end up in IRB::Context#evaluate` and we would call `set_last_value`[^3]. There's a bit of a quirk here when using the `irb` console. I was hoping in `thread_client` to evaluate the result then set the `_` local variable and the `@last_value` ivar all in the same method, similar to how `irb` does it[^1]. However, because we're using the `irb` console we use a new `irb` `Workspace` which nils out `_` when initialized[^4]. To get around that, we set the local variable just before evaluating. [^1]:(https://github.com/ruby/irb/blob/d43c3d764ae439706aa1b26a3ec299cc45eaed5b/lib/irb/context.rb#L460-L465) [^2]:(https://github.com/ruby/irb/blob/d43c3d764ae439706aa1b26a3ec299cc45eaed5b/lib/irb.rb#L195-L198) [^3]:(https://github.com/ruby/irb/blob/d43c3d764ae439706aa1b26a3ec299cc45eaed5b/lib/irb/context.rb#L550-L558) [^4]:(https://github.com/ruby/irb/blob/d43c3d764ae439706aa1b26a3ec299cc45eaed5b/lib/irb/workspace.rb#L81) This PR was created by Zendesk's Ruby infrastructure team: Co-authored-by: Benjamin Quorning Co-authored-by: Edyta RozczypaƂa Co-authored-by: Jury Razumau Co-authored-by: Leonid Batizhevskii Co-authored-by: Luis Manotas Co-authored-by: Thomas Countz --- lib/debug/thread_client.rb | 2 ++ test/console/debugger_local_test.rb | 12 ++++++++++++ test/console/irb_test.rb | 14 ++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/lib/debug/thread_client.rb b/lib/debug/thread_client.rb index 0a92fe356..979503253 100644 --- a/lib/debug/thread_client.rb +++ b/lib/debug/thread_client.rb @@ -443,7 +443,9 @@ def frame_eval src, re_raise: false, binding_location: false b.local_variable_set(name, var) if /\%/ !~ name end + b.local_variable_set(:_, @last_result) result = frame_eval_core(src, b, binding_location: binding_location) + @last_result = result @success_last_eval = true result diff --git a/test/console/debugger_local_test.rb b/test/console/debugger_local_test.rb index f32f43d0c..cfd18f2b0 100644 --- a/test/console/debugger_local_test.rb +++ b/test/console/debugger_local_test.rb @@ -19,6 +19,18 @@ def test_locals_added_in_locals_are_accessible_between_evaluations type "c" end end + + def test_evaluated_result_is_set_as_underscore_local + debug_code(program) do + type "_" + assert_line_text(/nil/) + type "3 * 3" + type "foo = _" + type "9 == foo" + assert_line_text(/true/) + type "c" + end + end end class RaisedTest < ConsoleTestCase diff --git a/test/console/irb_test.rb b/test/console/irb_test.rb index 746f9acdb..de4290050 100644 --- a/test/console/irb_test.rb +++ b/test/console/irb_test.rb @@ -84,6 +84,20 @@ def test_irb_console_config_activates_irb ENV["RUBY_DEBUG_IRB_CONSOLE"] = nil end + def test_irb_console_evaluated_result_is_set_as_underscore_local + debug_code(program, remote: false) do + type 'irb' + type "_" + assert_line_text(/nil/) + type "3 * 3" + assert_raw_line_text 'irb:rdbg(main):003> 3 * 3' + type "foo = _" + type "9 == foo" + assert_line_text(/true/) + type "c" + end + end + private # assert_line_text ignores the prompt line, so we can't use it to assert the prompt transition From 49101a1a4393dd2181113a3a80b407cf4dd0882a Mon Sep 17 00:00:00 2001 From: joe-sharp Date: Mon, 2 Nov 2026 11:09:34 -0600 Subject: [PATCH 2/2] Don't clobber the debuggee's `_` and only update it on user evals eval_binding is a dup of the frame binding, and a dup shares the frame's locals, so setting `_` when the debuggee already has one overwrote the program's own variable (and it stayed overwritten after `continue`). Only inject `_` when the original frame binding doesn't define it. Also only update the last value for user-driven evaluations (p, pp, call) so `display` expressions and other internal evals don't replace `_`. Co-Authored-By: Claude Opus 5.5 --- lib/debug/thread_client.rb | 22 +++++++++++------- test/console/debugger_local_test.rb | 35 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/lib/debug/thread_client.rb b/lib/debug/thread_client.rb index 979503253..3bb33a8c8 100644 --- a/lib/debug/thread_client.rb +++ b/lib/debug/thread_client.rb @@ -434,18 +434,24 @@ def frame_eval_core src, b, binding_location: false [:return_value, "_return"], ] - def frame_eval src, re_raise: false, binding_location: false + def frame_eval src, re_raise: false, binding_location: false, update_last_value: false @success_last_eval = false - b = current_frame&.eval_binding || TOPLEVEL_BINDING + frame = current_frame + b = frame&.eval_binding || TOPLEVEL_BINDING - special_local_variables current_frame do |name, var| + special_local_variables frame do |name, var| b.local_variable_set(name, var) if /\%/ !~ name end - b.local_variable_set(:_, @last_result) + # eval_binding is a dup that shares the frame's locals, so setting an + # existing `_` would overwrite the debuggee's own variable. + if frame && !(frame.binding || TOPLEVEL_BINDING).local_variable_defined?(:_) + b.local_variable_set(:_, @last_value) + end + result = frame_eval_core(src, b, binding_location: binding_location) - @last_result = result + @last_value = result if update_last_value @success_last_eval = true result @@ -1047,19 +1053,19 @@ def wait_next_action_ case eval_type when :p - result = frame_eval(eval_src) + result = frame_eval(eval_src, update_last_value: true) puts "=> " + color_pp(result, 2 ** 30) if alloc_path = ObjectSpace.allocation_sourcefile(result) puts "allocated at #{alloc_path}:#{ObjectSpace.allocation_sourceline(result)}" end when :pp - result = frame_eval(eval_src) + result = frame_eval(eval_src, update_last_value: true) puts color_pp(result, SESSION.width) if alloc_path = ObjectSpace.allocation_sourcefile(result) puts "allocated at #{alloc_path}:#{ObjectSpace.allocation_sourceline(result)}" end when :call - result = frame_eval(eval_src) + result = frame_eval(eval_src, update_last_value: true) when :display, :try_display failed_results = [] eval_src.each_with_index{|src, i| diff --git a/test/console/debugger_local_test.rb b/test/console/debugger_local_test.rb index cfd18f2b0..dbb806ac0 100644 --- a/test/console/debugger_local_test.rb +++ b/test/console/debugger_local_test.rb @@ -31,6 +31,41 @@ def test_evaluated_result_is_set_as_underscore_local type "c" end end + + def test_display_does_not_update_underscore_local + debug_code(program) do + type "display 42" + type "3 * 3" + type "display" + type "_" + assert_line_text(/9/) + type "c" + end + end + end + + class UnderscoreDefinedByProgramTest < ConsoleTestCase + def program + <<~RUBY + 1| _ = :mine + 2| binding.b + 3| p _ + 4| binding.b + 5| nil + RUBY + end + + def test_underscore_defined_by_program_is_not_clobbered + debug_code(program) do + type "c" + type "3 * 3" + type "_" + assert_line_text(/:mine/) + type "c" + assert_line_text(/:mine/) + type "c" + end + end end class RaisedTest < ConsoleTestCase