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,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