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
+8 -6
View File
@@ -3,7 +3,7 @@ name: chrome_launch
kind: function
lang: go
domain: browser
version: "1.3.0"
version: "1.4.0"
purity: impure
signature: "func ChromeLaunch(opts ChromeLaunchOpts) (int, error)"
description: "Lanza Google Chrome con remote debugging habilitado en el puerto indicado. En Linux nativo busca primero chromium/google-chrome/brave; en WSL2 busca chrome.exe primero. En WSL2+chrome.exe traduce UserDataDir a ruta Windows via wslpath e inyecta --remote-debugging-address=0.0.0.0. En Linux nativo setea Setpgid=true para crear grupo de proceso propio (permite matar el arbol completo con CdpClose). Espera hasta 15s a que el puerto CDP este listo. Retorna el PID del proceso."
@@ -16,10 +16,10 @@ error_type: "error_go_core"
imports: [fmt, net, os, os/exec, regexp, strings, syscall, time]
params:
- name: opts
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag)"
output: "int: PID del proceso Chrome lanzado"
desc: "opciones de lanzamiento: Port (defecto 9222), UserDataDir (defecto /tmp/chrome-cdp-profile en Linux, C:\\Users\\<USER>\\AppData\\Local\\fn-chrome-cdp-profile en WSL2+exe), Headless, ChromePath, ExtraArgs, KeepExtensions (si true no añade --disable-extensions, util para cargar extensiones del perfil), ProfileDirectory (selecciona el perfil con --profile-directory, ej: Default / Automation; vacío = no se pasa el flag), ReuseExisting (si true y el puerto CDP ya responde, no lanza Chrome nuevo y devuelve pid 0 — anti-duplicado)"
output: "int: PID del proceso Chrome lanzado, o 0 si ReuseExisting=true y ya había un Chrome vivo en el puerto"
tested: true
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect"]
tests: ["TestIsWSL2", "TestTranslateUserDataDirForWindows", "TestIsWindowsExe", "TestFindChrome", "TestChromeLaunchAndConnect", "TestCdpPortResponds", "TestChromeLaunchReuseExisting"]
test_file_path: "functions/browser/chrome_launch_test.go"
file_path: "functions/browser/chrome_launch.go"
---
@@ -71,8 +71,9 @@ Cuando necesites lanzar Chrome con CDP desde Go para automatizacion (scraping, t
- **KeepExtensions**: por defecto se añade `--disable-extensions`. Pasar `KeepExtensions: true` para omitir ese flag y mantener extensiones del perfil (útil con perfiles reales de usuario).
- **`wslpath` debe estar disponible** (WSL2 desde Windows 10 1903+): se invoca como subproceso en modo WSL2+exe. Si falla, `ChromeLaunch` retorna error.
- **ProfileDirectory obligatorio con múltiples perfiles**: sin `--profile-directory`, si el `user-data-dir` contiene varios perfiles (Default, Personal, Profile 1, Automation…) Chrome se queda atascado en el selector de perfil y no carga nada — el puerto CDP responde pero no hay perfil activo y las extensiones no se procesan. Pasar `ProfileDirectory: "Default"` (o el nombre exacto del subdirectorio) para evitarlo.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos.
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión.
- **Chrome no cierra solo**: el PID devuelto es el proceso Chrome. Usar `CdpClose(nil, pid)` para terminar el arbol de procesos. Quien lance debe guardar el pid; sin él, `CdpClose(c, 0)` solo cierra el WebSocket y deja Chrome huérfano (~789 MiB RSS cada uno). Acumular lanzamientos sin matar = leak de RAM.
- **Puerto ocupado**: si el puerto ya está en uso por otra instancia de Chrome, `waitCDPReady` puede conectar al proceso previo. Usar puertos distintos por sesión, o pasar `ReuseExisting: true` para que la función NO lance un duplicado y devuelva pid 0 (el caller se adjunta al Chrome existente).
- **ReuseExisting + pid 0**: con `ReuseExisting: true` un retorno `(0, nil)` significa "ya había un Chrome vivo en el puerto, no lancé otro". El caller NO debe registrar ni intentar matar ese pid 0; el proceso no es suyo (puede ser el navegador diario del usuario).
## Notas
@@ -95,3 +96,4 @@ El struct `ChromeLaunchOpts` se define en el mismo archivo.
- v1.1.0 (2026-05-16) — auto-handle WSL2→Windows chrome.exe: translate user-data-dir via wslpath + inject --remote-debugging-address=0.0.0.0
- v1.2.0 (2026-06-05) — Linux-first: reordena busqueda (chromium antes que chrome.exe) en Linux nativo; añade KeepExtensions; setea Setpgid=true en Linux para habilitar kill-by-group en CdpClose
- v1.3.0 (2026-06-05) — añade ProfileDirectory / --profile-directory para seleccionar perfil dentro del user-data-dir (evita quedarse atascado en el selector cuando hay varios perfiles)
- v1.4.0 (2026-06-06) — añade ReuseExisting: guarda anti-duplicado que devuelve (0, nil) sin lanzar cuando el puerto CDP ya responde. Extrae helper dialCDP/cdpPortResponds (sondeo TCP reutilizado por waitCDPReady). Cierra el leak de chromium huérfanos del browser_mcp (lanzamientos repetidos al mismo puerto)
+44
View File
@@ -1,6 +1,7 @@
package browser
import (
"net"
"os"
"regexp"
"strings"
@@ -288,3 +289,46 @@ func TestCdpScreenshot(t *testing.T) {
t.Logf("Screenshot creado: %s (%d bytes)", outputPath, info.Size())
})
}
// TestCdpPortResponds verifica el sondeo TCP del puerto CDP sin Chrome real:
// un net.Listener local hace de "puerto ocupado" y, al cerrarlo, el puerto
// deja de responder.
func TestCdpPortResponds(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if !cdpPortResponds(port) {
t.Errorf("cdpPortResponds(%d) = false con listener vivo, want true", port)
}
if err := ln.Close(); err != nil {
t.Fatalf("close listener: %v", err)
}
if cdpPortResponds(port) {
t.Errorf("cdpPortResponds(%d) = true tras cerrar el listener, want false", port)
}
}
// TestChromeLaunchReuseExisting verifica que con ReuseExisting=true y un puerto
// ya ocupado, ChromeLaunch NO lanza Chrome y devuelve (0, nil). No requiere
// Chrome real: el listener simula un endpoint CDP vivo. Esto es la guarda
// anti-duplicado que evita el leak de procesos chromium huerfanos.
func TestChromeLaunchReuseExisting(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
pid, err := ChromeLaunch(ChromeLaunchOpts{Port: port, ReuseExisting: true})
if err != nil {
t.Fatalf("ChromeLaunch(ReuseExisting): %v", err)
}
if pid != 0 {
t.Errorf("pid = %d, want 0 (debe reusar el existente sin lanzar Chrome)", pid)
}
}