Runtime API: Make logs already available before client is fully

initialized
This commit is contained in:
Zef Hemel
2026-03-17 11:01:25 +01:00
parent 63b71f1689
commit 587b27fbd9
3 changed files with 64 additions and 35 deletions
+37 -12
View File
@@ -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
}
+7 -18
View File
@@ -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) ---
+20 -5
View File
@@ -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