Files
browser_mcp/pool_e2e_test.go
T
egutierrez 254f089982 fix: matar los chromium que el MCP lanza para cerrar el leak de RAM
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>
2026-06-06 17:06:14 +02:00

216 lines
6.8 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
// Estos tests lanzan y matan Chrome REAL. Gate BMCP_E2E=1 y deben correr
// AISLADOS en un servicio transitorio systemd-run --user: matar chromium desde
// el árbol de procesos del Bash tool dispara exit-144. Ver
// .claude/rules y la memoria harness-exit-144-chromium.
func requireE2E(t *testing.T) {
t.Helper()
if os.Getenv("BMCP_E2E") != "1" {
t.Skip("skip: requiere BMCP_E2E=1 + Chrome real, correr bajo systemd-run --user")
}
}
// chromePIDsByUDD cuenta los procesos chromium (browser + zygotes + renderers)
// que comparten un user-data-dir concreto, leyendo /proc/<pid>/cmdline. Usar el
// UDD como aguja cuenta el ÁRBOL completo (los hijos heredan --user-data-dir),
// y aísla el conteo del navegador diario en 9222 (UDD distinto).
func chromePIDsByUDD(udd string) []int {
var pids []int
needle := "--user-data-dir=" + udd
matches, _ := filepath.Glob("/proc/[0-9]*/cmdline")
for _, m := range matches {
b, err := os.ReadFile(m)
if err != nil {
continue
}
cmd := strings.ReplaceAll(string(b), "\x00", " ")
if strings.Contains(cmd, needle) {
parts := strings.Split(m, "/")
if len(parts) >= 3 {
if pid, err := strconv.Atoi(parts[2]); err == nil {
pids = append(pids, pid)
}
}
}
}
return pids
}
// rssKB suma el VmRSS (KiB) de un conjunto de PIDs.
func rssKB(pids []int) int64 {
var total int64
for _, pid := range pids {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
f := strings.Fields(line)
if len(f) >= 2 {
if v, err := strconv.ParseInt(f[1], 10, 64); err == nil {
total += v
}
}
}
}
}
return total
}
// TestE2EPoolKillsLaunchedChromes — GOLDEN PATH del fix del leak.
// Lanza 3 Chrome headless en puertos aislados, los registra en el pool, mide su
// RSS, llama closeAll() (lo que hace el shutdown del MCP) y verifica CERO
// huérfanos. Reporta el RSS liberado.
func TestE2EPoolKillsLaunchedChromes(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_golden")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
ports := []int{9401, 9402, 9403}
udds := map[int]string{}
pool := newConnPool()
defer pool.closeAll() // red de seguridad si el test aborta a mitad
for _, p := range ports {
udd := filepath.Join(base, strconv.Itoa(p))
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", udd, err)
}
udds[p] = udd
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
Port: p,
Headless: true,
UserDataDir: udd,
ReuseExisting: true,
})
if err != nil {
t.Fatalf("ChromeLaunch port=%d: %v", p, err)
}
if pid == 0 {
t.Fatalf("port=%d ya estaba ocupado (ReuseExisting devolvió 0); usa otro puerto", p)
}
pool.setPID(p, pid)
t.Logf("lanzado Chrome pid=%d port=%d", pid, p)
}
// Verificar que los 3 árboles están vivos + medir RSS.
var alive int
var rssBefore int64
for _, p := range ports {
pids := chromePIDsByUDD(udds[p])
alive += len(pids)
rssBefore += rssKB(pids)
}
if alive < len(ports) {
t.Fatalf("esperaba >=%d procesos chrome vivos, vivos=%d", len(ports), alive)
}
t.Logf("ANTES: %d procesos chrome vivos, RSS total ~%d MiB", alive, rssBefore/1024)
// El kill: closeAll mata cada grupo de proceso registrado.
pool.closeAll()
time.Sleep(2 * time.Second) // dar tiempo al SIGKILL del grupo
var after int
for _, p := range ports {
after += len(chromePIDsByUDD(udds[p]))
}
if after != 0 {
t.Fatalf("LEAK: %d procesos chrome siguen vivos tras closeAll (esperaba 0)", after)
}
t.Logf("DESPUES: 0 huérfanos. RSS liberado ~%d MiB (%d → 0)", rssBefore/1024, rssBefore/1024)
}
// TestE2EDedupSamePort — EDGE: dos ChromeLaunch(ReuseExisting) al mismo puerto
// no duplican el proceso; el segundo devuelve pid 0.
func TestE2EDedupSamePort(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_dedup")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
udd := filepath.Join(base, "9404")
if err := os.MkdirAll(udd, 0o755); err != nil {
t.Fatal(err)
}
pid1, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("primer launch: %v", err)
}
if pid1 == 0 {
t.Fatal("primer launch devolvió 0 (puerto ya ocupado)")
}
defer browser.CdpClose(nil, pid1) // cleanup: mata el grupo
pid2, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9404, Headless: true, UserDataDir: udd, ReuseExisting: true})
if err != nil {
t.Fatalf("segundo launch: %v", err)
}
if pid2 != 0 {
// matar el duplicado antes de fallar para no dejar huérfanos
_ = browser.CdpClose(nil, pid2)
t.Fatalf("segundo launch lanzó un DUPLICADO pid=%d (esperaba 0 = reuso)", pid2)
}
if n := len(chromePIDsByUDD(udd)); n == 0 {
t.Fatalf("el primer Chrome debería seguir vivo")
}
t.Logf("dedup OK: pid1=%d vivo, segundo launch reusó (pid 0)", pid1)
}
// TestE2EDropKillsOwnNotExternal — EDGE + SEGURIDAD: drop mata el Chrome que el
// MCP lanzó (pid registrado), pero NO mata un Chrome que el MCP no lanzó (pid no
// registrado en el pool) — la salvaguarda que protege el navegador diario.
func TestE2EDropKillsOwnNotExternal(t *testing.T) {
requireE2E(t)
base := filepath.Join(os.TempDir(), "bmcp_e2e_drop")
_ = os.RemoveAll(base)
defer os.RemoveAll(base)
// (a) Chrome PROPIO en 9405: registrado → drop debe matarlo.
uddOwn := filepath.Join(base, "9405")
_ = os.MkdirAll(uddOwn, 0o755)
ownPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9405, Headless: true, UserDataDir: uddOwn, ReuseExisting: true})
if err != nil || ownPID == 0 {
t.Fatalf("launch propio 9405: pid=%d err=%v", ownPID, err)
}
pool := newConnPool()
pool.setPID(9405, ownPID)
// (b) Chrome EXTERNO en 9406: NO registrado en el pool → drop NO debe matarlo.
uddExt := filepath.Join(base, "9406")
_ = os.MkdirAll(uddExt, 0o755)
extPID, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{Port: 9406, Headless: true, UserDataDir: uddExt, ReuseExisting: true})
if err != nil || extPID == 0 {
t.Fatalf("launch externo 9406: pid=%d err=%v", extPID, err)
}
defer browser.CdpClose(nil, extPID) // lo mata el test, no el pool
// drop sobre ambos puertos.
pool.drop(9405) // pid registrado → mata
pool.drop(9406) // pid NO registrado → solo cierra WS, NO mata
time.Sleep(2 * time.Second)
if n := len(chromePIDsByUDD(uddOwn)); n != 0 {
t.Fatalf("drop NO mató el Chrome propio 9405: %d vivos", n)
}
if n := len(chromePIDsByUDD(uddExt)); n == 0 {
t.Fatalf("drop MATÓ un Chrome externo 9406 (debía respetarlo)")
}
t.Logf("OK: propio 9405 muerto, externo 9406 respetado (salvaguarda navegador diario)")
}