Class: DebugMcp::Tools::Connect

Inherits:
MCP::Tool
  • Object
show all
Defined in:
lib/debug_mcp/tools/connect.rb

Constant Summary collapse

FORCE_RESET_CONNECT_TIMEOUT =
30

Class Method Summary collapse

Class Method Details

.call(path: nil, host: nil, port: nil, session_id: nil, remote: nil, restore_breakpoints: nil, auto_escape: nil, force_reset: nil, server_context:) ⇒ Object



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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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
253
# File 'lib/debug_mcp/tools/connect.rb', line 77

def call(path: nil, host: nil, port: nil, session_id: nil, remote: nil,
         restore_breakpoints: nil, auto_escape: nil, force_reset: nil, server_context:)
  manager = server_context[:session_manager]

  # Force reset: clean up existing sessions aggressively before reconnecting
  if force_reset
    begin
      existing_client = manager.client(session_id)
      # Try auto_repause! (includes HTTP wake for remote) before resuming
      unless existing_client.paused
        begin
          existing_client.auto_repause!
        rescue DebugMcp::Error
          # auto_repause failed — try HTTP wake + check_paused as last resort
          # (auto_repause already sent the pause message — avoid sending more)
          if existing_client.remote && existing_client.listen_ports&.any?
            begin
              existing_client.wake_io_blocked_process(existing_client.listen_ports.first)
              sleep DebugClient::HTTP_WAKE_SETTLE_TIME
              existing_client.check_paused(timeout: 5)
            rescue DebugMcp::Error
              # Best-effort
            end
          end
        end
      end
      existing_client.send_command_no_wait("c", force: true) rescue nil
      manager.disconnect(session_id)
      sleep 1
    rescue DebugMcp::Error
      # No existing session or already disconnected
    end
  end

  # Clear saved breakpoints unless explicitly restoring
  manager.clear_breakpoint_specs unless restore_breakpoints

  # Detect target PID and listen ports BEFORE connecting.
  # When the process is IO-blocked (e.g., Puma in IO.select), the debug
  # gem's SIGURG sets a pending pause flag but can't interrupt IO.select
  # (SA_RESTART). An HTTP request wakes IO.select, causing the trace point
  # to fire and the pending pause to execute.
  pre_target_pid = resolve_target_pid(path, port)
  pre_listen_ports = pre_target_pid ? detect_listen_ports(pre_target_pid) : []

  woke = false
  connect_timeout = if force_reset
    FORCE_RESET_CONNECT_TIMEOUT
  elsif pre_listen_ports.any?
    5
  end

  result = manager.connect(
    session_id: session_id,
    path: path,
    host: host,
    port: port,
    remote: remote,
    connect_timeout: connect_timeout,
    pre_cleanup_pid: pre_target_pid,
    pre_cleanup_port: port,
  ) {
    if pre_listen_ports.any?
      woke = true
      wake_process_via_http(pre_listen_ports)
    end
  }

  client = manager.client(result[:session_id])

  # Health check for force_reset: verify the session is responsive
  if force_reset
    begin
      health = client.send_command("p :debug_mcp_health_check", timeout: 5)
      unless health.include?("debug_mcp_health_check")
        # Process is stuck — try to resume and let the caller reconnect
        client.send_command_no_wait("c", force: true) rescue nil
        manager.disconnect(result[:session_id])
        raise ConnectionError, "Health check failed after force_reset. " \
          "The process may need to be restarted."
      end
    rescue DebugMcp::TimeoutError
      client.send_command_no_wait("c", force: true) rescue nil
      manager.disconnect(result[:session_id])
      raise ConnectionError, "Health check timed out after force_reset. " \
        "The process may need to be restarted."
    end
  end

  text = "Connected to debug session.\n" \
         "  Session ID: #{result[:session_id]}\n" \
         "  PID: #{result[:pid]}\n"

  if woke
    text += "  Status: Woke IO-blocked process via HTTP (port #{pre_listen_ports.first})\n"
  end

  # Detect listen ports (useful for trigger_request URL)
  listen_ports = detect_listen_ports(result[:pid])
  # Docker/TCP: /proc-based detection fails due to PID namespace mismatch.
  # Fall back to Docker inspect to find web server port mappings.
  if listen_ports.empty? && client.remote && port
    listen_ports = TcpSessionDiscovery.container_web_ports(port)
  end
  if listen_ports.any?
    port_list = listen_ports.map { |p| "http://127.0.0.1:#{p}" }.join(", ")
    text += "  Listening on: #{port_list}\n"
  end

  # Check if this is a Rails process (needed for auto-escape and summary)
  is_rails = RailsHelper.rails?(client)

  # Compute route summary before escape (trap-safe, needed for auto-escape target)
  route_info = is_rails ? RailsHelper.route_summary(client, limit: 5) : nil

  # Detect and escape signal trap context (common with Puma/SIGURG).
  # In trap context, Mutex/thread operations fail with ThreadError.
  auto_escape_enabled = auto_escape != false
  text += escape_trap_context(client,
    listen_ports: listen_ports,
    route_info: route_info,
    auto_escape: auto_escape_enabled)

  # Cache escape info for auto_repause! (re-escape after continue_execution)
  client.listen_ports = listen_ports
  if is_rails && listen_ports.any?
    url_path = extract_get_path(route_info)
    client.escape_target = find_target_from_framework(client, url_path)
  end

  # Install double Ctrl+C force-quit handler on the target process
  install_sigint_handler(client)

  # Clear existing breakpoints from previous sessions (unless restoring)
  unless restore_breakpoints
    clear_process_breakpoints(client)
  end

  escaped = text.include?("Auto-escaped signal trap context")

  text += "\nIMPORTANT: The target process is now PAUSED. " \
          "Use 'continue_execution' to resume it when done investigating, " \
          "or 'disconnect' to detach (which also resumes the process).\n" \
          "Note: stdout/stderr are not captured for 'connect' sessions " \
          "(use 'run_script' for capture).\n\n" \
          "Initial state:\n#{result[:output]}"

  if is_rails
    text += build_rails_summary(client, result[:output], listen_ports, route_info,
      escaped: escaped)
  end

  # Restore breakpoints from previous sessions
  restored = manager.restore_breakpoints(client)
  if restored.any?
    text += "\n\nRestored #{restored.size} breakpoint(s) from previous session:"
    restored.each do |r|
      text += if r[:error]
        "\n  #{r[:spec]} -> Error: #{r[:error]}"
      else
        "\n  #{r[:spec]} -> #{r[:output]}"
      end
    end
  end

  MCP::Tool::Response.new([{ type: "text", text: text }])
rescue DebugMcp::Error => e
  error_text = "Error: #{e.message}"
  unless force_reset
    if e.message.include?("timed out") || e.message.include?("stuck")
      error_text += "\n\nTip: Try 'connect' with force_reset: true to force cleanup of any stuck session."
    end
  else
    error_text += "\n\nThe process may need to be restarted (kill and re-launch with 'rdbg --open')."
  end
  MCP::Tool::Response.new([{ type: "text", text: error_text }])
end