Module: DebugMcp::ClientCleanup

Defined in:
lib/debug_mcp/client_cleanup.rb

Overview

Shared cleanup logic for graceful disconnect. Used by Disconnect tool and SessionManager reaper to avoid ~100 lines of duplication. Performs: stdout restore, breakpoint deletion, SIGINT handler restore, process resume, and stale pause defense.

Class Method Summary collapse

Class Method Details

.cleanup_and_resume(client, deadline:, max_stale_retries: 2) ⇒ Object

Perform best-effort cleanup and resume a paused debug client. The client MUST be paused before calling this method.

Parameters:

  • client (DebugClient)

    the client to clean up

  • deadline (Time)

    hard deadline for all cleanup operations

  • max_stale_retries (Integer) (defaults to: 2)

    max retries for stale pause defense



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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
# File 'lib/debug_mcp/client_cleanup.rb', line 14

def self.cleanup_and_resume(client, deadline:, max_stale_retries: 2)
  # Restore $stdout if evaluate_code left it redirected (its ensure block
  # fails when send_command timeout sets @paused=false).
  remaining = deadline - Time.now
  if remaining > 0
    begin
      client.send_command(
        '$stdout = STDOUT if $stdout != STDOUT',
        timeout: [remaining, 1].min,
      )
    rescue DebugMcp::Error
      # Best-effort
    end
  end

  # Delete all breakpoints FIRST — this is the most critical step.
  # If breakpoints remain and the process resumes without a client,
  # it will hit a breakpoint and become stuck with no way to continue.
  delete_all_breakpoints(client, deadline)

  # Restore original SIGINT handler
  remaining = deadline - Time.now
  if remaining > 0
    begin
      client.send_command(
        "p $_debug_mcp_orig_int ? (trap('INT',$_debug_mcp_orig_int);$_debug_mcp_orig_int=nil;:ok) : nil",
        timeout: [remaining, 2].min,
      )
    rescue DebugMcp::Error
      # Best-effort
    end
  end

  # Resume the process. If a cleanup command timed out (setting
  # @paused=false even though the process is actually still paused),
  # use force: true to bypass the @paused check.
  client.send_command_no_wait("c", force: true)

  # Wait for the debug gem to settle after 'c'. After SIGINT recovery,
  # the main thread needs to finish the interrupted eval and re-enter
  # the command loop (sending `input PID`). If we close the socket
  # before this completes, the debug gem's cleanup_reader closes @q_msg
  # while the main thread is still pushing results, leaving it stuck
  # on a futex. Draining here gives the debug gem time to settle.
  client.ensure_paused(timeout: 2)

  # Stale pause defense: after 'c' → ensure_paused, the process might
  # have been re-paused by a stale `pause` message left in the debug
  # gem's socket buffer. If still paused, delete remaining BPs and
  # send 'c' again (bounded retries to prevent infinite loop).
  stale_retries = 0
  while client.paused && stale_retries < max_stale_retries
    stale_retries += 1
    remaining = deadline - Time.now
    break if remaining <= 0

    delete_all_breakpoints(client, deadline)

    remaining = deadline - Time.now
    break if remaining <= 0

    client.send_command_no_wait("c", force: true)
    client.ensure_paused(timeout: [remaining, 1].min)
  end
end

.delete_all_breakpoints(client, deadline) ⇒ Object

Delete all breakpoints from the debug session.

Parameters:

  • client (DebugClient)

    the client

  • deadline (Time)

    hard deadline



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/debug_mcp/client_cleanup.rb', line 83

def self.delete_all_breakpoints(client, deadline)
  remaining = deadline - Time.now
  return if remaining <= 0

  bp_output = client.send_command("info breakpoints", timeout: [remaining, 2].min)
  return if bp_output.strip.empty?

  bp_output.each_line do |line|
    remaining = deadline - Time.now
    break if remaining <= 0

    if (match = line.match(/#(\d+)/))
      client.send_command("delete #{match[1]}", timeout: [remaining, 2].min) rescue nil
    end
  end
rescue DebugMcp::Error
  # Best-effort
end