Files
goclaw/pkg/browser/browser_page.go
T
viettranx 1ac08155b0 feat(trace): reliable stop/abort with ctx-aware streams and 2-phase router
Makes the Stop button on the traces page actually stop running traces.
Seven-phase implementation across provider HTTP, agent router, trace
persistence, WS events, tool exec, i18n, and integration tests.

- Provider HTTP+SSE ctx-aware: close socket on cancel via CtxBody wrapper
- Router 2-phase abort: CAS state machine, 3s grace, force-mark fallback
- Trace retry: 3 inline retries + 10-max retry queue, stale recovery 10min
- trace.status WS event: real-time UI updates (invalidates query on receive)
- Tool exec: process-group kill (SIGTERM→3s→SIGKILL), Rod page ctx watch
- i18n: 6 abort toast variants in en/vi/zh
- Integration: 9 scenarios, -race clean

Fixes tenant-ctx loss in forceMarkTraceAborted and retry worker broadcast
(caught by code-reviewer: C1/C2). Stale threshold intentionally 10min
because start_time-based; last_span_at migration is a follow-up.
2026-04-14 18:28:31 +07:00

123 lines
2.8 KiB
Go

package browser
import (
"context"
"fmt"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
)
// watchPageClose spawns a goroutine that closes page when ctx is cancelled.
// Returns a cancel func that stops the watchdog on normal-path close.
// Uses sync.Once so page.Close() is idempotent if both paths fire concurrently.
func watchPageClose(ctx context.Context, page *rod.Page) (stopWatchdog func()) {
var once sync.Once
closeFn := func() { _ = page.Close() }
stopped := make(chan struct{})
go func() {
select {
case <-ctx.Done():
once.Do(closeFn)
case <-stopped:
}
}()
return func() {
close(stopped)
}
}
// Snapshot takes an accessibility snapshot of a page.
func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) {
tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return nil, err
}
result, err := proto.AccessibilityGetFullAXTree{}.Call(page)
if err != nil {
return nil, fmt.Errorf("get AX tree: %w", err)
}
snap := FormatSnapshot(result.Nodes, opts)
info, _ := page.Info()
snap.TargetID = targetID
if info != nil {
snap.URL = info.URL
snap.Title = info.Title
}
// Cache refs
m.refs.Store(targetID, snap.Refs)
return snap, nil
}
// Screenshot captures a page screenshot as PNG bytes.
func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) {
tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return nil, err
}
if fullPage {
return page.Screenshot(fullPage, &proto.PageCaptureScreenshot{
Format: proto.PageCaptureScreenshotFormatPng,
})
}
return page.Screenshot(false, nil)
}
// Navigate navigates a page to a URL.
// A ctx-cancel watchdog closes the page if ctx is done during the blocking WaitStable call.
func (m *Manager) Navigate(ctx context.Context, targetID, url string) error {
tenantID := tenantIDFromCtx(ctx)
m.mu.Lock()
page, err := m.getPageForTenant(targetID, tenantID)
m.mu.Unlock()
if err != nil {
return err
}
// Watchdog: close page on ctx cancel to unblock any pending Rod CDP calls.
stop := watchPageClose(ctx, page)
defer stop()
if err := page.Navigate(url); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("navigate: %w", err)
}
if err := page.WaitStable(300 * time.Millisecond); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("wait stable after navigate: %w", err)
}
return nil
}
// Close shuts down the browser if running.
func (m *Manager) Close() error {
return m.Stop(context.Background())
}
// Refs returns the RefStore for external use (e.g. actions).
func (m *Manager) Refs() *RefStore {
return m.refs
}