254f089982
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>
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// resultText concatena el texto de un CallToolResult para asserts.
|
|
func resultText(r *mcp.CallToolResult) string {
|
|
var sb strings.Builder
|
|
for _, c := range r.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
sb.WriteString(tc.Text)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// TestPoolPIDLifecycle verifica set/get/clear/count del registro de PIDs sin
|
|
// tocar Chrome real.
|
|
func TestPoolPIDLifecycle(t *testing.T) {
|
|
p := newConnPool()
|
|
if n := p.launchedCount(); n != 0 {
|
|
t.Fatalf("launchedCount inicial = %d, want 0", n)
|
|
}
|
|
p.setPID(9333, 4242)
|
|
if pid, ok := p.getPID(9333); !ok || pid != 4242 {
|
|
t.Fatalf("getPID(9333) = (%d,%v), want (4242,true)", pid, ok)
|
|
}
|
|
if n := p.launchedCount(); n != 1 {
|
|
t.Fatalf("launchedCount tras setPID = %d, want 1", n)
|
|
}
|
|
p.clearPID(9333)
|
|
if _, ok := p.getPID(9333); ok {
|
|
t.Fatalf("getPID(9333) sigue presente tras clearPID")
|
|
}
|
|
if n := p.launchedCount(); n != 0 {
|
|
t.Fatalf("launchedCount tras clearPID = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestInstanceCapRejectsWithoutLaunching verifica el tope duro: con
|
|
// maxLaunchedChromes PIDs ya registrados, browser_launch en un puerto nuevo
|
|
// devuelve error de tool y NO intenta lanzar Chrome (el cap se evalúa antes de
|
|
// ChromeLaunch, por eso este test no necesita Chrome real). Cubre el edge
|
|
// "superar el tope → error claro".
|
|
func TestInstanceCapRejectsWithoutLaunching(t *testing.T) {
|
|
p := newConnPool()
|
|
for i := 0; i < maxLaunchedChromes; i++ {
|
|
p.setPID(9500+i, 100000+i) // PIDs ficticios: nunca se matan en este test
|
|
}
|
|
d := &deps{pool: p}
|
|
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9600})
|
|
if err != nil {
|
|
t.Fatalf("handleLaunch err = %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatalf("esperaba IsError=true por cap, got text=%q", resultText(res))
|
|
}
|
|
if txt := resultText(res); !strings.Contains(txt, "cap") {
|
|
t.Fatalf("mensaje no menciona el cap: %q", txt)
|
|
}
|
|
// El puerto nuevo no debe haberse registrado.
|
|
if _, ok := p.getPID(9600); ok {
|
|
t.Fatalf("el puerto rechazado por cap no debe registrarse")
|
|
}
|
|
}
|
|
|
|
// TestLaunchReusesRegisteredPort verifica idempotencia: si el MCP ya lanzó un
|
|
// Chrome en el puerto (PID registrado), un segundo browser_launch lo reusa sin
|
|
// lanzar otro proceso. No necesita Chrome real (el reuse corta antes de
|
|
// ChromeLaunch). Cubre el edge "dos browser_launch al mismo puerto no duplica".
|
|
func TestLaunchReusesRegisteredPort(t *testing.T) {
|
|
p := newConnPool()
|
|
p.setPID(9333, 777777)
|
|
d := &deps{pool: p}
|
|
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9333})
|
|
if err != nil {
|
|
t.Fatalf("handleLaunch err = %v", err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("no esperaba error, got %q", resultText(res))
|
|
}
|
|
if txt := resultText(res); !strings.Contains(txt, "reused pid=777777") {
|
|
t.Fatalf("esperaba reuse del pid registrado, got %q", txt)
|
|
}
|
|
if n := p.launchedCount(); n != 1 {
|
|
t.Fatalf("launchedCount = %d, want 1 (no debe duplicar)", n)
|
|
}
|
|
}
|
|
|
|
// TestDropClearsMapsNoPID verifica que drop sobre un puerto sin conn ni pid no
|
|
// panica y deja los mapas limpios (no mata nada — caso del navegador externo
|
|
// del que solo se soltó el WebSocket).
|
|
func TestDropClearsMapsNoPID(t *testing.T) {
|
|
p := newConnPool()
|
|
p.drop(9222) // puerto externo, sin conn ni pid registrado: no-op seguro
|
|
if n := p.launchedCount(); n != 0 {
|
|
t.Fatalf("launchedCount = %d, want 0", n)
|
|
}
|
|
}
|