feat(browser): add timeout, idle auto-close, and max-pages safety mechanisms

Prevent resource leaks and hanging actions in the browser tool:
- Per-action context timeout (default 30s, configurable via timeoutMs param or config)
- Idle page reaper goroutine closes pages unused for 10min (configurable)
- Max pages per tenant (default 5) with LRU eviction
- RefStore cleanup on page close/evict/reap to prevent memory leaks
This commit is contained in:
viettranx committed 2026-03-25 13:55:33 +07:00
1 parent afbecd3e4c
commit ee4d34ae28
8 files changed
+226 -12

No files matched your search

+62 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
@@ -19,9 +20,14 @@ type Manager struct {
console map[string][]ConsoleMessage // targetID → console messages
tenantCtxs map[string]*rod.Browser // tenantID → incognito browser context
pageTenants map[string]string // targetID → tenantID (for filtering)
headless bool
remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
logger *slog.Logger
pageLastUsed map[string]time.Time // targetID → last access time
headless bool
remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher
actionTimeout time.Duration // per-action context timeout (default 30s)
idleTimeout time.Duration // auto-close pages idle longer than this (default 10m, 0=disabled)
maxPages int // max open pages per tenant (default 5)
stopReaper chan struct{} // signal to stop the reaper goroutine
logger *slog.Logger
}
// Option configures a Manager.
@@ -43,15 +49,34 @@ func WithLogger(l *slog.Logger) Option {
return func(m *Manager) { m.logger = l }
}
// WithActionTimeout sets the per-action context timeout.
func WithActionTimeout(d time.Duration) Option {
return func(m *Manager) { m.actionTimeout = d }
}
// WithIdleTimeout sets the idle page auto-close timeout. 0 disables the reaper.
func WithIdleTimeout(d time.Duration) Option {
return func(m *Manager) { m.idleTimeout = d }
}
// WithMaxPages sets the max open pages per tenant.
func WithMaxPages(n int) Option {
return func(m *Manager) { m.maxPages = n }
}
// New creates a Manager with options.
func New(opts ...Option) *Manager {
m := &Manager{
refs: NewRefStore(),
pages: make(map[string]*rod.Page),
console: make(map[string][]ConsoleMessage),
tenantCtxs: make(map[string]*rod.Browser),
pageTenants: make(map[string]string),
logger: slog.Default(),
refs: NewRefStore(),
pages: make(map[string]*rod.Page),
console: make(map[string][]ConsoleMessage),
tenantCtxs: make(map[string]*rod.Browser),
pageTenants: make(map[string]string),
pageLastUsed: make(map[string]time.Time),
actionTimeout: 30 * time.Second,
idleTimeout: 10 * time.Minute,
maxPages: 5,
logger: slog.Default(),
}
for _, o := range opts {
o(m)
@@ -59,6 +84,16 @@ func New(opts ...Option) *Manager {
return m
}
// ActionTimeout returns the configured per-action timeout.
func (m *Manager) ActionTimeout() time.Duration {
return m.actionTimeout
}
// touchPageLocked updates the last-used timestamp for a page. Must be called with mu held.
func (m *Manager) touchPageLocked(targetID string) {
m.pageLastUsed[targetID] = time.Now()
}
// Start launches a local Chrome browser or connects to a remote one.
// If already connected but the connection is dead, it reconnects automatically.
func (m *Manager) Start(ctx context.Context) error {
@@ -112,11 +147,28 @@ func (m *Manager) Start(ctx context.Context) error {
}
m.browser = b
// Start idle-page reaper if configured
if m.idleTimeout > 0 && m.stopReaper == nil {
m.stopReaper = make(chan struct{})
go m.runReaper()
}
return nil
}
// Stop closes the Chrome browser (local) or disconnects (remote sidecar).
func (m *Manager) Stop(ctx context.Context) error {
// Grab and nil-out stopReaper under the lock, then close outside to avoid
// deadlock (reaper goroutine also acquires mu).
m.mu.Lock()
ch := m.stopReaper
m.stopReaper = nil
m.mu.Unlock()
if ch != nil {
close(ch)
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -137,6 +189,7 @@ func (m *Manager) Stop(ctx context.Context) error {
m.pages = make(map[string]*rod.Page)
m.console = make(map[string][]ConsoleMessage)
m.pageTenants = make(map[string]string)
m.pageLastUsed = make(map[string]time.Time)
return err
}