Class: DebugMcp::DebugClient

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeDebugClient

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

#connectedObject (readonly)

Returns the value of attribute connected.



20
21
22
# File 'lib/debug_mcp/debug_client.rb', line 20

def connected
  @connected
end

#escape_targetObject

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_portsObject

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

#pausedObject (readonly)

Returns the value of attribute paused.



20
21
22
# File 'lib/debug_mcp/debug_client.rb', line 20

def paused
  @paused
end

#pending_httpObject

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

#pidObject (readonly)

Returns the value of attribute pid.



20
21
22
# File 'lib/debug_mcp/debug_client.rb', line 20

def pid
  @pid
end

#portObject (readonly)

Returns the value of attribute port.



20
21
22
# File 'lib/debug_mcp/debug_client.rb', line 20

def port
  @port
end

#remoteObject (readonly)

Returns the value of attribute remote.



20
21
22
# File 'lib/debug_mcp/debug_client.rb', line 20

def remote
  @remote
end

#script_argsObject

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_fileObject

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_fileObject

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_fileObject

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_bpsObject

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_contextObject (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_threadObject

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_sessionsObject

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

Returns:

  • (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.

Returns:

  • (Boolean)


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_dirObject

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_exceptionObject

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.message}"
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.message}. " \
                         "Ensure the debug process is running with 'rdbg --open'."
rescue Errno::ENOENT => e
  raise ConnectionError, "Socket not found: #{e.message}. " \
                         "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.message}"
end

#connected?Boolean

Returns:

  • (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.message}"
end

#disconnectObject



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.

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


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_outputObject

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_outputObject

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.message}"
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.message}. " \
                         "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.message}"
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