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,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "client_cleanup"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
class SessionManager
|
|
8
|
+
# Default session timeout: 30 minutes of inactivity
|
|
9
|
+
DEFAULT_TIMEOUT = 30 * 60
|
|
10
|
+
# Reaper interval: check every 60 seconds
|
|
11
|
+
REAPER_INTERVAL = 60
|
|
12
|
+
# How long to remember reaped sessions for diagnostic messages
|
|
13
|
+
RECENTLY_REAPED_TTL = 10 * 60
|
|
14
|
+
|
|
15
|
+
SessionInfo = Struct.new(:client, :connected_at, :last_activity_at, :acknowledged_warnings, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
attr_reader :timeout
|
|
18
|
+
|
|
19
|
+
def initialize(timeout: DEFAULT_TIMEOUT)
|
|
20
|
+
@sessions = {}
|
|
21
|
+
@default_session_id = nil
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@reaper_thread = nil
|
|
25
|
+
@breakpoint_specs = [] # Breakpoint commands to restore across sessions
|
|
26
|
+
@recently_reaped = {} # { sid => { reason:, pid:, reaped_at: } }
|
|
27
|
+
start_reaper
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Connect to a debug session and register it.
|
|
31
|
+
# Cleans up existing sessions with the same sid or same PID to prevent
|
|
32
|
+
# socket leaks when reconnecting to the same process.
|
|
33
|
+
# Accepts an optional block that is passed through to DebugClient#connect
|
|
34
|
+
# as the on_initial_timeout callback (used to wake IO-blocked processes).
|
|
35
|
+
# @param pre_cleanup_port [Integer, nil] TCP port of the debug connection target.
|
|
36
|
+
# Used to disconnect existing sessions connected to the same port before
|
|
37
|
+
# establishing a new connection. Essential for TCP/Docker reconnections where
|
|
38
|
+
# the target PID is unknown until after connecting (unlike Unix sockets where
|
|
39
|
+
# the PID is encoded in the socket filename).
|
|
40
|
+
def connect(session_id: nil, path: nil, host: nil, port: nil, remote: nil,
|
|
41
|
+
connect_timeout: nil, pre_cleanup_pid: nil, pre_cleanup_port: nil, &on_initial_timeout)
|
|
42
|
+
# Pre-cleanup: disconnect existing sessions for the same PID/session_id/port
|
|
43
|
+
# BEFORE establishing a new connection. The old session's socket occupies
|
|
44
|
+
# the debug gem, so the new connect() would timeout if not cleaned up first.
|
|
45
|
+
pre_cleanup(session_id: session_id, pid: pre_cleanup_pid, port: pre_cleanup_port)
|
|
46
|
+
|
|
47
|
+
client = DebugClient.new
|
|
48
|
+
result = client.connect(path: path, host: host, port: port, remote: remote,
|
|
49
|
+
connect_timeout: connect_timeout, &on_initial_timeout)
|
|
50
|
+
|
|
51
|
+
now = Time.now
|
|
52
|
+
sid = session_id || "session_#{client.pid}"
|
|
53
|
+
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
# Clean up existing session with the same sid (socket leak prevention)
|
|
56
|
+
old_info = @sessions.delete(sid)
|
|
57
|
+
old_info&.client&.disconnect rescue nil
|
|
58
|
+
|
|
59
|
+
# Clean up sessions connected to the same PID but with a different sid
|
|
60
|
+
same_pid_sids = @sessions.each_with_object([]) do |(existing_sid, info), acc|
|
|
61
|
+
acc << existing_sid if info.client.pid.to_s == client.pid.to_s
|
|
62
|
+
end
|
|
63
|
+
same_pid_sids.each do |existing_sid|
|
|
64
|
+
info = @sessions.delete(existing_sid)
|
|
65
|
+
info&.client&.disconnect rescue nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@sessions[sid] = SessionInfo.new(
|
|
69
|
+
client: client,
|
|
70
|
+
connected_at: now,
|
|
71
|
+
last_activity_at: now,
|
|
72
|
+
acknowledged_warnings: Set.new,
|
|
73
|
+
)
|
|
74
|
+
@default_session_id = sid
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result.merge(session_id: sid)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get the client for a session (also updates last_activity_at)
|
|
81
|
+
def client(session_id = nil)
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
sid = session_id || @default_session_id
|
|
84
|
+
raise SessionError, "No active debug session. Use the 'connect' tool first." unless sid
|
|
85
|
+
|
|
86
|
+
info = @sessions[sid]
|
|
87
|
+
unless info
|
|
88
|
+
if (reaped = @recently_reaped[sid])
|
|
89
|
+
elapsed = (Time.now - reaped[:reaped_at]).to_i
|
|
90
|
+
reason_msg = case reaped[:reason]
|
|
91
|
+
when :idle_timeout
|
|
92
|
+
"was automatically disconnected after #{format_elapsed(@timeout)} of inactivity"
|
|
93
|
+
when :process_died
|
|
94
|
+
"was removed because the target process (PID #{reaped[:pid]}) exited"
|
|
95
|
+
when :socket_closed
|
|
96
|
+
"was removed because the debug socket connection was lost"
|
|
97
|
+
else
|
|
98
|
+
"was removed"
|
|
99
|
+
end
|
|
100
|
+
raise SessionError,
|
|
101
|
+
"Session '#{sid}' #{reason_msg} (#{format_elapsed(elapsed)} ago). " \
|
|
102
|
+
"Use 'connect' to start a new session."
|
|
103
|
+
end
|
|
104
|
+
raise SessionError, "Session '#{sid}' not found. Use 'list_paused_sessions' to see active sessions."
|
|
105
|
+
end
|
|
106
|
+
raise SessionError, "Session '#{sid}' is disconnected. Use 'connect' to reconnect." unless info.client.connected?
|
|
107
|
+
|
|
108
|
+
info.last_activity_at = Time.now
|
|
109
|
+
info.client
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Disconnect a session
|
|
114
|
+
def disconnect(session_id = nil)
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
sid = session_id || @default_session_id
|
|
117
|
+
return unless sid
|
|
118
|
+
|
|
119
|
+
info = @sessions.delete(sid)
|
|
120
|
+
info&.client&.disconnect
|
|
121
|
+
|
|
122
|
+
if @default_session_id == sid
|
|
123
|
+
@default_session_id = @sessions.keys.first
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Disconnect all sessions and stop reaper
|
|
129
|
+
# Note: safe to call from trap context (does not use mutex)
|
|
130
|
+
def disconnect_all
|
|
131
|
+
stop_reaper
|
|
132
|
+
|
|
133
|
+
# Avoid mutex here so this can be called from signal trap context.
|
|
134
|
+
# At shutdown, thread safety is not a concern.
|
|
135
|
+
has_connect_sessions = false
|
|
136
|
+
@sessions.each_value do |info|
|
|
137
|
+
# Resume connect sessions (no wait_thread) so the target process
|
|
138
|
+
# doesn't stay stuck at the debugger prompt after we disconnect.
|
|
139
|
+
unless info.client.wait_thread
|
|
140
|
+
socket = info.client.instance_variable_get(:@socket)
|
|
141
|
+
next unless socket && !socket.closed?
|
|
142
|
+
|
|
143
|
+
pid = info.client.pid
|
|
144
|
+
# Restore original SIGINT handler (best-effort, raw protocol).
|
|
145
|
+
restore_cmd = "p $_debug_mcp_orig_int ? (trap('INT',$_debug_mcp_orig_int);$_debug_mcp_orig_int=nil;:ok) : nil"
|
|
146
|
+
socket.write("command #{pid} 500 #{restore_cmd}\n".b) rescue nil
|
|
147
|
+
# Delete breakpoints #0-#9 (best-effort) then continue.
|
|
148
|
+
# In signal trap context we can't use send_command, so write raw
|
|
149
|
+
# protocol messages directly to the socket.
|
|
150
|
+
(0..9).reverse_each do |n|
|
|
151
|
+
socket.write("command #{pid} 500 delete #{n}\n".b) rescue nil
|
|
152
|
+
end
|
|
153
|
+
socket.write("command #{pid} 500 c\n".b) rescue nil
|
|
154
|
+
socket.flush rescue nil
|
|
155
|
+
has_connect_sessions = true
|
|
156
|
+
end
|
|
157
|
+
rescue StandardError
|
|
158
|
+
# ignore
|
|
159
|
+
end
|
|
160
|
+
# One sleep for all sessions — give debug gems time to process continue
|
|
161
|
+
sleep 0.3 if has_connect_sessions
|
|
162
|
+
@sessions.each_value do |info|
|
|
163
|
+
info.client.disconnect rescue nil
|
|
164
|
+
end
|
|
165
|
+
@sessions.clear
|
|
166
|
+
@default_session_id = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Record a breakpoint spec for preservation across sessions.
|
|
170
|
+
# Spec is the debugger command string (e.g., "break file.rb:42", "catch NoMethodError").
|
|
171
|
+
def record_breakpoint(spec)
|
|
172
|
+
@mutex.synchronize do
|
|
173
|
+
@breakpoint_specs << spec unless @breakpoint_specs.include?(spec)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Clear all recorded breakpoint specs.
|
|
178
|
+
def clear_breakpoint_specs
|
|
179
|
+
@mutex.synchronize { @breakpoint_specs.clear }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Remove breakpoint specs that match a pattern (substring match).
|
|
183
|
+
def remove_breakpoint_specs_matching(pattern)
|
|
184
|
+
@mutex.synchronize do
|
|
185
|
+
@breakpoint_specs.reject! { |s| s.include?(pattern) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Restore recorded breakpoints on a client. Returns an array of results.
|
|
190
|
+
def restore_breakpoints(client)
|
|
191
|
+
specs = @mutex.synchronize { @breakpoint_specs.dup }
|
|
192
|
+
return [] if specs.empty?
|
|
193
|
+
|
|
194
|
+
specs.filter_map do |spec|
|
|
195
|
+
output = client.send_command(spec)
|
|
196
|
+
{ spec: spec, output: output.lines.first&.strip }
|
|
197
|
+
rescue DebugMcp::Error => e
|
|
198
|
+
{ spec: spec, error: e.message }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Acknowledge a warning category for a session (suppresses future warnings of this category).
|
|
203
|
+
def acknowledge_warning(session_id, category)
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
sid = session_id || @default_session_id
|
|
206
|
+
info = @sessions[sid]
|
|
207
|
+
info&.acknowledged_warnings&.add(category)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Get the set of acknowledged warning categories for a session.
|
|
212
|
+
def acknowledged_warnings(session_id = nil)
|
|
213
|
+
@mutex.synchronize do
|
|
214
|
+
sid = session_id || @default_session_id
|
|
215
|
+
info = @sessions[sid]
|
|
216
|
+
info&.acknowledged_warnings || Set.new
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Clean up sessions whose target process has died or whose socket has disconnected.
|
|
221
|
+
# Returns an array of cleaned-up session info hashes.
|
|
222
|
+
def cleanup_dead_sessions
|
|
223
|
+
cleaned = []
|
|
224
|
+
now = Time.now
|
|
225
|
+
|
|
226
|
+
@mutex.synchronize do
|
|
227
|
+
dead_sids = @sessions.each_with_object({}) do |(sid, info), acc|
|
|
228
|
+
unless process_alive?(info.client.pid)
|
|
229
|
+
acc[sid] = :process_died
|
|
230
|
+
else
|
|
231
|
+
unless info.client.connected?
|
|
232
|
+
acc[sid] = :socket_closed
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
dead_sids.each do |sid, reason|
|
|
238
|
+
info = @sessions.delete(sid)
|
|
239
|
+
cleaned << { session_id: sid, pid: info.client.pid }
|
|
240
|
+
@recently_reaped[sid] = { reason: reason, pid: info.client.pid, reaped_at: now }
|
|
241
|
+
info.client.disconnect
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if dead_sids.key?(@default_session_id)
|
|
245
|
+
@default_session_id = @sessions.keys.first
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
cleanup_recently_reaped(now)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
cleaned
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# List active sessions with timing info.
|
|
255
|
+
# When include_client is true, includes a :client reference for
|
|
256
|
+
# additional queries (e.g., current stop location).
|
|
257
|
+
def active_sessions(include_client: false)
|
|
258
|
+
@mutex.synchronize do
|
|
259
|
+
@sessions.map do |sid, info|
|
|
260
|
+
entry = {
|
|
261
|
+
session_id: sid,
|
|
262
|
+
pid: info.client.pid,
|
|
263
|
+
connected: info.client.connected?,
|
|
264
|
+
paused: info.client.paused,
|
|
265
|
+
connected_at: info.connected_at,
|
|
266
|
+
last_activity_at: info.last_activity_at,
|
|
267
|
+
idle_seconds: (Time.now - info.last_activity_at).to_i,
|
|
268
|
+
timeout_seconds: @timeout,
|
|
269
|
+
}
|
|
270
|
+
entry[:client] = info.client if include_client
|
|
271
|
+
entry
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
def pre_cleanup(session_id:, pid:, port: nil)
|
|
279
|
+
@mutex.synchronize do
|
|
280
|
+
if session_id
|
|
281
|
+
info = @sessions.delete(session_id)
|
|
282
|
+
info&.client&.disconnect rescue nil
|
|
283
|
+
end
|
|
284
|
+
if pid
|
|
285
|
+
pid_str = pid.to_s
|
|
286
|
+
sids = @sessions.each_with_object([]) do |(sid, info), acc|
|
|
287
|
+
acc << sid if info.client.pid.to_s == pid_str
|
|
288
|
+
end
|
|
289
|
+
sids.each do |sid|
|
|
290
|
+
info = @sessions.delete(sid)
|
|
291
|
+
info&.client&.disconnect rescue nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
if port
|
|
295
|
+
port_int = port.to_i
|
|
296
|
+
sids = @sessions.each_with_object([]) do |(sid, info), acc|
|
|
297
|
+
acc << sid if info.client.port == port_int
|
|
298
|
+
end
|
|
299
|
+
sids.each do |sid|
|
|
300
|
+
info = @sessions.delete(sid)
|
|
301
|
+
info&.client&.disconnect rescue nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def start_reaper
|
|
308
|
+
@reaper_thread = Thread.new do
|
|
309
|
+
loop do
|
|
310
|
+
sleep REAPER_INTERVAL
|
|
311
|
+
reap_stale_sessions
|
|
312
|
+
end
|
|
313
|
+
rescue StandardError
|
|
314
|
+
# Reaper should not crash the server
|
|
315
|
+
retry
|
|
316
|
+
end
|
|
317
|
+
@reaper_thread.name = "debug-mcp-reaper"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def stop_reaper
|
|
321
|
+
@reaper_thread&.kill
|
|
322
|
+
@reaper_thread = nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def reap_stale_sessions
|
|
326
|
+
now = Time.now
|
|
327
|
+
stale = {} # { sid => reason }
|
|
328
|
+
|
|
329
|
+
@mutex.synchronize do
|
|
330
|
+
@sessions.each do |sid, info|
|
|
331
|
+
# Remove sessions whose target process has died
|
|
332
|
+
unless process_alive?(info.client.pid)
|
|
333
|
+
stale[sid] = :process_died
|
|
334
|
+
next
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Remove sessions that lost their socket connection
|
|
338
|
+
unless info.client.connected?
|
|
339
|
+
stale[sid] = :socket_closed
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Remove sessions that have been idle too long
|
|
344
|
+
if (now - info.last_activity_at) > @timeout
|
|
345
|
+
stale[sid] = :idle_timeout
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Resume target processes before disconnecting, so they don't
|
|
350
|
+
# stay stuck at the debugger prompt after we disconnect.
|
|
351
|
+
stale.each do |sid, reason|
|
|
352
|
+
info = @sessions[sid]
|
|
353
|
+
next unless info
|
|
354
|
+
|
|
355
|
+
resume_before_disconnect(info)
|
|
356
|
+
@sessions.delete(sid)
|
|
357
|
+
@recently_reaped[sid] = { reason: reason, pid: info.client.pid, reaped_at: now }
|
|
358
|
+
info.client.disconnect rescue nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
if stale.key?(@default_session_id)
|
|
362
|
+
@default_session_id = @sessions.keys.first
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
cleanup_recently_reaped(now)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
RESUME_DEADLINE = 5
|
|
370
|
+
|
|
371
|
+
# Delete all breakpoints and send continue before disconnecting so the
|
|
372
|
+
# target process resumes cleanly. Bounded by a hard deadline to prevent
|
|
373
|
+
# the reaper thread from blocking indefinitely.
|
|
374
|
+
def resume_before_disconnect(info)
|
|
375
|
+
return unless info.client.connected?
|
|
376
|
+
return if info.client.wait_thread # run_script sessions don't need resume
|
|
377
|
+
|
|
378
|
+
client = info.client
|
|
379
|
+
|
|
380
|
+
# If not paused, try repause() to get control back (works for remote/local)
|
|
381
|
+
unless client.paused
|
|
382
|
+
begin
|
|
383
|
+
client.repause(timeout: 3)
|
|
384
|
+
rescue DebugMcp::Error
|
|
385
|
+
# Best-effort
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# For remote clients with listen ports, try HTTP wake to break IO.select
|
|
389
|
+
if !client.paused && client.remote && client.listen_ports&.any?
|
|
390
|
+
begin
|
|
391
|
+
client.wake_io_blocked_process(client.listen_ports.first)
|
|
392
|
+
sleep DebugClient::HTTP_WAKE_SETTLE_TIME
|
|
393
|
+
client.repause(timeout: 5)
|
|
394
|
+
rescue DebugMcp::Error
|
|
395
|
+
# Best-effort
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
return unless client.paused # Can't send commands to a running process
|
|
401
|
+
|
|
402
|
+
ClientCleanup.cleanup_and_resume(client, deadline: Time.now + RESUME_DEADLINE)
|
|
403
|
+
rescue StandardError
|
|
404
|
+
# Best-effort: don't let resume failure prevent cleanup
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def process_alive?(pid)
|
|
408
|
+
return false unless pid
|
|
409
|
+
|
|
410
|
+
Process.kill(0, pid.to_i)
|
|
411
|
+
true
|
|
412
|
+
rescue Errno::EPERM
|
|
413
|
+
# EPERM means the process exists but we lack permission to signal it.
|
|
414
|
+
# Common for Docker containers (PID 1 in container namespace maps to a
|
|
415
|
+
# different process on the host) or processes owned by other users.
|
|
416
|
+
true
|
|
417
|
+
rescue Errno::ESRCH
|
|
418
|
+
# ESRCH means no process with this PID exists.
|
|
419
|
+
false
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def cleanup_recently_reaped(now)
|
|
423
|
+
@recently_reaped.delete_if { |_, v| (now - v[:reaped_at]) > RECENTLY_REAPED_TTL }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def format_elapsed(seconds)
|
|
427
|
+
if seconds < 60
|
|
428
|
+
"#{seconds}s"
|
|
429
|
+
elsif seconds < 3600
|
|
430
|
+
"#{seconds / 60}m"
|
|
431
|
+
else
|
|
432
|
+
"#{seconds / 3600}h #{(seconds % 3600) / 60}m"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugMcp
|
|
4
|
+
# Annotates debug output with human-readable explanations of stop events.
|
|
5
|
+
#
|
|
6
|
+
# The debug gem uses TracePoint events to determine when a breakpoint fires:
|
|
7
|
+
# (line) - about to execute the line
|
|
8
|
+
# (call) - entering a method (before body executes)
|
|
9
|
+
# (return) - returning from a method (line has ALREADY been executed)
|
|
10
|
+
# (b_call) - entering a block (before body executes)
|
|
11
|
+
# (b_return) - returning from a block (line has ALREADY been executed)
|
|
12
|
+
# (c_call) - entering a C method
|
|
13
|
+
# (c_return) - returning from a C method
|
|
14
|
+
#
|
|
15
|
+
# The (return) and (b_return) events are particularly confusing because the
|
|
16
|
+
# source listing shows the line with "=>" as if it's about to execute, but
|
|
17
|
+
# in reality it has already been executed.
|
|
18
|
+
module StopEventAnnotator
|
|
19
|
+
BREAKPOINT_SET_NOTES = {
|
|
20
|
+
"return" => "WARNING - Stop event (return): The debug gem assigned this breakpoint to the method's " \
|
|
21
|
+
"return event. This means:\n" \
|
|
22
|
+
" - It fires AFTER the method finishes and the line has ALREADY been executed\n" \
|
|
23
|
+
" - The current line (=>) shown when hit will be the 'def' line, NOT line you specified\n" \
|
|
24
|
+
"Tip: To stop BEFORE execution at the exact line, set the breakpoint on a line " \
|
|
25
|
+
"inside the method body instead (e.g., the first line after 'def').",
|
|
26
|
+
"b_return" => "WARNING - Stop event (b_return): The debug gem assigned this breakpoint to the block's " \
|
|
27
|
+
"return event. This means:\n" \
|
|
28
|
+
" - It fires AFTER each block iteration returns (stops on EVERY iteration)\n" \
|
|
29
|
+
" - The line has ALREADY been executed when the breakpoint hits\n" \
|
|
30
|
+
" - The current line (=>) shown when hit may differ from the line you specified\n" \
|
|
31
|
+
"Tip: To stop BEFORE execution, set the breakpoint on the first line inside the block. " \
|
|
32
|
+
"To stop only once, use one_shot: true, or set the breakpoint on the line where " \
|
|
33
|
+
"the block method is called (e.g., the .map line).",
|
|
34
|
+
"call" => "WARNING - Stop event (call): The debug gem assigned this breakpoint to a method entry event " \
|
|
35
|
+
"instead of a line event. This typically happens when the breakpoint line is a method " \
|
|
36
|
+
"definition (e.g., 'def foo').\n" \
|
|
37
|
+
" - It fires when the method is entered, which may not match your expectation\n" \
|
|
38
|
+
"Tip: Set the breakpoint on a line inside the method body instead.",
|
|
39
|
+
"b_call" => "WARNING - Stop event (b_call): The debug gem assigned this breakpoint to a block entry event " \
|
|
40
|
+
"instead of a line event. This typically happens when the breakpoint line is a block " \
|
|
41
|
+
"definition (e.g., 'do ... end' or '{ ... }').\n" \
|
|
42
|
+
" - It fires when the block is entered (stops on EVERY iteration)\n" \
|
|
43
|
+
"Tip: Set the breakpoint on the first line inside the block body instead. " \
|
|
44
|
+
"Use one_shot: true to stop only once.",
|
|
45
|
+
"c_call" => "WARNING - Stop event (c_call): The debug gem assigned this breakpoint to a C method entry event. " \
|
|
46
|
+
"This means the line maps to a native C method call, not a Ruby line.\n" \
|
|
47
|
+
" - Behavior may be unexpected since C methods don't have Ruby source lines\n" \
|
|
48
|
+
"Tip: Set the breakpoint on a different line that contains Ruby code.",
|
|
49
|
+
"c_return" => "WARNING - Stop event (c_return): The debug gem assigned this breakpoint to a C method return event. " \
|
|
50
|
+
"This means the line maps to a native C method return.\n" \
|
|
51
|
+
" - The C method has ALREADY finished executing when the breakpoint hits\n" \
|
|
52
|
+
"Tip: Set the breakpoint on a different line that contains Ruby code.",
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# Hit notes only for return/b_return events where the "already executed" semantics
|
|
56
|
+
# are confusing. call/b_call/c_call/c_return don't need hit annotations because
|
|
57
|
+
# the set-time warning already advises moving the breakpoint to a different line.
|
|
58
|
+
BREAKPOINT_HIT_NOTES = {
|
|
59
|
+
"return" => "Stop event (return): the marked line (=>) is the method definition. " \
|
|
60
|
+
"The method has ALREADY finished executing and returned.",
|
|
61
|
+
"b_return" => "Stop event (b_return): the marked line (=>) has ALREADY been executed. " \
|
|
62
|
+
"This is a block return — the block iteration just completed.",
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
RETURN_EVENTS = %w[return b_return c_return].freeze
|
|
66
|
+
|
|
67
|
+
STOP_EVENT_PATTERN = /BP - \w+\s+.+\((\w+)\)/
|
|
68
|
+
CATCH_BREAKPOINT_PATTERN = /BP - Catch\s+"([^"]+)"/
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
# Annotate breakpoint creation output with stop event explanation.
|
|
73
|
+
def annotate_breakpoint_set(output)
|
|
74
|
+
annotate(output, BREAKPOINT_SET_NOTES)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Annotate breakpoint hit output with stop event explanation.
|
|
78
|
+
def annotate_breakpoint_hit(output)
|
|
79
|
+
annotate(output, BREAKPOINT_HIT_NOTES)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Enrich output with runtime context from the debug client.
|
|
83
|
+
# At catch breakpoints: fetches exception class and message.
|
|
84
|
+
# At return events: fetches __return_value__ and $! to distinguish
|
|
85
|
+
# normal return from exception unwinding.
|
|
86
|
+
# At all events: checks $! for in-scope exceptions.
|
|
87
|
+
def enrich_stop_context(output, client)
|
|
88
|
+
event = detect_stop_event(output)
|
|
89
|
+
at_return = event && RETURN_EVENTS.include?(event)
|
|
90
|
+
at_catch = output&.match?(CATCH_BREAKPOINT_PATTERN)
|
|
91
|
+
|
|
92
|
+
parts = [output]
|
|
93
|
+
|
|
94
|
+
if at_return
|
|
95
|
+
# Fetch return value (only available at return/b_return/c_return events)
|
|
96
|
+
begin
|
|
97
|
+
ret_val = client.send_command("p __return_value__")
|
|
98
|
+
cleaned = ret_val.strip.sub(/\A=> /, "")
|
|
99
|
+
unless cleaned.include?("NameError") || cleaned.include?("undefined")
|
|
100
|
+
parts << "Return value: #{cleaned}"
|
|
101
|
+
end
|
|
102
|
+
rescue DebugMcp::Error
|
|
103
|
+
# __return_value__ not available
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check for exception in scope ($!)
|
|
108
|
+
exception_info = client.check_current_exception
|
|
109
|
+
|
|
110
|
+
# At catch breakpoints, $! is not yet set because the :raise TracePoint
|
|
111
|
+
# fires before Ruby assigns $!. Fall back to ObjectSpace to find the
|
|
112
|
+
# most recently created instance of the caught exception class.
|
|
113
|
+
if at_catch && exception_info.nil?
|
|
114
|
+
exception_class = output.match(CATCH_BREAKPOINT_PATTERN)&.captures&.first
|
|
115
|
+
exception_info = client.find_raised_exception(exception_class) if exception_class
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if exception_info
|
|
119
|
+
if at_catch
|
|
120
|
+
parts << "Caught exception: #{exception_info}"
|
|
121
|
+
elsif at_return
|
|
122
|
+
parts << "Exception in scope: #{exception_info}\n" \
|
|
123
|
+
"This method/block is returning due to an exception, not a normal return. " \
|
|
124
|
+
"The return value above may be nil or meaningless."
|
|
125
|
+
else
|
|
126
|
+
parts << "Exception in scope: #{exception_info}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
parts.length > 1 ? parts.join("\n\n") : output
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Detect the stop event type from debug output.
|
|
134
|
+
# Returns the event name string (e.g., "b_return") or nil.
|
|
135
|
+
def detect_stop_event(output)
|
|
136
|
+
return nil unless output
|
|
137
|
+
|
|
138
|
+
match = output.match(STOP_EVENT_PATTERN)
|
|
139
|
+
match ? match[1] : nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def annotate(output, notes)
|
|
143
|
+
return output unless output
|
|
144
|
+
|
|
145
|
+
event = detect_stop_event(output)
|
|
146
|
+
return output unless event
|
|
147
|
+
|
|
148
|
+
note = notes[event]
|
|
149
|
+
note ? "#{output}\n\n#{note}" : output
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|