Merge issue/fix-chromium-ram-leak: cerrar leak de RAM de chromium huérfanos del MCP
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