Skip to content

Commit 587b27f

Browse files
committed
Runtime API: Make logs already available before client is fully
initialized
1 parent 63b71f1 commit 587b27f

File tree

3 files changed

+64
-35
lines changed

3 files changed

+64
-35
lines changed

server/headless.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@ type HeadlessBrowser struct {
4040
logsMu sync.Mutex
4141
logs []ConsoleLogEntry
4242
maxLogs int // ring buffer capacity
43+
44+
readyCh chan struct{} // closed when client eval functions are ready
45+
readyErr error // non-nil if client failed to become ready
4346
}
4447

4548
// StartHeadlessBrowser launches a headless Chrome browser and navigates to the SilverBullet URL.
46-
// It returns after the client's eval functions are ready, or returns an error on failure.
49+
// It returns as soon as the browser is navigating and collecting logs. The client may not be
50+
// fully ready yet; call WaitReady to block until eval functions are available.
4751
func StartHeadlessBrowser(config *HeadlessConfig) (*HeadlessBrowser, error) {
4852
hb := &HeadlessBrowser{
4953
config: config,
@@ -61,6 +65,16 @@ func StartHeadlessBrowser(config *HeadlessConfig) (*HeadlessBrowser, error) {
6165
return hb, nil
6266
}
6367

68+
// WaitReady blocks until the client's eval functions are ready, or ctx is cancelled.
69+
func (hb *HeadlessBrowser) WaitReady(ctx context.Context) error {
70+
select {
71+
case <-hb.readyCh:
72+
return hb.readyErr
73+
case <-ctx.Done():
74+
return ctx.Err()
75+
}
76+
}
77+
6478
func (hb *HeadlessBrowser) launch() error {
6579
// Various options to reduce memory consumption, primarily
6680
opts := append(chromedp.DefaultExecAllocatorOptions[:],
@@ -134,17 +148,22 @@ func (hb *HeadlessBrowser) launch() error {
134148
return fmt.Errorf("failed to navigate: %w", err)
135149
}
136150

137-
// Wait for the client eval functions to be ready
138-
readyCtx, readyCancel := context.WithTimeout(ctx, 60*time.Second)
139-
defer readyCancel()
140-
141-
if err := waitForClientReady(readyCtx); err != nil {
142-
cancel()
143-
allocCancel()
144-
return fmt.Errorf("client did not become ready: %w", err)
145-
}
151+
// Wait for client readiness in the background so logs are available immediately
152+
readyCh := make(chan struct{})
153+
hb.readyCh = readyCh
154+
hb.readyErr = nil
155+
go func() {
156+
defer close(readyCh)
157+
readyCtx, readyCancel := context.WithTimeout(ctx, 60*time.Second)
158+
defer readyCancel()
159+
if err := waitForClientReady(readyCtx); err != nil {
160+
hb.readyErr = fmt.Errorf("client did not become ready: %w", err)
161+
log.Printf("[Headless] %v", hb.readyErr)
162+
} else {
163+
log.Println("[Headless] Browser client connected successfully")
164+
}
165+
}()
146166

147-
log.Println("[Headless] Browser client connected successfully")
148167
return nil
149168
}
150169

@@ -203,7 +222,13 @@ func (hb *HeadlessBrowser) monitor() {
203222
continue
204223
}
205224

206-
// Success - reset backoff
225+
// Wait for client to become fully ready before declaring success
226+
if err := hb.WaitReady(hb.ctx); err != nil {
227+
log.Printf("[Headless] Restart client readiness failed: %v", err)
228+
backoff = min(backoff*2, maxBackoff)
229+
continue
230+
}
231+
207232
log.Println("[Headless] Restart successful")
208233
backoff = 2 * time.Second
209234
}

server/integration_headless_test.go

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package server
44

55
import (
6+
"context"
67
"encoding/json"
78
"fmt"
89
"io"
@@ -139,9 +140,13 @@ func startHeadless(t *testing.T, opts ...testServerOption) *testServer {
139140
ServerURL: ts.Server.URL,
140141
HeadlessToken: ts.Config.HeadlessToken,
141142
})
142-
require.NoError(t, err, "headless browser should start and become ready")
143+
require.NoError(t, err, "headless browser should start")
143144
t.Cleanup(hb.Stop)
144145

146+
readyCtx, readyCancel := context.WithTimeout(context.Background(), 60*time.Second)
147+
defer readyCancel()
148+
require.NoError(t, hb.WaitReady(readyCtx), "headless browser client should become ready")
149+
145150
ts.Config.RuntimeBridge.SetBrowser(hb)
146151

147152
return ts
@@ -469,23 +474,6 @@ return x + y`
469474
}
470475
})
471476

472-
t.Run("Timeout", func(t *testing.T) {
473-
req, _ := http.NewRequest(http.MethodPost, ts.Server.URL+"/.runtime/lua_script", strings.NewReader(`
474-
local i = 0
475-
while i < 999999999 do
476-
i = i + 1
477-
end
478-
return i
479-
`))
480-
req.Header.Set("X-Timeout", "1")
481-
482-
resp, err := http.DefaultClient.Do(req)
483-
require.NoError(t, err)
484-
defer resp.Body.Close()
485-
486-
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
487-
})
488-
489477
t.Run("Screenshot", func(t *testing.T) {
490478
resp, err := http.Get(ts.Server.URL + "/.runtime/screenshot")
491479
require.NoError(t, err)
@@ -540,6 +528,7 @@ return i
540528
require.True(t, ok)
541529
assert.LessOrEqual(t, len(logs), 5)
542530
})
531+
543532
}
544533

545534
// --- Auth-enabled headless tests (single Chrome instance) ---

server/runtime_api.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ func NewRuntimeBridge(config *HeadlessConfig) *RuntimeBridge {
3939
}
4040
}
4141

42-
// EnsureRunning starts the headless browser if not already running.
43-
// Multiple concurrent callers coalesce on the starting channel.
44-
func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error {
42+
// ensureLaunched starts the headless browser if not already launched.
43+
// Returns as soon as the browser process is running and collecting logs,
44+
// but the client may not be fully ready for eval yet.
45+
func (b *RuntimeBridge) ensureLaunched(ctx context.Context) error {
4546
if b.config == nil {
4647
return nil // headless disabled, nothing to start
4748
}
@@ -84,6 +85,18 @@ func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error {
8485
return err
8586
}
8687

88+
// EnsureRunning starts the headless browser and waits for the client to be fully ready.
89+
func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error {
90+
if err := b.ensureLaunched(ctx); err != nil {
91+
return err
92+
}
93+
browser := b.getBrowser()
94+
if browser == nil {
95+
return nil
96+
}
97+
return browser.WaitReady(ctx)
98+
}
99+
87100
// SetBrowser sets the headless browser instance on the bridge (used in tests).
88101
func (b *RuntimeBridge) SetBrowser(hb *HeadlessBrowser) {
89102
b.mu.Lock()
@@ -183,14 +196,16 @@ func (b *RuntimeBridge) HandleScreenshot(w http.ResponseWriter, r *http.Request)
183196
}
184197

185198
// HandleConsoleLogs returns recent console log entries from the headless browser.
199+
// Unlike other runtime endpoints, this does not wait for the client to be fully ready,
200+
// so it can return boot logs while the client is still loading.
186201
func (b *RuntimeBridge) HandleConsoleLogs(w http.ResponseWriter, r *http.Request) {
187-
if err := b.EnsureRunning(r.Context()); err != nil {
202+
if err := b.ensureLaunched(r.Context()); err != nil {
188203
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": fmt.Sprintf("Failed to start headless browser: %v", err)})
189204
return
190205
}
191206
browser := b.getBrowser()
192207
if browser == nil {
193-
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "No headless browser running"})
208+
writeJSON(w, http.StatusOK, map[string]any{"logs": []ConsoleLogEntry{}})
194209
return
195210
}
196211
limit := 100

0 commit comments

Comments
 (0)