From 37aacfcfa9c234a50c7d417a8382942c0a32f01b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 6 Jun 2026 17:06:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(browser):=20chrome=5Flaunch=20ReuseExistin?= =?UTF-8?q?g=20=E2=80=94=20guarda=20anti-duplicado=20de=20Chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- functions/browser/chrome_launch.go | 45 ++++++++++++++++++++++--- functions/browser/chrome_launch.md | 14 ++++---- functions/browser/chrome_launch_test.go | 44 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/functions/browser/chrome_launch.go b/functions/browser/chrome_launch.go index b62ad610..aaf98a49 100644 --- a/functions/browser/chrome_launch.go +++ b/functions/browser/chrome_launch.go @@ -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 diff --git a/functions/browser/chrome_launch.md b/functions/browser/chrome_launch.md index 16bc7fb6..0218106a 100644 --- a/functions/browser/chrome_launch.md +++ b/functions/browser/chrome_launch.md @@ -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\\\\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\\\\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) diff --git a/functions/browser/chrome_launch_test.go b/functions/browser/chrome_launch_test.go index 8ff0ce29..7e863ccf 100644 --- a/functions/browser/chrome_launch_test.go +++ b/functions/browser/chrome_launch_test.go @@ -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) + } +}