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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user