Class: DebugMcp::SessionManager

Inherits:
Object
  • Object
show all
Defined in:
lib/debug_mcp/session_manager.rb

Defined Under Namespace

Classes: SessionInfo

Constant Summary collapse

DEFAULT_TIMEOUT =

Default session timeout: 30 minutes of inactivity

30 * 60
REAPER_INTERVAL =

Reaper interval: check every 60 seconds

60
RECENTLY_REAPED_TTL =

How long to remember reaped sessions for diagnostic messages

10 * 60

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(timeout: DEFAULT_TIMEOUT) ⇒ SessionManager

Returns a new instance of SessionManager.



19
20
21
22
23
24
25
26
27
28
# File 'lib/debug_mcp/session_manager.rb', line 19

def initialize(timeout: DEFAULT_TIMEOUT)
  @sessions = {}
  @default_session_id = nil
  @timeout = timeout
  @mutex = Mutex.new
  @reaper_thread = nil
  @breakpoint_specs = [] # Breakpoint commands to restore across sessions
  @recently_reaped = {} # { sid => { reason:, pid:, reaped_at: } }
  start_reaper
end

Instance Attribute Details

#timeoutObject (readonly)

Returns the value of attribute timeout.



17
18
19
# File 'lib/debug_mcp/session_manager.rb', line 17

def timeout
  @timeout
end

Instance Method Details

#acknowledge_warning(session_id, category) ⇒ Object

Acknowledge a warning category for a session (suppresses future warnings of this category).



203
204
205
206
207
208
209
# File 'lib/debug_mcp/session_manager.rb', line 203

def acknowledge_warning(session_id, category)
  @mutex.synchronize do
    sid = session_id || @default_session_id
    info = @sessions[sid]
    info&.acknowledged_warnings&.add(category)
  end
end

#acknowledged_warnings(session_id = nil) ⇒ Object

Get the set of acknowledged warning categories for a session.



212
213
214
215
216
217
218
# File 'lib/debug_mcp/session_manager.rb', line 212

def acknowledged_warnings(session_id = nil)
  @mutex.synchronize do
    sid = session_id || @default_session_id
    info = @sessions[sid]
    info&.acknowledged_warnings || Set.new
  end
end

#active_sessions(include_client: false) ⇒ Object

List active sessions with timing info. When include_client is true, includes a :client reference for additional queries (e.g., current stop location).



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/debug_mcp/session_manager.rb', line 257

def active_sessions(include_client: false)
  @mutex.synchronize do
    @sessions.map do |sid, info|
      entry = {
        session_id: sid,
        pid: info.client.pid,
        connected: info.client.connected?,
        paused: info.client.paused,
        connected_at: info.connected_at,
        last_activity_at: info.last_activity_at,
        idle_seconds: (Time.now - info.last_activity_at).to_i,
        timeout_seconds: @timeout,
      }
      entry[:client] = info.client if include_client
      entry
    end
  end
end

#cleanup_dead_sessionsObject

Clean up sessions whose target process has died or whose socket has disconnected. Returns an array of cleaned-up session info hashes.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/debug_mcp/session_manager.rb', line 222

def cleanup_dead_sessions
  cleaned = []
  now = Time.now

  @mutex.synchronize do
    dead_sids = @sessions.each_with_object({}) do |(sid, info), acc|
      unless process_alive?(info.client.pid)
        acc[sid] = :process_died
      else
        unless info.client.connected?
          acc[sid] = :socket_closed
        end
      end
    end

    dead_sids.each do |sid, reason|
      info = @sessions.delete(sid)
      cleaned << { session_id: sid, pid: info.client.pid }
      @recently_reaped[sid] = { reason: reason, pid: info.client.pid, reaped_at: now }
      info.client.disconnect
    end

    if dead_sids.key?(@default_session_id)
      @default_session_id = @sessions.keys.first
    end

    cleanup_recently_reaped(now)
  end

  cleaned
end

#clear_breakpoint_specsObject

Clear all recorded breakpoint specs.



178
179
180
# File 'lib/debug_mcp/session_manager.rb', line 178

def clear_breakpoint_specs
  @mutex.synchronize { @breakpoint_specs.clear }
end

#client(session_id = nil) ⇒ Object

Get the client for a session (also updates last_activity_at)



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
106
107
108
109
110
111
# File 'lib/debug_mcp/session_manager.rb', line 81

def client(session_id = nil)
  @mutex.synchronize do
    sid = session_id || @default_session_id
    raise SessionError, "No active debug session. Use the 'connect' tool first." unless sid

    info = @sessions[sid]
    unless info
      if (reaped = @recently_reaped[sid])
        elapsed = (Time.now - reaped[:reaped_at]).to_i
        reason_msg = case reaped[:reason]
          when :idle_timeout
            "was automatically disconnected after #{format_elapsed(@timeout)} of inactivity"
          when :process_died
            "was removed because the target process (PID #{reaped[:pid]}) exited"
          when :socket_closed
            "was removed because the debug socket connection was lost"
          else
            "was removed"
          end
        raise SessionError,
          "Session '#{sid}' #{reason_msg} (#{format_elapsed(elapsed)} ago). " \
          "Use 'connect' to start a new session."
      end
      raise SessionError, "Session '#{sid}' not found. Use 'list_paused_sessions' to see active sessions."
    end
    raise SessionError, "Session '#{sid}' is disconnected. Use 'connect' to reconnect." unless info.client.connected?

    info.last_activity_at = Time.now
    info.client
  end
end

#connect(session_id: nil, path: nil, host: nil, port: nil, remote: nil, connect_timeout: nil, pre_cleanup_pid: nil, pre_cleanup_port: nil, &on_initial_timeout) ⇒ Object

