Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions server/cmd/api/api/window_promotion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package api

import (
"context"
"log/slog"
"time"

"github.com/kernel/kernel-images/server/lib/cdpclient"
"github.com/kernel/kernel-images/server/lib/logger"
)

const (
// promoteTimeout bounds a single promotion attempt: a fresh browser can
// take a few seconds to open its first page target, which
// SetWindowBoundsMaximized needs to resolve the window.
promoteTimeout = 30 * time.Second
// promoteRetryDelay spaces the retries while the first page target is
// still missing.
promoteRetryDelay = 500 * time.Millisecond
)

// StartWindowPromotion puts the browser window back into the maximized state
// after every chromium start on the headful image.
//
// The launcher seeds --window-size/--window-position to the X root so an
// un-maximized window restores to a sane, root-sized geometry instead of
// chromium's ~1050x1080 default sliver. Chromium 155 ships the window in
// normal state when --window-size is present — it overrides the maximized
// startup state — and an openbox normal window does not auto-track RANDR
// resizes the way a maximized one does. This promotion runs once per browser
// start (the devtools upstream URL changes on every chromium restart) and
// restores the maximized contract. It is a no-op on headless (Xvfb) images.
//
// SetWindowBoundsMaximized is idempotent and leaves fullscreen windows alone,
// so the promotion is safe on kiosk sessions and on starts that somehow
// already ended up maximized.
func (s *ApiService) StartWindowPromotion() {
if s.detectDisplayMode(context.Background()) != "xorg" {
return
}
ch, unsubscribe := s.upstreamMgr.Subscribe()
go s.promoteWindowsLoop(ch, unsubscribe)
}

// promoteWindowsLoop promotes the window on each newly discovered upstream
// URL. The initial URL is read through Current before entering the select so
// an upstream that was already discovered before Subscribe ran is not missed;
// the subscription's latest-wins channel covers every later restart.
func (s *ApiService) promoteWindowsLoop(ch <-chan string, unsubscribe func()) {
defer unsubscribe()
seen := ""
for {
if url := s.upstreamMgr.Current(); url != "" && url != seen {
seen = url
s.promoteWindowMaximized(url)
}
select {
case <-s.lifecycleCtx.Done():
return
case url, ok := <-ch:
if !ok {
return
}
if url == seen {
continue
}
seen = url
s.promoteWindowMaximized(url)
}
}
}

// promoteWindowMaximized dials the upstream and re-asserts the maximized
// window state, retrying within the deadline. A fresh browser can take a
// few seconds to open its first page target (SetWindowBoundsMaximized needs
// one), the devtools endpoint can refuse an early connection while chromium
// is still binding its socket, and a connected client can drop mid-attempt —
// each is retried, redialing once the transport is gone. Past the deadline
// the attempt is logged and abandoned: a window left in normal state still
// covers the desktop (that is what the seeded bounds are for); only the
// exact-root geometry and the RANDR auto-reflow are at stake.
func (s *ApiService) promoteWindowMaximized(url string) {
log := logger.FromContext(s.lifecycleCtx)
ctx, cancel := context.WithTimeout(s.lifecycleCtx, promoteTimeout)
defer cancel()

var client *cdpclient.Client
defer func() {
if client != nil {
client.Close()
}
}()

for {
if client == nil {
c, err := cdpclient.Dial(ctx, url)
if err != nil {
if !s.retryWindowPromotion(ctx, url, err, log) {
return
}
continue
}
client = c
}

err := client.SetWindowBoundsMaximized(ctx)
if err == nil {
log.Info("window promoted to maximized state")
return
}
// A dropped transport never recovers on this client; clear it
// so the next pass redials.
if client.IsClosed() {
client.Close()
client = nil
}
if !s.retryWindowPromotion(ctx, url, err, log) {
return
}
}
}

// retryWindowPromotion reports whether another promotion pass is worth
// running, spacing passes with promoteRetryDelay. It returns false once the
// deadline is up or a newer upstream — a chromium restart — has taken over,
// abandoning this promotion; the newer URL gets its own.
func (s *ApiService) retryWindowPromotion(ctx context.Context, url string, cause error, log *slog.Logger) bool {
if s.upstreamMgr.Current() != url || ctx.Err() != nil {
log.Warn("window maximize abandoned", "error", cause)
return false
}
select {
case <-ctx.Done():
log.Warn("window maximize abandoned", "error", cause)
return false
case <-time.After(promoteRetryDelay):
return true
}
}
192 changes: 192 additions & 0 deletions server/cmd/api/api/window_promotion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package api

import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

