girb-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +16 -0
  4. data/LICENSE +21 -0
  5. data/README.ja.md +349 -0
  6. data/README.md +351 -0
  7. data/examples/01_simple_bug.rb +43 -0
  8. data/examples/02_data_pipeline.rb +93 -0
  9. data/examples/03_recursion.rb +96 -0
  10. data/examples/RAILS_SCENARIOS.md +350 -0
  11. data/examples/SCENARIOS.md +142 -0
  12. data/examples/rails_test_app/setup.sh +428 -0
  13. data/examples/rails_test_app/testapp/.dockerignore +10 -0
  14. data/examples/rails_test_app/testapp/.ruby-version +1 -0
  15. data/examples/rails_test_app/testapp/Dockerfile +23 -0
  16. data/examples/rails_test_app/testapp/Gemfile +17 -0
  17. data/examples/rails_test_app/testapp/README.md +65 -0
  18. data/examples/rails_test_app/testapp/Rakefile +6 -0
  19. data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
  20. data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
  21. data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
  22. data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
  23. data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
  24. data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
  25. data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
  26. data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
  27. data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
  28. data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
  29. data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
  30. data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
  31. data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
  32. data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
  33. data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
  34. data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
  35. data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
  36. data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
  37. data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
  38. data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
  39. data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
  40. data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
  41. data/examples/rails_test_app/testapp/bin/ci +6 -0
  42. data/examples/rails_test_app/testapp/bin/dev +2 -0
  43. data/examples/rails_test_app/testapp/bin/rails +4 -0
  44. data/examples/rails_test_app/testapp/bin/rake +4 -0
  45. data/examples/rails_test_app/testapp/bin/setup +35 -0
  46. data/examples/rails_test_app/testapp/config/application.rb +42 -0
  47. data/examples/rails_test_app/testapp/config/boot.rb +3 -0
  48. data/examples/rails_test_app/testapp/config/ci.rb +14 -0
  49. data/examples/rails_test_app/testapp/config/database.yml +32 -0
  50. data/examples/rails_test_app/testapp/config/environment.rb +5 -0
  51. data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
  52. data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
  53. data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
  54. data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
  55. data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
  56. data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
  57. data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
  58. data/examples/rails_test_app/testapp/config/puma.rb +39 -0
  59. data/examples/rails_test_app/testapp/config/routes.rb +34 -0
  60. data/examples/rails_test_app/testapp/config.ru +6 -0
  61. data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
  62. data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
  63. data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
  64. data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
  65. data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
  66. data/examples/rails_test_app/testapp/db/schema.rb +71 -0
  67. data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
  68. data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
  69. data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
  70. data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
  71. data/examples/rails_test_app/testapp/log/.keep +0 -0
  72. data/examples/rails_test_app/testapp/public/400.html +135 -0
  73. data/examples/rails_test_app/testapp/public/404.html +135 -0
  74. data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
  75. data/examples/rails_test_app/testapp/public/422.html +135 -0
  76. data/examples/rails_test_app/testapp/public/500.html +135 -0
  77. data/examples/rails_test_app/testapp/public/icon.png +0 -0
  78. data/examples/rails_test_app/testapp/public/icon.svg +3 -0
  79. data/examples/rails_test_app/testapp/public/robots.txt +1 -0
  80. data/examples/rails_test_app/testapp/script/.keep +0 -0
  81. data/examples/rails_test_app/testapp/storage/.keep +0 -0
  82. data/examples/rails_test_app/testapp/tmp/.keep +0 -0
  83. data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
  84. data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
  85. data/examples/rails_test_app/testapp/vendor/.keep +0 -0
  86. data/exe/girb-mcp +39 -0
  87. data/exe/girb-rails +127 -0
  88. data/lib/girb_mcp/client_cleanup.rb +102 -0
  89. data/lib/girb_mcp/code_safety_analyzer.rb +124 -0
  90. data/lib/girb_mcp/debug_client.rb +1109 -0
  91. data/lib/girb_mcp/exit_message_builder.rb +112 -0
  92. data/lib/girb_mcp/pending_http_helper.rb +25 -0
  93. data/lib/girb_mcp/rails_helper.rb +155 -0
  94. data/lib/girb_mcp/server.rb +363 -0
  95. data/lib/girb_mcp/session_manager.rb +436 -0
  96. data/lib/girb_mcp/stop_event_annotator.rb +152 -0
  97. data/lib/girb_mcp/tcp_session_discovery.rb +226 -0
  98. data/lib/girb_mcp/tools/connect.rb +668 -0
  99. data/lib/girb_mcp/tools/continue_execution.rb +161 -0
  100. data/lib/girb_mcp/tools/disconnect.rb +168 -0
  101. data/lib/girb_mcp/tools/evaluate_code.rb +354 -0
  102. data/lib/girb_mcp/tools/finish.rb +84 -0
  103. data/lib/girb_mcp/tools/get_context.rb +217 -0
  104. data/lib/girb_mcp/tools/get_source.rb +193 -0
  105. data/lib/girb_mcp/tools/inspect_object.rb +107 -0
  106. data/lib/girb_mcp/tools/list_debug_sessions.rb +60 -0
  107. data/lib/girb_mcp/tools/list_files.rb +189 -0
  108. data/lib/girb_mcp/tools/list_paused_sessions.rb +108 -0
  109. data/lib/girb_mcp/tools/next.rb +70 -0
  110. data/lib/girb_mcp/tools/rails_info.rb +200 -0
  111. data/lib/girb_mcp/tools/rails_model.rb +362 -0
  112. data/lib/girb_mcp/tools/rails_routes.rb +186 -0
  113. data/lib/girb_mcp/tools/read_file.rb +214 -0
  114. data/lib/girb_mcp/tools/remove_breakpoint.rb +173 -0
  115. data/lib/girb_mcp/tools/run_debug_command.rb +55 -0
  116. data/lib/girb_mcp/tools/run_script.rb +293 -0
  117. data/lib/girb_mcp/tools/set_breakpoint.rb +206 -0
  118. data/lib/girb_mcp/tools/step.rb +67 -0
  119. data/lib/girb_mcp/tools/trigger_request.rb +515 -0
  120. data/lib/girb_mcp/version.rb +5 -0
  121. data/lib/girb_mcp.rb +40 -0
  122. metadata +237 -0
