feat(browser): chrome_launch ReuseExisting — guarda anti-duplicado de Chrome

Añade el campo ReuseExisting a ChromeLaunchOpts. Con ReuseExisting=true, si el
puerto CDP ya responde a una conexión TCP, ChromeLaunch NO lanza un Chrome nuevo
y devuelve (0, nil) para que el caller se adjunte al existente. Evita acumular
procesos chromium duplicados en el mismo puerto (cada uno ~789 MiB RSS), causa
del leak de RAM del browser_mcp.

Extrae el sondeo de puerto a dialCDP/cdpPortResponds (net.Dial con timeout), que
waitCDPReady ahora reutiliza en su bucle. Tests sin Chrome real (TestCdpPortResponds,
TestChromeLaunchReuseExisting) usando un net.Listener local como puerto ocupado.
Bump a 1.4.0 + growth log + gotchas en el .md (pid 0 = no es nuestro, no matar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 029dbf57bd
commit 37aacfcfa9
3 changed files with 92 additions and 11 deletions
+40 -5
View File
@@ -33,6 +33,12 @@ type ChromeLaunchOpts struct {
// Vacío = no se pasa el flag (Chrome usa su default o muestra el selector si hay varios perfiles).
// Ej: "Default", "Automation".
ProfileDirectory string
// ReuseExisting, si es true y el puerto CDP ya responde a una conexion TCP,
// NO lanza un Chrome nuevo: devuelve (0, nil) para que el caller reutilice el
// navegador que ya está vivo en ese puerto. Evita acumular procesos chromium
// duplicados (cada uno ~789 MiB RSS) cuando se llama repetidamente al mismo
// puerto. El caller distingue el reuso por pid == 0.
ReuseExisting bool
}
// reWindowsPath coincide con rutas absolutas de Windows (C:\... D:\... etc.).
@@ -137,6 +143,30 @@ func findChrome() (string, error) {
return "", fmt.Errorf("chrome: ejecutable no encontrado en PATH ni en rutas conocidas")
}
// dialCDP intenta una conexion TCP unica al puerto CDP. Devuelve true si el
// puerto acepta la conexion (hay algo escuchando), false en caso contrario.
// host vacio usa "127.0.0.1".
func dialCDP(host string, port int, timeout time.Duration) bool {
if host == "" {
host = "127.0.0.1"
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
conn.Close()
return true
}
// cdpPortResponds indica si ya hay un proceso escuchando el puerto CDP en
// 127.0.0.1. Es un sondeo TCP unico con timeout corto, usado por ChromeLaunch
// (opts.ReuseExisting) para no relanzar un Chrome duplicado cuando el puerto ya
// tiene uno vivo.
func cdpPortResponds(port int) bool {
return dialCDP("127.0.0.1", port, 300*time.Millisecond)
}
// waitCDPReady espera hasta que el puerto CDP responda conexiones TCP.
// host puede estar vacio (usa "127.0.0.1").
func waitCDPReady(host string, port int, timeout time.Duration) error {
@@ -144,16 +174,14 @@ func waitCDPReady(host string, port int, timeout time.Duration) error {
host = "127.0.0.1"
}
deadline := time.Now().Add(timeout)
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
conn.Close()
if dialCDP(host, port, 200*time.Millisecond) {
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s", addr, timeout)
return fmt.Errorf("chrome: puerto CDP %s no disponible despues de %s",
net.JoinHostPort(host, fmt.Sprintf("%d", port)), timeout)
}
// ChromeLaunch lanza Google Chrome con remote debugging habilitado en el puerto indicado.
@@ -170,6 +198,13 @@ func ChromeLaunch(opts ChromeLaunchOpts) (int, error) {
opts.Port = 9222
}
// Anti-duplicado: si el caller pide reusar y ya hay un Chrome escuchando el
// puerto CDP, no lanzamos otro. Devolvemos pid 0 para que el caller sepa que
// debe adjuntarse al existente en vez de registrar un proceso nuevo.
if opts.ReuseExisting && cdpPortResponds(opts.Port) {
return 0, nil
}
chromePath := opts.ChromePath
if chromePath == "" {
var err error