Connect to a debug session and register it. Cleans up existing sessions with the same sid or same PID to prevent socket leaks when reconnecting to the same process. Accepts an optional block that is passed through to DebugClient#connect as the on_initial_timeout callback (used to wake IO-blocked processes).

Parameters:

  • pre_cleanup_port (Integer, nil) (defaults to: nil)

    TCP port of the debug connection target. Used to disconnect existing sessions connected to the same port before establishing a new connection. Essential for TCP/Docker reconnections where the target PID is unknown until after connecting (unlike Unix sockets where the PID is encoded in the socket filename).



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/session_manager.rb', line 40

def connect(session_id: nil, path: nil, host: nil, port: nil, remote: nil,
            connect_timeout: nil, pre_cleanup_pid: nil, pre_cleanup_port: nil, &on_initial_timeout)
  # Pre-cleanup: disconnect existing sessions for the same PID/session_id/port
  # BEFORE establishing a new connection. The old session's socket occupies
  # the debug gem, so the new connect() would timeout if not cleaned up first.
  pre_cleanup(session_id: session_id, pid: pre_cleanup_pid, port: pre_cleanup_port)

  client = DebugClient.new
  result = client.connect(path: path, host: host, port: port, remote: remote,
                          connect_timeout: connect_timeout, &on_initial_timeout)

  now = Time.now
  sid = session_id || "session_#{client.pid}"

  @mutex.synchronize do
    # Clean up existing session with the same sid (socket leak prevention)
    old_info = @sessions.delete(sid)
    old_info&.client&.disconnect rescue nil

    # Clean up sessions connected to the same PID but with a different sid
    same_pid_sids = @sessions.each_with_object([]) do |(existing_sid, info), acc|
      acc << existing_sid if info.client.pid.to_s == client.pid.to_s
    end
    same_pid_sids.each do |existing_sid|
      info = @sessions.delete(existing_sid)
      info&.client&.disconnect rescue nil
    end

    @sessions[sid] = SessionInfo.new(
      client: client,
      connected_at: now,
      last_activity_at: now,
      acknowledged_warnings: Set.new,
    )
    @default_session_id = sid
  end

  result.merge(session_id: sid)
end

#disconnect(session_id = nil) ⇒ Object

Disconnect a session



114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/debug_mcp/session_manager.rb', line 114

def disconnect(session_id = nil)
  @mutex.synchronize do
    sid = session_id || @default_session_id
    return unless sid

    info = @sessions.delete(sid)
    info&.client&.disconnect

    if @default_session_id == sid
      @default_session_id = @sessions.keys.first
    end
  end
end

#disconnect_allObject

Disconnect all sessions and stop reaper Note: safe to call from trap context (does not use mutex)



130
131
132
133
134
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
# File 'lib/debug_mcp/session_manager.rb', line 130

def disconnect_all
  stop_reaper

  # Avoid mutex here so this can be called from signal trap context.
  # At shutdown, thread safety is not a concern.
  has_connect_sessions = false
  @sessions.each_value do |info|
    # Resume connect sessions (no wait_thread) so the target process
    # doesn't stay stuck at the debugger prompt after we disconnect.
    unless info.client.wait_thread
      socket = info.client.instance_variable_get(:@socket)
      next unless socket && !socket.closed?

      pid = info.client.pid
      # Restore original SIGINT handler (best-effort, raw protocol).
      restore_cmd = "p $_debug_mcp_orig_int ? (trap('INT',$_debug_mcp_orig_int);$_debug_mcp_orig_int=nil;:ok) : nil"
      socket.write("command #{pid} 500 #{restore_cmd}\n".b) rescue nil
      # Delete breakpoints #0-#9 (best-effort) then continue.
      # In signal trap context we can't use send_command, so write raw
      # protocol messages directly to the socket.
      (0..9).reverse_each do |n|
        socket.write("command #{pid} 500 delete #{n}\n".b) rescue nil
      end
      socket.write("command #{pid} 500 c\n".b) rescue nil
      socket.flush rescue nil
      has_connect_sessions = true
    end
  rescue StandardError
    # ignore
  end
  # One sleep for all sessions — give debug gems time to process continue
  sleep 0.3 if has_connect_sessions
  @sessions.each_value do |info|
    info.client.disconnect rescue nil
  end
  @sessions.clear
  @default_session_id = nil
end

#record_breakpoint(spec) ⇒ Object

Record a breakpoint spec for preservation across sessions. Spec is the debugger command string (e.g., “break file.rb:42”, “catch NoMethodError”).



171
172
173
174
175
# File 'lib/debug_mcp/session_manager.rb', line 171

def record_breakpoint(spec)
  @mutex.synchronize do
    @breakpoint_specs << spec unless @breakpoint_specs.include?(spec)
  end
end

#remove_breakpoint_specs_matching(pattern) ⇒ Object

Remove breakpoint specs that match a pattern (substring match).



183
184
185
186
187
# File 'lib/debug_mcp/session_manager.rb', line 183

def remove_breakpoint_specs_matching(pattern)
  @mutex.synchronize do
    @breakpoint_specs.reject! { |s| s.include?(pattern) }
  end
end

#restore_breakpoints(client) ⇒ Object

Restore recorded breakpoints on a client. Returns an array of results.



190
191
192
193
194
195
196
197
198
199
200
# File 'lib/debug_mcp/session_manager.rb', line 190

def restore_breakpoints(client)
  specs = @mutex.synchronize { @breakpoint_specs.dup }
  return [] if specs.empty?

  specs.filter_map do |spec|
    output = client.send_command(spec)
    { spec: spec, output: output.lines.first&.strip }
  rescue DebugMcp::Error => e
    { spec: spec, error: e.message }
  end
end