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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.ja.md +383 -0
- data/README.md +384 -0
- data/examples/01_simple_bug.rb +43 -0
- data/examples/02_data_pipeline.rb +93 -0
- data/examples/03_recursion.rb +96 -0
- data/examples/RAILS_SCENARIOS.md +350 -0
- data/examples/SCENARIOS.md +142 -0
- data/examples/rails_test_app/setup.sh +428 -0
- data/examples/rails_test_app/testapp/.dockerignore +10 -0
- data/examples/rails_test_app/testapp/.ruby-version +1 -0
- data/examples/rails_test_app/testapp/Dockerfile +23 -0
- data/examples/rails_test_app/testapp/Gemfile +17 -0
- data/examples/rails_test_app/testapp/README.md +65 -0
- data/examples/rails_test_app/testapp/Rakefile +6 -0
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
- data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
- data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
- data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
- data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
- data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
- data/examples/rails_test_app/testapp/bin/ci +6 -0
- data/examples/rails_test_app/testapp/bin/dev +2 -0
- data/examples/rails_test_app/testapp/bin/rails +4 -0
- data/examples/rails_test_app/testapp/bin/rake +4 -0
- data/examples/rails_test_app/testapp/bin/setup +35 -0
- data/examples/rails_test_app/testapp/config/application.rb +42 -0
- data/examples/rails_test_app/testapp/config/boot.rb +3 -0
- data/examples/rails_test_app/testapp/config/ci.rb +14 -0
- data/examples/rails_test_app/testapp/config/database.yml +32 -0
- data/examples/rails_test_app/testapp/config/environment.rb +5 -0
- data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
- data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
- data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
- data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
- data/examples/rails_test_app/testapp/config/puma.rb +39 -0
- data/examples/rails_test_app/testapp/config/routes.rb +34 -0
- data/examples/rails_test_app/testapp/config.ru +6 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
- data/examples/rails_test_app/testapp/db/schema.rb +71 -0
- data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
- data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
- data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
- data/examples/rails_test_app/testapp/log/.keep +0 -0
- data/examples/rails_test_app/testapp/public/400.html +135 -0
- data/examples/rails_test_app/testapp/public/404.html +135 -0
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
- data/examples/rails_test_app/testapp/public/422.html +135 -0
- data/examples/rails_test_app/testapp/public/500.html +135 -0
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +3 -0
- data/examples/rails_test_app/testapp/public/robots.txt +1 -0
- data/examples/rails_test_app/testapp/script/.keep +0 -0
- data/examples/rails_test_app/testapp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/vendor/.keep +0 -0
- data/exe/debug-mcp +39 -0
- data/exe/debug-rails +127 -0
- data/lib/debug_mcp/client_cleanup.rb +102 -0
- data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
- data/lib/debug_mcp/debug_client.rb +1143 -0
- data/lib/debug_mcp/exit_message_builder.rb +112 -0
- data/lib/debug_mcp/pending_http_helper.rb +25 -0
- data/lib/debug_mcp/rails_helper.rb +155 -0
- data/lib/debug_mcp/server.rb +364 -0
- data/lib/debug_mcp/session_manager.rb +436 -0
- data/lib/debug_mcp/stop_event_annotator.rb +152 -0
- data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
- data/lib/debug_mcp/tools/connect.rb +669 -0
- data/lib/debug_mcp/tools/continue_execution.rb +161 -0
- data/lib/debug_mcp/tools/disconnect.rb +169 -0
- data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
- data/lib/debug_mcp/tools/finish.rb +84 -0
- data/lib/debug_mcp/tools/get_context.rb +217 -0
- data/lib/debug_mcp/tools/get_source.rb +193 -0
- data/lib/debug_mcp/tools/inspect_object.rb +107 -0
- data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
- data/lib/debug_mcp/tools/list_files.rb +189 -0
- data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
- data/lib/debug_mcp/tools/next.rb +70 -0
- data/lib/debug_mcp/tools/rails_info.rb +200 -0
- data/lib/debug_mcp/tools/rails_model.rb +362 -0
- data/lib/debug_mcp/tools/rails_routes.rb +186 -0
- data/lib/debug_mcp/tools/read_file.rb +214 -0
- data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
- data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
- data/lib/debug_mcp/tools/run_script.rb +293 -0
- data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
- data/lib/debug_mcp/tools/step.rb +67 -0
- data/lib/debug_mcp/tools/trigger_request.rb +515 -0
- data/lib/debug_mcp/version.rb +5 -0
- data/lib/debug_mcp.rb +40 -0
- 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
|