mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 12:13:15 +00:00
Start() bound the shared *rod.Browser to a context built with context.WithTimeout(ctx, 15s) and a deferred cancel, so the browser's context was canceled the moment Start() returned. Any later reuse of the shared handle then failed immediately with "context canceled" — most visibly m.browser.Incognito() in tenantBrowserLocked, so a successful "start" action was followed by a failing tab creation for scoped (tenant|user|agent) agent sessions. The getPage path masked this via its auto-reconnect (reconnectLocked uses a background context), but the incognito path has no such recovery. Connect with the default background context, the same way reconnectLocked already does. The local launcher path stays bounded by launchCtx and remote reachability by resolveRemoteCDP, so no meaningful connect-time guard is lost.
322 lines
9.8 KiB
Go
322 lines
9.8 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-rod/rod"
|
|
"github.com/go-rod/rod/lib/launcher"
|
|
)
|
|
|
|
// Manager handles the Chrome browser lifecycle and page management.
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
browser *rod.Browser
|
|
launcher *launcher.Launcher // retained for PID-based cleanup on crash
|
|
refs *RefStore
|
|
pages map[string]*rod.Page // targetID → page
|
|
console map[string][]ConsoleMessage // targetID → console messages
|
|
tenantCtxs map[string]*rod.Browser // browser scope key → incognito browser context
|
|
pageTenants map[string]string // targetID → browser scope key (for filtering)
|
|
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)
|
|
cookieProvider CookieProvider
|
|
stopReaper chan struct{} // signal to stop the reaper goroutine
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// Option configures a Manager.
|
|
type Option func(*Manager)
|
|
|
|
// WithHeadless sets headless mode (default false).
|
|
func WithHeadless(h bool) Option {
|
|
return func(m *Manager) { m.headless = h }
|
|
}
|
|
|
|
// WithRemoteURL sets a remote CDP endpoint (e.g. "ws://chrome:9222").
|
|
// When set, Start() connects to the remote Chrome instead of launching locally.
|
|
func WithRemoteURL(url string) Option {
|
|
return func(m *Manager) { m.remoteURL = url }
|
|
}
|
|
|
|
// WithLogger sets a custom logger.
|
|
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 }
|
|
}
|
|
|
|
// WithCookieProvider sets the provider for selected cookie sync into new pages.
|
|
func WithCookieProvider(p CookieProvider) Option {
|
|
return func(m *Manager) { m.cookieProvider = p }
|
|
}
|
|
|
|
// 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),
|
|
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)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ActionTimeout returns the configured per-action timeout.
|
|
func (m *Manager) ActionTimeout() time.Duration {
|
|
return m.actionTimeout
|
|
}
|
|
|
|
// SetCookieProvider updates the selected-cookie provider after store setup.
|
|
func (m *Manager) SetCookieProvider(p CookieProvider) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.cookieProvider = p
|
|
}
|
|
|
|
// 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 {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// If browser exists, check if connection is still alive
|
|
if m.browser != nil {
|
|
if _, err := m.browser.Pages(); err == nil {
|
|
return nil // already connected and healthy
|
|
}
|
|
// Connection dead — clean up and reconnect
|
|
m.logger.Info("browser connection lost, reconnecting")
|
|
m.cleanupDeadBrowserLocked()
|
|
}
|
|
|
|
var controlURL string
|
|
|
|
if m.remoteURL != "" {
|
|
// Remote Chrome sidecar — query /json/version and fix host for Docker networking
|
|
u, err := resolveRemoteCDP(m.remoteURL)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve remote Chrome at %s: %w", m.remoteURL, err)
|
|
}
|
|
controlURL = u
|
|
m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL)
|
|
} else {
|
|
// Local Chrome — launch via rod launcher with stability flags
|
|
launchCtx, launchCancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer launchCancel()
|
|
|
|
l := launcher.New().
|
|
Context(launchCtx).
|
|
Leakless(true).
|
|
Headless(m.headless).
|
|
Set("disable-gpu").
|
|
Set("no-first-run").
|
|
Set("no-default-browser-check").
|
|
Set("disable-dev-shm-usage").
|
|
Set("disable-software-rasterizer").
|
|
Set("disable-extensions").
|
|
Set("disable-background-networking").
|
|
Set("disable-renderer-backgrounding").
|
|
Set("disable-background-timer-throttling").
|
|
Set("disable-backgrounding-occluded-windows")
|
|
|
|
u, err := l.Launch()
|
|
if err != nil {
|
|
return fmt.Errorf("launch Chrome: %w", err)
|
|
}
|
|
controlURL = u
|
|
m.launcher = l
|
|
m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless, "pid", l.PID())
|
|
}
|
|
|
|
// Connect using a long-lived background context, the same way reconnectLocked
|
|
// does. Binding m.browser to a timeout/request context here would cancel it
|
|
// the moment Start returns (via defer), leaving the shared browser handle with
|
|
// a dead context. Later operations that reuse it — notably m.browser.Incognito()
|
|
// in tenantBrowserLocked — then fail immediately with "context canceled", which
|
|
// is why a successful "start" is followed by a failing "open"/tab creation.
|
|
// resolveRemoteCDP already bounds remote reachability, and the local launcher
|
|
// path is bounded by launchCtx above.
|
|
b := rod.New().ControlURL(controlURL)
|
|
if err := b.Connect(); err != nil {
|
|
// If local launch succeeded but connect failed, kill the orphan process
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
return fmt.Errorf("connect to Chrome: %w", err)
|
|
}
|
|
|
|
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()
|
|
|
|
if m.browser == nil {
|
|
return nil
|
|
}
|
|
|
|
m.closeTenantContextsLocked()
|
|
|
|
var err error
|
|
if m.remoteURL == "" {
|
|
// Local Chrome — close the browser process
|
|
err = m.browser.Close()
|
|
// Force-kill via launcher if retained
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
}
|
|
// Remote Chrome — just drop the connection; sidecar stays alive
|
|
|
|
m.browser = nil
|
|
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
|
|
}
|
|
|
|
// closeTenantContextsLocked closes all incognito browser contexts. Must be called with mu held.
|
|
func (m *Manager) closeTenantContextsLocked() {
|
|
for tid, ctx := range m.tenantCtxs {
|
|
if err := ctx.Close(); err != nil {
|
|
m.logger.Warn("failed to close tenant browser context", "tenant", tid, "error", err)
|
|
}
|
|
}
|
|
m.tenantCtxs = make(map[string]*rod.Browser)
|
|
}
|
|
|
|
// cleanupDeadBrowserLocked resets all state and kills any orphan Chrome process.
|
|
// Must be called with mu held.
|
|
func (m *Manager) cleanupDeadBrowserLocked() {
|
|
m.closeTenantContextsLocked()
|
|
if m.launcher != nil {
|
|
m.launcher.Kill()
|
|
m.launcher.Cleanup()
|
|
m.launcher = nil
|
|
}
|
|
m.browser = nil
|
|
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)
|
|
m.refs = NewRefStore()
|
|
}
|
|
|
|
// MasterTenantID is the well-known master tenant UUID string.
|
|
// Pages opened without a tenant context or by the master tenant use the main browser directly.
|
|
const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001"
|
|
|
|
// tenantBrowserLocked returns an isolated incognito browser context for the given scope.
|
|
// Legacy master/empty scopes with no user/agent use the main browser.
|
|
// Must be called with mu held.
|
|
func (m *Manager) tenantBrowserLocked(scopeKey string) (*rod.Browser, error) {
|
|
if m.browser == nil {
|
|
return nil, fmt.Errorf("browser not running")
|
|
}
|
|
if scopeKey == "" || scopeKey == MasterTenantID {
|
|
return m.browser, nil
|
|
}
|
|
// Return existing incognito context
|
|
if ctx, ok := m.tenantCtxs[scopeKey]; ok {
|
|
return ctx, nil
|
|
}
|
|
// Create new incognito context for this scope.
|
|
incognito, err := m.browser.Incognito()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create incognito context for browser scope %s: %w", scopeKey, err)
|
|
}
|
|
m.tenantCtxs[scopeKey] = incognito
|
|
m.logger.Info("created incognito browser context", "scope", scopeKey)
|
|
return incognito, nil
|
|
}
|
|
|
|
// Status returns current browser status.
|
|
func (m *Manager) Status() *StatusInfo {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
info := &StatusInfo{
|
|
Running: m.browser != nil,
|
|
Headless: m.headless,
|
|
RemoteURL: m.remoteURL,
|
|
ActionTimeoutMs: int(m.actionTimeout / time.Millisecond),
|
|
IdleTimeoutMs: int(m.idleTimeout / time.Millisecond),
|
|
MaxPages: m.maxPages,
|
|
IsolationMode: "tenant_user_agent",
|
|
CookieSync: m.cookieProvider != nil,
|
|
}
|
|
|
|
if m.browser == nil {
|
|
return info
|
|
}
|
|
|
|
pages, _ := m.browser.Pages()
|
|
info.Tabs = len(pages)
|
|
if len(pages) > 0 {
|
|
if pageInfo, err := pages[0].Info(); err == nil {
|
|
info.URL = pageInfo.URL
|
|
}
|
|
}
|
|
return info
|
|
}
|