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//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)") }