diff --git a/doc/Index.md b/doc/Index.md index c25f4d016..aaada15ca 100644 --- a/doc/Index.md +++ b/doc/Index.md @@ -93,6 +93,43 @@ You can use IRB as a debugging console with `debug.gem` with these options: To learn more about debugging with IRB, see [Debugging with IRB](#label-Debugging+with+IRB). +### Agent Mode (Experimental) + +`binding.agent` starts a non-interactive IRB session designed for AI agents and scripts. Instead of opening a terminal REPL, it exposes an IRB session over a Unix socket using a simple request/response protocol. It is available on platforms where Ruby supports Unix sockets. + +The behavior depends on the `IRB_SOCK_PATH` environment variable: + +- **Not set**: prints instructions explaining the workflow, then exits. This lets the agent discover the breakpoint and learn the protocol, but the process state from this run is not retained. +- **Set**: starts a Unix socket server at the given path. Each connection accepts one complete request, evaluates it, returns IRB's output, and closes. The IRB session state persists across connections. Send `exit` to end the session and resume app execution, or `exit!` to exit the process. + +IRB results, errors, and command output are returned through the socket. Output written by the program itself remains on the program's standard output or error stream, so other application threads are not redirected into an agent response. + +Use a unique socket path in a private directory. IRB refuses active and non-socket paths and checks socket identity before cleanup, but reclaiming a stale path is not an atomic security boundary against another process that can write to the same directory. + +Commands that inspect or mutate the current IRB session work normally, including registered custom commands, aliases, `history`, `ls`, `show_source`, and `show_doc` with a target. Commands that start another interactive input loop are unavailable: debug commands, deprecated multi-IRB commands, `edit`, and `show_doc` without a target. Agent history lasts for the session and is not written to the user's IRB history file. + +```console +# app.rb +require "irb" +binding.agent +``` + +```console +# 1. First run: discover the breakpoint. +$ ruby app.rb +IRB agent breakpoint hit at app.rb:14 in `cook!` +... + +# 2. Re-run in background with a socket path. +$ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb & + +# 3. Send commands. +$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close' +$ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close' +``` + +See IRB::AgentSession for more details. + ## Startup At startup, IRB: @@ -681,7 +718,7 @@ This integration offers several benefits over `debug.gem`'s native console: However, there are some limitations to be aware of: 1. `binding.irb` doesn't support `pre` and `do` arguments like [binding.break](https://github.com/ruby/debug#bindingbreak-method). -2. As IRB [doesn't currently support remote-connection](https://github.com/ruby/irb/issues/672), it can't be used with `debug.gem`'s remote debugging feature. +2. The regular `binding.irb` console does not support `debug.gem`'s remote debugging feature. For agent-oriented remote evaluation, use the separate `binding.agent` Unix-socket workflow. 3. Access to the previous return value via the underscore `_` is not supported. ## Encodings diff --git a/lib/irb.rb b/lib/irb.rb index af65c8a13..5d9a83508 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -101,10 +101,10 @@ class << self attr_reader :from_binding # Creates a new irb session - def initialize(workspace = nil, input_method = nil, from_binding: false) + def initialize(workspace = nil, input_method = nil, from_binding: false, verbose: IRB.conf[:VERBOSE]) @from_binding = from_binding @prompt_part_cache = nil - @context = Context.new(self, workspace, input_method) + @context = Context.new(self, workspace, input_method, verbose: verbose) @context.workspace.load_helper_methods_to_main @signal_status = :IN_IRB @scanner = RubyLex.new @@ -429,7 +429,7 @@ def handle_exception(exc) order = if RUBY_VERSION < '3.0.0' - STDOUT.tty? ? :bottom : :top + output.tty? ? :bottom : :top else # '3.0.0' <= RUBY_VERSION :top end @@ -563,19 +563,19 @@ def output_value(omit = false) # :nodoc: content = "#{content}\e[0m" if Color.colorable? end puts format(@context.return_format, content.chomp) - elsif Pager.should_page? && @context.inspector_support_stream_output? + elsif Pager.should_page?(output: output) && @context.inspector_support_stream_output? formatter_proc = ->(content, multipage) do content = content.chomp content = "\n#{content}" if @context.newline_before_multiline_output? && (multipage || content.include?("\n")) format(@context.return_format, content) end - Pager.page_with_preview(winwidth, winheight, formatter_proc) do |out| + Pager.page_with_preview(winwidth, winheight, formatter_proc, output: output) do |out| @context.inspect_last_value(out) end else content = @context.inspect_last_value.chomp content = "\n#{content}" if @context.newline_before_multiline_output? && content.include?("\n") - Pager.page_content(format(@context.return_format, content), retain_content: true) + Pager.page_content(format(@context.return_format, content), retain_content: true, output: output) end end @@ -598,6 +598,30 @@ def inspect private + def output + @context.output + end + + def write(*args) + output.write(*args) + end + + def print(*args) + output.print(*args) + end + + def printf(*args) + output.printf(*args) + end + + def puts(*args) + output.puts(*args) + end + + def warn(*messages, **kwargs) + output.warn(*messages, **kwargs) + end + def with_prompt_part_cached @prompt_part_cache = {} yield @@ -745,9 +769,11 @@ class Binding # Cooked potato: true # # See IRB 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: []) unless IRB.initialized? + # Create a new workspace using the current binding workspace = IRB::WorkSpace.new(self) # Print the code around the binding if show_code is true @@ -775,4 +801,93 @@ def irb(show_code: true) binding_irb.debug_break end end + + # Opens a non-interactive IRB session designed for AI agents and scripts + # (experimental). Instead of opening a REPL, it exposes an IRB session over a + # Unix socket using a simple request/response protocol. + # + # The behavior depends on the +IRB_SOCK_PATH+ environment variable: + # + # - *Not set* (Phase 1 - discovery): prints instructions explaining the + # workflow, then exits. This lets the agent discover the breakpoint and + # learn the protocol. + # - *Set* (Phase 2 - debug session): starts a Unix socket server at the + # given path. Each connection accepts one request, evaluates it, returns + # IRB's output, and closes. The IRB session state persists across + # connections. Output written by the host program stays with the host. + # Send +exit+ to end the session and resume app execution. + # + # See IRB::AgentSession for the full protocol and workflow. + def agent + sock_path = ENV["IRB_SOCK_PATH"] + + # Phase 1 (discovery): no socket path set, so print instructions + # teaching the agent how to connect, then exit immediately. + # No IRB.setup needed since we're not starting a session. + if sock_path.nil? || sock_path.empty? + print_agent_instructions + Kernel.exit(0) + end + + # Phase 2 (debug session): socket path set, start a request/response + # server that the agent can send commands to. + require_relative "irb/agent" + IRB.setup(source_location[0], argv: []) unless IRB.initialized? + session = IRB::AgentSession.new(self, sock_path: sock_path) + session.run + end + + private + + def print_agent_instructions + file, line = source_location + method_name = begin + eval("__method__") + rescue NameError + nil + end + location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}" + + $stdout.puts <<~MSG + ========================================================== + IRB agent breakpoint hit at #{location} + + No IRB_SOCK_PATH set - exiting without starting a debug session. + This run's process state will not be retained. + + Add breakpoints with: require "irb"; binding.agent + + To debug this breakpoint: + + 1. Run the app in the BACKGROUND with a socket path: + + IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock & + + The process will block waiting for a connection. + + 2. Wait for the socket file to appear: + + ls /tmp/irb-UNIQUE_ID.sock + + 3. Send commands to the socket with FOREGROUND commands: + + ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close' + + Each invocation sends one command and prints the result. + The IRB session persists between invocations. + Output written by the program remains on the program's stdout/stderr. + + Commands that start another interactive program are unavailable: + debug commands, multi-IRB commands, edit, and show_doc without a target. + + Examples: + ... s.puts "ls"; s.close_write # list methods and variables + ... s.puts "show_source foo"; s.close_write # see source of a method + ... s.puts "@name"; s.close_write # inspect a variable + ... s.puts "exit"; s.close_write # end session, resume app + + ========================================================== + MSG + $stdout.flush + end end diff --git a/lib/irb/agent.rb b/lib/irb/agent.rb new file mode 100644 index 000000000..493e895be --- /dev/null +++ b/lib/irb/agent.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require "socket" + +module IRB + # A stateful IRB session for agents, transported over a Unix socket. + class AgentSession + def initialize(binding_context, sock_path:) + @binding_context = binding_context + @sock_path = sock_path + end + + def run + input = SocketInputMethod.new(@sock_path) + binding_irb = create_irb(input) + binding_irb.run(IRB.conf) + binding_irb.debug_break + ensure + begin + input&.close + rescue IOError, SystemCallError + nil + end + end + + private + + def create_irb(input) + workspace = IRB::WorkSpace.new(@binding_context) + irb = IRB::Irb.new(workspace, input, from_binding: true, verbose: false) + irb.context.irb_path = File.expand_path(@binding_context.source_location[0]) + irb.context.newline_before_multiline_output = false + irb + end + + # One client connection is one complete IRB request. The client half-closes + # its write side, then reads until this input method closes the response. + class SocketInputMethod < IRB::InputMethod + def initialize(sock_path) + super() + @sock_path = sock_path + @history = [] + @server = bind_server + end + + def gets + close_client + + loop do + @client = @server.accept + input = @client.read + if input.empty? + close_client + next + end + + entry = input.chomp + @history << entry unless entry.empty? || entry == @history.last + return input + end + end + + def check_termination + # Returning an entire request from #gets gives IRB one complete input, + # including multiline expressions, without terminal-driven prompting. + end + + def encoding + Encoding::UTF_8 + end + + def history + @history + end + + def output + @client || $stdout + end + + def tty? + false + end + + def remote? + true + end + + def inspect + "AgentSocketInputMethod" + end + + def command_unavailable_reason(command_class, arg) + if command_class <= IRB::Command::Debug + "debugger commands start a separate interactive console" + elsif command_class <= IRB::Command::MultiIRBCommand + "multi-IRB commands switch between interactive input loops" + elsif command_class == IRB::Command::Edit + "edit launches an interactive program on the host" + elsif command_class == IRB::Command::ShowDoc && arg.strip.empty? + "interactive RI requires a terminal; pass a documentation target instead" + end + end + + def write(*args) + safely { output.write(*args) } + end + + def print(*args) + safely { output.print(*args) } + end + + def printf(*args) + safely { output.printf(*args) } + end + + def puts(*args) + safely { output.puts(*args) } + end + + def warn(*messages, **_kwargs) + puts(*messages) + end + + def flush + safely { output.flush } + end + + def close + close_client + ensure + begin + close_server + ensure + remove_owned_socket + end + end + + private + + def bind_server + retries = 0 + + begin + server = UNIXServer.new(@sock_path) + rescue Errno::EADDRINUSE + raise if retries == 1 + + remove_stale_socket + retries += 1 + retry + end + + @socket_identity = socket_identity + File.chmod(0600, @sock_path) + server + rescue + begin + server&.close + rescue IOError, SystemCallError + nil + end + remove_owned_socket + raise + end + + def remove_stale_socket + stale_identity = socket_identity + raise Errno::EADDRINUSE, "IRB agent socket path is not a socket: #{@sock_path}" unless stale_identity[:socket] + + if active_socket? + raise Errno::EADDRINUSE, "IRB agent socket is already in use: #{@sock_path}" + end + + current_identity = socket_identity + return unless same_socket?(stale_identity, current_identity) + + File.unlink(@sock_path) + rescue Errno::ENOENT + nil + end + + def active_socket? + socket = UNIXSocket.new(@sock_path) + true + rescue Errno::ECONNREFUSED, Errno::ENOENT + false + ensure + socket&.close + end + + def socket_identity + stat = File.lstat(@sock_path) + { device: stat.dev, inode: stat.ino, socket: stat.socket? } + end + + def same_socket?(left, right) + left[:device] == right[:device] && left[:inode] == right[:inode] + end + + def remove_owned_socket + return unless @socket_identity + + current_identity = socket_identity + File.unlink(@sock_path) if current_identity[:socket] && same_socket?(@socket_identity, current_identity) + rescue SystemCallError + nil + ensure + @socket_identity = nil + end + + def close_client + client = @client + @client = nil + client&.close + rescue IOError, SystemCallError + nil + end + + def close_server + server = @server + @server = nil + server&.close + rescue IOError, SystemCallError + nil + end + + def safely + yield + rescue Errno::EPIPE, IOError + nil + end + end + end +end diff --git a/lib/irb/color.rb b/lib/irb/color.rb index 1fdf51994..1d07e43db 100644 --- a/lib/irb/color.rb +++ b/lib/irb/color.rb @@ -117,7 +117,13 @@ module Color class << self def colorable? - supported = $stdout.tty? && (/mswin|mingw/.match?(RUBY_PLATFORM) || (ENV.key?('TERM') && ENV['TERM'] != 'dumb')) + context = begin + IRB.CurrentContext if IRB.respond_to?(:CurrentContext) + rescue StandardError + nil + end + output = context&.output || $stdout + supported = output.tty? && (/mswin|mingw/.match?(RUBY_PLATFORM) || (ENV.key?('TERM') && ENV['TERM'] != 'dumb')) # because ruby/debug also uses irb's color module selectively, # irb won't be activated in that case. diff --git a/lib/irb/command/base.rb b/lib/irb/command/base.rb index 720e47af8..a5c0e9a17 100644 --- a/lib/irb/command/base.rb +++ b/lib/irb/command/base.rb @@ -34,7 +34,7 @@ def help_message(help_message = nil) def execute(irb_context, arg) new(irb_context).execute(arg) rescue CommandArgumentError => e - puts e.message + irb_context.output.puts e.message end # Returns formatted lines for display in the doc dialog popup. @@ -88,6 +88,32 @@ def initialize(irb_context) def execute(arg) #nop end + + private + + def output + irb_context.output + end + + def write(*args) + output.write(*args) + end + + def print(*args) + output.print(*args) + end + + def printf(*args) + output.printf(*args) + end + + def puts(*args) + output.puts(*args) + end + + def warn(*messages, **kwargs) + output.warn(*messages, **kwargs) + end end Nop = Base # :nodoc: diff --git a/lib/irb/command/context.rb b/lib/irb/command/context.rb index b4fc80734..2bf980b2d 100644 --- a/lib/irb/command/context.rb +++ b/lib/irb/command/context.rb @@ -9,7 +9,7 @@ class Context < Base def execute(_arg) # This command just displays the configuration. # Modifying the configuration is achieved by sending a message to IRB.conf. - Pager.page_content(IRB.CurrentContext.inspect) + Pager.page_content(IRB.CurrentContext.inspect, output: irb_context.output) end end end diff --git a/lib/irb/command/copy.rb b/lib/irb/command/copy.rb index 549e2424e..ebe42787f 100644 --- a/lib/irb/command/copy.rb +++ b/lib/irb/command/copy.rb @@ -25,7 +25,7 @@ def execute(arg) arg = '_' if arg.to_s.strip.empty? value = irb_context.workspace.binding.eval(arg) - output = irb_context.inspect_method.inspect_value(value, +'', colorize: false).chomp + output = irb_context.inspect_method.inspect_value(value, +'', colorize: false, error_output: irb_context.output).chomp if clipboard_available? copy_to_clipboard(output) diff --git a/lib/irb/command/help.rb b/lib/irb/command/help.rb index 12b468fef..8b866984b 100644 --- a/lib/irb/command/help.rb +++ b/lib/irb/command/help.rb @@ -17,7 +17,7 @@ def execute(command_name) "Can't find command `#{command_name}`. Please check the command name and try again.\n\n" end end - Pager.page_content(content) + Pager.page_content(content, output: irb_context.output) end private diff --git a/lib/irb/command/history.rb b/lib/irb/command/history.rb index e385c6610..99d588520 100644 --- a/lib/irb/command/history.rb +++ b/lib/irb/command/history.rb @@ -18,7 +18,7 @@ def execute(arg) grep = Regexp.new(match[:grep]) end - formatted_inputs = irb_context.io.class::HISTORY.each_with_index.reverse_each.filter_map do |input, index| + formatted_inputs = irb_context.output.history.each_with_index.reverse_each.filter_map do |input, index| next if grep && !input.match?(grep) header = "#{index}: " @@ -36,7 +36,7 @@ def execute(arg) [first_line, *other_lines].join("\n") + "\n" end - Pager.page_content(formatted_inputs.join) + Pager.page_content(formatted_inputs.join, output: irb_context.output) end end end diff --git a/lib/irb/command/ls.rb b/lib/irb/command/ls.rb index eae643ff4..811f13592 100644 --- a/lib/irb/command/ls.rb +++ b/lib/irb/command/ls.rb @@ -53,7 +53,7 @@ def execute(arg) grep = evaluate(grep_regexp_code) end - o = Output.new(grep: grep) + o = Output.new(grep: grep, output: irb_context.output) klass = Kernel.instance_method(:class).bind(obj).call obj_is_class_or_module = Module === obj @@ -106,14 +106,15 @@ def class_method_map(classes, dumped_mods) class Output MARGIN = " " - def initialize(grep: nil) + def initialize(grep: nil, output: $stdout) @grep = grep + @output = output @line_width = screen_width - MARGIN.length # right padding @io = StringIO.new end def print_result - Pager.page_content(@io.string) + Pager.page_content(@io.string, output: @output) end def dump(name, strs) diff --git a/lib/irb/command/show_doc.rb b/lib/irb/command/show_doc.rb index 8a2188e4e..b31ee05a5 100644 --- a/lib/irb/command/show_doc.rb +++ b/lib/irb/command/show_doc.rb @@ -27,16 +27,43 @@ def execute(arg) name = unwrap_string_literal(arg) require 'rdoc/ri/driver' - unless ShowDoc.const_defined?(:Ri) + driver = if output.remote? + unless ShowDoc.const_defined?(:RemoteDriver, false) + remote_driver = Class.new(RDoc::RI::Driver) do + def initialize(options, output) + @output = output + super(options) + end + + def page + yield @output + ensure + @paging = false + end + + def formatter(_io) + require 'rdoc/markup/to_rdoc' + RDoc::Markup::ToRdoc.new + end + end + ShowDoc.const_set(:RemoteDriver, remote_driver) + end + opts = RDoc::RI::Driver.process_args([]) - ShowDoc.const_set(:Ri, RDoc::RI::Driver.new(opts)) + ShowDoc::RemoteDriver.new(opts, output) + else + unless ShowDoc.const_defined?(:Ri, false) + opts = RDoc::RI::Driver.process_args([]) + ShowDoc.const_set(:Ri, RDoc::RI::Driver.new(opts)) + end + ShowDoc::Ri end if name.nil? - Ri.interactive + driver.interactive else begin - Ri.display_name(name) + driver.display_name(name) rescue RDoc::RI::Error puts $!.message end diff --git a/lib/irb/command/show_source.rb b/lib/irb/command/show_source.rb index f4c6f104a..f8f9f5e34 100644 --- a/lib/irb/command/show_source.rb +++ b/lib/irb/command/show_source.rb @@ -63,7 +63,7 @@ def show_source(source) CONTENT end - Pager.page_content(content) + Pager.page_content(content, output: irb_context.output) end def bold(str) diff --git a/lib/irb/context.rb b/lib/irb/context.rb index 284946be0..049db30fa 100644 --- a/lib/irb/context.rb +++ b/lib/irb/context.rb @@ -24,7 +24,7 @@ class Context # +nil+:: uses stdin or Reline or Readline # +String+:: uses a File # +other+:: uses this as InputMethod - def initialize(irb, workspace = nil, input_method = nil) + def initialize(irb, workspace = nil, input_method = nil, verbose: IRB.conf[:VERBOSE]) @irb = irb @workspace_stack = [] if workspace @@ -62,7 +62,7 @@ def initialize(irb, workspace = nil, input_method = nil) @use_multiline = nil end @use_autocomplete = IRB.conf[:USE_AUTOCOMPLETE] - @verbose = IRB.conf[:VERBOSE] + @verbose = verbose @io = nil self.inspect_mode = IRB.conf[:INSPECT_MODE] @@ -135,6 +135,7 @@ def initialize(irb, workspace = nil, input_method = nil) else @io = input_method end + @output = @io @extra_doc_dirs = IRB.conf[:EXTRA_DOC_DIRS] @echo = IRB.conf[:ECHO] @@ -216,6 +217,8 @@ def main # RelineInputMethod, FileInputMethod or other specified when the # context is created. See ::new for more # information on +input_method+. attr_accessor :io + # The output method paired with this context. + attr_accessor :output # Current irb session. attr_accessor :irb @@ -557,7 +560,12 @@ def evaluate(statement, line_no) # :nodoc: result = evaluate_expression(statement.code, line_no) set_last_value(result) when Statement::Command - statement.command_class.execute(self, statement.arg) + if reason = output.command_unavailable_reason(statement.command_class, statement.arg) + command_name = statement.code.split(/\s+/, 2).first + output.puts "The `#{command_name}` command is unavailable in this session because #{reason}." + else + statement.command_class.execute(self, statement.arg) + end when Statement::IncorrectAlias warn statement.message end @@ -660,7 +668,7 @@ def colorize_input(input, complete:) end def inspect_last_value(output = +'') # :nodoc: - @inspect_method.inspect_value(@last_value, output) + @inspect_method.inspect_value(@last_value, output, error_output: @output) end def inspector_support_stream_output? @@ -668,7 +676,7 @@ def inspector_support_stream_output? end NOPRINTING_IVARS = ["@last_value"] # :nodoc: - NO_INSPECTING_IVARS = ["@irb", "@io"] # :nodoc: + NO_INSPECTING_IVARS = ["@irb", "@io", "@output"] # :nodoc: IDNAME_IVARS = ["@prompt_mode"] # :nodoc: alias __inspect__ inspect @@ -705,6 +713,26 @@ def safe_method_call_on_main(method_name) private + def output_method + @output || @io || $stdout + end + + def print(*args) + output_method.print(*args) + end + + def puts(*args) + output_method.puts(*args) + end + + def warn(*messages, **kwargs) + if @output || @io + output_method.warn(*messages, **kwargs) + else + Kernel.warn(*messages, **kwargs) + end + end + def term_interactive? return true if ENV['TEST_IRB_FORCE_INTERACTIVE'] STDIN.tty? && ENV['TERM'] != 'dumb' diff --git a/lib/irb/init.rb b/lib/irb/init.rb index f933f5cec..6bead3c99 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -150,7 +150,7 @@ def IRB.init_config(ap_path) time = Time.now result = block.() now = Time.now - puts 'processing time: %fs' % (now - time) if IRB.conf[:MEASURE] + context.output.puts 'processing time: %fs' % (now - time) if IRB.conf[:MEASURE] result } # arg can be either a symbol for the mode (:cpu, :wall, ..) or a hash for @@ -163,7 +163,7 @@ def IRB.init_config(ap_path) require 'stackprof' success = true rescue LoadError - puts 'Please run "gem install stackprof" before measuring by StackProf.' + context.output.puts 'Please run "gem install stackprof" before measuring by StackProf.' end if success result = nil @@ -173,11 +173,12 @@ def IRB.init_config(ap_path) end case stackprof_result when File - puts "StackProf report saved to #{stackprof_result.path}" + context.output.puts "StackProf report saved to #{stackprof_result.path}" when Hash - StackProf::Report.new(stackprof_result).print_text + report = StackProf::Report.new(stackprof_result) + print_stackprof_report(report, context.output) else - puts "Stackprof ran with #{arg.inspect}" + context.output.puts "Stackprof ran with #{arg.inspect}" end result else @@ -481,6 +482,21 @@ def IRB.load_modules class << IRB private + def print_stackprof_report(report, output) + parameters = report.method(:print_text).parameters + output_parameter_index = parameters.index { |_, name| name == :f } + return report.print_text unless output_parameter_index + + parameter_type, = parameters[output_parameter_index] + if [:key, :keyreq].include?(parameter_type) + report.print_text(f: output) + else + arguments = Array.new(output_parameter_index) + arguments[0] = false unless arguments.empty? + report.print_text(*arguments, output) + end + end + def prepare_irbrc_name_generators return if @existing_rc_name_generators diff --git a/lib/irb/input-method.rb b/lib/irb/input-method.rb index 32bdce14f..20fca3987 100644 --- a/lib/irb/input-method.rb +++ b/lib/irb/input-method.rb @@ -49,6 +49,55 @@ def prompting? false end + # The output target paired with this input method. + def output + $stdout + end + + def write(*args) + output.write(*args) + end + + def print(*args) + output.print(*args) + end + + def printf(*args) + output.printf(*args) + end + + def puts(*args) + output.puts(*args) + end + + def warn(*messages, **kwargs) + Kernel.warn(*messages, **kwargs) + end + + def flush + output.flush + end + + def tty? + output.tty? + end + + def history + if self.class.const_defined?(:HISTORY) + self.class.const_get(:HISTORY) + else + [] + end + end + + def command_unavailable_reason(command_class, arg) + nil + end + + def remote? + false + end + # For debug message def inspect 'Abstract InputMethod' diff --git a/lib/irb/inspector.rb b/lib/irb/inspector.rb index 07d2cf75d..845fcc078 100644 --- a/lib/irb/inspector.rb +++ b/lib/irb/inspector.rb @@ -98,19 +98,23 @@ def support_stream_output? end # Proc to call when the input is evaluated and output in irb. - def inspect_value(v, output, colorize: true) - support_stream_output? ? @inspect.call(v, output, colorize: colorize) : output << @inspect.call(v, colorize: colorize) + def inspect_value(v, output, colorize: true, error_output: $stdout) + options = { colorize: colorize } + if @inspect.parameters.any? { |type, name| type == :keyrest || ([:key, :keyreq].include?(type) && name == :error_output) } + options[:error_output] = error_output + end + + support_stream_output? ? @inspect.call(v, output, **options) : output << @inspect.call(v, **options) rescue => e - puts "An error occurred when inspecting the object: #{e.inspect}" + error_output.puts "An error occurred when inspecting the object: #{e.inspect}" begin - puts "Result of Kernel#inspect: #{KERNEL_INSPECT.bind_call(v)}" - '' + error_output.puts "Result of Kernel#inspect: #{KERNEL_INSPECT.bind_call(v)}" rescue => e - puts "An error occurred when running Kernel#inspect: #{e.inspect}" - puts e.backtrace.join("\n") - '' + error_output.puts "An error occurred when running Kernel#inspect: #{e.inspect}" + error_output.puts e.backtrace.join("\n") end + +'' end end @@ -121,11 +125,11 @@ def inspect_value(v, output, colorize: true) Inspector.def_inspector([true, :pp, :pretty_inspect], proc{require_relative "color_printer"}){|v, output, colorize: true| IRB::ColorPrinter.pp(v, output, colorize: colorize) } - Inspector.def_inspector([:yaml, :YAML], proc{require "yaml"}){|v| + Inspector.def_inspector([:yaml, :YAML], proc{require "yaml"}){|v, error_output: $stdout, **_kwargs| begin YAML.dump(v) rescue - puts "(can't dump yaml. use inspect)" + error_output.puts "(can't dump yaml. use inspect)" v.inspect end } diff --git a/lib/irb/pager.rb b/lib/irb/pager.rb index 64d0ae5d4..54b0864d6 100644 --- a/lib/irb/pager.rb +++ b/lib/irb/pager.rb @@ -9,18 +9,18 @@ class Pager PAGE_COMMANDS = [ENV['RI_PAGER'], ENV['PAGER'], 'less', 'more'].compact.uniq class << self - def page_content(content, **options) + def page_content(content, output: $stdout, **options) if content_exceeds_screen_height?(content) - page(**options) do |io| + page(output: output, **options) do |io| io.puts content end else - $stdout.puts content + output.puts content end end - def page(retain_content: false) - if should_page? && pager = setup_pager(retain_content: retain_content) + def page(retain_content: false, output: $stdout) + if should_page?(output: output) && pager = setup_pager(retain_content: retain_content) begin pid = pager.pid yield pager @@ -28,7 +28,7 @@ def page(retain_content: false) pager.close end else - yield $stdout + yield output end # When user presses Ctrl-C, IRB would raise `IRB::Abort` # But since Pager is implemented by running paging commands like `less` in another process with `IO.popen`, @@ -57,28 +57,28 @@ def page(retain_content: false) rescue Errno::EPIPE end - def should_page? - IRB.conf[:USE_PAGER] && STDIN.tty? && (ENV.key?("TERM") && ENV["TERM"] != "dumb") + def should_page?(output: $stdout) + IRB.conf[:USE_PAGER] && STDIN.tty? && output.tty? && (ENV.key?("TERM") && ENV["TERM"] != "dumb") end - def page_with_preview(width, height, formatter_proc) + def page_with_preview(width, height, formatter_proc, output: $stdout) overflow_callback = ->(lines) do modified_output = formatter_proc.call(lines.join, true) content, = take_first_page(width, [height - 2, 0].max) {|o| o.write modified_output } content = content.chomp content = "#{content}\e[0m" if Color.colorable? - $stdout.puts content - $stdout.puts 'Preparing full inspection value...' + output.puts content + output.puts 'Preparing full inspection value...' end out = PageOverflowIO.new(width, height, overflow_callback, delay: 0.1) yield out content = formatter_proc.call(out.string, out.multipage?) if out.multipage? - page(retain_content: true) do |io| + page(retain_content: true, output: output) do |io| io.puts content end else - $stdout.puts content + output.puts content end end diff --git a/test/irb/test_agent_workflow.rb b/test/irb/test_agent_workflow.rb new file mode 100644 index 000000000..a5e9cc954 --- /dev/null +++ b/test/irb/test_agent_workflow.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +require "socket" +require "tempfile" +require_relative "helper" + +module TestIRB + class AgentWorkflowTest < IntegrationTestCase + def test_phase1_prints_instructions_and_exits + write_ruby <<~'RUBY' + require "irb" + puts "BEFORE" + binding.agent + puts "AFTER" + RUBY + + output = run_ruby_file do + type("should not get here") + end + + assert_include output, "IRB agent breakpoint hit at" + assert_include output, "IRB_SOCK_PATH" + assert_include output, 'require "irb"; binding.agent' + assert_include output, "BEFORE" + # exit(0) means AFTER should not print + assert_not_include output, "AFTER" + end + + def test_phase2_basic_eval + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "1 + 1") + send_command(sock_path, "exit") + end + + assert_include output, "=> 2" + end + + def test_phase2_ls_command + write_ruby <<~'RUBY' + require "irb" + class Potato + attr_accessor :name + def initialize(name); @name = name; end + end + Potato.new("Russet").instance_eval { binding.agent } + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "ls") + send_command(sock_path, "exit") + end + + assert_include output, "name" + assert_include output, "@name" + end + + def test_phase2_show_source_command + write_ruby <<~'RUBY' + require "irb" + class Potato + def cook!; "done"; end + end + Potato.new.instance_eval { binding.agent } + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "show_source cook!") + send_command(sock_path, "exit") + end + + assert_include output, "def cook!" + end + + def test_phase2_show_doc_command + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + documentation = nil + run_agent_session do |sock_path| + documentation = send_command(sock_path, "show_doc Array#each") + send_command(sock_path, "exit") + end + + assert_include documentation, "Array#each" + end + + def test_phase2_source_command_keeps_session_output_policy_and_history + Tempfile.create(["agent_source", ".rb"]) do |source_file| + source_file.write(<<~'RUBY') + history + edit "missing-agent-file.rb" + source_value = 21 + source_value * 2 + RUBY + source_file.flush + + write_ruby <<~RUBY + require "irb" + binding.agent + RUBY + + source_output = nil + run_agent_session do |sock_path| + source_output = send_command(sock_path, "source #{source_file.path.dump}") + send_command(sock_path, "exit") + end + + assert_include source_output, "=> 42" + assert_include source_output, source_file.path + assert_include source_output, "edit launches an interactive program on the host" + end + end + + def test_phase2_error_handling + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "undefined_var") + send_command(sock_path, "exit") + end + + assert_include output, "NameError" + end + + def test_phase2_routes_yaml_fallback_through_the_socket + write_ruby <<~'RUBY' + require "irb" + class BadYaml + def encode_with(_coder); raise "not serializable"; end + end + binding.agent + RUBY + + response = nil + run_agent_session do |sock_path| + send_command(sock_path, "IRB.CurrentContext.inspect_mode = :yaml") + response = send_command(sock_path, "BadYaml.new") + send_command(sock_path, "exit") + end + + assert_match(/\A\(can't dump yaml\. use inspect\)\n=> #\n\z/, response) + end + + def test_phase2_multiline_expression + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "def double(x)\n x * 2\nend") + send_command(sock_path, "double(21)") + send_command(sock_path, "exit") + end + + assert_include output, "=> :double" + assert_include output, "=> 42" + end + + def test_phase2_session_state_persists + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + history = nil + output = run_agent_session do |sock_path| + send_command(sock_path, "x = 42") + send_command(sock_path, "x * 2") + history = send_command(sock_path, "history") + send_command(sock_path, "exit") + end + + assert_include output, "=> 42" + assert_include output, "=> 84" + assert_include history, "x = 42" + assert_include history, "x * 2" + end + + def test_phase2_resumes_execution_after_exit + write_ruby <<~'RUBY' + require "irb" + puts "BEFORE" + binding.agent + puts "AFTER" + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "exit") + end + + assert_include output, "BEFORE" + assert_include output, "AFTER" + end + + def test_phase2_exit_bang_survives_socket_cleanup_failure + write_ruby <<~'RUBY' + require "irb" + at_exit { puts "AT_EXIT_EXCEPTION=#{$!.class}" } + puts "BEFORE" + binding.agent + puts "AFTER" + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, <<~'RUBY') + File.singleton_class.prepend(Module.new do + def unlink(path) + raise Errno::EACCES, path if path == ENV["IRB_SOCK_PATH"] + super + end + end) + RUBY + send_command(sock_path, "exit!") + end + + assert_include output, "BEFORE" + assert_include output, "AT_EXIT_EXCEPTION=SystemExit" + assert_not_include output, "AFTER" + assert_not_include output, "Permission denied" + end + + def test_phase2_help_command + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "help") + send_command(sock_path, "exit") + end + + assert_include output, "show_source" + assert_include output, "ls" + end + + def test_phase2_ignores_empty_connections + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + close_connection_without_input(sock_path) + send_command(sock_path, "1 + 1") + send_command(sock_path, "exit") + end + + assert_include output, "=> 2" + end + + def test_phase2_does_not_capture_other_threads_output + write_ruby <<~'RUBY' + require "irb" + output_thread = Thread.new do + sleep 0.05 + puts "HOST_THREAD_OUTPUT" + end + binding.agent + output_thread.join + RUBY + + response = nil + output = run_agent_session do |sock_path| + response = send_command(sock_path, "sleep 0.15; 42") + send_command(sock_path, "exit") + end + + assert_include response, "=> 42" + assert_not_include response, "HOST_THREAD_OUTPUT" + assert_include output, "HOST_THREAD_OUTPUT" + assert_not_include output, "Switch to inspect mode." + end + + def test_phase2_does_not_replace_an_active_socket + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + second_server_error = nil + output = run_agent_session do |sock_path| + second_server_error = send_command(sock_path, <<~'RUBY') + begin + IRB::AgentSession::SocketInputMethod.new(ENV.fetch("IRB_SOCK_PATH")) + rescue => error + "#{error.class}: #{error.message}" + end + RUBY + send_command(sock_path, "1 + 1") + send_command(sock_path, "exit") + end + + assert_include second_server_error, "Errno::EADDRINUSE" + assert_include output, "=> 2" + end + + def test_phase2_replaces_a_stale_socket + write_ruby <<~'RUBY' + require "irb" + require "socket" + stale_server = UNIXServer.new(ENV.fetch("IRB_SOCK_PATH")) + stale_server.close + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "1 + 1") + send_command(sock_path, "exit") + end + + assert_include output, "=> 2" + end + + def test_phase2_preserves_a_replacement_socket_during_cleanup + write_ruby <<~'RUBY' + require "irb" + require "socket" + binding.agent + path = ENV.fetch("IRB_SOCK_PATH") + puts "REPLACEMENT_PRESERVED=#{File.socket?(path)}" + $replacement_server.close + File.unlink(path) + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, <<~'RUBY') + path = ENV.fetch("IRB_SOCK_PATH") + File.unlink(path) + $replacement_server = UNIXServer.new(path) + IRB.irb_exit + RUBY + end + + assert_include output, "REPLACEMENT_PRESERVED=true" + end + + def test_phase2_rejects_commands_that_start_other_interactive_loops + write_ruby <<~'RUBY' + require "irb" + binding.agent + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "debug") + send_command(sock_path, "irb") + send_command(sock_path, "edit") + send_command(sock_path, "show_doc") + send_command(sock_path, "exit") + end + + assert_include output, "debugger commands start a separate interactive console" + assert_include output, "multi-IRB commands switch between interactive input loops" + assert_include output, "edit launches an interactive program on the host" + assert_include output, "interactive RI requires a terminal" + end + + def test_phase2_restores_irb_configuration + write_ruby <<~'RUBY' + require "irb" + IRB.setup(__FILE__, argv: []) + previous_context = Object.new + IRB.conf[:MAIN_CONTEXT] = previous_context + IRB.conf[:USE_PAGER] = true + + binding.agent + + puts "MAIN_CONTEXT_RESTORED=#{IRB.conf[:MAIN_CONTEXT].equal?(previous_context)}" + puts "USE_PAGER_RESTORED=#{IRB.conf[:USE_PAGER].inspect}" + RUBY + + output = run_agent_session do |sock_path| + send_command(sock_path, "exit") + end + + assert_include output, "MAIN_CONTEXT_RESTORED=true" + assert_include output, "USE_PAGER_RESTORED=true" + end + + private + + def run_agent_session(timeout: TIMEOUT_SEC) + cmd = [EnvUtil.rubybin, "-I", LIB, @ruby_file.to_path] + tmp_dir = Dir.mktmpdir + sock_path = File.join(tmp_dir, "irb-test.sock") + pty_lines = [] + @command_output = +"" + + @envs["HOME"] ||= tmp_dir + @envs["XDG_CONFIG_HOME"] ||= tmp_dir + @envs["IRBRC"] = nil unless @envs.key?("IRBRC") + + envs_for_spawn = { 'TERM' => 'dumb', 'IRB_SOCK_PATH' => sock_path }.merge(@envs) + + PTY.spawn(envs_for_spawn, *cmd) do |read, write, pid| + Timeout.timeout(timeout) do + # Collect PTY output in background; the process produces no stdout + # until after the IRB session ends (e.g. puts after the breakpoint). + reader = Thread.new do + while line = safe_gets(read) + pty_lines << line + end + end + + poll_until { socket_accepting?(sock_path) } + + yield sock_path + + reader.join(timeout) + end + ensure + read.close + write.close + kill_safely(pid) + end + + pty_lines.join + @command_output + rescue Timeout::Error + message = <<~MSG + Test timed out. + + #{'=' * 30} PTY OUTPUT #{'=' * 30} + #{pty_lines.map { |l| " #{l}" }.join} + #{'=' * 27} COMMAND OUTPUT #{'=' * 27} + #{@command_output} + #{'=' * 27} END #{'=' * 27} + MSG + assert_block(message) { false } + ensure + FileUtils.remove_entry tmp_dir + end + + def send_command(sock_path, cmd) + sock = UNIXSocket.new(sock_path) + sock.puts cmd + sock.close_write + result = sock.read + @command_output << result + result + ensure + sock&.close + end + + def close_connection_without_input(sock_path) + sock = UNIXSocket.new(sock_path) + sock.close_write + sock.read + ensure + sock&.close + end + + def socket_accepting?(sock_path) + sock = UNIXSocket.new(sock_path) + true + rescue Errno::ECONNREFUSED, Errno::ENOENT + false + ensure + sock&.close + end + + def poll_until(timeout: TIMEOUT_SEC, interval: 0.05) + deadline = Time.now + timeout + until yield + raise Timeout::Error, "poll_until timed out" if Time.now > deadline + sleep interval + end + end + end +end diff --git a/test/irb/test_command.rb b/test/irb/test_command.rb index fd98a5a1b..e3c847430 100644 --- a/test/irb/test_command.rb +++ b/test/irb/test_command.rb @@ -201,6 +201,44 @@ def test_irb_info_lang end class MeasureTest < CommandTestCase + def test_stackprof_report_supports_legacy_output_argument + report = Class.new do + attr_reader :arguments + + def print_text(sort_by_total = false, limit = nil, f = $stdout) + @arguments = [sort_by_total, limit, f] + f.puts "legacy report" + end + end.new + output = StringIO.new + + IRB.__send__(:print_stackprof_report, report, output) + + assert_equal [false, nil, output], report.arguments + assert_equal "legacy report\n", output.string + end + + def test_stackprof_report_uses_context_output + require "stackprof" + + IRB.init_config(nil) + output = StringIO.new + context = Struct.new(:output).new(output) + IRB.conf[:MEASURE] = true + + host_output, = capture_output do + result = IRB.conf[:MEASURE_PROC][:STACKPROF].call(context, "1 + 1", 1, { mode: :wall, interval: 100 }) { 2 } + assert_equal 2, result + end + + assert_empty host_output + assert_not_empty output.string + rescue LoadError + omit "stackprof is not installed" + ensure + IRB.conf[:MEASURE] = false + end + def test_measure conf = { PROMPT: { diff --git a/test/irb/test_context.rb b/test/irb/test_context.rb index f90360379..6292efcef 100644 --- a/test/irb/test_context.rb +++ b/test/irb/test_context.rb @@ -120,6 +120,32 @@ def test_output_to_pipe assert_equal "=> 1\n", out end + def test_yaml_fallback_keeps_diagnostic_separate_from_result + input = TestInputMethod.new([ + "bad_yaml = Object.new; def bad_yaml.encode_with(_); raise 'no yaml'; end; bad_yaml\n", + ]) + irb = IRB::Irb.new(IRB::WorkSpace.new(Object.new), input) + irb.context.inspect_mode = :yaml + + out, err = capture_output do + irb.eval_input + end + + assert_empty err + assert_match(/\A\(can't dump yaml\. use inspect\)\n#\n\z/, out) + end + + def test_inspector_allows_positional_output_named_error_output + inspector = IRB::Inspector.new(proc { |value, error_output, colorize: true| error_output << "#{value}:#{colorize}" }) + output = +'' + diagnostics = StringIO.new + + inspector.inspect_value("value", output, error_output: diagnostics) + + assert_equal "value:true", output + assert_empty diagnostics.string + end + { successful: [ [false, "class Foo < Struct.new(:bar); end; Foo.new(123)\n", /#/], diff --git a/test/irb/test_input_method.rb b/test/irb/test_input_method.rb index c77dab1ac..1a6638c87 100644 --- a/test/irb/test_input_method.rb +++ b/test/irb/test_input_method.rb @@ -22,6 +22,15 @@ def teardown # Reset Reline configuration overridden by RelineInputMethod. Reline.instance_variable_set(:@core, nil) end + + def test_history_uses_inherited_provider + history = ["1 + 1"] + parent = Class.new(IRB::InputMethod) + parent.const_set(:HISTORY, history) + child = Class.new(parent) + + assert_same history, child.new.history + end end class RelineInputMethodTest < InputMethodTest diff --git a/test/irb/test_irb.rb b/test/irb/test_irb.rb index 4d92fa088..c1f7a0750 100644 --- a/test/irb/test_irb.rb +++ b/test/irb/test_irb.rb @@ -994,6 +994,12 @@ class ContextModeTest < TestIRB::IntegrationTestCase def test_context_mode_ruby_box omit if RUBY_VERSION < "4.0.0" @envs['RUBY_BOX'] = '1' + @envs["RUBYLIB"] = Gem.loaded_specs.fetch("reline") + .full_require_paths.join(File::PATH_SEPARATOR) + ENV.each_key.grep(/\ABUNDLE/).each do |key| + @envs[key] = nil + end + @envs["RUBYOPT"] = nil write_rc <<~'RUBY' IRB.conf[:CONTEXT_MODE] = 5