diff --git a/server/headless.go b/server/headless.go index 6316c530..2f9a86fb 100644 --- a/server/headless.go +++ b/server/headless.go @@ -40,10 +40,14 @@ type HeadlessBrowser struct { logsMu sync.Mutex logs []ConsoleLogEntry maxLogs int // ring buffer capacity + + readyCh chan struct{} // closed when client eval functions are ready + readyErr error // non-nil if client failed to become ready } // StartHeadlessBrowser launches a headless Chrome browser and navigates to the SilverBullet URL. -// It returns after the client's eval functions are ready, or returns an error on failure. +// It returns as soon as the browser is navigating and collecting logs. The client may not be +// fully ready yet; call WaitReady to block until eval functions are available. func StartHeadlessBrowser(config *HeadlessConfig) (*HeadlessBrowser, error) { hb := &HeadlessBrowser{ config: config, @@ -61,6 +65,16 @@ func StartHeadlessBrowser(config *HeadlessConfig) (*HeadlessBrowser, error) { return hb, nil } +// WaitReady blocks until the client's eval functions are ready, or ctx is cancelled. +func (hb *HeadlessBrowser) WaitReady(ctx context.Context) error { + select { + case <-hb.readyCh: + return hb.readyErr + case <-ctx.Done(): + return ctx.Err() + } +} + func (hb *HeadlessBrowser) launch() error { // Various options to reduce memory consumption, primarily opts := append(chromedp.DefaultExecAllocatorOptions[:], @@ -134,17 +148,22 @@ func (hb *HeadlessBrowser) launch() error { return fmt.Errorf("failed to navigate: %w", err) } - // Wait for the client eval functions to be ready - readyCtx, readyCancel := context.WithTimeout(ctx, 60*time.Second) - defer readyCancel() + // Wait for client readiness in the background so logs are available immediately + readyCh := make(chan struct{}) + hb.readyCh = readyCh + hb.readyErr = nil + go func() { + defer close(readyCh) + readyCtx, readyCancel := context.WithTimeout(ctx, 60*time.Second) + defer readyCancel() + if err := waitForClientReady(readyCtx); err != nil { + hb.readyErr = fmt.Errorf("client did not become ready: %w", err) + log.Printf("[Headless] %v", hb.readyErr) + } else { + log.Println("[Headless] Browser client connected successfully") + } + }() - if err := waitForClientReady(readyCtx); err != nil { - cancel() - allocCancel() - return fmt.Errorf("client did not become ready: %w", err) - } - - log.Println("[Headless] Browser client connected successfully") return nil } @@ -203,7 +222,13 @@ func (hb *HeadlessBrowser) monitor() { continue } - // Success - reset backoff + // Wait for client to become fully ready before declaring success + if err := hb.WaitReady(hb.ctx); err != nil { + log.Printf("[Headless] Restart client readiness failed: %v", err) + backoff = min(backoff*2, maxBackoff) + continue + } + log.Println("[Headless] Restart successful") backoff = 2 * time.Second } diff --git a/server/integration_headless_test.go b/server/integration_headless_test.go index 40b2f159..cadd372f 100644 --- a/server/integration_headless_test.go +++ b/server/integration_headless_test.go @@ -3,6 +3,7 @@ package server import ( + "context" "encoding/json" "fmt" "io" @@ -139,9 +140,13 @@ func startHeadless(t *testing.T, opts ...testServerOption) *testServer { ServerURL: ts.Server.URL, HeadlessToken: ts.Config.HeadlessToken, }) - require.NoError(t, err, "headless browser should start and become ready") + require.NoError(t, err, "headless browser should start") t.Cleanup(hb.Stop) + readyCtx, readyCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer readyCancel() + require.NoError(t, hb.WaitReady(readyCtx), "headless browser client should become ready") + ts.Config.RuntimeBridge.SetBrowser(hb) return ts @@ -469,23 +474,6 @@ return x + y` } }) - t.Run("Timeout", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodPost, ts.Server.URL+"/.runtime/lua_script", strings.NewReader(` -local i = 0 -while i < 999999999 do - i = i + 1 -end -return i -`)) - req.Header.Set("X-Timeout", "1") - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode) - }) - t.Run("Screenshot", func(t *testing.T) { resp, err := http.Get(ts.Server.URL + "/.runtime/screenshot") require.NoError(t, err) @@ -540,6 +528,7 @@ return i require.True(t, ok) assert.LessOrEqual(t, len(logs), 5) }) + } // --- Auth-enabled headless tests (single Chrome instance) --- diff --git a/server/runtime_api.go b/server/runtime_api.go index fd9f5d91..b0c638f5 100644 --- a/server/runtime_api.go +++ b/server/runtime_api.go @@ -39,9 +39,10 @@ func NewRuntimeBridge(config *HeadlessConfig) *RuntimeBridge { } } -// EnsureRunning starts the headless browser if not already running. -// Multiple concurrent callers coalesce on the starting channel. -func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error { +// ensureLaunched starts the headless browser if not already launched. +// Returns as soon as the browser process is running and collecting logs, +// but the client may not be fully ready for eval yet. +func (b *RuntimeBridge) ensureLaunched(ctx context.Context) error { if b.config == nil { return nil // headless disabled, nothing to start } @@ -84,6 +85,18 @@ func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error { return err } +// EnsureRunning starts the headless browser and waits for the client to be fully ready. +func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error { + if err := b.ensureLaunched(ctx); err != nil { + return err + } + browser := b.getBrowser() + if browser == nil { + return nil + } + return browser.WaitReady(ctx) +} + // SetBrowser sets the headless browser instance on the bridge (used in tests). func (b *RuntimeBridge) SetBrowser(hb *HeadlessBrowser) { b.mu.Lock() @@ -183,14 +196,16 @@ func (b *RuntimeBridge) HandleScreenshot(w http.ResponseWriter, r *http.Request) } // HandleConsoleLogs returns recent console log entries from the headless browser. +// Unlike other runtime endpoints, this does not wait for the client to be fully ready, +// so it can return boot logs while the client is still loading. func (b *RuntimeBridge) HandleConsoleLogs(w http.ResponseWriter, r *http.Request) { - if err := b.EnsureRunning(r.Context()); err != nil { + if err := b.ensureLaunched(r.Context()); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": fmt.Sprintf("Failed to start headless browser: %v", err)}) return } browser := b.getBrowser() if browser == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "No headless browser running"}) + writeJSON(w, http.StatusOK, map[string]any{"logs": []ConsoleLogEntry{}}) return } limit := 100