Files
browser_mcp/pool.go
T
egutierrez fa1efe6fd5 feat: modo de velocidad de sesión (browser_set_mode) + acciones más rápidas en auto
Añade un flag de velocidad por sesión para que el manejo del navegador sea muy rápido por defecto, conservando un modo sigiloso para cuando haya detección anti-bot fuerte.

- Nueva tool browser_set_mode (tools_session.go): fija el modo de la sesión por puerto en el pool. 'auto' (default del MCP) = rápido; 'human' = sigiloso anti-detección; también admite 'fast'/'instant'. Cada tool de acción puede overridearlo con su arg mode.
- pool.go: estado de modo por puerto (modes map + setMode/getMode), limpiado en drop y closeAll.
- tools_dom.go: effectiveMode resuelve el modo (arg de la llamada > modo de sesión > 'auto'). settleForMode reemplaza el sleep ciego fijo de 400ms tras cada acción mutante: 60ms en auto/fast, aleatorio 250-650ms en human (ritmo no-máquina), 0 en instant. dom_type_ref gana arg mode y rutea a CdpTypeRefFast (insertText, un round-trip) en auto o CdpTypeRef (carácter a carácter) en human. Descripciones del arg mode actualizadas (el default ya no es human).
- tools_lifecycle.go: browser_launch_profile reemplaza el sleep(1s) ciego por un poll del puerto CDP (waitCDPPort).
- .gitignore: ignora registry.db/operations.db (no deben vivir en la app; regla db_locations).

Doctrina invertida respecto a la anterior 'humanizado siempre': ahora rápido por defecto, sigiloso bajo demanda.
2026-06-13 14:27:56 +02:00

218 lines
7.2 KiB
Go

package main
import (
"strings"
"sync"
"fn-registry/functions/browser"
)
// 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
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
modes map[int]string // puerto -> modo de velocidad de sesión ("auto"|"human"|...)
}
func newConnPool() *connPool {
return &connPool{
conns: map[int]*browser.CDPConn{},
pids: map[int]int{},
cancels: map[int]func(){},
dialogLogs: map[int]*browser.DialogLog{},
modes: map[int]string{},
}
}
// setMode fija el modo de velocidad de sesión para un puerto (lo lee
// effectiveMode cuando una tool de acción no trae su propio arg `mode`).
func (p *connPool) setMode(port int, mode string) {
p.mu.Lock()
defer p.mu.Unlock()
p.modes[port] = mode
}
// getMode devuelve el modo de sesión del puerto ("" si no se fijó ninguno).
func (p *connPool) getMode(port int) string {
p.mu.Lock()
defer p.mu.Unlock()
return p.modes[port]
}
func (p *connPool) get(port int) (*browser.CDPConn, error) {
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.conns[port]; ok && c != nil {
return c, nil
}
c, err := browser.CdpConnect(port)
if err != nil {
return nil, err
}
p.conns[port] = c
return c, nil
}
// 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 {
cancel()
delete(p.cancels, port)
}
delete(p.dialogLogs, port)
if c, ok := p.conns[port]; ok && c != nil {
// 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)
delete(p.modes, 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". 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.releaseConn(port)
c, err := browser.CdpConnectTarget("localhost", port, match)
if err != nil {
return nil, err
}
p.mu.Lock()
p.conns[port] = c
p.mu.Unlock()
return c, nil
}
// setDialog guarda el cancel y el DialogLog del auto-handler de diálogos del
// puerto. Si ya había uno armado, lo cancela primero.
func (p *connPool) setDialog(port int, cancel func(), dlog *browser.DialogLog) {
p.mu.Lock()
defer p.mu.Unlock()
if old := p.cancels[port]; old != nil {
old()
}
p.cancels[port] = cancel
p.dialogLogs[port] = dlog
}
// dialogSnapshot devuelve el estado del log de diálogos del puerto (0,"","" si
// no hay handler armado).
func (p *connPool) dialogSnapshot(port int) (int, string, string) {
p.mu.Lock()
defer p.mu.Unlock()
if dl := p.dialogLogs[port]; dl != nil {
return dl.Snapshot()
}
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()
for port, c := range p.conns {
if cancel := p.cancels[port]; cancel != nil {
cancel()
}
_ = 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{}
p.modes = map[int]string{}
}
// isConnErr reconoce errores de conexión CDP muerta para reintentar UNA vez.
func isConnErr(err error) bool {
s := err.Error()
return strings.Contains(s, "connection close") || strings.Contains(s, "broken pipe") ||
strings.Contains(s, "use of closed") || strings.Contains(s, "ws read") || strings.Contains(s, "EOF")
}