Class: DebugMcp::DebugClient
- Inherits:
-
Object
- Object
- DebugMcp::DebugClient
- Defined in:
- lib/debug_mcp/debug_client.rb
Constant Summary collapse
- DEFAULT_WIDTH =
500- DEFAULT_TIMEOUT =
15- CONTINUE_TIMEOUT =
30- DISCONNECT_SOCKET_TIMEOUT =
2- HTTP_WAKE_SETTLE_TIME =
0.3- ANSI_ESCAPE =
ANSI escape code pattern
/\e\[[0-9;]*m/
Instance Attribute Summary collapse
-
#connected ⇒ Object
readonly
Returns the value of attribute connected.
-
#escape_target ⇒ Object
Returns the value of attribute escape_target.
-
#listen_ports ⇒ Object
Returns the value of attribute listen_ports.
-
#paused ⇒ Object
readonly
Returns the value of attribute paused.
-
#pending_http ⇒ Object
Returns the value of attribute pending_http.
-
#pid ⇒ Object
readonly
Returns the value of attribute pid.
-
#port ⇒ Object
readonly
Returns the value of attribute port.
-
#remote ⇒ Object
readonly
Returns the value of attribute remote.
-
#script_args ⇒ Object
Returns the value of attribute script_args.
-
#script_file ⇒ Object
Returns the value of attribute script_file.
-
#stderr_file ⇒ Object
Returns the value of attribute stderr_file.
-
#stdout_file ⇒ Object
Returns the value of attribute stdout_file.
-
#suspended_catch_bps ⇒ Object
Returns the value of attribute suspended_catch_bps.
-
#trap_context ⇒ Object
readonly
Returns the value of attribute trap_context.
-
#wait_thread ⇒ Object
Returns the value of attribute wait_thread.
Class Method Summary collapse
- .extract_pid(path) ⇒ Object
- .extract_session_name(path) ⇒ Object
-
.list_sessions ⇒ Object
List available debug sessions.
- .process_alive?(pid) ⇒ Boolean
-
.socket_connectable?(path) ⇒ Boolean
Quick liveness probe: verify the Unix socket is actually accepting connections.
-
.socket_dir ⇒ Object
Get socket directory for current user.
-
.wake_io_blocked_process(port) ⇒ Object
Wake an IO-blocked process (e.g., Puma in IO.select) by sending an HTTP GET in a background thread.
Instance Method Summary collapse
-
#auto_repause! ⇒ Object
Automatically re-pause the process if it’s running.
-
#check_current_exception ⇒ Object
Check if there is a current exception in scope ($!) Returns “ExceptionClass: message” string, or nil if no exception.
-
#check_paused(timeout: 3) ⇒ Object
Check if the process has paused (drain buffer + wait) without sending a pause signal.
-
#cleanup_one_shot_breakpoints(output) ⇒ Object
Check if a one-shot breakpoint was hit and auto-remove it.
-
#connect(path: nil, host: nil, port: nil, remote: nil, connect_timeout: nil, &on_initial_timeout) ⇒ Object
Connect to a debug session via Unix domain socket or TCP.
- #connected? ⇒ Boolean
-
#continue_and_wait(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Resume the paused process and wait for the next breakpoint hit.
- #disconnect ⇒ Object
-
#ensure_paused(timeout: 2) ⇒ Object
Try to determine if the process is paused by draining any pending output from the socket.
-
#escape_trap_context!(timeout: 3) ⇒ Object
Attempt to escape from signal trap context by stepping to the next line.
-
#find_raised_exception(exception_class_name) ⇒ Object
Find the most recently raised exception of a given class via ObjectSpace.
-
#in_trap_context? ⇒ Boolean
Check if the debugger is currently in a signal trap context.
-
#initialize ⇒ DebugClient
constructor
A new instance of DebugClient.
-
#interrupt_and_wait(timeout: 5) ⇒ Object
Interrupt a running process via SIGINT and wait for it to pause.
-
#process_finished?(timeout: 1) ⇒ Boolean
Check if the spawned process has exited.
-
#read_stderr_output ⇒ Object
Read captured stderr output (available for processes launched via run_script) Returns the stderr content string, or nil.
-
#read_stdout_output ⇒ Object
Read captured stdout output (available for processes launched via run_script) Returns the stdout content string, or nil.
-
#register_one_shot(bp_number) ⇒ Object
Register a breakpoint number as one-shot (auto-remove after first hit).
-
#repause(timeout: 3) ⇒ Object
Re-pause a running process by sending SIGURG directly.
-
#send_command(command, timeout: DEFAULT_TIMEOUT) ⇒ Object
Send a debugger command and return the output.
-
#send_command_no_wait(command, force: false) ⇒ Object
Send a command and wait briefly for the debug gem to read it, but don’t wait for a full response (which may never come, e.g., after ‘c` with no breakpoint).
-
#send_continue(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Send continue and wait for the next breakpoint.
-
#wait_for_breakpoint(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Wait for the process to pause (breakpoint hit) without sending any command.
- #wake_io_blocked_process(port) ⇒ Object
Constructor Details
#initialize ⇒ DebugClient
Returns a new instance of DebugClient.
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/debug_mcp/debug_client.rb', line 24 def initialize @socket = nil @pid = nil @connected = false @paused = false @trap_context = nil @remote = false # True for TCP connections (e.g., Docker) where Process.kill won't work @port = nil @width = DEFAULT_WIDTH @mutex = Mutex.new @one_shot_breakpoints = Set.new @listen_ports = [] @escape_target = nil @suspended_catch_bps = [] end |
Instance Attribute Details
#connected ⇒ Object (readonly)
Returns the value of attribute connected.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def connected @connected end |
#escape_target ⇒ Object
Returns the value of attribute escape_target.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def escape_target @escape_target end |
#listen_ports ⇒ Object
Returns the value of attribute listen_ports.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def listen_ports @listen_ports end |
#paused ⇒ Object (readonly)
Returns the value of attribute paused.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def paused @paused end |
#pending_http ⇒ Object
Returns the value of attribute pending_http.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def pending_http @pending_http end |
#pid ⇒ Object (readonly)
Returns the value of attribute pid.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def pid @pid end |
#port ⇒ Object (readonly)
Returns the value of attribute port.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def port @port end |
#remote ⇒ Object (readonly)
Returns the value of attribute remote.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def remote @remote end |
#script_args ⇒ Object
Returns the value of attribute script_args.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def script_args @script_args end |
#script_file ⇒ Object
Returns the value of attribute script_file.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def script_file @script_file end |
#stderr_file ⇒ Object
Returns the value of attribute stderr_file.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def stderr_file @stderr_file end |
#stdout_file ⇒ Object
Returns the value of attribute stdout_file.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def stdout_file @stdout_file end |
#suspended_catch_bps ⇒ Object
Returns the value of attribute suspended_catch_bps.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def suspended_catch_bps @suspended_catch_bps end |
#trap_context ⇒ Object (readonly)
Returns the value of attribute trap_context.
20 21 22 |
# File 'lib/debug_mcp/debug_client.rb', line 20 def trap_context @trap_context end |
#wait_thread ⇒ Object
Returns the value of attribute wait_thread.
21 22 23 |
# File 'lib/debug_mcp/debug_client.rb', line 21 def wait_thread @wait_thread end |
Class Method Details
.extract_pid(path) ⇒ Object
1108 1109 1110 1111 1112 1113 |
# File 'lib/debug_mcp/debug_client.rb', line 1108 def self.extract_pid(path) basename = File.basename(path) if basename =~ /\Ardbg-(\d+)/ $1.to_i end end |
.extract_session_name(path) ⇒ Object
1115 1116 1117 1118 1119 1120 |
# File 'lib/debug_mcp/debug_client.rb', line 1115 def self.extract_session_name(path) basename = File.basename(path) if basename =~ /\Ardbg-\d+-(.*)/ $1 end end |
.list_sessions ⇒ Object
List available debug sessions. Filters by: socket file exists and socket is connectable (liveness probe). The connectable check is the sole authority — if the socket accepts connections, a debug process is listening regardless of whether the PID in the filename still matches (e.g. daemonized Rails servers fork after creating the socket, so the original PID exits).
610 611 612 613 614 615 616 617 618 619 620 621 622 |
# File 'lib/debug_mcp/debug_client.rb', line 610 def self.list_sessions dir = socket_dir return [] unless dir && Dir.exist?(dir) Dir.glob(File.join(dir, "rdbg*")).select do |path| File.socket?(path) end.filter_map do |path| pid = extract_pid(path) next unless pid && socket_connectable?(path) { path: path, pid: pid, name: extract_session_name(path) } end end |
.process_alive?(pid) ⇒ Boolean
1122 1123 1124 1125 1126 1127 |
# File 'lib/debug_mcp/debug_client.rb', line 1122 def self.process_alive?(pid) Process.kill(0, pid) true rescue Errno::ESRCH, Errno::EPERM false end |
.socket_connectable?(path) ⇒ Boolean
Quick liveness probe: verify the Unix socket is actually accepting connections. Filters out stale socket files where the PID was reused by a different process that doesn’t listen on the debug socket. Does NOT send any protocol data — just connects and immediately closes.
1133 1134 1135 1136 1137 1138 1139 1140 1141 |
# File 'lib/debug_mcp/debug_client.rb', line 1133 def self.socket_connectable?(path) sock = Socket.unix(path) sock.close true rescue Errno::ECONNREFUSED, Errno::ENOENT, Errno::EACCES, IOError false rescue StandardError false end |
.socket_dir ⇒ Object
Get socket directory for current user
625 626 627 628 629 630 631 632 633 634 635 636 |
# File 'lib/debug_mcp/debug_client.rb', line 625 def self.socket_dir if (dir = ENV["RUBY_DEBUG_SOCK_DIR"]) dir elsif (dir = ENV["XDG_RUNTIME_DIR"]) dir else tmpdir = Dir.tmpdir uid = Process.uid dir = File.join(tmpdir, "rdbg-#{uid}") dir if Dir.exist?(dir) end end |
.wake_io_blocked_process(port) ⇒ Object
Wake an IO-blocked process (e.g., Puma in IO.select) by sending an HTTP GET in a background thread. The request itself doesn’t matter —it just needs to break IO.select so the debug gem’s pending pause trace point can fire. Returns the background thread.
642 643 644 645 646 647 648 649 650 651 |
# File 'lib/debug_mcp/debug_client.rb', line 642 def self.wake_io_blocked_process(port) Thread.new do http = Net::HTTP.new("127.0.0.1", port) http.open_timeout = 3 http.read_timeout = 3 http.get("/") rescue StandardError # Ignore — we only care about waking IO.select end end |
Instance Method Details
#auto_repause! ⇒ Object
Automatically re-pause the process if it’s running. Returns false if already paused, true if repause was performed. Raises SessionError if the process cannot be re-paused (e.g., blocked on I/O).
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 |
# File 'lib/debug_mcp/debug_client.rb', line 376 def auto_repause! return false if @paused debug_log("auto_repause!: starting (paused=#{@paused}, trap=#{@trap_context})") result = repause(timeout: 3) if result.nil? if @remote # Remote: interrupt_and_wait always returns nil (SIGINT can't reach the # target process in a different PID namespace). Wait for the single # `pause` message (already sent by repause above) to take effect. # Use check_paused (no new pause message) to avoid stale messages # accumulating in the debug gem server's read buffer. sleep HTTP_WAKE_SETTLE_TIME result = check_paused(timeout: 5) # If still not paused and we have listen ports, the process is likely # blocked in IO.select (SA_RESTART prevents SIGURG from breaking through). # Send an HTTP GET to wake it, then check again. if result.nil? && @listen_ports&.any? debug_log("auto_repause!: waking IO-blocked process via HTTP") wake_thread = wake_io_blocked_process(@listen_ports.first) sleep HTTP_WAKE_SETTLE_TIME result = check_paused(timeout: 5) wake_thread.join(1) rescue nil end if result.nil? sleep 0.5 result = check_paused(timeout: 8) end else # Local: SIGINT can interrupt IO.select and similar C-level blocking calls. result = interrupt_and_wait(timeout: 5) end if result.nil? raise SessionError, "Process is not paused and could not be interrupted. " \ "The process may have exited or be in an unrecoverable state. " \ "Use 'disconnect' and 'connect' to re-attach." end end debug_log("auto_repause!: repause done (paused=#{@paused}, trap=#{@trap_context})") # After repause via SIGURG, the process is in trap context. # Auto-escape if we have cached escape target and listen ports. if @trap_context && @listen_ports&.any? && @escape_target debug_log("auto_repause!: attempting trap escape (trap=#{@trap_context})") attempt_trap_escape! debug_log("auto_repause!: after trap escape (trap=#{@trap_context})") # If trap escape left the process running (e.g., HTTP completed before # breakpoint was hit), re-pause without attempting escape again. unless @paused debug_log("auto_repause!: trap escape left process unpaused, re-pausing") re_result = repause(timeout: 3) if re_result.nil? raise SessionError, "Process could not be re-paused after failed trap escape. " \ "Use 'disconnect' and 'connect' to re-attach." end # Stay in trap context — escape failed but process is at least paused end # Protocol sync: after attempt_trap_escape!, TCP in-flight data from # the escape sequence (break, continue, delete commands) may still be # arriving. Send a no-op command to perform a full round-trip and # consume any stale data before the next real command. if @paused begin send_command("p nil", timeout: 3) rescue DebugMcp::Error # Best-effort sync. If this times out, send_command sets @paused=false. # Restore it: the process was confirmed paused at the escape breakpoint, # and drain_stale_data on the next command will consume the stale response. @paused = true end end end true end |
#check_current_exception ⇒ Object
Check if there is a current exception in scope ($!) Returns “ExceptionClass: message” string, or nil if no exception
221 222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'lib/debug_mcp/debug_client.rb', line 221 def check_current_exception # Use a single expression to get "ClassName: message" format when $! is set. # The debug gem prefixes output with "=> ", which we strip. result = send_command('p(($!) ? "#{$!.class}: #{$!.message}" : nil)') cleaned = result.strip.sub(/\A=> /, "") return nil if cleaned == "nil" || cleaned.empty? # Remove surrounding quotes from string output (e.g., "NoMethodError: ..." -> NoMethodError: ...) cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"') cleaned.empty? ? nil : cleaned rescue DebugMcp::Error nil end |
#check_paused(timeout: 3) ⇒ Object
Check if the process has paused (drain buffer + wait) without sending a pause signal. Used by auto_repause! for retry attempts to avoid stale pause messages accumulating in the debug gem server’s read buffer (which cause unexpected SIGURGs after disconnect).
524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 |
# File 'lib/debug_mcp/debug_client.rb', line 524 def check_paused(timeout: 3) return "" if @paused return nil unless connected? @mutex.synchronize do return "" if @paused drain_socket_buffer return "" if @paused wait_for_pause(timeout) end rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e @connected = false @paused = false raise ConnectionError, "Connection lost: #{e.}" end |
#cleanup_one_shot_breakpoints(output) ⇒ Object
Check if a one-shot breakpoint was hit and auto-remove it. Call this after execution commands (continue, next, step). Returns the deleted breakpoint number, or nil.
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 |
# File 'lib/debug_mcp/debug_client.rb', line 586 def cleanup_one_shot_breakpoints(output) return nil unless @one_shot_breakpoints.any? return nil unless output # Debug gem output when hitting a breakpoint: "Stop by #3 BP - Line ..." match = output.match(/Stop by #(\d+)/) return nil unless match bp_num = match[1].to_i return nil unless @one_shot_breakpoints.delete?(bp_num) send_command("delete #{bp_num}") bp_num rescue DebugMcp::Error # Best-effort cleanup nil end |
#connect(path: nil, host: nil, port: nil, remote: nil, connect_timeout: nil, &on_initial_timeout) ⇒ Object
Connect to a debug session via Unix domain socket or TCP. Accepts an optional block (&on_initial_timeout) that is called when the initial read_until_input times out. This allows the caller to wake an IO-blocked process (e.g., by sending an HTTP request) so that the debug gem’s pending pause (set by the SIGURG from greeting) can fire. If the block is given and the initial read times out, the block is called and read_until_input is retried once with a 10s timeout.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/debug_mcp/debug_client.rb', line 51 def connect(path: nil, host: nil, port: nil, remote: nil, connect_timeout: nil, &on_initial_timeout) disconnect if connected? if path @socket = Socket.unix(path) @remote = remote.nil? ? false : remote elsif port @socket = Socket.tcp(host || "localhost", port.to_i) @remote = remote.nil? ? true : remote @port = port.to_i else path = discover_socket @socket = Socket.unix(path) @remote = remote.nil? ? false : remote end # The debug gem protocol: client sends greeting first, then reads server output send_greeting initial_output = begin read_until_input(timeout: connect_timeout || DEFAULT_TIMEOUT) rescue TimeoutError raise unless on_initial_timeout on_initial_timeout.call read_until_input(timeout: 10) end @connected = true @paused = true { success: true, pid: @pid, output: initial_output } rescue Errno::ECONNREFUSED => e raise ConnectionError, "Connection refused: #{e.}. " \ "Ensure the debug process is running with 'rdbg --open'." rescue Errno::ENOENT => e raise ConnectionError, "Socket not found: #{e.}. " \ "The debug process may have exited. Use 'list_debug_sessions' to check." rescue TimeoutError disconnect raise ConnectionError, "Connection timed out: the debug process did not respond.\n" \ "Possible causes:\n" \ " - Another debugger client is already connected " \ "(only one client allowed at a time)\n" \ " - A previous debugger client disconnected uncleanly, " \ "leaving the session stuck\n" \ " - The target process is blocked and cannot respond " \ "to the debug interrupt\n" \ "To resolve:\n" \ " - Close other debugger clients " \ "(e.g., IDE debugger, other terminal sessions)\n" \ " - Restart the target process with 'rdbg --open'" rescue DebugMcp::Error raise rescue StandardError => e disconnect raise ConnectionError, "Connection failed: #{e.class}: #{e.}" end |
#connected? ⇒ Boolean
40 41 42 |
# File 'lib/debug_mcp/debug_client.rb', line 40 def connected? @connected && @socket && !@socket.closed? end |
#continue_and_wait(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Resume the paused process and wait for the next breakpoint hit. Supports an interrupt check block that is called every 0.5s. If the block returns true, the wait is interrupted and :interrupted is returned. Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 |
# File 'lib/debug_mcp/debug_client.rb', line 544 def continue_and_wait(timeout: CONTINUE_TIMEOUT, &interrupt_check) raise SessionError, "Not connected to a debug session." unless connected? @mutex.synchronize do drain_stale_data msg = "command #{@pid} #{@width} c\n" @socket.write(msg.b) @socket.flush @paused = false read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check) end rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e @connected = false @paused = false raise ConnectionError, "Connection lost: #{e.}" end |
#disconnect ⇒ Object
107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/debug_mcp/debug_client.rb', line 107 def disconnect force_close_socket @socket = nil @pid = nil @connected = false @paused = false @trap_context = nil @port = nil @pending_http = nil @listen_ports = [] @escape_target = nil @suspended_catch_bps = [] cleanup_captured_files end |
#ensure_paused(timeout: 2) ⇒ Object
Try to determine if the process is paused by draining any pending output from the socket. Returns the pending output string if the process is paused (input prompt found), or nil if still running. Does NOT send any commands - safe to call at any time.
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/debug_mcp/debug_client.rb', line 304 def ensure_paused(timeout: 2) return "" if @paused return nil unless connected? @mutex.synchronize do return "" if @paused pending_output = [] deadline = Time.now + timeout while Time.now < deadline remaining = [deadline - Time.now, 0.1].min break if remaining <= 0 ready = @socket.wait_readable(remaining) next unless ready line = @socket.gets break unless line line = line.chomp.force_encoding(Encoding::UTF_8) line = line.scrub unless line.valid_encoding? case line when /\Aout (.*)/ pending_output << strip_ansi($1) when /\Ainput (\d+)/ @pid = $1 @paused = true return pending_output.join("\n") when /\Aask (\d+) (.*)/ @socket.write("answer #{$1} y\n".b) @socket.flush when /\Aquit/ @connected = false @paused = false return nil end end nil # Still not paused (no input prompt received) end rescue Errno::EPIPE, Errno::ECONNRESET, IOError @connected = false @paused = false nil end |
#escape_trap_context!(timeout: 3) ⇒ Object
Attempt to escape from signal trap context by stepping to the next line. The ‘next’ command causes the debugger to return from the signal handler and pause at the next Ruby line in normal context. Uses a short timeout (3s) because if the process is blocked in IO.select (common with Puma), ‘next’ will never complete regardless of wait time. Returns the step output on success, nil on failure.
285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
# File 'lib/debug_mcp/debug_client.rb', line 285 def escape_trap_context!(timeout: 3) return nil unless in_trap_context? output = send_command("next", timeout: timeout) if in_trap_context? nil # Still in trap context (step didn't escape) else @trap_context = false output end rescue DebugMcp::Error nil end |
#find_raised_exception(exception_class_name) ⇒ Object
Find the most recently raised exception of a given class via ObjectSpace. Used as a fallback at catch breakpoints where $! is not yet set (the :raise TracePoint fires before $! is assigned). Returns “ExceptionClass: message” string, or nil if not found.
249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/debug_mcp/debug_client.rb', line 249 def find_raised_exception(exception_class_name) result = send_command( "p(begin; klass = Object.const_get(#{exception_class_name.inspect}); " \ "e = ObjectSpace.each_object(klass).max_by(&:object_id); " \ 'e ? "#{e.class}: #{e.message}" : nil; rescue; nil; end)', ) cleaned = result.strip.sub(/\A=> /, "") return nil if cleaned == "nil" || cleaned.empty? cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"') cleaned.empty? ? nil : cleaned rescue DebugMcp::Error nil end |
#in_trap_context? ⇒ Boolean
Check if the debugger is currently in a signal trap context. This is common when connecting to Puma/Rails processes via SIGURG. In trap context, thread operations (Mutex lock, DB pools, autoloading) fail with ThreadError: “can’t be called from trap context”. Note: Mutex.new alone succeeds in trap context (Ruby 3.3+) — it’s just object allocation. Mutex#lock is needed to actually test for trap context restrictions.
270 271 272 273 274 275 276 277 |
# File 'lib/debug_mcp/debug_client.rb', line 270 def in_trap_context? result = send_command("p begin; Mutex.new.lock; 'normal'; rescue ThreadError; 'trap'; end") in_trap = result.strip.sub(/\A=> /, "").include?("trap") @trap_context = in_trap in_trap rescue DebugMcp::Error false end |
#interrupt_and_wait(timeout: 5) ⇒ Object
Interrupt a running process via SIGINT and wait for it to pause. SIGINT can break through C-level blocking IO (unlike SIGURG used by repause). Returns “” if the process is now paused, nil if the process couldn’t be found or the interrupt didn’t work.
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/debug_mcp/debug_client.rb', line 356 def interrupt_and_wait(timeout: 5) return nil unless @pid return "" if @paused if @remote # For remote connections, SIGINT can't be sent directly. # The `pause` protocol message (sent by repause) is the best we can do. # Return nil to indicate we can't interrupt. return nil end Process.kill("INT", @pid.to_i) ensure_paused(timeout: timeout) rescue Errno::ESRCH, Errno::EPERM nil end |
#process_finished?(timeout: 1) ⇒ Boolean
Check if the spawned process has exited. Waits briefly to allow the process to finish cleanup. Only meaningful for run_script sessions (wait_thread is nil for connect sessions).
238 239 240 241 242 243 |
# File 'lib/debug_mcp/debug_client.rb', line 238 def process_finished?(timeout: 1) return false unless wait_thread wait_thread.join(timeout) !wait_thread.alive? end |
#read_stderr_output ⇒ Object
Read captured stderr output (available for processes launched via run_script) Returns the stderr content string, or nil
130 131 132 |
# File 'lib/debug_mcp/debug_client.rb', line 130 def read_stderr_output read_captured_file(@stderr_file) end |
#read_stdout_output ⇒ Object
Read captured stdout output (available for processes launched via run_script) Returns the stdout content string, or nil
124 125 126 |
# File 'lib/debug_mcp/debug_client.rb', line 124 def read_stdout_output read_captured_file(@stdout_file) end |
#register_one_shot(bp_number) ⇒ Object
Register a breakpoint number as one-shot (auto-remove after first hit)
579 580 581 |
# File 'lib/debug_mcp/debug_client.rb', line 579 def register_one_shot(bp_number) @one_shot_breakpoints.add(bp_number) end |
#repause(timeout: 3) ⇒ Object
Re-pause a running process by sending SIGURG directly.
First drains any buffered socket data (in case a breakpoint was hit but the ‘input PID` wasn’t consumed due to a timeout). If not already paused, sends SIGURG to the target process to trigger the debug gem’s pause handler.
Uses Process.kill(“URG”) instead of the ‘pause` protocol message to avoid leaving stale messages in the server’s socket read buffer. When ‘pause` is written to the socket and the reader thread is blocked (e.g., executing a long-running eval), the message sits unread. After SIGINT recovery, the reader thread eventually reads the stale `pause` and fires SIGURG at an unexpected time — typically after disconnect, when `c` resumes the process. This re-pauses the process with no client connected, leaving the session thread stuck on process_group.sync and blocking future connections.
Returns “” on success (process is paused), nil on timeout/failure.
472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 |
# File 'lib/debug_mcp/debug_client.rb', line 472 def repause(timeout: 3) return "" if @paused return nil unless connected? @mutex.synchronize do return "" if @paused # Step 1: Drain buffered data — a breakpoint may have been hit but # the `input PID` wasn't consumed (e.g., after continue_and_wait timeout). drain_socket_buffer debug_log("repause: after drain_socket_buffer (paused=#{@paused})") return "" if @paused # Step 2: Pause the running process. # For local processes, send SIGURG directly — this avoids leaving stale # data in the server's socket read buffer (unlike the `pause` protocol message). # For remote/TCP connections (e.g., Docker), Process.kill won't reach the # target process (PID is in a different namespace), so use the `pause` # protocol message which the debug gem server sends as SIGURG to itself. if @remote debug_log("repause: sending pause message (remote)") @socket.write("pause #{@pid}\n".b) @socket.flush else debug_log("repause: sending SIGURG (local)") Process.kill("URG", @pid.to_i) end # Step 3: Wait for `input PID` prompt result = wait_for_pause(timeout) debug_log("repause: wait_for_pause result=#{result.nil? ? 'nil' : 'ok'}") # SIGURG puts the process in signal trap context. Mark it so callers # can adapt (e.g., avoid `require` or Mutex operations that hang in # trap context). @trap_context = true if result result end rescue Errno::ESRCH, Errno::EPERM # Process not found or no permission — can't repause nil rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e @connected = false @paused = false raise ConnectionError, "Connection lost: #{e.}" end |
#send_command(command, timeout: DEFAULT_TIMEOUT) ⇒ Object
Send a debugger command and return the output.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/debug_mcp/debug_client.rb', line 135 def send_command(command, timeout: DEFAULT_TIMEOUT) raise SessionError, "Not connected to a debug session. Use 'connect' to establish a connection." unless connected? @mutex.synchronize do debug_log("send_command: paused=#{@paused} cmd=#{command[0, 60]}") # Drain stale data from previous operations (e.g., responses from # timed-out commands, late breakpoint `input PID` after continue timeout). # This prevents protocol desync where send_command reads a stale response # instead of the response to the command being sent. drain_stale_data unless @paused raise SessionError, "Process is not paused. Cannot send debug commands while the process is running. " \ "Use 'trigger_request' to send an HTTP request (which auto-resumes), " \ "or 'disconnect' and reconnect." end # Encode as binary to avoid Encoding::CompatibilityError when the # command contains non-ASCII characters (e.g., Japanese) and the # socket uses ASCII-8BIT encoding. msg = "command #{@pid} #{@width} #{command}\n" @socket.write(msg.b) @socket.flush debug_log("send_command: written OK, reading response...") output = read_until_input(timeout: timeout) debug_log("send_command: got response (#{output.length} bytes)") @paused = true output end rescue TimeoutError # The debug gem is still processing the command — it hasn't sent # `input PID` back. Mark as not paused so subsequent tool calls # trigger auto_repause! (SIGURG/SIGINT) instead of sending another # command into the unresponsive session. @paused = false raise rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e @connected = false @paused = false raise ConnectionError, "Connection lost while executing '#{command}': #{e.}. " \ "The debug process may have exited. Use 'connect' to reconnect." end |
#send_command_no_wait(command, force: false) ⇒ Object
Send a command and wait briefly for the debug gem to read it, but don’t wait for a full response (which may never come, e.g., after ‘c` with no breakpoint). Used when resuming a process before disconnecting.
When force: true, bypasses the @paused check. Use ONLY during cleanup/disconnect when a prior send_command timeout set @paused=false but the process is actually still paused in the debugger.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/debug_mcp/debug_client.rb', line 186 def send_command_no_wait(command, force: false) return unless connected? return unless force || @paused # Sending command while running crashes the reader thread @mutex.synchronize do msg = "command #{@pid} #{@width} #{command}\n" @socket.write(msg.b) @socket.flush @paused = false end # Give the debug gem time to read the command from the socket buffer # and begin processing it before the socket is closed. sleep 0.3 rescue Errno::EPIPE, Errno::ECONNRESET, IOError # Socket already closed, ignore end |
#send_continue(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Send continue and wait for the next breakpoint. Uses continue_and_wait (IO.select-based) instead of send_command to avoid protocol desync: send_command sets @paused=true on timeout even when no input prompt was received, causing subsequent commands to read stale responses.
207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/debug_mcp/debug_client.rb', line 207 def send_continue(timeout: CONTINUE_TIMEOUT, &interrupt_check) result = continue_and_wait(timeout: timeout, &interrupt_check) case result[:type] when :breakpoint result[:output] when :timeout, :timeout_with_output raise TimeoutError, "Timeout after #{timeout}s waiting for breakpoint." when :interrupted result[:output] end end |
#wait_for_breakpoint(timeout: CONTINUE_TIMEOUT, &interrupt_check) ⇒ Object
Wait for the process to pause (breakpoint hit) without sending any command. Use when the process is already running after a previous continue. Returns a hash: { type: :breakpoint/:interrupted/:timeout, output: String }
565 566 567 568 569 570 571 572 573 574 575 576 |
# File 'lib/debug_mcp/debug_client.rb', line 565 def wait_for_breakpoint(timeout: CONTINUE_TIMEOUT, &interrupt_check) raise SessionError, "Not connected to a debug session." unless connected? @mutex.synchronize do @paused = false read_until_input_interruptible(timeout: timeout, interrupt_check: interrupt_check) end rescue Errno::EPIPE, Errno::ECONNRESET, IOError => e @connected = false @paused = false raise ConnectionError, "Connection lost: #{e.}" end |
#wake_io_blocked_process(port) ⇒ Object
653 654 655 |
# File 'lib/debug_mcp/debug_client.rb', line 653 def wake_io_blocked_process(port) self.class.wake_io_blocked_process(port) end |