debug-mcp 0.1.2

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 +83 -0
  4. data/LICENSE +21 -0
  5. data/README.ja.md +383 -0
  6. data/README.md +384 -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/debug-mcp +39 -0
  87. data/exe/debug-rails +127 -0
  88. data/lib/debug_mcp/client_cleanup.rb +102 -0
  89. data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
  90. data/lib/debug_mcp/debug_client.rb +1143 -0
  91. data/lib/debug_mcp/exit_message_builder.rb +112 -0
  92. data/lib/debug_mcp/pending_http_helper.rb +25 -0
  93. data/lib/debug_mcp/rails_helper.rb +155 -0
  94. data/lib/debug_mcp/server.rb +364 -0
  95. data/lib/debug_mcp/session_manager.rb +436 -0
  96. data/lib/debug_mcp/stop_event_annotator.rb +152 -0
  97. data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
  98. data/lib/debug_mcp/tools/connect.rb +669 -0
  99. data/lib/debug_mcp/tools/continue_execution.rb +161 -0
  100. data/lib/debug_mcp/tools/disconnect.rb +169 -0
  101. data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
  102. data/lib/debug_mcp/tools/finish.rb +84 -0
  103. data/lib/debug_mcp/tools/get_context.rb +217 -0
  104. data/lib/debug_mcp/tools/get_source.rb +193 -0
  105. data/lib/debug_mcp/tools/inspect_object.rb +107 -0
  106. data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
  107. data/lib/debug_mcp/tools/list_files.rb +189 -0
  108. data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
  109. data/lib/debug_mcp/tools/next.rb +70 -0
  110. data/lib/debug_mcp/tools/rails_info.rb +200 -0
  111. data/lib/debug_mcp/tools/rails_model.rb +362 -0
  112. data/lib/debug_mcp/tools/rails_routes.rb +186 -0
  113. data/lib/debug_mcp/tools/read_file.rb +214 -0
  114. data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
  115. data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
  116. data/lib/debug_mcp/tools/run_script.rb +293 -0
  117. data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
  118. data/lib/debug_mcp/tools/step.rb +67 -0
  119. data/lib/debug_mcp/tools/trigger_request.rb +515 -0
  120. data/lib/debug_mcp/version.rb +5 -0
  121. data/lib/debug_mcp.rb +40 -0
  122. metadata +251 -0
