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
10 changes: 7 additions & 3 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ type ApiService struct {

// viewportOverride stores the last viewport dimensions set via CDP so
// that getCurrentResolution can return consistent values even while
// Xvfb is restarting in the background.
viewportMu sync.RWMutex
viewportOverride *[3]int // [width, height, refreshRate] or nil
// Xvfb is restarting in the background. lastHeadlessRefreshRate
// persists across override clears because Xvfb does not surface the
// refresh rate via xrandr — without it, repeat requests at the same
// non-default rate would not be detected as no-ops.
viewportMu sync.RWMutex
viewportOverride *[3]int // [width, height, refreshRate] or nil
lastHeadlessRefreshRate int

// cachedDisplayMode caches the result of detectDisplayMode since the
// display server type (xorg vs xvfb) does not change at runtime.
Expand Down
18 changes: 8 additions & 10 deletions server/cmd/api/api/chromium_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,21 +632,18 @@ func chromiumPrepareDisplay(ctx context.Context, s *ApiService, displayJSON *str
logger.FromContext(ctx).Error("failed to get current resolution", "error", err)
return nil, cfg500ConfigureStep(chromiumConfigureStepDisplay, "failed to get current display resolution")
}
width, height, refreshRate := currentWidth, currentHeight, currentRefreshRate
if body.Width != nil {
width = *body.Width
}
if body.Height != nil {
height = *body.Height
}
if body.RefreshRate != nil {
refreshRate = int(*body.RefreshRate)
}
width, height, refreshRate, changed := resolveDisplayParams(body, currentWidth, currentHeight, currentRefreshRate)

if width <= 0 || height <= 0 {
return nil, cfg400("invalid width/height")
}

// Display already matches — return a nil plan so the caller skips the apply step.
if !changed {
logger.FromContext(ctx).Info("display already at requested resolution, skipping resize", "width", width, "height", height, "refreshRate", refreshRate)
return nil, nil
}

requireIdle := true
if body.RequireIdle != nil {
requireIdle = *body.RequireIdle
Expand Down Expand Up @@ -683,6 +680,7 @@ func chromiumDisplayApplyWhileStopped(ctx context.Context, s *ApiService, plan *
err := s.resizeXvfb(ctx, w, h)
if err == nil {
s.clearViewportOverride()
s.recordHeadlessRefreshRate(rr)
}
s.xvfbResizeMu.Unlock()
if err != nil {
Expand Down
106 changes: 82 additions & 24 deletions server/cmd/api/api/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,24 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
log.Error("failed to get current resolution", "error", err)
return oapi.PatchDisplay500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get current display resolution"}}, nil
}
width := currentWidth
height := currentHeight
refreshRate := currentRefreshRate

if req.Body.Width != nil {
width = *req.Body.Width
}
if req.Body.Height != nil {
height = *req.Body.Height
}
if req.Body.RefreshRate != nil {
refreshRate = int(*req.Body.RefreshRate)
}
width, height, refreshRate, changed := resolveDisplayParams(req.Body, currentWidth, currentHeight, currentRefreshRate)

if width <= 0 || height <= 0 {
return oapi.PatchDisplay400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid width/height"}}, nil
}

// Display already matches the requested state. Skip the idle check,
// recording stop, resize, and chromium restart — all of which would be
// no-ops at the existing resolution.
if !changed {
log.Info("display already at requested resolution, skipping resize", "width", width, "height", height, "refreshRate", refreshRate)
return oapi.PatchDisplay200JSONResponse{
Width: &width,
Height: &height,
RefreshRate: &refreshRate,
}, nil
}

log.Info(fmt.Sprintf("resolution change requested from %dx%d@%d to %dx%d@%d", currentWidth, currentHeight, currentRefreshRate, width, height, refreshRate))

// Parse requireIdle flag (default true)
Expand Down Expand Up @@ -137,6 +137,7 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
err = s.resizeXvfb(ctx, width, height)
if err == nil {
s.clearViewportOverride()
s.recordHeadlessRefreshRate(refreshRate)
}
s.xvfbResizeMu.Unlock()
if err == nil {
Expand Down Expand Up @@ -177,6 +178,28 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
}, nil
}

// resolveDisplayParams merges the request body with the current display
// state, returning the final width, height, and refresh rate plus whether
// any field would actually change. Callers use the changed flag to skip
// the resize path when the request is a no-op.
func resolveDisplayParams(body *oapi.PatchDisplayJSONRequestBody, currentWidth, currentHeight, currentRefreshRate int) (width, height, refreshRate int, changed bool) {
width, height, refreshRate = currentWidth, currentHeight, currentRefreshRate
if body == nil {
return
}
if body.Width != nil {
width = *body.Width
}
if body.Height != nil {
height = *body.Height
}
if body.RefreshRate != nil {
refreshRate = int(*body.RefreshRate)
}
changed = width != currentWidth || height != currentHeight || refreshRate != currentRefreshRate
return
}

// detectDisplayMode detects whether we're running Xorg (headful) or Xvfb
// (headless). The result is cached because the display server type does not
// change during the container's lifetime, and querying supervisorctl during
Expand Down Expand Up @@ -424,37 +447,70 @@ func (s *ApiService) resolveDisplayFromEnv() string {

// setViewportOverride stores the last-known viewport dimensions so
// getCurrentResolution can return them even while Xvfb is restarting.
// It also records the refresh rate as the sticky headless value.
func (s *ApiService) setViewportOverride(width, height, refreshRate int) {
s.viewportMu.Lock()
s.viewportOverride = &[3]int{width, height, refreshRate}
if refreshRate > 0 {
s.lastHeadlessRefreshRate = refreshRate
}
s.viewportMu.Unlock()
}

// clearViewportOverride removes the viewport override (e.g. after Xvfb
// finishes restarting and xrandr is reliable again).
// finishes restarting and xrandr is reliable again). The sticky
// lastHeadlessRefreshRate is intentionally preserved so that
// getCurrentResolution can still return the requested refresh rate after
// xrandr stops surfacing it.
func (s *ApiService) clearViewportOverride() {
s.viewportMu.Lock()
s.viewportOverride = nil
s.viewportMu.Unlock()
}

// recordHeadlessRefreshRate stores the last refresh rate applied via a
// headless path so identical follow-up requests are detected as no-ops.
// Zero values are ignored — they signal "rate not specified".
func (s *ApiService) recordHeadlessRefreshRate(refreshRate int) {
if refreshRate <= 0 {
return
}
s.viewportMu.Lock()
s.lastHeadlessRefreshRate = refreshRate
s.viewportMu.Unlock()
}

// getCurrentResolution returns the current display resolution and refresh
// rate. If a viewport override is set (from a recent CDP resize while Xvfb
// restarts in the background), it returns the override instead of querying
// xrandr, which may fail during Xvfb restarts.
// xrandr, which may fail during Xvfb restarts. When xrandr does not surface
// a refresh rate (Xvfb), the last-applied headless rate is used so repeat
// requests at the same rate are detected as no-ops.
func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int, int, error) {
s.viewportMu.RLock()
override := s.viewportOverride
stickyRate := s.lastHeadlessRefreshRate
s.viewportMu.RUnlock()
if override != nil {
return override[0], override[1], override[2], nil
}

return s.getCurrentResolutionFromXrandr(ctx)
w, h, rr, rateFromXrandr, err := s.getCurrentResolutionFromXrandr(ctx)
if err != nil {
return 0, 0, 0, err
}
if !rateFromXrandr && stickyRate > 0 {
rr = stickyRate
}
return w, h, rr, nil
}

// getCurrentResolutionFromXrandr queries xrandr for the current display resolution.
func (s *ApiService) getCurrentResolutionFromXrandr(ctx context.Context) (int, int, int, error) {
// getCurrentResolutionFromXrandr queries xrandr for the current display
// resolution. The fourth return value reports whether the refresh rate was
// parsed from xrandr output (true) or is the synthesized fallback (false);
// callers can use it to prefer a previously-recorded rate when xrandr is
// silent, as Xvfb is.
func (s *ApiService) getCurrentResolutionFromXrandr(ctx context.Context) (int, int, int, bool, error) {
log := logger.FromContext(ctx)
display := s.resolveDisplayFromEnv()

Expand All @@ -466,41 +522,43 @@ func (s *ApiService) getCurrentResolutionFromXrandr(ctx context.Context) (int, i
out, err := cmd.Output()
if err != nil {
log.Error("failed to get current resolution", "error", err)
return 0, 0, 0, fmt.Errorf("failed to execute xrandr command: %w", err)
return 0, 0, 0, false, fmt.Errorf("failed to execute xrandr command: %w", err)
}

resStr := strings.TrimSpace(string(out))
parts := strings.Split(resStr, "x")
if len(parts) != 2 {
log.Error("unexpected xrandr output format", "output", resStr)
return 0, 0, 0, fmt.Errorf("unexpected xrandr output format: %s", resStr)
return 0, 0, 0, false, fmt.Errorf("unexpected xrandr output format: %s", resStr)
}

width, err := strconv.Atoi(parts[0])
if err != nil {
log.Error("failed to parse width", "error", err, "value", parts[0])
return 0, 0, 0, fmt.Errorf("failed to parse width '%s': %w", parts[0], err)
return 0, 0, 0, false, fmt.Errorf("failed to parse width '%s': %w", parts[0], err)
}

// Parse height and refresh rate (e.g., "1080_60.00" -> height=1080, rate=60)
heightStr := parts[1]
refreshRate := 60 // default
refreshRate := 60 // default when xrandr omits the _rate suffix (e.g. Xvfb)
rateFromXrandr := false
if idx := strings.Index(heightStr, "_"); idx != -1 {
rateStr := heightStr[idx+1:]
heightStr = heightStr[:idx]
// Parse the refresh rate (e.g., "60.00" -> 60)
if rateFloat, err := strconv.ParseFloat(rateStr, 64); err == nil {
refreshRate = int(rateFloat)
rateFromXrandr = true
}
}

height, err := strconv.Atoi(heightStr)
if err != nil {
log.Error("failed to parse height", "error", err, "value", heightStr)
return 0, 0, 0, fmt.Errorf("failed to parse height '%s': %w", heightStr, err)
return 0, 0, 0, false, fmt.Errorf("failed to parse height '%s': %w", heightStr, err)
}

return width, height, refreshRate, nil
return width, height, refreshRate, rateFromXrandr, nil
}

// stoppedRecordingInfo holds state captured from a recording that was stopped
Expand Down
98 changes: 98 additions & 0 deletions server/cmd/api/api/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/kernel/kernel-images/server/lib/logger"
oapi "github.com/kernel/kernel-images/server/lib/oapi"
"github.com/kernel/kernel-images/server/lib/recorder"
"github.com/kernel/kernel-images/server/lib/scaletozero"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -253,6 +254,103 @@ func TestStopAndStartNewSegment_RoundTrip(t *testing.T) {
_ = newRec.Stop(ctx)
}

func TestResolveDisplayParams(t *testing.T) {
intPtr := func(v int) *int { return &v }
rrPtr := func(v int) *oapi.PatchDisplayRequestRefreshRate {
x := oapi.PatchDisplayRequestRefreshRate(v)
return &x
}

cases := []struct {
name string
body *oapi.PatchDisplayJSONRequestBody
curW, curH, curRR int
wantW, wantH, wantRR int
wantChanged bool
}{
{
name: "matching dims, no refresh rate",
body: &oapi.PatchDisplayJSONRequestBody{Width: intPtr(1920), Height: intPtr(1080)},
curW: 1920, curH: 1080, curRR: 25,
wantW: 1920, wantH: 1080, wantRR: 25,
wantChanged: false,
},
{
name: "matching dims and matching refresh rate",
body: &oapi.PatchDisplayJSONRequestBody{Width: intPtr(1920), Height: intPtr(1080), RefreshRate: rrPtr(60)},
curW: 1920, curH: 1080, curRR: 60,
wantW: 1920, wantH: 1080, wantRR: 60,
wantChanged: false,
},
{
name: "different width",
body: &oapi.PatchDisplayJSONRequestBody{Width: intPtr(1280), Height: intPtr(1080)},
curW: 1920, curH: 1080, curRR: 25,
wantW: 1280, wantH: 1080, wantRR: 25,
wantChanged: true,
},
{
name: "different refresh rate, matching dims",
body: &oapi.PatchDisplayJSONRequestBody{Width: intPtr(1920), Height: intPtr(1080), RefreshRate: rrPtr(60)},
curW: 1920, curH: 1080, curRR: 25,
wantW: 1920, wantH: 1080, wantRR: 60,
wantChanged: true,
},
{
name: "only width supplied, matches current",
body: &oapi.PatchDisplayJSONRequestBody{Width: intPtr(1920)},
curW: 1920, curH: 1080, curRR: 25,
wantW: 1920, wantH: 1080, wantRR: 25,
wantChanged: false,
},
{
name: "nil body",
body: nil,
curW: 1920, curH: 1080, curRR: 25,
wantW: 1920, wantH: 1080, wantRR: 25,
wantChanged: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w, h, rr, changed := resolveDisplayParams(tc.body, tc.curW, tc.curH, tc.curRR)
assert.Equal(t, tc.wantW, w)
assert.Equal(t, tc.wantH, h)
assert.Equal(t, tc.wantRR, rr)
assert.Equal(t, tc.wantChanged, changed)
})
}
}

func TestHeadlessRefreshRateSticky(t *testing.T) {
t.Run("setViewportOverride records sticky rate", func(t *testing.T) {
svc := &ApiService{}
svc.setViewportOverride(1280, 720, 25)
assert.Equal(t, 25, svc.lastHeadlessRefreshRate)
})

t.Run("clearViewportOverride preserves sticky rate", func(t *testing.T) {
svc := &ApiService{}
svc.setViewportOverride(1280, 720, 25)
svc.clearViewportOverride()
assert.Nil(t, svc.viewportOverride)
assert.Equal(t, 25, svc.lastHeadlessRefreshRate)
})

t.Run("recordHeadlessRefreshRate ignores zero", func(t *testing.T) {
svc := &ApiService{lastHeadlessRefreshRate: 30}
svc.recordHeadlessRefreshRate(0)
assert.Equal(t, 30, svc.lastHeadlessRefreshRate)
})

t.Run("recordHeadlessRefreshRate updates sticky rate", func(t *testing.T) {
svc := &ApiService{lastHeadlessRefreshRate: 30}
svc.recordHeadlessRefreshRate(75)
assert.Equal(t, 75, svc.lastHeadlessRefreshRate)
})
}

func TestProbeDisplayMode(t *testing.T) {
t.Run("detects xvfb when config exists", func(t *testing.T) {
svc := &ApiService{}
Expand Down
Loading