fix: matar los chromium que el MCP lanza para cerrar el leak de RAM
El pool nunca guardaba el PID del Chrome lanzado por browser_launch, así que closeAll() y drop() cerraban con CdpClose(c, 0): solo soltaban el WebSocket y dejaban el proceso chromium vivo y huérfano (~789 MiB RSS cada uno). Llamadas repetidas a browser_launch acumulaban instancias sin límite hasta saturar la RAM (apagón del 06/06/2026, ~35 chromium huérfanos). Cambios: - pool.go: el pool registra el PID lanzado por puerto (mapa `pids`) con setPID/getPID/clearPID/launchedCount. drop() y closeAll() matan el grupo de proceso completo (CdpClose con pid real) SOLO si el PID está registrado, es decir, si lo lanzó el MCP. Un Chrome externo sin PID registrado (el navegador diario del usuario en 9222) nunca se mata: pid=0 solo cierra el WebSocket. Nuevo releaseConn() suelta únicamente el WebSocket preservando el PID, para la reconexión interna (no debe matar el navegador). - tools_session.go: handleLaunch registra el PID devuelto por ChromeLaunch (setPID); es idempotente por puerto (reusa el Chrome ya lanzado), pasa ReuseExisting=true para no duplicar un Chrome ya vivo en el puerto, y aplica un tope duro de 4 instancias (maxLaunchedChromes) devolviendo un error de tool al superarlo. browser_disconnect ahora mata el Chrome propio. - main.go: handler SIGTERM/SIGINT que llama closeAll antes de salir (los defers no corren al recibir señal). El retry de withConn usa releaseConn en vez de drop para no matar el Chrome al reconectar. - pool_test.go: tests lógicos sin Chrome (cap, idempotencia, ciclo de PID, drop). - pool_e2e_test.go: tests con Chrome real (gate BMCP_E2E=1) — golden (3 launch → closeAll → 0 huérfanos), dedup mismo puerto, y salvaguarda propio-vs-externo. - app.md: e2e_checks (build, unit, leak_no_orphans) + growth log + bump a 0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+37
-3
@@ -21,6 +21,12 @@ func registerSessionTools(s *server.MCPServer, d *deps) {
|
||||
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
||||
}
|
||||
|
||||
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
||||
// vivas a la vez (una por puerto). Cada chromium ocioso pesa ~789 MiB RSS; sin
|
||||
// tope, llamadas repetidas a browser_launch saturan la RAM (apagón 06/06/2026).
|
||||
// Al superarlo, browser_launch devuelve un error de tool en vez de lanzar más.
|
||||
const maxLaunchedChromes = 4
|
||||
|
||||
// ---- browser_launch (MUTA) ----
|
||||
|
||||
type launchArgs struct {
|
||||
@@ -41,6 +47,22 @@ func launchTool() mcp.Tool {
|
||||
}
|
||||
|
||||
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
|
||||
port := portOr(a.Port)
|
||||
|
||||
// (1) Idempotente: si el MCP ya lanzó un Chrome en este puerto, reusarlo en
|
||||
// vez de duplicar el proceso. (Si el proceso hubiera muerto, withConn/connect
|
||||
// fallará y el usuario puede browser_disconnect + relanzar.)
|
||||
if pid, ok := d.pool.getPID(port); ok && pid > 0 {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("reused pid=%d port=%d (already launched by this MCP)", pid, port)), nil
|
||||
}
|
||||
|
||||
// (2) Tope duro de instancias propias. Cada chromium ocioso ~789 MiB RSS.
|
||||
if d.pool.launchedCount() >= maxLaunchedChromes {
|
||||
return mcp.NewToolResultError(fmt.Sprintf(
|
||||
"instance cap reached: the MCP already launched %d Chrome instances (max %d); browser_disconnect one before launching another",
|
||||
d.pool.launchedCount(), maxLaunchedChromes)), nil
|
||||
}
|
||||
|
||||
// SECURITY (P0.3): default to an isolated user-data-dir so the MCP never
|
||||
// reuses the user's daily browser profile. Created on demand.
|
||||
userDataDir := a.UserDataDir
|
||||
@@ -49,9 +71,13 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
|
||||
_ = os.MkdirAll(userDataDir, 0o755)
|
||||
}
|
||||
opts := browser.ChromeLaunchOpts{
|
||||
Port: portOr(a.Port),
|
||||
Port: port,
|
||||
Headless: a.Headless,
|
||||
UserDataDir: userDataDir,
|
||||
// (3) Anti-duplicado: si ya hay un Chrome vivo en el puerto (incluido el
|
||||
// navegador diario externo en 9222), ChromeLaunch NO lanza otro y devuelve
|
||||
// pid 0 — nos adjuntamos al existente sin registrarlo como nuestro.
|
||||
ReuseExisting: true,
|
||||
}
|
||||
if a.URL != "" {
|
||||
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
|
||||
@@ -60,7 +86,15 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), nil
|
||||
if pid == 0 {
|
||||
// Había un Chrome externo en el puerto: lo reusamos pero NO lo registramos
|
||||
// (no es nuestro → browser_disconnect no debe matarlo).
|
||||
return mcp.NewToolResultText(fmt.Sprintf("reused existing chrome on port=%d (external, not killed by the MCP)", port)), nil
|
||||
}
|
||||
// (4) Registrar el PID: a partir de aquí el MCP puede matar este Chrome en
|
||||
// browser_disconnect / shutdown. Esto es lo que cierra el leak de RAM.
|
||||
d.pool.setPID(port, pid)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, port, userDataDir)), nil
|
||||
}
|
||||
|
||||
// ---- browser_connect ----
|
||||
@@ -92,7 +126,7 @@ type disconnectArgs struct {
|
||||
|
||||
func disconnectTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_disconnect",
|
||||
mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."),
|
||||
mcp.WithDescription("Close the pooled CDP connection for the given port (cancels any armed dialog handler). If the MCP LAUNCHED the Chrome on that port (via browser_launch), it also KILLS that Chrome process group, freeing its RAM. A Chrome the MCP did not launch (e.g. the user's daily browser on 9222) is never killed — only the WebSocket is closed."),
|
||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user