Class: DebugMcp::SessionManager
- Inherits:
-
Object
- Object
- DebugMcp::SessionManager
- 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
-
#timeout ⇒ Object
readonly
Returns the value of attribute timeout.
Instance Method Summary collapse
-
#acknowledge_warning(session_id, category) ⇒ Object
Acknowledge a warning category for a session (suppresses future warnings of this category).
-
#acknowledged_warnings(session_id = nil) ⇒ Object
Get the set of acknowledged warning categories for a session.
-
#active_sessions(include_client: false) ⇒ Object
List active sessions with timing info.
-
#cleanup_dead_sessions ⇒ Object
Clean up sessions whose target process has died or whose socket has disconnected.
-
#clear_breakpoint_specs ⇒ Object
Clear all recorded breakpoint specs.
-
#client(session_id = nil) ⇒ Object
Get the client for a session (also updates last_activity_at).
-
#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.
-
#disconnect(session_id = nil) ⇒ Object
Disconnect a session.
-
#disconnect_all ⇒ Object
Disconnect all sessions and stop reaper Note: safe to call from trap context (does not use mutex).
-
#initialize(timeout: DEFAULT_TIMEOUT) ⇒ SessionManager
constructor
A new instance of SessionManager.
-
#record_breakpoint(spec) ⇒ Object
Record a breakpoint spec for preservation across sessions.
-
#remove_breakpoint_specs_matching(pattern) ⇒ Object
Remove breakpoint specs that match a pattern (substring match).
-
#restore_breakpoints(client) ⇒ Object
Restore recorded breakpoints on a client.
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
#timeout ⇒ Object (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_sessions ⇒ Object
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_specs ⇒ Object
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).
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_all ⇒ Object
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. } end end |