@@ -0,0 +1,1109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/wait"
5
+ require "set"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module GirbMcp
10
+ class DebugClient
11
+ DEFAULT_WIDTH = 500
12
+ DEFAULT_TIMEOUT = 15
13
+ CONTINUE_TIMEOUT = 30
14
+ DISCONNECT_SOCKET_TIMEOUT = 2
15
+ HTTP_WAKE_SETTLE_TIME = 0.3
16
+
17
+ # ANSI escape code pattern
18
+ ANSI_ESCAPE = /\e\[[0-9;]*m/
19
+
20
+ attr_reader :pid, :connected, :paused, :trap_context, :remote, :port
21
+ attr_accessor :stderr_file, :stdout_file, :wait_thread, :script_file, :script_args, :pending_http,
22
+ :listen_ports, :escape_target, :suspended_catch_bps
23
+
24
+ def initialize
25
+ @socket = nil
26
+ @pid = nil
27
+ @connected = false
28
+ @paused = false
29
+ @trap_context = nil
30
+ @remote = false # True for TCP connections (e.g., Docker) where Process.kill won't work
31
+ @port = nil
32
+ @width = DEFAULT_WIDTH
33
+ @mutex = Mutex.new
34
+ @one_shot_breakpoints = Set.new
35
+ @listen_ports = []
36
+ @escape_target = nil
37
+ @suspended_catch_bps = []
38
+ end
39
+
40
+ def connected?
41
+ @connected && @socket && !@socket.closed?
42
+ end
43
+
44
+ # Connect to a debug session via Unix domain socket or TCP.
45
+ # Accepts an optional block (&on_initial_timeout) that is called when the
46
+ # initial read_until_input times out. This allows the caller to wake an
47
+ # IO-blocked process (e.g., by sending an HTTP request) so that the debug
48
+ # gem's pending pause (set by the SIGURG from greeting) can fire.
49
+ # If the block is given and the initial read times out, the block is called
50
+ # and read_until_input is retried once with a 10s timeout.
51
+ def connect(path: nil, host: nil, port: nil, remote: nil, connect_timeout: nil, &on_initial_timeout)
52
+ disconnect if connected?
53
+
54
+ if path
55
+ @socket = Socket.unix(path)
56
+ @remote = remote.nil? ? false : remote
57
+ elsif port
58
+ @socket = Socket.tcp(host || "localhost", port.to_i)
59
+ @remote = remote.nil? ? true : remote
60
+ @port = port.to_i
61
+ else
62
+ path = discover_socket
63
+ @socket = Socket.unix(path)
64
+ @remote = remote.nil? ? false : remote
65
+ end
66
+
67
+ # The debug gem protocol: client sends greeting first, then reads server output
68
+ send_greeting
69
+ initial_output = begin
70
+ read_until_input(timeout: connect_timeout || DEFAULT_TIMEOUT)
71
+ rescue TimeoutError
72
+ raise unless on_initial_timeout
73
+ on_initial_timeout.call
74
+ read_until_input(timeout: 10)
75
+ end
76
+ @connected = true
77
+ @paused = true
78
+
79
+ { success: true, pid: @pid, output: initial_output }
80
+ rescue Errno::ECONNREFUSED => e
81
+ raise ConnectionError, "Connection refused: #{e.message}. " \
82
+ "Ensure the debug process is running with 'rdbg --open'."
83
+ rescue Errno::ENOENT => e
84
+ raise ConnectionError, "Socket not found: #{e.message}. " \
85
+ "The debug process may have exited. Use 'list_debug_sessions' to check."
86
+ rescue TimeoutError
87
+ disconnect
88
+ raise ConnectionError, "Connection timed out: the debug process did not respond.\n" \
89
+ "Possible causes:\n" \
90
+ " - Another debugger client is already connected " \
91
+ "(only one client allowed at a time)\n" \
92
+ " - A previous debugger client disconnected uncleanly, " \
93
+ "leaving the session stuck\n" \
94
+ " - The target process is blocked and cannot respond " \
95
+ "to the debug interrupt\n" \
96
+ "To resolve:\n" \
97
+ " - Close other debugger clients " \
98
+ "(e.g., IDE debugger, other terminal sessions)\n" \
99
+ " - Restart the target process with 'rdbg --open'"
100
+ rescue GirbMcp::Error
101
+ raise
102
+ rescue StandardError => e
103
+ disconnect
104
+ raise ConnectionError, "Connection failed: #{e.class}: #{e.message}"
105
+ end
106
+
107
+ def disconnect
108
+ force_close_socket
109
+ @socket = nil
110
+ @pid = nil
111
+ @connected = false
112
+ @paused = false
113
+ @trap_context = nil
114
+ @port = nil
115
+ @pending_http = nil
116
+ @listen_ports = []
117
+ @escape_target = nil
118
+ @suspended_catch_bps = []
119
+ cleanup_captured_files
120
+ end
121
+
122
+ # Read captured stdout output (available for processes launched via run_script)
123
+ # Returns the stdout content string, or nil
124
+ def read_stdout_output
125
+ read_captured_file(@stdout_file)
126
+ end
127
+
128
+ # Read captured stderr output (available for processes launched via run_script)
129
+ # Returns the stderr content string, or nil
130
+ def read_stderr_output
131
+ read_captured_file(@stderr_file)
132
+ end
133
+
134
+ # Send a debugger command and return the output.
135
+ def send_command(command, timeout: DEFAULT_TIMEOUT)
136
+ raise SessionError, "Not connected to a debug session. Use 'connect' to establish a connection." unless connected?
137
+
138
+ @mutex.synchronize do
139
+ debug_log("send_command: paused=#{@paused} cmd=#{command[0, 60]}")
140
+
141
+ # Drain stale data from previous operations (e.g., responses from
142
+ # timed-out commands, late breakpoint `input PID` after continue timeout).
143
+ # This prevents protocol desync where send_command reads a stale response
144
+ # instead of the response to the command being sent.
145
+ drain_stale_data
146
+
147
+ unless @paused
148
+ raise SessionError, "Process is not paused. Cannot send debug commands while the process is running. " \
149
+ "Use 'trigger_request' to send an HTTP request (which auto-resumes), " \
150
+ "or 'disconnect' and reconnect."
151
+ end
152
+
153
+ # Encode as binary to avoid Encoding::CompatibilityError when the
154
+ # command contains non-ASCII characters (e.g., Japanese) and the
155
+ # socket uses ASCII-8BIT encoding.
156
+ msg = "command #{@pid} #{@width} #{command}\n"
157
+ @socket.write(msg.b)
158
+ @socket.flush
159
+ debug_log("send_command: written OK, reading response...")
160
+ output = read_until_input(timeout: timeout)
161
+ debug_log("send_command: got response (#{output.length} bytes)")
162
+ @paused = true
163
+ output
164
+ end
165
+ rescue TimeoutError
166
+ # The debug gem is still processing the command — it hasn't sent
167
+ # `input PID` back. Mark as not paused so subsequent tool calls
168
+ # trigger auto_repause! (SIGURG/SIGINT) instead of sending another
169
+ # command into the unresponsive session.
170
+ @paused = false
171
+ raise
172
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
173
+ @connected = false
174
+ @paused = false
175
+ raise ConnectionError, "Connection lost while executing '#{command}': #{e.message}. " \
176
+ "The debug process may have exited. Use 'connect' to reconnect."
177
+ end
178
+
179
+ # Send a command and wait briefly for the debug gem to read it, but don't
180
+ # wait for a full response (which may never come, e.g., after `c` with no
181
+ # breakpoint). Used when resuming a process before disconnecting.
182
+ #
183
+ # When force: true, bypasses the @paused check. Use ONLY during cleanup/disconnect
184
+ # when a prior send_command timeout set @paused=false but the process is
185
+ # actually still paused in the debugger.
186
+ def send_command_no_wait(command, force: false)
187
+ return unless connected?
188
+ return unless force || @paused # Sending command while running crashes the reader thread
189
+
190
+ @mutex.synchronize do
191
+ msg = "command #{@pid} #{@width} #{command}\n"
192
+ @socket.write(msg.b)
193
+ @socket.flush
194
+ @paused = false
195
+ end
196
+ # Give the debug gem time to read the command from the socket buffer
197
+ # and begin processing it before the socket is closed.
198
+ sleep 0.3
199
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
200
+ # Socket already closed, ignore
201
+ end
202
+
203
+ # Send continue and wait for the next breakpoint.
204
+ # Uses continue_and_wait (IO.select-based) instead of send_command to avoid
205
+ # protocol desync: send_command sets @paused=true on timeout even when no
206
+ # input prompt was received, causing subsequent commands to read stale responses.
207
+ def send_continue(timeout: CONTINUE_TIMEOUT, &interrupt_check)
208
+ result = continue_and_wait(timeout: timeout, &interrupt_check)
209
+ case result[:type]
210
+ when :breakpoint
211
+ result[:output]
212
+ when :timeout, :timeout_with_output
213
+ raise TimeoutError, "Timeout after #{timeout}s waiting for breakpoint."
214
+ when :interrupted
215
+ result[:output]
216
+ end
217
+ end
218
+
219
+ # Check if there is a current exception in scope ($!)
220
+ # Returns "ExceptionClass: message" string, or nil if no exception
221
+ def check_current_exception
222
+ # Use a single expression to get "ClassName: message" format when $! is set.
223
+ # The debug gem prefixes output with "=> ", which we strip.
224
+ result = send_command('p(($!) ? "#{$!.class}: #{$!.message}" : nil)')
225
+ cleaned = result.strip.sub(/\A=> /, "")
226
+ return nil if cleaned == "nil" || cleaned.empty?
227
+
228
+ # Remove surrounding quotes from string output (e.g., "NoMethodError: ..." -> NoMethodError: ...)
229
+ cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"')
230
+ cleaned.empty? ? nil : cleaned
231
+ rescue GirbMcp::Error
232
+ nil
233
+ end
234
+
235
+ # Check if the spawned process has exited.
236
+ # Waits briefly to allow the process to finish cleanup.
237
+ # Only meaningful for run_script sessions (wait_thread is nil for connect sessions).
238
+ def process_finished?(timeout: 1)
239
+ return false unless wait_thread
240
+
241
+ wait_thread.join(timeout)
242
+ !wait_thread.alive?
243
+ end
244
+
245
+ # Find the most recently raised exception of a given class via ObjectSpace.
246
+ # Used as a fallback at catch breakpoints where $! is not yet set
247
+ # (the :raise TracePoint fires before $! is assigned).
248
+ # Returns "ExceptionClass: message" string, or nil if not found.
249
+ def find_raised_exception(exception_class_name)
250
+ result = send_command(
251
+ "p(begin; klass = Object.const_get(#{exception_class_name.inspect}); " \
252
+ "e = ObjectSpace.each_object(klass).max_by(&:object_id); " \
253
+ 'e ? "#{e.class}: #{e.message}" : nil; rescue; nil; end)',
254
+ )
255
+ cleaned = result.strip.sub(/\A=> /, "")
256
+ return nil if cleaned == "nil" || cleaned.empty?
257
+
258
+ cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"')
259
+ cleaned.empty? ? nil : cleaned
260
+ rescue GirbMcp::Error
261
+ nil
262
+ end
263
+
264
+ # Check if the debugger is currently in a signal trap context.
265
+ # This is common when connecting to Puma/Rails processes via SIGURG.
266
+ # In trap context, thread operations (Mutex lock, DB pools, autoloading) fail
267
+ # with ThreadError: "can't be called from trap context".
268
+ # Note: Mutex.new alone succeeds in trap context (Ruby 3.3+) — it's just object
269
+ # allocation. Mutex#lock is needed to actually test for trap context restrictions.
270
+ def in_trap_context?
271
+ result = send_command("p begin; Mutex.new.lock; 'normal'; rescue ThreadError; 'trap'; end")
272
+ in_trap = result.strip.sub(/\A=> /, "").include?("trap")
273
+ @trap_context = in_trap
274
+ in_trap
275
+ rescue GirbMcp::Error
276
+ false
277
+ end
278
+
279
+ # Attempt to escape from signal trap context by stepping to the next line.
280
+ # The 'next' command causes the debugger to return from the signal handler
281
+ # and pause at the next Ruby line in normal context.
282
+ # Uses a short timeout (3s) because if the process is blocked in IO.select
283
+ # (common with Puma), 'next' will never complete regardless of wait time.
284
+ # Returns the step output on success, nil on failure.
285
+ def escape_trap_context!(timeout: 3)
286
+ return nil unless in_trap_context?
287
+
288
+ output = send_command("next", timeout: timeout)
289
+
290
+ if in_trap_context?
291
+ nil # Still in trap context (step didn't escape)
292
+ else
293
+ @trap_context = false
294
+ output
295
+ end
296
+ rescue GirbMcp::Error
297
+ nil
298
+ end
299
+
300
+ # Try to determine if the process is paused by draining any pending
301
+ # output from the socket. Returns the pending output string if the
302
+ # process is paused (input prompt found), or nil if still running.
303
+ # Does NOT send any commands - safe to call at any time.
304
+ def ensure_paused(timeout: 2)
305
+ return "" if @paused
306
+ return nil unless connected?
307
+
308
+ @mutex.synchronize do
309
+ return "" if @paused
310
+
311
+ pending_output = []
312
+ deadline = Time.now + timeout
313
+
314
+ while Time.now < deadline
315
+ remaining = [deadline - Time.now, 0.1].min
316
+ break if remaining <= 0
317
+
318
+ ready = @socket.wait_readable(remaining)
319
+ next unless ready
320
+
321
+ line = @socket.gets
322
+ break unless line
323
+
324
+ line = line.chomp.force_encoding(Encoding::UTF_8)
325
+ line = line.scrub unless line.valid_encoding?
326
+
327
+ case line
328
+ when /\Aout (.*)/
329
+ pending_output << strip_ansi($1)
330
+ when /\Ainput (\d+)/
331
+ @pid = $1
332
+ @paused = true
333
+ return pending_output.join("\n")
334
+ when /\Aask (\d+) (.*)/
335
+ @socket.write("answer #{$1} y\n".b)
336
+ @socket.flush
337
+ when /\Aquit/
338
+ @connected = false
339
+ @paused = false
340
+ return nil
341
+ end
342
+ end
343
+
344
+ nil # Still not paused (no input prompt received)
345
+ end
346
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
347
+ @connected = false
348
+ @paused = false
349
+ nil
350
+ end
351
+
352
+ # Interrupt a running process via SIGINT and wait for it to pause.
353
+ # SIGINT can break through C-level blocking IO (unlike SIGURG used by repause).
354
+ # Returns "" if the process is now paused, nil if the process couldn't be found
355
+ # or the interrupt didn't work.
356
+ def interrupt_and_wait(timeout: 5)
357
+ return nil unless @pid
358
+ return "" if @paused
359
+
360
+ if @remote
361
+ # For remote connections, SIGINT can't be sent directly.
362
+ # The `pause` protocol message (sent by repause) is the best we can do.
363
+ # Return nil to indicate we can't interrupt.
364
+ return nil
365
+ end
366
+
367
+ Process.kill("INT", @pid.to_i)
368
+ ensure_paused(timeout: timeout)
369
+ rescue Errno::ESRCH, Errno::EPERM
370
+ nil
371
+ end
372
+
373
+ # Automatically re-pause the process if it's running.
374
+ # Returns false if already paused, true if repause was performed.
375
+ # Raises SessionError if the process cannot be re-paused (e.g., blocked on I/O).
376
+ def auto_repause!
377
+ return false if @paused
378
+
379
+ debug_log("auto_repause!: starting (paused=#{@paused}, trap=#{@trap_context})")
380
+ result = repause(timeout: 3)
381
+ if result.nil?
382
+ if @remote
383
+ # Remote: interrupt_and_wait always returns nil (SIGINT can't reach the
384
+ # target process in a different PID namespace). Retry repause with
385
+ # progressive delays — the reader thread may need time to process
386
+ # the `pause` message, especially over TCP.
387
+ sleep HTTP_WAKE_SETTLE_TIME
388
+ result = repause(timeout: 5)
389
+ # If repause still fails and we have listen ports, the process is likely
390
+ # blocked in IO.select (SA_RESTART prevents SIGURG from breaking through).
391
+ # Send an HTTP GET to wake it, then retry repause.
392
+ if result.nil? && @listen_ports&.any?
393
+ debug_log("auto_repause!: waking IO-blocked process via HTTP")
394
+ wake_thread = wake_io_blocked_process(@listen_ports.first)
395
+ sleep HTTP_WAKE_SETTLE_TIME
396
+ result = repause(timeout: 5)
397
+ wake_thread.join(1) rescue nil
398
+ end
399
+ if result.nil?
400
+ sleep 0.5
401
+ result = repause(timeout: 8)
402
+ end
403
+ else
404
+ # Local: SIGINT can interrupt IO.select and similar C-level blocking calls.
405
+ result = interrupt_and_wait(timeout: 5)
406
+ end
407
+
408
+ if result.nil?
409
+ raise SessionError, "Process is not paused and could not be interrupted. " \
410
+ "The process may have exited or be in an unrecoverable state. " \
411
+ "Use 'disconnect' and 'connect' to re-attach."
412
+ end
413
+ end
414
+
415
+ debug_log("auto_repause!: repause done (paused=#{@paused}, trap=#{@trap_context})")
416
+
417
+ # After repause via SIGURG, the process is in trap context.
418
+ # Auto-escape if we have cached escape target and listen ports.
419
+ if @trap_context && @listen_ports&.any? && @escape_target
420
+ debug_log("auto_repause!: attempting trap escape (trap=#{@trap_context})")
421
+ attempt_trap_escape!
422
+ debug_log("auto_repause!: after trap escape (trap=#{@trap_context})")
423
+
424
+ # Protocol sync: after attempt_trap_escape!, TCP in-flight data from
425
+ # the escape sequence (break, continue, delete commands) may still be
426
+ # arriving. Send a no-op command to perform a full round-trip and
427
+ # consume any stale data before the next real command.
428
+ if @paused
429
+ begin
430
+ send_command("p nil", timeout: 3)
431
+ rescue GirbMcp::Error
432
+ # Best-effort sync. If this times out, send_command sets @paused=false.
433
+ # Restore it: the process was confirmed paused at the escape breakpoint,
434
+ # and drain_stale_data on the next command will consume the stale response.
435
+ @paused = true
436
+ end
437
+ end
438
+ end
439
+
440
+ true
441
+ end
442
+
443
+ # Re-pause a running process by sending SIGURG directly.
444
+ #
445
+ # First drains any buffered socket data (in case a breakpoint was hit but the
446
+ # `input PID` wasn't consumed due to a timeout). If not already paused, sends
447
+ # SIGURG to the target process to trigger the debug gem's pause handler.
448
+ #
449
+ # Uses Process.kill("URG") instead of the `pause` protocol message to avoid
450
+ # leaving stale messages in the server's socket read buffer. When `pause` is
451
+ # written to the socket and the reader thread is blocked (e.g., executing a
452
+ # long-running eval), the message sits unread. After SIGINT recovery, the
453
+ # reader thread eventually reads the stale `pause` and fires SIGURG at an
454
+ # unexpected time — typically after disconnect, when `c` resumes the process.
455
+ # This re-pauses the process with no client connected, leaving the session
456
+ # thread stuck on process_group.sync and blocking future connections.
457
+ #
458
+ # Returns "" on success (process is paused), nil on timeout/failure.
459
+ def repause(timeout: 3)
460
+ return "" if @paused
461
+ return nil unless connected?
462
+
463
+ @mutex.synchronize do
464
+ return "" if @paused
465
+
466
+ # Step 1: Drain buffered data — a breakpoint may have been hit but
467
+ # the `input PID` wasn't consumed (e.g., after continue_and_wait timeout).
468
+ drain_socket_buffer
469
+ debug_log("repause: after drain_socket_buffer (paused=#{@paused})")
470
+
471
+ return "" if @paused
472
+
473
+ # Step 2: Pause the running process.
474
+ # For local processes, send SIGURG directly — this avoids leaving stale
475
+ # data in the server's socket read buffer (unlike the `pause` protocol message).
476
+ # For remote/TCP connections (e.g., Docker), Process.kill won't reach the
477
+ # target process (PID is in a different namespace), so use the `pause`
478
+ # protocol message which the debug gem server sends as SIGURG to itself.
479
+ if @remote
480
+ debug_log("repause: sending pause message (remote)")
481
+ @socket.write("pause #{@pid}\n".b)
482
+ @socket.flush
483
+ else
484
+ debug_log("repause: sending SIGURG (local)")
485
+ Process.kill("URG", @pid.to_i)
486
+ end
487
+
488
+ # Step 3: Wait for `input PID` prompt
489
+ result = wait_for_pause(timeout)
490
+ debug_log("repause: wait_for_pause result=#{result.nil? ? 'nil' : 'ok'}")
491
+
492
+ # SIGURG puts the process in signal trap context. Mark it so callers
493
+ # can adapt (e.g., avoid `require` or Mutex operations that hang in
494
+ # trap context).
495
+ @trap_context = true if result
496
+
497
+ result
498
+ end
499
+ rescue Errno::ESRCH, Errno::EPERM
500
+ # Process not found or no permission — can't repause
501
+ nil
502
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
503
+ @connected = false
504
+ @paused = false
505
+ raise ConnectionError, "Connection lost: #{e.message}"
506
+ end
507
+
508
+ # Resume the paused process and wait for the next breakpoint hit.
509
+ # Supports an interrupt check block that is called every 0.5s.
510
+ # If the block returns true, the wait is interrupted and :interrupted is returned.
511
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
512
+ def continue_and_wait(timeout: CONTINUE_TIMEOUT, &interrupt_check)
513
+ raise SessionError, "Not connected to a debug session." unless connected?
514
+
515
+ @mutex.synchronize do
516
+ drain_stale_data
517
+ msg = "command #{@pid} #{@width} c\n"
518
+ @socket.write(msg.b)
519
+ @socket.flush
520
+ @paused = false
521
+
522
+ read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check)
523
+ end
524
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
525
+ @connected = false
526
+ @paused = false
527
+ raise ConnectionError, "Connection lost: #{e.message}"
528
+ end
529
+
530
+ # Wait for the process to pause (breakpoint hit) without sending any command.
531
+ # Use when the process is already running after a previous continue.
532
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
533
+ def wait_for_breakpoint(timeout: CONTINUE_TIMEOUT, &interrupt_check)
534
+ raise SessionError, "Not connected to a debug session." unless connected?
535
+
536
+ @mutex.synchronize do
537
+ @paused = false
538
+ read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check)
539
+ end
540
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
541
+ @connected = false
542
+ @paused = false
543
+ raise ConnectionError, "Connection lost: #{e.message}"
544
+ end
545
+
546
+ # Register a breakpoint number as one-shot (auto-remove after first hit)
547
+ def register_one_shot(bp_number)
548
+ @one_shot_breakpoints.add(bp_number)
549
+ end
550
+
551
+ # Check if a one-shot breakpoint was hit and auto-remove it.
552
+ # Call this after execution commands (continue, next, step).
553
+ # Returns the deleted breakpoint number, or nil.
554
+ def cleanup_one_shot_breakpoints(output)
555
+ return nil unless @one_shot_breakpoints.any?
556
+ return nil unless output
557
+
558
+ # Debug gem output when hitting a breakpoint: "Stop by #3 BP - Line ..."
559
+ match = output.match(/Stop by #(\d+)/)
560
+ return nil unless match
561
+
562
+ bp_num = match[1].to_i
563
+ return nil unless @one_shot_breakpoints.delete?(bp_num)
564
+
565
+ send_command("delete #{bp_num}")
566
+ bp_num
567
+ rescue GirbMcp::Error
568
+ # Best-effort cleanup
569
+ nil
570
+ end
571
+
572
+ # List available debug sessions.
573
+ # Filters by: socket file exists and socket is connectable (liveness probe).
574
+ # The connectable check is the sole authority — if the socket accepts
575
+ # connections, a debug process is listening regardless of whether the
576
+ # PID in the filename still matches (e.g. daemonized Rails servers fork
577
+ # after creating the socket, so the original PID exits).
578
+ def self.list_sessions
579
+ dir = socket_dir
580
+ return [] unless dir && Dir.exist?(dir)
581
+
582
+ Dir.glob(File.join(dir, "rdbg*")).select do |path|
583
+ File.socket?(path)
584
+ end.filter_map do |path|
585
+ pid = extract_pid(path)
586
+ next unless pid && socket_connectable?(path)
587
+
588
+ { path: path, pid: pid, name: extract_session_name(path) }
589
+ end
590
+ end
591
+
592
+ # Get socket directory for current user
593
+ def self.socket_dir
594
+ if (dir = ENV["RUBY_DEBUG_SOCK_DIR"])
595
+ dir
596
+ elsif (dir = ENV["XDG_RUNTIME_DIR"])
597
+ dir
598
+ else
599
+ tmpdir = Dir.tmpdir
600
+ uid = Process.uid
601
+ dir = File.join(tmpdir, "rdbg-#{uid}")
602
+ dir if Dir.exist?(dir)
603
+ end
604
+ end
605
+
606
+ # Wake an IO-blocked process (e.g., Puma in IO.select) by sending an
607
+ # HTTP GET in a background thread. The request itself doesn't matter —
608
+ # it just needs to break IO.select so the debug gem's pending pause
609
+ # trace point can fire. Returns the background thread.
610
+ def self.wake_io_blocked_process(port)
611
+ Thread.new do
612
+ http = Net::HTTP.new("127.0.0.1", port)
613
+ http.open_timeout = 3
614
+ http.read_timeout = 3
615
+ http.get("/")
616
+ rescue StandardError
617
+ # Ignore — we only care about waking IO.select
618
+ end
619
+ end
620
+
621
+ def wake_io_blocked_process(port)
622
+ self.class.wake_io_blocked_process(port)
623
+ end
624
+
625
+ private
626
+
627
+ # Attempt to escape trap context after repause by setting a breakpoint
628
+ # on the cached escape target (typically ActionController::Metal#dispatch)
629
+ # and sending an HTTP GET to trigger it.
630
+ # On success, sets @trap_context = false.
631
+ # On failure, tries to re-pause and leaves @trap_context = true (same as before).
632
+ def attempt_trap_escape!
633
+ file = @escape_target[:file]
634
+ line = @escape_target[:line]
635
+ path = @escape_target[:path] || "/"
636
+ port = @listen_ports.first
637
+
638
+ # Set a temporary breakpoint
639
+ debug_log("attempt_trap_escape!: setting BP at #{file}:#{line}")
640
+ bp_output = send_command("break #{file}:#{line}")
641
+ bp_match = bp_output.match(/#(\d+)/)
642
+ return unless bp_match
643
+
644
+ bp_number = bp_match[1].to_i
645
+
646
+ # Send GET request in background thread and wait for breakpoint.
647
+ # Use a short read_timeout (3s) to avoid occupying a Puma worker for too long.
648
+ # In single-worker configs, a long timeout blocks subsequent requests.
649
+ url = "http://127.0.0.1:#{port}#{path}"
650
+ http_done = false
651
+ http_ref = nil
652
+ http_thread = Thread.new do
653
+ uri = URI.parse(url)
654
+ http = Net::HTTP.new(uri.host, uri.port)
655
+ http.open_timeout = 5
656
+ http.read_timeout = 3
657
+ http_ref = http
658
+ http.get(uri.request_uri)
659
+ rescue StandardError
660
+ # Ignore HTTP errors — we only care about triggering the breakpoint
661
+ ensure
662
+ http_done = true
663
+ end
664
+
665
+ debug_log("attempt_trap_escape!: sending HTTP GET to #{url}")
666
+ result = continue_and_wait(timeout: 10) { http_done }
667
+ debug_log("attempt_trap_escape!: continue_and_wait result=#{result[:type]}")
668
+
669
+ # Close HTTP connection immediately to free the Puma worker
670
+ begin
671
+ http_ref&.finish
672
+ rescue StandardError
673
+ # ignore
674
+ end
675
+ http_thread.join(2)
676
+
677
+ if result[:type] == :breakpoint
678
+ @trap_context = false
679
+ else
680
+ # Escape failed — try to re-pause so subsequent commands work
681
+ ensure_paused(timeout: 3)
682
+ end
683
+
684
+ # Clean up the temporary breakpoint (best-effort)
685
+ begin
686
+ send_command("delete #{bp_number}")
687
+ rescue GirbMcp::Error
688
+ # Best-effort cleanup
689
+ end
690
+ rescue GirbMcp::Error
691
+ # Failed to escape — stay in trap context (same behavior as before)
692
+ end
693
+
694
+ def force_close_socket
695
+ return unless @socket && !@socket.closed?
696
+
697
+ socket = @socket
698
+ closer = Thread.new do
699
+ socket.shutdown(:RDWR) rescue nil
700
+ socket.close rescue nil
701
+ end
702
+ unless closer.join(DISCONNECT_SOCKET_TIMEOUT)
703
+ closer.kill
704
+ end
705
+ end
706
+
707
+ def send_greeting
708
+ debug_version = resolve_debug_version
709
+ cookie = ENV["RUBY_DEBUG_COOKIE"] || "-"
710
+ greeting = "version: #{debug_version} width: #{@width} cookie: #{cookie} nonstop: false\n"
711
+ @socket.write(greeting.b)
712
+ @socket.flush
713
+ end
714
+
715
+ def resolve_debug_version
716
+ # Try to load the debug gem version
717
+ return DEBUGGER__::VERSION if defined?(DEBUGGER__::VERSION)
718
+
719
+ begin
720
+ require "debug/version"
721
+ return DEBUGGER__::VERSION if defined?(DEBUGGER__::VERSION)
722
+ rescue LoadError
723
+ # ignore
724
+ end
725
+
726
+ # Fallback: read from gem spec
727
+ spec = Gem::Specification.find_by_name("debug")
728
+ spec.version.to_s
729
+ rescue StandardError
730
+ "1.0.0"
731
+ end
732
+
733
+ # Read protocol messages from the socket until an `input` prompt is received.
734
+ # Uses IO.select for safe, non-interruptible timeout handling (no Timeout.timeout).
735
+ def read_until_input(timeout: DEFAULT_TIMEOUT)
736
+ output_lines = []
737
+ deadline = Time.now + timeout
738
+ debug_log("read_until_input: start timeout=#{timeout}")
739
+
740
+ while Time.now < deadline
741
+ remaining = deadline - Time.now
742
+ break if remaining <= 0
743
+
744
+ # wait_readable checks Ruby's internal IO buffer first, then falls back
745
+ # to the OS-level check. IO.select only checks the OS-level file descriptor
746
+ # and misses data already buffered by Ruby (e.g., when multiple protocol
747
+ # lines arrive in a single read), causing spurious timeouts.
748
+ ready = @socket.wait_readable(remaining)
749
+ unless ready
750
+ debug_log("read_until_input: wait_readable returned nil (timeout)")
751
+ break
752
+ end
753
+
754
+ line = @socket.gets
755
+ unless line
756
+ # EOF - connection closed
757
+ debug_log("read_until_input: EOF!")
758
+ @connected = false
759
+ final = output_lines.join("\n")
760
+ raise ConnectionError.new(
761
+ "Debug session connection closed unexpectedly. The target process may have exited.",
762
+ final_output: final.empty? ? nil : final,
763
+ )
764
+ end
765
+
766
+ # Socket reads are ASCII-8BIT but debug gem output contains UTF-8 text
767
+ line = line.chomp.force_encoding(Encoding::UTF_8)
768
+ line = line.scrub unless line.valid_encoding?
769
+ debug_log("read_until_input: line=#{line[0, 80]}")
770
+
771
+ case line
772
+ when /\Aout (.*)/
773
+ output_lines << strip_ansi($1)
774
+ when /\Ainput (\d+)/
775
+ @pid = $1
776
+ debug_log("read_until_input: got input prompt, returning")
777
+ return output_lines.join("\n")
778
+ when /\Aask (\d+) (.*)/
779
+ # Auto-answer yes to questions
780
+ @socket.write("answer #{$1} y\n".b)
781
+ @socket.flush
782
+ when /\Aquit/
783
+ @connected = false
784
+ final = output_lines.join("\n")
785
+ raise SessionError.new(
786
+ "Debug session ended. The target process has finished execution.",
787
+ final_output: final.empty? ? nil : final,
788
+ )
789
+ end
790
+ end
791
+
792
+ # Timeout — always raise. Returning partial output without `input PID`
793
+ # causes protocol desync: send_command sets @paused=true even though the
794
+ # debug gem hasn't finished processing the command.
795
+ final = output_lines.join("\n")
796
+ debug_log("read_until_input: TIMEOUT lines=#{output_lines.size}")
797
+ raise TimeoutError.new(
798
+ "Timeout after #{timeout}s waiting for debugger response. " \
799
+ "The evaluated code may be blocking or taking too long.\n\n" \
800
+ "Recovery: the session will automatically try to interrupt and recover on the next command. " \
801
+ "If this persists, use 'disconnect' and reconnect.",
802
+ final_output: final.empty? ? nil : final,
803
+ )
804
+ end
805
+
806
+ # Non-blocking read that uses IO.select instead of Timeout.timeout.
807
+ # Supports an interrupt check that is polled every 0.5s.
808
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout/:timeout_with_output, output: String }
809
+ def read_until_input_interruptible(timeout:, interrupt_check: nil)
810
+ output_lines = []
811
+ deadline = Time.now + timeout
812
+ debug_log("read_interruptible: start timeout=#{timeout}")
813
+
814
+ while Time.now < deadline
815
+ if interrupt_check&.call
816
+ debug_log("read_interruptible: interrupt_check=true, draining...")
817
+ # Before returning :interrupted, drain any buffered data. A breakpoint
818
+ # may have been hit just before the interrupt — its `input PID` could
819
+ # already be in the socket buffer. Prefer :breakpoint over :interrupted
820
+ # to keep @paused consistent and avoid needing SIGURG-based repause.
821
+ drain_socket_buffer(output_lines)
822
+ if @paused
823
+ debug_log("read_interruptible: drain found breakpoint!")
824
+ @trap_context = false
825
+ return { type: :breakpoint, output: output_lines.join("\n") }
826
+ end
827
+ debug_log("read_interruptible: returning :interrupted")
828
+ return { type: :interrupted, output: output_lines.join("\n") }
829
+ end
830
+
831
+ remaining = [deadline - Time.now, 0.0].max
832
+ wait_time = [0.5, remaining].min
833
+ break if wait_time <= 0
834
+
835
+ next unless @socket.wait_readable(wait_time)
836
+
837
+ line = @socket.gets
838
+ unless line
839
+ @connected = false
840
+ @paused = false
841
+ final = output_lines.join("\n")
842
+ raise ConnectionError.new(
843
+ "Debug session connection closed unexpectedly.",
844
+ final_output: final.empty? ? nil : final,
845
+ )
846
+ end
847
+
848
+ line = line.chomp.force_encoding(Encoding::UTF_8)
849
+ line = line.scrub unless line.valid_encoding?
850
+
851
+ case line
852
+ when /\Aout (.*)/
853
+ output_lines << strip_ansi($1)
854
+ when /\Ainput (\d+)/
855
+ @pid = $1
856
+ @paused = true
857
+ @trap_context = false # Breakpoint hit implies normal context
858
+ debug_log("read_interruptible: got input prompt → :breakpoint")
859
+ return { type: :breakpoint, output: output_lines.join("\n") }
860
+ when /\Aask (\d+) (.*)/
861
+ @socket.write("answer #{$1} y\n".b)
862
+ @socket.flush
863
+ when /\Aquit/
864
+ @connected = false
865
+ @paused = false
866
+ final = output_lines.join("\n")
867
+ raise SessionError.new(
868
+ "Debug session ended.",
869
+ final_output: final.empty? ? nil : final,
870
+ )
871
+ end
872
+ end
873
+
874
+ debug_log("read_interruptible: main loop ended, entering grace period")
875
+ # Timeout — but the breakpoint might have JUST arrived. Do a short grace
876
+ # period to catch `input PID` that was buffered right at the deadline.
877
+ # Without this, the stale `input PID` left in the socket causes protocol
878
+ # desync on subsequent commands.
879
+ grace_deadline = Time.now + 1
880
+ while Time.now < grace_deadline
881
+ remaining = grace_deadline - Time.now
882
+ break if remaining <= 0
883
+ break unless @socket.wait_readable(remaining)
884
+
885
+ line = @socket.gets
886
+ break unless line
887
+
888
+ line = line.chomp.force_encoding(Encoding::UTF_8)
889
+ line = line.scrub unless line.valid_encoding?
890
+
891
+ case line
892
+ when /\Aout (.*)/
893
+ output_lines << strip_ansi($1)
894
+ when /\Ainput (\d+)/
895
+ @pid = $1
896
+ @paused = true
897
+ @trap_context = false
898
+ return { type: :breakpoint, output: output_lines.join("\n") }
899
+ when /\Aask (\d+) (.*)/
900
+ @socket.write("answer #{$1} y\n".b)
901
+ @socket.flush
902
+ when /\Aquit/
903
+ @connected = false
904
+ @paused = false
905
+ final = output_lines.join("\n")
906
+ raise SessionError.new(
907
+ "Debug session ended.",
908
+ final_output: final.empty? ? nil : final,
909
+ )
910
+ end
911
+ end
912
+
913
+ meaningful = output_lines.reject { |l| l.strip.empty? }
914
+ output = output_lines.join("\n")
915
+ if meaningful.any?
916
+ { type: :timeout_with_output, output: output }
917
+ else
918
+ { type: :timeout, output: output }
919
+ end
920
+ end
921
+
922
+ # Drain ALL stale data from the socket buffer without blocking.
923
+ # Unlike drain_socket_buffer, this does NOT stop at the first `input PID`.
924
+ # It consumes everything available, tracking the LAST `input PID` seen.
925
+ # This is essential for recovering from protocol desync (e.g., when a
926
+ # previous send_command timed out and its response is still in the buffer).
927
+ # Must be called inside @mutex.synchronize.
928
+ def drain_stale_data
929
+ last_input_pid = nil
930
+
931
+ while @socket&.wait_readable(0)
932
+ line = @socket.gets
933
+ break unless line
934
+
935
+ line = line.chomp.force_encoding(Encoding::UTF_8)
936
+ line = line.scrub unless line.valid_encoding?
937
+
938
+ case line
939
+ when /\Ainput (\d+)/
940
+ last_input_pid = $1
941
+ when /\Aask (\d+) (.*)/
942
+ @socket.write("answer #{$1} y\n".b)
943
+ @socket.flush
944
+ when /\Aquit/
945
+ @connected = false
946
+ @paused = false
947
+ return
948
+ end
949
+ # `out` lines are silently discarded (stale data)
950
+ end
951
+
952
+ if last_input_pid
953
+ debug_log("drain_stale_data: found stale input PID=#{last_input_pid}, setting paused=true")
954
+ @pid = last_input_pid
955
+ @paused = true
956
+ end
957
+ end
958
+
959
+ # Drain all buffered data from the socket without blocking.
960
+ # This catches `input PID` that may have been buffered (e.g., from a
961
+ # breakpoint hit during a continue_and_wait timeout or interrupt).
962
+ # Sets @paused = true if `input PID` is found.
963
+ # Optionally appends drained `out` lines to the provided array.
964
+ def drain_socket_buffer(output_lines = nil)
965
+ while @socket.wait_readable(0)
966
+ line = @socket.gets
967
+ return unless line
968
+
969
+ line = line.chomp.force_encoding(Encoding::UTF_8)
970
+ line = line.scrub unless line.valid_encoding?
971
+
972
+ case line
973
+ when /\Ainput (\d+)/
974
+ @pid = $1
975
+ @paused = true
976
+ return
977
+ when /\Aout (.*)/
978
+ output_lines << strip_ansi($1) if output_lines
979
+ when /\Aask (\d+) (.*)/
980
+ @socket.write("answer #{$1} y\n".b)
981
+ @socket.flush
982
+ when /\Aquit/
983
+ @connected = false
984
+ @paused = false
985
+ return
986
+ end
987
+ end
988
+ end
989
+
990
+ # Wait for `input PID` prompt after sending a `pause` protocol message.
991
+ # Returns "" on success (process paused), nil on timeout.
992
+ def wait_for_pause(timeout)
993
+ deadline = Time.now + timeout
994
+
995
+ while Time.now < deadline
996
+ remaining = deadline - Time.now
997
+ break if remaining <= 0
998
+
999
+ break unless @socket.wait_readable(remaining)
1000
+
1001
+ line = @socket.gets
1002
+ break unless line
1003
+
1004
+ line = line.chomp.force_encoding(Encoding::UTF_8)
1005
+ line = line.scrub unless line.valid_encoding?
1006
+
1007
+ case line
1008
+ when /\Ainput (\d+)/
1009
+ @pid = $1
1010
+ @paused = true
1011
+ return ""
1012
+ when /\Aout/
1013
+ # Consume output during pause
1014
+ when /\Aask (\d+) (.*)/
1015
+ @socket.write("answer #{$1} y\n".b)
1016
+ @socket.flush
1017
+ when /\Aquit/
1018
+ @connected = false
1019
+ @paused = false
1020
+ return nil
1021
+ end
1022
+ end
1023
+
1024
+ nil # Timeout
1025
+ end
1026
+
1027
+ DEBUG_LOG_PATH = "/tmp/girb_debug.log"
1028
+
1029
+ def debug_log(msg)
1030
+ File.open(DEBUG_LOG_PATH, "a") do |f|
1031
+ f.puts "[#{Time.now.strftime('%H:%M:%S.%L')}] #{msg}"
1032
+ end
1033
+ rescue StandardError
1034
+ # ignore
1035
+ end
1036
+
1037
+ def strip_ansi(str)
1038
+ str.gsub(ANSI_ESCAPE, "")
1039
+ end
1040
+
1041
+ def read_captured_file(path)
1042
+ return nil unless path && File.exist?(path)
1043
+
1044
+ content = File.read(path, encoding: "UTF-8")
1045
+ content = content.scrub unless content.valid_encoding?
1046
+ content.empty? ? nil : content.strip
1047
+ rescue StandardError
1048
+ nil
1049
+ end
1050
+
1051
+ def cleanup_captured_files
1052
+ [@stderr_file, @stdout_file].each do |path|
1053
+ File.delete(path) if path && File.exist?(path)
1054
+ rescue StandardError
1055
+ # ignore
1056
+ end
1057
+ @stderr_file = nil
1058
+ @stdout_file = nil
1059
+ end
1060
+
1061
+ def discover_socket
1062
+ sessions = self.class.list_sessions
1063
+ case sessions.size
1064
+ when 0
1065
+ raise ConnectionError, "No debug sessions found. Start a Ruby process with: rdbg --open <script.rb>"
1066
+ when 1
1067
+ sessions.first[:path]
1068
+ else
1069
+ paths = sessions.map { |s| " PID #{s[:pid]}: #{s[:path]}" }.join("\n")
1070
+ raise ConnectionError, "Multiple debug sessions found. Specify a path:\n#{paths}"
1071
+ end
1072
+ end
1073
+
1074
+ def self.extract_pid(path)
1075
+ basename = File.basename(path)
1076
+ if basename =~ /\Ardbg-(\d+)/
1077
+ $1.to_i
1078
+ end
1079
+ end
1080
+
1081
+ def self.extract_session_name(path)
1082
+ basename = File.basename(path)
1083
+ if basename =~ /\Ardbg-\d+-(.*)/
1084
+ $1
1085
+ end
1086
+ end
1087
+
1088
+ def self.process_alive?(pid)
1089
+ Process.kill(0, pid)
1090
+ true
1091
+ rescue Errno::ESRCH, Errno::EPERM
1092
+ false
1093
+ end
1094
+
1095
+ # Quick liveness probe: verify the Unix socket is actually accepting
1096
+ # connections. Filters out stale socket files where the PID was reused
1097
+ # by a different process that doesn't listen on the debug socket.
1098
+ # Does NOT send any protocol data — just connects and immediately closes.
1099
+ def self.socket_connectable?(path)
1100
+ sock = Socket.unix(path)
1101
+ sock.close
1102
+ true
1103
+ rescue Errno::ECONNREFUSED, Errno::ENOENT, Errno::EACCES, IOError
1104
+ false
1105
+ rescue StandardError
1106
+ false
1107
+ end
1108
+ end
1109
+ end