Runtime API: Make logs already available before client is fully
initialized
This commit is contained in:
+37
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user