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>
This commit is contained in:
@@ -2,9 +2,20 @@
|
||||
name: browser_mcp
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
description: "Servidor MCP que expone control total del navegador via CDP (40 tools: navegación, DOM, cookies, iframes, teclado/scroll, diálogos, estado de sesión, selección determinista de pestaña, lectura compacta texto/AX + bucle percibir→actuar por #ref con auto-observe, incluyendo find-ref-by-text) reusando funciones del dominio browser del registry con un pool de conexiones CDP vivas. Por defecto opera sobre un Chrome aislado (puerto 9333) separado del navegador diario."
|
||||
tags: [mcp, browser, cdp, automation, scraping]
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "cd projects/web_scraping/apps/browser_mcp && go build -o browser_mcp ."
|
||||
timeout_s: 120
|
||||
- id: unit
|
||||
cmd: "cd projects/web_scraping/apps/browser_mcp && go test -count=1 ./..."
|
||||
timeout_s: 120
|
||||
- id: leak_no_orphans
|
||||
cmd: "cd projects/web_scraping/apps/browser_mcp && go test -c -o /tmp/bmcp_e2e.test . && systemd-run --user --quiet --collect --unit=bmcp_e2e_ci --wait -p Type=oneshot --setenv=BMCP_E2E=1 -p StandardOutput=journal /tmp/bmcp_e2e.test -test.run TestE2E -test.v"
|
||||
timeout_s: 180
|
||||
severity: warning
|
||||
uses_functions:
|
||||
- chrome_launch_go_browser
|
||||
- cdp_connect_go_browser
|
||||
@@ -230,6 +241,15 @@ Funciones del dominio `browser` que NO se exponen como tools en esta versión, c
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.5.0 (2026-06-06) — Fix del leak de RAM (chromium huérfanos, apagón 06/06/2026). El pool
|
||||
ahora registra el PID del Chrome que lanzó por puerto (`pids` map + setPID/getPID/clearPID/
|
||||
launchedCount). `browser_disconnect` (drop) y el shutdown (closeAll) matan el grupo de proceso
|
||||
completo SOLO si el PID está registrado (lo lanzó el MCP) — un Chrome externo (navegador diario
|
||||
en 9222) nunca se mata, solo se cierra el WebSocket. `browser_launch` es idempotente por puerto,
|
||||
reusa un Chrome ya vivo (`ChromeLaunch.ReuseExisting`, pid 0 = no relanza) y aplica un tope duro
|
||||
de 4 instancias. Handler SIGTERM/SIGINT en main.go llama closeAll (los defers no corren con
|
||||
señal). `withConn` retry usa `releaseConn` (suelta solo el WS) en vez de drop. Tests: pool_test.go
|
||||
(lógicos) + pool_e2e_test.go (Chrome real, gate BMCP_E2E=1). e2e_checks añadidos.
|
||||
- v0.3.0 (2026-06-06) — Cierre del bucle percibir→actuar. Nuevas tools `dom_click_ref`,
|
||||
`dom_type_ref`, `dom_hover_ref`: actúan sobre el `#ref` (backendDOMNodeId estable) del
|
||||
outline de `page_perceive` con humanización por defecto (Bézier+jitter) y auto-observe
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
@@ -42,8 +44,22 @@ func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})))
|
||||
|
||||
pool := newConnPool()
|
||||
// Cierre por EOF de stdio (ServeStdio retorna) o salida normal de serveHTTP.
|
||||
defer pool.closeAll()
|
||||
|
||||
// Cierre por señal: SIGTERM/SIGINT NO ejecutan defers, así que matamos los
|
||||
// Chrome propios explícitamente antes de salir. Sin esto, al matar el MCP los
|
||||
// chromium lanzados quedaban vivos y huérfanos (~789 MiB RSS cada uno) — el
|
||||
// leak que provocó el apagón por saturación de RAM (06/06/2026).
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
slog.Info("signal received, killing launched chromes", "signal", sig.String())
|
||||
pool.closeAll()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
d := &deps{pool: pool, readOnly: cfg.readOnly}
|
||||
|
||||
srv := server.NewMCPServer(
|
||||
@@ -109,7 +125,10 @@ func (d *deps) withConn(port int, fn func(c *browser.CDPConn) error) error {
|
||||
}
|
||||
err = fn(c)
|
||||
if err != nil && isConnErr(err) {
|
||||
d.pool.drop(port)
|
||||
// La conexión murió (Chrome pudo cerrar la tab). Soltamos SOLO el
|
||||
// WebSocket y reconectamos al mismo Chrome — releaseConn, no drop: drop
|
||||
// mataría el proceso y dejaría sin nada a qué reconectar.
|
||||
d.pool.releaseConn(port)
|
||||
c2, err2 := d.pool.get(port)
|
||||
if err2 != nil {
|
||||
return err2
|
||||
|
||||
@@ -10,16 +10,25 @@ import (
|
||||
// connPool reusa conexiones CDP entre invocaciones de tools. Clave = puerto CDP.
|
||||
// Una conexión = una sesión viva a una tab "page". Mantenerla evita pagar el
|
||||
// handshake WebSocket en cada tool y preserva estado (event handlers, contexto).
|
||||
//
|
||||
// El pool también registra el PID del Chrome que el MCP LANZÓ por puerto
|
||||
// (mapa `pids`). Sin ese PID, cerrar la conexión solo suelta el WebSocket y deja
|
||||
// el proceso chromium huérfano (~789 MiB RSS cada uno) — ese era el leak de RAM.
|
||||
// Con el PID registrado, `drop`/`closeAll` matan el grupo de proceso completo.
|
||||
// Un puerto SIN pid registrado (p.ej. el navegador diario del usuario en 9222,
|
||||
// que el MCP no lanzó) nunca se mata: solo se suelta el WebSocket.
|
||||
type connPool struct {
|
||||
mu sync.Mutex
|
||||
conns map[int]*browser.CDPConn
|
||||
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
||||
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
||||
pids map[int]int // puerto -> PID del Chrome lanzado por el MCP (solo los SUYOS)
|
||||
cancels map[int]func() // cancels de handlers persistentes (handle_dialog)
|
||||
dialogLogs map[int]*browser.DialogLog // log de diálogos auto-respondidos por puerto
|
||||
}
|
||||
|
||||
func newConnPool() *connPool {
|
||||
return &connPool{
|
||||
conns: map[int]*browser.CDPConn{},
|
||||
pids: map[int]int{},
|
||||
cancels: map[int]func(){},
|
||||
dialogLogs: map[int]*browser.DialogLog{},
|
||||
}
|
||||
@@ -39,7 +48,44 @@ func (p *connPool) get(port int) (*browser.CDPConn, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (p *connPool) drop(port int) {
|
||||
// setPID registra el PID del Chrome que el MCP lanzó en este puerto. A partir de
|
||||
// aquí drop/closeAll podrán matar ese proceso (es nuestro).
|
||||
func (p *connPool) setPID(port, pid int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.pids[port] = pid
|
||||
}
|
||||
|
||||
// getPID devuelve el PID registrado para el puerto (y si existe). pid<=0 o
|
||||
// ausente significa que el MCP no lanzó ningún Chrome propio en ese puerto.
|
||||
func (p *connPool) getPID(port int) (int, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
pid, ok := p.pids[port]
|
||||
return pid, ok
|
||||
}
|
||||
|
||||
// clearPID olvida el PID de un puerto sin matar nada (p.ej. el proceso ya murió).
|
||||
func (p *connPool) clearPID(port int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.pids, port)
|
||||
}
|
||||
|
||||
// launchedCount devuelve cuántos Chrome propios tiene vivos el MCP (uno por
|
||||
// puerto registrado). Alimenta el tope de instancias en handleLaunch.
|
||||
func (p *connPool) launchedCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.pids)
|
||||
}
|
||||
|
||||
// releaseConn cierra SOLO el WebSocket pooled del puerto (pid=0, no mata Chrome)
|
||||
// y lo borra del mapa, PRESERVANDO el PID registrado. Cancela el handler de
|
||||
// diálogo de esa sesión (está atado a la conexión que se suelta). Lo usan el
|
||||
// retry de withConn y connectTarget: necesitan reconectar al MISMO Chrome, no
|
||||
// matarlo.
|
||||
func (p *connPool) releaseConn(port int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if cancel, ok := p.cancels[port]; ok && cancel != nil {
|
||||
@@ -48,18 +94,41 @@ func (p *connPool) drop(port int) {
|
||||
}
|
||||
delete(p.dialogLogs, port)
|
||||
if c, ok := p.conns[port]; ok && c != nil {
|
||||
// pid=0: cerrar solo el WebSocket, sin matar Chrome (el navegador sigue
|
||||
// vivo; solo soltamos la sesión pooled).
|
||||
// pid=0: solo soltar el WebSocket. El Chrome sigue vivo para reconectar.
|
||||
_ = browser.CdpClose(c, 0)
|
||||
delete(p.conns, port)
|
||||
}
|
||||
}
|
||||
|
||||
// drop cierra la sesión del puerto Y mata el Chrome SI lo lanzó el MCP (pid
|
||||
// registrado). Para un Chrome externo (sin pid registrado, p.ej. el navegador
|
||||
// diario en 9222) pasa pid=0 a CdpClose: solo cierra el WebSocket, NUNCA mata el
|
||||
// navegador del usuario. Limpia todas las entradas del puerto.
|
||||
func (p *connPool) drop(port int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if cancel, ok := p.cancels[port]; ok && cancel != nil {
|
||||
cancel()
|
||||
delete(p.cancels, port)
|
||||
}
|
||||
delete(p.dialogLogs, port)
|
||||
|
||||
pid := p.pids[port] // 0 si el MCP no lanzó este Chrome
|
||||
c := p.conns[port]
|
||||
// CdpClose mata el grupo de proceso completo SOLO si pid>0 (Setpgid=true en
|
||||
// ChromeLaunch). Con c!=nil cierra además el WebSocket; con pid<=0 no toca el
|
||||
// proceso.
|
||||
_ = browser.CdpClose(c, pid)
|
||||
delete(p.conns, port)
|
||||
delete(p.pids, port)
|
||||
}
|
||||
|
||||
// connectTarget descarta la conexión actual del puerto y reconecta a un target
|
||||
// determinista (por id o substring de URL). Asegura que el agente opera sobre una
|
||||
// pestaña conocida y no sobre "la primera al azar".
|
||||
// pestaña conocida y no sobre "la primera al azar". Usa releaseConn (NO drop):
|
||||
// cambiar de pestaña no debe matar el Chrome, es el mismo navegador.
|
||||
func (p *connPool) connectTarget(port int, match string) (*browser.CDPConn, error) {
|
||||
p.drop(port)
|
||||
p.releaseConn(port)
|
||||
c, err := browser.CdpConnectTarget("localhost", port, match)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -93,6 +162,10 @@ func (p *connPool) dialogSnapshot(port int) (int, string, string) {
|
||||
return 0, "", ""
|
||||
}
|
||||
|
||||
// closeAll cierra todas las conexiones y mata TODOS los Chrome que el MCP lanzó
|
||||
// (pid registrado). Se llama con defer en main() (cierre por EOF de stdio) y
|
||||
// desde el handler de señales (SIGTERM/SIGINT). Idempotente: vacía los mapas, así
|
||||
// que una segunda llamada no hace nada. Un Chrome externo (sin pid) no se mata.
|
||||
func (p *connPool) closeAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -100,11 +173,19 @@ func (p *connPool) closeAll() {
|
||||
if cancel := p.cancels[port]; cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if c != nil {
|
||||
_ = browser.CdpClose(c, 0)
|
||||
_ = browser.CdpClose(c, p.pids[port]) // mata nuestro Chrome; pid=0 para externos
|
||||
delete(p.pids, port) // marcado como ya cerrado
|
||||
}
|
||||
// Matar también los Chrome propios cuya conexión ya fue soltada (releaseConn
|
||||
// preserva el pid pero borra la conn): pid registrado sin conn viva.
|
||||
for port, pid := range p.pids {
|
||||
if pid > 0 {
|
||||
_ = browser.CdpClose(nil, pid)
|
||||
}
|
||||
_ = port
|
||||
}
|
||||
p.conns = map[int]*browser.CDPConn{}
|
||||
p.pids = map[int]int{}
|
||||
p.cancels = map[int]func(){}
|
||||
p.dialogLogs = map[int]*browser.DialogLog{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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)")
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// resultText concatena el texto de un CallToolResult para asserts.
|
||||
func resultText(r *mcp.CallToolResult) string {
|
||||
var sb strings.Builder
|
||||
for _, c := range r.Content {
|
||||
if tc, ok := c.(mcp.TextContent); ok {
|
||||
sb.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// TestPoolPIDLifecycle verifica set/get/clear/count del registro de PIDs sin
|
||||
// tocar Chrome real.
|
||||
func TestPoolPIDLifecycle(t *testing.T) {
|
||||
p := newConnPool()
|
||||
if n := p.launchedCount(); n != 0 {
|
||||
t.Fatalf("launchedCount inicial = %d, want 0", n)
|
||||
}
|
||||
p.setPID(9333, 4242)
|
||||
if pid, ok := p.getPID(9333); !ok || pid != 4242 {
|
||||
t.Fatalf("getPID(9333) = (%d,%v), want (4242,true)", pid, ok)
|
||||
}
|
||||
if n := p.launchedCount(); n != 1 {
|
||||
t.Fatalf("launchedCount tras setPID = %d, want 1", n)
|
||||
}
|
||||
p.clearPID(9333)
|
||||
if _, ok := p.getPID(9333); ok {
|
||||
t.Fatalf("getPID(9333) sigue presente tras clearPID")
|
||||
}
|
||||
if n := p.launchedCount(); n != 0 {
|
||||
t.Fatalf("launchedCount tras clearPID = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstanceCapRejectsWithoutLaunching verifica el tope duro: con
|
||||
// maxLaunchedChromes PIDs ya registrados, browser_launch en un puerto nuevo
|
||||
// devuelve error de tool y NO intenta lanzar Chrome (el cap se evalúa antes de
|
||||
// ChromeLaunch, por eso este test no necesita Chrome real). Cubre el edge
|
||||
// "superar el tope → error claro".
|
||||
func TestInstanceCapRejectsWithoutLaunching(t *testing.T) {
|
||||
p := newConnPool()
|
||||
for i := 0; i < maxLaunchedChromes; i++ {
|
||||
p.setPID(9500+i, 100000+i) // PIDs ficticios: nunca se matan en este test
|
||||
}
|
||||
d := &deps{pool: p}
|
||||
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9600})
|
||||
if err != nil {
|
||||
t.Fatalf("handleLaunch err = %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("esperaba IsError=true por cap, got text=%q", resultText(res))
|
||||
}
|
||||
if txt := resultText(res); !strings.Contains(txt, "cap") {
|
||||
t.Fatalf("mensaje no menciona el cap: %q", txt)
|
||||
}
|
||||
// El puerto nuevo no debe haberse registrado.
|
||||
if _, ok := p.getPID(9600); ok {
|
||||
t.Fatalf("el puerto rechazado por cap no debe registrarse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchReusesRegisteredPort verifica idempotencia: si el MCP ya lanzó un
|
||||
// Chrome en el puerto (PID registrado), un segundo browser_launch lo reusa sin
|
||||
// lanzar otro proceso. No necesita Chrome real (el reuse corta antes de
|
||||
// ChromeLaunch). Cubre el edge "dos browser_launch al mismo puerto no duplica".
|
||||
func TestLaunchReusesRegisteredPort(t *testing.T) {
|
||||
p := newConnPool()
|
||||
p.setPID(9333, 777777)
|
||||
d := &deps{pool: p}
|
||||
res, err := d.handleLaunch(context.Background(), mcp.CallToolRequest{}, launchArgs{Port: 9333})
|
||||
if err != nil {
|
||||
t.Fatalf("handleLaunch err = %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("no esperaba error, got %q", resultText(res))
|
||||
}
|
||||
if txt := resultText(res); !strings.Contains(txt, "reused pid=777777") {
|
||||
t.Fatalf("esperaba reuse del pid registrado, got %q", txt)
|
||||
}
|
||||
if n := p.launchedCount(); n != 1 {
|
||||
t.Fatalf("launchedCount = %d, want 1 (no debe duplicar)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropClearsMapsNoPID verifica que drop sobre un puerto sin conn ni pid no
|
||||
// panica y deja los mapas limpios (no mata nada — caso del navegador externo
|
||||
// del que solo se soltó el WebSocket).
|
||||
func TestDropClearsMapsNoPID(t *testing.T) {
|
||||
p := newConnPool()
|
||||
p.drop(9222) // puerto externo, sin conn ni pid registrado: no-op seguro
|
||||
if n := p.launchedCount(); n != 0 {
|
||||
t.Fatalf("launchedCount = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
+37
-3
@@ -21,6 +21,12 @@ func registerSessionTools(s *server.MCPServer, d *deps) {
|
||||
s.AddTool(disconnectTool(), mcp.NewTypedToolHandler(d.handleDisconnect))
|
||||
}
|
||||
|
||||
// maxLaunchedChromes es el tope duro de instancias Chrome que el MCP puede tener
|
||||
// vivas a la vez (una por puerto). Cada chromium ocioso pesa ~789 MiB RSS; sin
|
||||
// tope, llamadas repetidas a browser_launch saturan la RAM (apagón 06/06/2026).
|
||||
// Al superarlo, browser_launch devuelve un error de tool en vez de lanzar más.
|
||||
const maxLaunchedChromes = 4
|
||||
|
||||
// ---- browser_launch (MUTA) ----
|
||||
|
||||
type launchArgs struct {
|
||||
@@ -41,6 +47,22 @@ func launchTool() mcp.Tool {
|
||||
}
|
||||
|
||||
func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchArgs) (*mcp.CallToolResult, error) {
|
||||
port := portOr(a.Port)
|
||||
|
||||
// (1) Idempotente: si el MCP ya lanzó un Chrome en este puerto, reusarlo en
|
||||
// vez de duplicar el proceso. (Si el proceso hubiera muerto, withConn/connect
|
||||
// fallará y el usuario puede browser_disconnect + relanzar.)
|
||||
if pid, ok := d.pool.getPID(port); ok && pid > 0 {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("reused pid=%d port=%d (already launched by this MCP)", pid, port)), nil
|
||||
}
|
||||
|
||||
// (2) Tope duro de instancias propias. Cada chromium ocioso ~789 MiB RSS.
|
||||
if d.pool.launchedCount() >= maxLaunchedChromes {
|
||||
return mcp.NewToolResultError(fmt.Sprintf(
|
||||
"instance cap reached: the MCP already launched %d Chrome instances (max %d); browser_disconnect one before launching another",
|
||||
d.pool.launchedCount(), maxLaunchedChromes)), nil
|
||||
}
|
||||
|
||||
// SECURITY (P0.3): default to an isolated user-data-dir so the MCP never
|
||||
// reuses the user's daily browser profile. Created on demand.
|
||||
userDataDir := a.UserDataDir
|
||||
@@ -49,9 +71,13 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
|
||||
_ = os.MkdirAll(userDataDir, 0o755)
|
||||
}
|
||||
opts := browser.ChromeLaunchOpts{
|
||||
Port: portOr(a.Port),
|
||||
Port: port,
|
||||
Headless: a.Headless,
|
||||
UserDataDir: userDataDir,
|
||||
// (3) Anti-duplicado: si ya hay un Chrome vivo en el puerto (incluido el
|
||||
// navegador diario externo en 9222), ChromeLaunch NO lanza otro y devuelve
|
||||
// pid 0 — nos adjuntamos al existente sin registrarlo como nuestro.
|
||||
ReuseExisting: true,
|
||||
}
|
||||
if a.URL != "" {
|
||||
opts.ExtraArgs = append(opts.ExtraArgs, a.URL)
|
||||
@@ -60,7 +86,15 @@ func (d *deps) handleLaunch(_ context.Context, _ mcp.CallToolRequest, a launchAr
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, opts.Port, userDataDir)), nil
|
||||
if pid == 0 {
|
||||
// Había un Chrome externo en el puerto: lo reusamos pero NO lo registramos
|
||||
// (no es nuestro → browser_disconnect no debe matarlo).
|
||||
return mcp.NewToolResultText(fmt.Sprintf("reused existing chrome on port=%d (external, not killed by the MCP)", port)), nil
|
||||
}
|
||||
// (4) Registrar el PID: a partir de aquí el MCP puede matar este Chrome en
|
||||
// browser_disconnect / shutdown. Esto es lo que cierra el leak de RAM.
|
||||
d.pool.setPID(port, pid)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("launched pid=%d port=%d user_data_dir=%s", pid, port, userDataDir)), nil
|
||||
}
|
||||
|
||||
// ---- browser_connect ----
|
||||
@@ -92,7 +126,7 @@ type disconnectArgs struct {
|
||||
|
||||
func disconnectTool() mcp.Tool {
|
||||
return mcp.NewTool("browser_disconnect",
|
||||
mcp.WithDescription("Close and drop the pooled CDP connection for the given port (cancels any armed dialog handler). Does NOT kill Chrome."),
|
||||
mcp.WithDescription("Close the pooled CDP connection for the given port (cancels any armed dialog handler). If the MCP LAUNCHED the Chrome on that port (via browser_launch), it also KILLS that Chrome process group, freeing its RAM. A Chrome the MCP did not launch (e.g. the user's daily browser on 9222) is never killed — only the WebSocket is closed."),
|
||||
mcp.WithNumber("port", mcp.Description("CDP port. Default 9333 (Chrome isolated del MCP); usa 9222 explícito solo para adjuntarte al navegador diario.")),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user