@@ -0,0 +1,1143 @@
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 DebugMcp
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 DebugMcp::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 DebugMcp::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 DebugMcp::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 DebugMcp::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 DebugMcp::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). Wait for the single
385
+ # `pause` message (already sent by repause above) to take effect.
386
+ # Use check_paused (no new pause message) to avoid stale messages
387
+ # accumulating in the debug gem server's read buffer.
388
+ sleep HTTP_WAKE_SETTLE_TIME
389
+ result = check_paused(timeout: 5)
390
+ # If still not paused and we have listen ports, the process is likely
391
+ # blocked in IO.select (SA_RESTART prevents SIGURG from breaking through).
392
+ # Send an HTTP GET to wake it, then check again.
393
+ if result.nil? && @listen_ports&.any?
394
+ debug_log("auto_repause!: waking IO-blocked process via HTTP")
395
+ wake_thread = wake_io_blocked_process(@listen_ports.first)
396
+ sleep HTTP_WAKE_SETTLE_TIME
397
+ result = check_paused(timeout: 5)
398
+ wake_thread.join(1) rescue nil
399
+ end
400
+ if result.nil?
401
+ sleep 0.5
402
+ result = check_paused(timeout: 8)
403
+ end
404
+ else
405
+ # Local: SIGINT can interrupt IO.select and similar C-level blocking calls.
406
+ result = interrupt_and_wait(timeout: 5)
407
+ end
408
+
409
+ if result.nil?
410
+ raise SessionError, "Process is not paused and could not be interrupted. " \
411
+ "The process may have exited or be in an unrecoverable state. " \
412
+ "Use 'disconnect' and 'connect' to re-attach."
413
+ end
414
+ end
415
+
416
+ debug_log("auto_repause!: repause done (paused=#{@paused}, trap=#{@trap_context})")
417
+
418
+ # After repause via SIGURG, the process is in trap context.
419
+ # Auto-escape if we have cached escape target and listen ports.
420
+ if @trap_context && @listen_ports&.any? && @escape_target
421
+ debug_log("auto_repause!: attempting trap escape (trap=#{@trap_context})")
422
+ attempt_trap_escape!
423
+ debug_log("auto_repause!: after trap escape (trap=#{@trap_context})")
424
+
425
+ # If trap escape left the process running (e.g., HTTP completed before
426
+ # breakpoint was hit), re-pause without attempting escape again.
427
+ unless @paused
428
+ debug_log("auto_repause!: trap escape left process unpaused, re-pausing")
429
+ re_result = repause(timeout: 3)
430
+ if re_result.nil?
431
+ raise SessionError, "Process could not be re-paused after failed trap escape. " \
432
+ "Use 'disconnect' and 'connect' to re-attach."
433
+ end
434
+ # Stay in trap context — escape failed but process is at least paused
435
+ end
436
+
437
+ # Protocol sync: after attempt_trap_escape!, TCP in-flight data from
438
+ # the escape sequence (break, continue, delete commands) may still be
439
+ # arriving. Send a no-op command to perform a full round-trip and
440
+ # consume any stale data before the next real command.
441
+ if @paused
442
+ begin
443
+ send_command("p nil", timeout: 3)
444
+ rescue DebugMcp::Error
445
+ # Best-effort sync. If this times out, send_command sets @paused=false.
446
+ # Restore it: the process was confirmed paused at the escape breakpoint,
447
+ # and drain_stale_data on the next command will consume the stale response.
448
+ @paused = true
449
+ end
450
+ end
451
+ end
452
+
453
+ true
454
+ end
455
+
456
+ # Re-pause a running process by sending SIGURG directly.
457
+ #
458
+ # First drains any buffered socket data (in case a breakpoint was hit but the
459
+ # `input PID` wasn't consumed due to a timeout). If not already paused, sends
460
+ # SIGURG to the target process to trigger the debug gem's pause handler.
461
+ #
462
+ # Uses Process.kill("URG") instead of the `pause` protocol message to avoid
463
+ # leaving stale messages in the server's socket read buffer. When `pause` is
464
+ # written to the socket and the reader thread is blocked (e.g., executing a
465
+ # long-running eval), the message sits unread. After SIGINT recovery, the
466
+ # reader thread eventually reads the stale `pause` and fires SIGURG at an
467
+ # unexpected time — typically after disconnect, when `c` resumes the process.
468
+ # This re-pauses the process with no client connected, leaving the session
469
+ # thread stuck on process_group.sync and blocking future connections.
470
+ #
471
+ # Returns "" on success (process is paused), nil on timeout/failure.
472
+ def repause(timeout: 3)
473
+ return "" if @paused
474
+ return nil unless connected?
475
+
476
+ @mutex.synchronize do
477
+ return "" if @paused
478
+
479
+ # Step 1: Drain buffered data — a breakpoint may have been hit but
480
+ # the `input PID` wasn't consumed (e.g., after continue_and_wait timeout).
481
+ drain_socket_buffer
482
+ debug_log("repause: after drain_socket_buffer (paused=#{@paused})")
483
+
484
+ return "" if @paused
485
+
486
+ # Step 2: Pause the running process.
487
+ # For local processes, send SIGURG directly — this avoids leaving stale
488
+ # data in the server's socket read buffer (unlike the `pause` protocol message).
489
+ # For remote/TCP connections (e.g., Docker), Process.kill won't reach the
490
+ # target process (PID is in a different namespace), so use the `pause`
491
+ # protocol message which the debug gem server sends as SIGURG to itself.
492
+ if @remote
493
+ debug_log("repause: sending pause message (remote)")
494
+ @socket.write("pause #{@pid}\n".b)
495
+ @socket.flush
496
+ else
497
+ debug_log("repause: sending SIGURG (local)")
498
+ Process.kill("URG", @pid.to_i)
499
+ end
500
+
501
+ # Step 3: Wait for `input PID` prompt
502
+ result = wait_for_pause(timeout)
503
+ debug_log("repause: wait_for_pause result=#{result.nil? ? 'nil' : 'ok'}")
504
+
505
+ # SIGURG puts the process in signal trap context. Mark it so callers
506
+ # can adapt (e.g., avoid `require` or Mutex operations that hang in
507
+ # trap context).
508
+ @trap_context = true if result
509
+
510
+ result
511
+ end
512
+ rescue Errno::ESRCH, Errno::EPERM
513
+ # Process not found or no permission — can't repause
514
+ nil
515
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
516
+ @connected = false
517
+ @paused = false
518
+ raise ConnectionError, "Connection lost: #{e.message}"
519
+ end
520
+
521
+ # Check if the process has paused (drain buffer + wait) without sending a pause signal.
522
+ # Used by auto_repause! for retry attempts to avoid stale pause messages accumulating
523
+ # in the debug gem server's read buffer (which cause unexpected SIGURGs after disconnect).
524
+ def check_paused(timeout: 3)
525
+ return "" if @paused
526
+ return nil unless connected?
527
+
528
+ @mutex.synchronize do
529
+ return "" if @paused
530
+ drain_socket_buffer
531
+ return "" if @paused
532
+ wait_for_pause(timeout)
533
+ end
534
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
535
+ @connected = false
536
+ @paused = false
537
+ raise ConnectionError, "Connection lost: #{e.message}"
538
+ end
539
+
540
+ # Resume the paused process and wait for the next breakpoint hit.
541
+ # Supports an interrupt check block that is called every 0.5s.
542
+ # If the block returns true, the wait is interrupted and :interrupted is returned.
543
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
544
+ def continue_and_wait(timeout: CONTINUE_TIMEOUT, &interrupt_check)
545
+ raise SessionError, "Not connected to a debug session." unless connected?
546
+
547
+ @mutex.synchronize do
548
+ drain_stale_data
549
+ msg = "command #{@pid} #{@width} c\n"
550
+ @socket.write(msg.b)
551
+ @socket.flush
552
+ @paused = false
553
+
554
+ read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check)
555
+ end
556
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
557
+ @connected = false
558
+ @paused = false
559
+ raise ConnectionError, "Connection lost: #{e.message}"
560
+ end
561
+
562
+ # Wait for the process to pause (breakpoint hit) without sending any command.
563
+ # Use when the process is already running after a previous continue.
564
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
565
+ def wait_for_breakpoint(timeout: CONTINUE_TIMEOUT, &interrupt_check)
566
+ raise SessionError, "Not connected to a debug session." unless connected?
567
+
568
+ @mutex.synchronize do
569
+ @paused = false
570
+ read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check)
571
+ end
572
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e
573
+ @connected = false
574
+ @paused = false
575
+ raise ConnectionError, "Connection lost: #{e.message}"
576
+ end
577
+
578
+ # Register a breakpoint number as one-shot (auto-remove after first hit)
579
+ def register_one_shot(bp_number)
580
+ @one_shot_breakpoints.add(bp_number)
581
+ end
582
+
583
+ # Check if a one-shot breakpoint was hit and auto-remove it.
584
+ # Call this after execution commands (continue, next, step).
585
+ # Returns the deleted breakpoint number, or nil.
586
+ def cleanup_one_shot_breakpoints(output)
587
+ return nil unless @one_shot_breakpoints.any?
588
+ return nil unless output
589
+
590
+ # Debug gem output when hitting a breakpoint: "Stop by #3 BP - Line ..."
591
+ match = output.match(/Stop by #(\d+)/)
592
+ return nil unless match
593
+
594
+ bp_num = match[1].to_i
595
+ return nil unless @one_shot_breakpoints.delete?(bp_num)
596
+
597
+ send_command("delete #{bp_num}")
598
+ bp_num
599
+ rescue DebugMcp::Error
600
+ # Best-effort cleanup
601
+ nil
602
+ end
603
+
604
+ # List available debug sessions.
605
+ # Filters by: socket file exists and socket is connectable (liveness probe).
606
+ # The connectable check is the sole authority — if the socket accepts
607
+ # connections, a debug process is listening regardless of whether the
608
+ # PID in the filename still matches (e.g. daemonized Rails servers fork
609
+ # after creating the socket, so the original PID exits).
610
+ def self.list_sessions
611
+ dir = socket_dir
612
+ return [] unless dir && Dir.exist?(dir)
613
+
614
+ Dir.glob(File.join(dir, "rdbg*")).select do |path|
615
+ File.socket?(path)
616
+ end.filter_map do |path|
617
+ pid = extract_pid(path)
618
+ next unless pid && socket_connectable?(path)
619
+
620
+ { path: path, pid: pid, name: extract_session_name(path) }
621
+ end
622
+ end
623
+
624
+ # Get socket directory for current user
625
+ def self.socket_dir
626
+ if (dir = ENV["RUBY_DEBUG_SOCK_DIR"])
627
+ dir
628
+ elsif (dir = ENV["XDG_RUNTIME_DIR"])
629
+ dir
630
+ else
631
+ tmpdir = Dir.tmpdir
632
+ uid = Process.uid
633
+ dir = File.join(tmpdir, "rdbg-#{uid}")
634
+ dir if Dir.exist?(dir)
635
+ end
636
+ end
637
+
638
+ # Wake an IO-blocked process (e.g., Puma in IO.select) by sending an
639
+ # HTTP GET in a background thread. The request itself doesn't matter —
640
+ # it just needs to break IO.select so the debug gem's pending pause
641
+ # trace point can fire. Returns the background thread.
642
+ def self.wake_io_blocked_process(port)
643
+ Thread.new do
644
+ http = Net::HTTP.new("127.0.0.1", port)
645
+ http.open_timeout = 3
646
+ http.read_timeout = 3
647
+ http.get("/")
648
+ rescue StandardError
649
+ # Ignore — we only care about waking IO.select
650
+ end
651
+ end
652
+
653
+ def wake_io_blocked_process(port)
654
+ self.class.wake_io_blocked_process(port)
655
+ end
656
+
657
+ private
658
+
659
+ # Attempt to escape trap context after repause by setting a breakpoint
660
+ # on the cached escape target (typically ActionController::Metal#dispatch)
661
+ # and sending an HTTP GET to trigger it.
662
+ # On success, sets @trap_context = false.
663
+ # On failure, tries to re-pause and leaves @trap_context = true (same as before).
664
+ def attempt_trap_escape!
665
+ file = @escape_target[:file]
666
+ line = @escape_target[:line]
667
+ path = @escape_target[:path] || "/"
668
+ port = @listen_ports.first
669
+
670
+ # Set a temporary breakpoint
671
+ debug_log("attempt_trap_escape!: setting BP at #{file}:#{line}")
672
+ bp_output = send_command("break #{file}:#{line}")
673
+ bp_match = bp_output.match(/#(\d+)/)
674
+ return unless bp_match
675
+
676
+ bp_number = bp_match[1].to_i
677
+
678
+ # Send GET request in background thread and wait for breakpoint.
679
+ # Use a short read_timeout (3s) to avoid occupying a Puma worker for too long.
680
+ # In single-worker configs, a long timeout blocks subsequent requests.
681
+ url = "http://127.0.0.1:#{port}#{path}"
682
+ http_done = false
683
+ http_ref = nil
684
+ http_thread = Thread.new do
685
+ uri = URI.parse(url)
686
+ http = Net::HTTP.new(uri.host, uri.port)
687
+ http.open_timeout = 5
688
+ http.read_timeout = 3
689
+ http_ref = http
690
+ http.get(uri.request_uri)
691
+ rescue StandardError
692
+ # Ignore HTTP errors — we only care about triggering the breakpoint
693
+ ensure
694
+ http_done = true
695
+ end
696
+
697
+ debug_log("attempt_trap_escape!: sending HTTP GET to #{url}")
698
+ result = continue_and_wait(timeout: 10) { http_done }
699
+ debug_log("attempt_trap_escape!: continue_and_wait result=#{result[:type]}")
700
+
701
+ # Close HTTP connection immediately to free the Puma worker
702
+ begin
703
+ http_ref&.finish
704
+ rescue StandardError
705
+ # ignore
706
+ end
707
+ http_thread.join(2)
708
+
709
+ if result[:type] == :breakpoint
710
+ @trap_context = false
711
+ else
712
+ # Escape failed — actively re-pause (SIGURG) instead of passive wait
713
+ repause(timeout: 3) unless @paused
714
+ end
715
+
716
+ # Clean up the temporary breakpoint (best-effort, only if paused)
717
+ if @paused
718
+ begin
719
+ send_command("delete #{bp_number}")
720
+ rescue DebugMcp::Error
721
+ # Best-effort cleanup
722
+ end
723
+ end
724
+ rescue DebugMcp::Error
725
+ # Failed to escape — stay in trap context (same behavior as before)
726
+ end
727
+
728
+ def force_close_socket
729
+ return unless @socket && !@socket.closed?
730
+
731
+ socket = @socket
732
+ closer = Thread.new do
733
+ socket.shutdown(:RDWR) rescue nil
734
+ socket.close rescue nil
735
+ end
736
+ unless closer.join(DISCONNECT_SOCKET_TIMEOUT)
737
+ closer.kill
738
+ end
739
+ end
740
+
741
+ def send_greeting
742
+ debug_version = resolve_debug_version
743
+ cookie = ENV["RUBY_DEBUG_COOKIE"] || "-"
744
+ greeting = "version: #{debug_version} width: #{@width} cookie: #{cookie} nonstop: false\n"
745
+ @socket.write(greeting.b)
746
+ @socket.flush
747
+ end
748
+
749
+ def resolve_debug_version
750
+ # Try to load the debug gem version
751
+ return DEBUGGER__::VERSION if defined?(DEBUGGER__::VERSION)
752
+
753
+ begin
754
+ require "debug/version"
755
+ return DEBUGGER__::VERSION if defined?(DEBUGGER__::VERSION)
756
+ rescue LoadError
757
+ # ignore
758
+ end
759
+
760
+ # Fallback: read from gem spec
761
+ spec = Gem::Specification.find_by_name("debug")
762
+ spec.version.to_s
763
+ rescue StandardError
764
+ "1.0.0"
765
+ end
766
+
767
+ # Read protocol messages from the socket until an `input` prompt is received.
768
+ # Uses IO.select for safe, non-interruptible timeout handling (no Timeout.timeout).
769
+ def read_until_input(timeout: DEFAULT_TIMEOUT)
770
+ output_lines = []
771
+ deadline = Time.now + timeout
772
+ debug_log("read_until_input: start timeout=#{timeout}")
773
+
774
+ while Time.now < deadline
775
+ remaining = deadline - Time.now
776
+ break if remaining <= 0
777
+
778
+ # wait_readable checks Ruby's internal IO buffer first, then falls back
779
+ # to the OS-level check. IO.select only checks the OS-level file descriptor
780
+ # and misses data already buffered by Ruby (e.g., when multiple protocol
781
+ # lines arrive in a single read), causing spurious timeouts.
782
+ ready = @socket.wait_readable(remaining)
783
+ unless ready
784
+ debug_log("read_until_input: wait_readable returned nil (timeout)")
785
+ break
786
+ end
787
+
788
+ line = @socket.gets
789
+ unless line
790
+ # EOF - connection closed
791
+ debug_log("read_until_input: EOF!")
792
+ @connected = false
793
+ final = output_lines.join("\n")
794
+ raise ConnectionError.new(
795
+ "Debug session connection closed unexpectedly. The target process may have exited.",
796
+ final_output: final.empty? ? nil : final,
797
+ )
798
+ end
799
+
800
+ # Socket reads are ASCII-8BIT but debug gem output contains UTF-8 text
801
+ line = line.chomp.force_encoding(Encoding::UTF_8)
802
+ line = line.scrub unless line.valid_encoding?
803
+ debug_log("read_until_input: line=#{line[0, 80]}")
804
+
805
+ case line
806
+ when /\Aout (.*)/
807
+ output_lines << strip_ansi($1)
808
+ when /\Ainput (\d+)/
809
+ @pid = $1
810
+ debug_log("read_until_input: got input prompt, returning")
811
+ return output_lines.join("\n")
812
+ when /\Aask (\d+) (.*)/
813
+ # Auto-answer yes to questions
814
+ @socket.write("answer #{$1} y\n".b)
815
+ @socket.flush
816
+ when /\Aquit/
817
+ @connected = false
818
+ final = output_lines.join("\n")
819
+ raise SessionError.new(
820
+ "Debug session ended. The target process has finished execution.",
821
+ final_output: final.empty? ? nil : final,
822
+ )
823
+ end
824
+ end
825
+
826
+ # Timeout — always raise. Returning partial output without `input PID`
827
+ # causes protocol desync: send_command sets @paused=true even though the
828
+ # debug gem hasn't finished processing the command.
829
+ final = output_lines.join("\n")
830
+ debug_log("read_until_input: TIMEOUT lines=#{output_lines.size}")
831
+ raise TimeoutError.new(
832
+ "Timeout after #{timeout}s waiting for debugger response. " \
833
+ "The evaluated code may be blocking or taking too long.\n\n" \
834
+ "Recovery: the session will automatically try to interrupt and recover on the next command. " \
835
+ "If this persists, use 'disconnect' and reconnect.",
836
+ final_output: final.empty? ? nil : final,
837
+ )
838
+ end
839
+
840
+ # Non-blocking read that uses IO.select instead of Timeout.timeout.
841
+ # Supports an interrupt check that is polled every 0.5s.
842
+ # Returns a hash: { type: :breakpoint/:interrupted/:timeout/:timeout_with_output, output: String }
843
+ def read_until_input_interruptible(timeout:, interrupt_check: nil)
844
+ output_lines = []
845
+ deadline = Time.now + timeout
846
+ debug_log("read_interruptible: start timeout=#{timeout}")
847
+
848
+ while Time.now < deadline
849
+ if interrupt_check&.call
850
+ debug_log("read_interruptible: interrupt_check=true, draining...")
851
+ # Before returning :interrupted, drain any buffered data. A breakpoint
852
+ # may have been hit just before the interrupt — its `input PID` could
853
+ # already be in the socket buffer. Prefer :breakpoint over :interrupted
854
+ # to keep @paused consistent and avoid needing SIGURG-based repause.
855
+ drain_socket_buffer(output_lines)
856
+ if @paused
857
+ debug_log("read_interruptible: drain found breakpoint!")
858
+ @trap_context = false
859
+ return { type: :breakpoint, output: output_lines.join("\n") }
860
+ end
861
+ debug_log("read_interruptible: returning :interrupted")
862
+ return { type: :interrupted, output: output_lines.join("\n") }
863
+ end
864
+
865
+ remaining = [deadline - Time.now, 0.0].max
866
+ wait_time = [0.5, remaining].min
867
+ break if wait_time <= 0
868
+
869
+ next unless @socket.wait_readable(wait_time)
870
+
871
+ line = @socket.gets
872
+ unless line
873
+ @connected = false
874
+ @paused = false
875
+ final = output_lines.join("\n")
876
+ raise ConnectionError.new(
877
+ "Debug session connection closed unexpectedly.",
878
+ final_output: final.empty? ? nil : final,
879
+ )
880
+ end
881
+
882
+ line = line.chomp.force_encoding(Encoding::UTF_8)
883
+ line = line.scrub unless line.valid_encoding?
884
+
885
+ case line
886
+ when /\Aout (.*)/
887
+ output_lines << strip_ansi($1)
888
+ when /\Ainput (\d+)/
889
+ @pid = $1
890
+ @paused = true
891
+ @trap_context = false # Breakpoint hit implies normal context
892
+ debug_log("read_interruptible: got input prompt → :breakpoint")
893
+ return { type: :breakpoint, output: output_lines.join("\n") }
894
+ when /\Aask (\d+) (.*)/
895
+ @socket.write("answer #{$1} y\n".b)
896
+ @socket.flush
897
+ when /\Aquit/
898
+ @connected = false
899
+ @paused = false
900
+ final = output_lines.join("\n")
901
+ raise SessionError.new(
902
+ "Debug session ended.",
903
+ final_output: final.empty? ? nil : final,
904
+ )
905
+ end
906
+ end
907
+
908
+ debug_log("read_interruptible: main loop ended, entering grace period")
909
+ # Timeout — but the breakpoint might have JUST arrived. Do a short grace
910
+ # period to catch `input PID` that was buffered right at the deadline.
911
+ # Without this, the stale `input PID` left in the socket causes protocol
912
+ # desync on subsequent commands.
913
+ grace_deadline = Time.now + 1
914
+ while Time.now < grace_deadline
915
+ remaining = grace_deadline - Time.now
916
+ break if remaining <= 0
917
+ break unless @socket.wait_readable(remaining)
918
+
919
+ line = @socket.gets
920
+ break unless line
921
+
922
+ line = line.chomp.force_encoding(Encoding::UTF_8)
923
+ line = line.scrub unless line.valid_encoding?
924
+
925
+ case line
926
+ when /\Aout (.*)/
927
+ output_lines << strip_ansi($1)
928
+ when /\Ainput (\d+)/
929
+ @pid = $1
930
+ @paused = true
931
+ @trap_context = false
932
+ return { type: :breakpoint, output: output_lines.join("\n") }
933
+ when /\Aask (\d+) (.*)/
934
+ @socket.write("answer #{$1} y\n".b)
935
+ @socket.flush
936
+ when /\Aquit/
937
+ @connected = false
938
+ @paused = false
939
+ final = output_lines.join("\n")
940
+ raise SessionError.new(
941
+ "Debug session ended.",
942
+ final_output: final.empty? ? nil : final,
943
+ )
944
+ end
945
+ end
946
+
947
+ meaningful = output_lines.reject { |l| l.strip.empty? }
948
+ output = output_lines.join("\n")
949
+ if meaningful.any?
950
+ { type: :timeout_with_output, output: output }
951
+ else
952
+ { type: :timeout, output: output }
953
+ end
954
+ end
955
+
956
+ # Drain ALL stale data from the socket buffer without blocking.
957
+ # Unlike drain_socket_buffer, this does NOT stop at the first `input PID`.
958
+ # It consumes everything available, tracking the LAST `input PID` seen.
959
+ # This is essential for recovering from protocol desync (e.g., when a
960
+ # previous send_command timed out and its response is still in the buffer).
961
+ # Must be called inside @mutex.synchronize.
962
+ def drain_stale_data
963
+ last_input_pid = nil
964
+
965
+ while @socket&.wait_readable(0)
966
+ line = @socket.gets
967
+ break 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
+ last_input_pid = $1
975
+ when /\Aask (\d+) (.*)/
976
+ @socket.write("answer #{$1} y\n".b)
977
+ @socket.flush
978
+ when /\Aquit/
979
+ @connected = false
980
+ @paused = false
981
+ return
982
+ end
983
+ # `out` lines are silently discarded (stale data)
984
+ end
985
+
986
+ if last_input_pid
987
+ debug_log("drain_stale_data: found stale input PID=#{last_input_pid}, setting paused=true")
988
+ @pid = last_input_pid
989
+ @paused = true
990
+ end
991
+ end
992
+
993
+ # Drain all buffered data from the socket without blocking.
994
+ # This catches `input PID` that may have been buffered (e.g., from a
995
+ # breakpoint hit during a continue_and_wait timeout or interrupt).
996
+ # Sets @paused = true if `input PID` is found.
997
+ # Optionally appends drained `out` lines to the provided array.
998
+ def drain_socket_buffer(output_lines = nil)
999
+ while @socket.wait_readable(0)
1000
+ line = @socket.gets
1001
+ return unless line
1002
+
1003
+ line = line.chomp.force_encoding(Encoding::UTF_8)
1004
+ line = line.scrub unless line.valid_encoding?
1005
+
1006
+ case line
1007
+ when /\Ainput (\d+)/
1008
+ @pid = $1
1009
+ @paused = true
1010
+ return
1011
+ when /\Aout (.*)/
1012
+ output_lines << strip_ansi($1) if output_lines
1013
+ when /\Aask (\d+) (.*)/
1014
+ @socket.write("answer #{$1} y\n".b)
1015
+ @socket.flush
1016
+ when /\Aquit/
1017
+ @connected = false
1018
+ @paused = false
1019
+ return
1020
+ end
1021
+ end
1022
+ end
1023
+
1024
+ # Wait for `input PID` prompt after sending a `pause` protocol message.
1025
+ # Returns "" on success (process paused), nil on timeout.
1026
+ def wait_for_pause(timeout)
1027
+ deadline = Time.now + timeout
1028
+
1029
+ while Time.now < deadline
1030
+ remaining = deadline - Time.now
1031
+ break if remaining <= 0
1032
+
1033
+ break unless @socket.wait_readable(remaining)
1034
+
1035
+ line = @socket.gets
1036
+ break unless line
1037
+
1038
+ line = line.chomp.force_encoding(Encoding::UTF_8)
1039
+ line = line.scrub unless line.valid_encoding?
1040
+
1041
+ case line
1042
+ when /\Ainput (\d+)/
1043
+ @pid = $1
1044
+ @paused = true
1045
+ return ""
1046
+ when /\Aout/
1047
+ # Consume output during pause
1048
+ when /\Aask (\d+) (.*)/
1049
+ @socket.write("answer #{$1} y\n".b)
1050
+ @socket.flush
1051
+ when /\Aquit/
1052
+ @connected = false
1053
+ @paused = false
1054
+ return nil
1055
+ end
1056
+ end
1057
+
1058
+ nil # Timeout
1059
+ end
1060
+
1061
+ DEBUG_LOG_PATH = "/tmp/debug_mcp.log"
1062
+
1063
+ def debug_log(msg)
1064
+ File.open(DEBUG_LOG_PATH, "a") do |f|
1065
+ f.puts "[#{Time.now.strftime('%H:%M:%S.%L')}] #{msg}"
1066
+ end
1067
+ rescue StandardError
1068
+ # ignore
1069
+ end
1070
+
1071
+ def strip_ansi(str)
1072
+ str.gsub(ANSI_ESCAPE, "")
1073
+ end
1074
+
1075
+ def read_captured_file(path)
1076
+ return nil unless path && File.exist?(path)
1077
+
1078
+ content = File.read(path, encoding: "UTF-8")
1079
+ content = content.scrub unless content.valid_encoding?
1080
+ content.empty? ? nil : content.strip
1081
+ rescue StandardError
1082
+ nil
1083
+ end
1084
+
1085
+ def cleanup_captured_files
1086
+ [@stderr_file, @stdout_file].each do |path|
1087
+ File.delete(path) if path && File.exist?(path)
1088
+ rescue StandardError
1089
+ # ignore
1090
+ end
1091
+ @stderr_file = nil
1092
+ @stdout_file = nil
1093
+ end
1094
+
1095
+ def discover_socket
1096
+ sessions = self.class.list_sessions
1097
+ case sessions.size
1098
+ when 0
1099
+ raise ConnectionError, "No debug sessions found. Start a Ruby process with: rdbg --open <script.rb>"
1100
+ when 1
1101
+ sessions.first[:path]
1102
+ else
1103
+ paths = sessions.map { |s| " PID #{s[:pid]}: #{s[:path]}" }.join("\n")
1104
+ raise ConnectionError, "Multiple debug sessions found. Specify a path:\n#{paths}"
1105
+ end
1106
+ end
1107
+
1108
+ def self.extract_pid(path)
1109
+ basename = File.basename(path)
1110
+ if basename =~ /\Ardbg-(\d+)/
1111
+ $1.to_i
1112
+ end
1113
+ end
1114
+
1115
+ def self.extract_session_name(path)
1116
+ basename = File.basename(path)
1117
+ if basename =~ /\Ardbg-\d+-(.*)/
1118
+ $1
1119
+ end
1120
+ end
1121
+
1122
+ def self.process_alive?(pid)
1123
+ Process.kill(0, pid)
1124
+ true
1125
+ rescue Errno::ESRCH, Errno::EPERM
1126
+ false
1127
+ end
1128
+
1129
+ # Quick liveness probe: verify the Unix socket is actually accepting
1130
+ # connections. Filters out stale socket files where the PID was reused
1131
+ # by a different process that doesn't listen on the debug socket.
1132
+ # Does NOT send any protocol data — just connects and immediately closes.
1133
+ def self.socket_connectable?(path)
1134
+ sock = Socket.unix(path)
1135
+ sock.close
1136
+ true
1137
+ rescue Errno::ECONNREFUSED, Errno::ENOENT, Errno::EACCES, IOError
1138
+ false
1139
+ rescue StandardError
1140
+ false
1141
+ end
1142
+ end
1143
+ end