|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'socket' |
| 4 | +require 'stringio' |
| 5 | + |
| 6 | +module IRB |
| 7 | + # A request/response server for agent-driven IRB sessions over a Unix socket |
| 8 | + # (experimental). |
| 9 | + # |
| 10 | + # When <tt>binding.irb(agent: true)</tt> is called, the behavior depends on |
| 11 | + # the +IRB_SOCK_PATH+ environment variable: |
| 12 | + # |
| 13 | + # - *Not set* (Phase 1 — discovery): prints instructions explaining how to |
| 14 | + # start a debug session, then calls <tt>exit(0)</tt>. This lets the agent |
| 15 | + # see the instructions before the process terminates. |
| 16 | + # |
| 17 | + # - *Set* (Phase 2 — debug session): starts a Unix socket server at the |
| 18 | + # given path. The server accepts one connection at a time in a loop. Each |
| 19 | + # connection is a single request: the client sends Ruby code (or an IRB |
| 20 | + # command), closes its write end, and reads back the result. The IRB session |
| 21 | + # state persists across requests. Sending +exit+ ends the loop and resumes |
| 22 | + # execution of the host program. |
| 23 | + # |
| 24 | + # == Example agent workflow |
| 25 | + # |
| 26 | + # # 1. Run the app — hits breakpoint, prints instructions, exits: |
| 27 | + # $ ruby app.rb |
| 28 | + # |
| 29 | + # # 2. Re-run in background with a socket path: |
| 30 | + # $ IRB_SOCK_PATH=/tmp/irb-debug.sock ruby app.rb & |
| 31 | + # |
| 32 | + # # 3. Send commands (one per connection): |
| 33 | + # $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "ls"; s.close_write; puts s.read; s.close' |
| 34 | + # $ ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-debug.sock"); s.puts "exit"; s.close_write; puts s.read; s.close' |
| 35 | + # |
| 36 | + class RemoteServer |
| 37 | + def initialize(binding_context, sock_path:) |
| 38 | + @binding_context = binding_context |
| 39 | + @sock_path = sock_path |
| 40 | + end |
| 41 | + |
| 42 | + class StringInput < InputMethod |
| 43 | + def initialize(str) |
| 44 | + super() |
| 45 | + @io = StringIO.new(str) |
| 46 | + end |
| 47 | + |
| 48 | + def gets |
| 49 | + @io.gets |
| 50 | + end |
| 51 | + |
| 52 | + def eof? |
| 53 | + @io.eof? |
| 54 | + end |
| 55 | + |
| 56 | + def encoding |
| 57 | + Encoding::UTF_8 |
| 58 | + end |
| 59 | + end |
| 60 | + |
| 61 | + def run |
| 62 | + File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier |
| 63 | + |
| 64 | + server = UNIXServer.new(@sock_path) |
| 65 | + File.chmod(0600, @sock_path) |
| 66 | + |
| 67 | + original_stdout = $stdout |
| 68 | + |
| 69 | + IRB.conf[:USE_PAGER] = false |
| 70 | + |
| 71 | + binding_irb = create_irb |
| 72 | + IRB.conf[:MAIN_CONTEXT] = binding_irb.context |
| 73 | + |
| 74 | + begin |
| 75 | + loop do |
| 76 | + client = server.accept |
| 77 | + input = client.read |
| 78 | + break if input.nil? || input.empty? |
| 79 | + |
| 80 | + binding_irb.context.io = StringInput.new(input) |
| 81 | + |
| 82 | + not_exited = catch(:IRB_EXIT) do |
| 83 | + begin |
| 84 | + $stdout = client |
| 85 | + binding_irb.eval_input |
| 86 | + ensure |
| 87 | + $stdout = original_stdout |
| 88 | + end |
| 89 | + :not_exited |
| 90 | + end |
| 91 | + |
| 92 | + client.close rescue nil # rubocop:disable Style/RescueModifier |
| 93 | + |
| 94 | + break unless not_exited == :not_exited |
| 95 | + end |
| 96 | + ensure |
| 97 | + $stdout = original_stdout |
| 98 | + server&.close |
| 99 | + File.delete(@sock_path) rescue Errno::ENOENT # rubocop:disable Style/RescueModifier |
| 100 | + end |
| 101 | + end |
| 102 | + |
| 103 | + private |
| 104 | + |
| 105 | + def create_irb |
| 106 | + workspace = IRB::WorkSpace.new(@binding_context) |
| 107 | + irb = IRB::Irb.new(workspace, StringInput.new(""), from_binding: true) |
| 108 | + irb.context.irb_path = File.expand_path(@binding_context.source_location[0]) |
| 109 | + irb.context.verbose = false |
| 110 | + irb.context.newline_before_multiline_output = false |
| 111 | + irb |
| 112 | + end |
| 113 | + |
| 114 | + class << self |
| 115 | + def print_instructions(binding_context) |
| 116 | + file, line = binding_context.source_location |
| 117 | + method_name = binding_context.eval("__method__") rescue nil # rubocop:disable Style/RescueModifier |
| 118 | + location = method_name ? "#{file}:#{line} in `#{method_name}`" : "#{file}:#{line}" |
| 119 | + |
| 120 | + $stdout.puts <<~MSG |
| 121 | + ══════════════════════════════════════════════════════════ |
| 122 | + IRB agent breakpoint hit at #{location} |
| 123 | +
|
| 124 | + No IRB_SOCK_PATH set — exiting without starting a debug session. |
| 125 | +
|
| 126 | + To debug this breakpoint: |
| 127 | +
|
| 128 | + 1. Run the app in the BACKGROUND with a socket path: |
| 129 | +
|
| 130 | + IRB_SOCK_PATH=/tmp/irb-UNIQUE_ID.sock <your command> |
| 131 | +
|
| 132 | + The process will block waiting for a connection. |
| 133 | +
|
| 134 | + 2. Wait for the socket file to appear: |
| 135 | +
|
| 136 | + ls /tmp/irb-UNIQUE_ID.sock |
| 137 | +
|
| 138 | + 3. Send commands to the socket with FOREGROUND commands: |
| 139 | +
|
| 140 | + ruby -e 'require "socket"; s = UNIXSocket.new("/tmp/irb-UNIQUE_ID.sock"); s.puts "help"; s.close_write; puts s.read; s.close' |
| 141 | +
|
| 142 | + Each invocation sends one command and prints the result. |
| 143 | + The IRB session persists between invocations. |
| 144 | +
|
| 145 | + Examples: |
| 146 | + ... s.puts "ls"; s.close_write # list methods and variables |
| 147 | + ... s.puts "show_source foo"; s.close_write # see source of a method |
| 148 | + ... s.puts "@name"; s.close_write # inspect a variable |
| 149 | + ... s.puts "exit"; s.close_write # end session, resume app |
| 150 | +
|
| 151 | + ══════════════════════════════════════════════════════════ |
| 152 | + MSG |
| 153 | + $stdout.flush |
| 154 | + end |
| 155 | + end |
| 156 | + end |
| 157 | +end |
0 commit comments