254f089982
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>
216 lines
6.8 KiB
Go
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)")
|
|
}
|