"github.com/coder/websocket"
"github.com/kernel/kernel-images/server/lib/devtoolsproxy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// fakeDevTools serves the CDP surface promoteWindowMaximized needs.
// failDials HTTP requests are rejected outright, reproducing a devtools
// endpoint that refuses early connections while chromium binds its socket.
// When dropFirstConn is set, the first upgraded websocket reads one command
// and then drops without responding, reproducing a mid-promotion transport
// drop.
type fakeDevTools struct {
mu sync.Mutex

failDials int
dropFirstConn bool
pageTargetID string
windowState string

dials int
conns int
getTargets int
getWindow int
setMaximized int
}

func (f *fakeDevTools) handler(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.dials++
fail := f.dials <= f.failDials
f.mu.Unlock()
if fail {
w.WriteHeader(http.StatusServiceUnavailable)
return
}

conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer conn.CloseNow()

f.mu.Lock()
f.conns++
drop := f.dropFirstConn && f.conns == 1
f.mu.Unlock()
if drop {
_, _, _ = conn.Read(r.Context())
return
}

for {
_, msg, err := conn.Read(r.Context())
if err != nil {
return
}
var req struct {
ID int64 `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(msg, &req); err != nil {
continue
}

var result any
switch req.Method {
case "Target.getTargets":
f.mu.Lock()
f.getTargets++
f.mu.Unlock()
var targets []map[string]string
if f.pageTargetID != "" {
targets = append(targets, map[string]string{
"targetId": f.pageTargetID,
"type": "page",
"url": "https://example.com/",
})
}
result = map[string]any{"targetInfos": targets}
case "Browser.getWindowForTarget":
f.mu.Lock()
f.getWindow++
f.mu.Unlock()
result = map[string]any{
"windowId": 7,
"bounds": map[string]any{"width": 800, "height": 600, "windowState": f.windowState},
}
case "Browser.setWindowBounds":
f.mu.Lock()
f.setMaximized++
f.mu.Unlock()
result = map[string]any{}
default:
result = map[string]any{}
}

resp := map[string]any{"id": req.ID, "result": result}
b, _ := json.Marshal(resp)
if err := conn.Write(r.Context(), websocket.MessageText, b); err != nil {
return
}
}
}

func (f *fakeDevTools) start(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}

// newPromotionService builds an ApiService whose upstream manager has
// discovered the given devtools URL, ready for promoteWindowMaximized.
func newPromotionService(t *testing.T, url string) *ApiService {
t.Helper()
logPath := filepath.Join(t.TempDir(), "chromium.log")
line := "2026-11-05T00:00:00 chromium DevTools listening on " + url + "\n"
require.NoError(t, os.WriteFile(logPath, []byte(line), 0o644))

um := devtoolsproxy.NewUpstreamManager(logPath, slog.Default())
ctx, cancel := context.WithCancel(context.Background())
um.Start(ctx)
t.Cleanup(cancel)
t.Cleanup(um.Stop)

_, err := um.WaitForInitial(10 * time.Second)
require.NoError(t, err, "upstream manager never discovered the fake url")
require.Equal(t, url, um.Current())

return &ApiService{lifecycleCtx: ctx, upstreamMgr: um}
}

func TestPromoteWindowMaximizedHappyPath(t *testing.T) {
f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"}
url := f.start(t)
s := newPromotionService(t, url)

s.promoteWindowMaximized(url)

f.mu.Lock()
defer f.mu.Unlock()
assert.Equal(t, 1, f.dials, "dial count")
assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls")
}

func TestPromoteWindowMaximizedRetriesFailedDial(t *testing.T) {
// The devtools endpoint refuses the first connection (chromium still
// binding its socket); the promotion must redial within its deadline
// instead of giving up on the url.
f := &fakeDevTools{failDials: 1, pageTargetID: "target-1", windowState: "normal"}
url := f.start(t)
s := newPromotionService(t, url)

s.promoteWindowMaximized(url)

f.mu.Lock()
defer f.mu.Unlock()
assert.Equal(t, 2, f.dials, "the failed dial must be retried")
assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls")
}

func TestPromoteWindowMaximizedRedialsAfterTransportDrop(t *testing.T) {
// The first connection accepts, reads one command, then drops without
// responding. Commands on that client will never succeed; the promotion
// must redial and complete on a fresh connection.
f := &fakeDevTools{dropFirstConn: true, pageTargetID: "target-1", windowState: "normal"}
url := f.start(t)
s := newPromotionService(t, url)

s.promoteWindowMaximized(url)

f.mu.Lock()
defer f.mu.Unlock()
assert.Equal(t, 2, f.dials, "the dropped transport must be redialed")
assert.Equal(t, 1, f.setMaximized, "setWindowBounds maximized calls")
}
4 changes: 4 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ func main() {
os.Exit(1)
}

// Headful: keep the browser window maximized across chromium restarts
// (no-op on the headless image).
apiService.StartWindowPromotion()

// api_call event emission. Off until the telemetry handlers flip it on.
r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish))
r.Use(api.WebMCPRequestSizeMiddleware)
Expand Down
Loading
Loading