Skip to content
Open
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
2 changes: 1 addition & 1 deletion server/cmd/api/api/chromium_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,7 @@ func chromiumDisplayApplyWhileStopped(ctx context.Context, s *ApiService, plan *
if s.isNekoEnabled() {
err = s.setResolutionViaNeko(ctx, w, h, rr)
} else {
err = s.setResolutionXorgViaXrandr(ctx, w, h, rr, false)
err = s.setResolutionXorgViaXrandr(ctx, w, h, rr)
}
if err != nil {
return cfg500ConfigureStep(chromiumConfigureStepDisplay, err.Error())
Expand Down
128 changes: 115 additions & 13 deletions server/cmd/api/api/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,27 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ
err = s.setResolutionViaNeko(ctx, width, height, refreshRate)
} else {
log.Info("using xrandr for Xorg resolution change (Neko disabled)")
err = s.setResolutionXorgViaXrandr(ctx, width, height, refreshRate, restartChrome)
err = s.setResolutionXorgViaXrandr(ctx, width, height, refreshRate)
}
if err == nil && restartChrome {
if restartErr := s.restartChromiumAndWait(ctx, "resolution change"); restartErr != nil {
log.Error("failed to restart chromium after resolution change", "error", restartErr)
// Re-assert the maximized window state via CDP after the X root
// resize. Mutter reflows a window in windowState=maximized (or
// fullscreen) to fill the new root automatically, so this single
// idempotent call is all we need post-resize. The previous
// approach of restarting chromium so it could re-apply
// --start-maximized cost ~9s per resize and also wiped browser-
// side state (Emulation.* overrides, devtools sessions). The
// restart_chromium request field is still accepted for API
// compatibility but no longer triggers a restart on this path.
//
// The CDP call is the only thing that recovers a window already
// in the "normal" state, so its failure is fatal: returning 200
// after a CDP error could leave the browser window stuck at the
// old size while the X root is at the new size, and the caller
// would have no signal of the mismatch.
if err == nil {
if cdpErr := s.setWindowMaximizedViaCDP(ctx, width, height); cdpErr != nil {
log.Error("CDP maximize re-assert failed after Xorg resolution change", "error", cdpErr)
err = fmt.Errorf("CDP maximize re-assert failed: %w", cdpErr)
}
}
} else if len(stopped) > 0 {
Expand Down Expand Up @@ -224,20 +240,31 @@ func (s *ApiService) probeDisplayMode(ctx context.Context) string {
}

// setResolutionXorgViaXrandr changes resolution for Xorg using xrandr (fallback when Neko is disabled)
func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, height, refreshRate int, restartChrome bool) error {
func (s *ApiService) setResolutionXorgViaXrandr(ctx context.Context, width, height, refreshRate int) error {
log := logger.FromContext(ctx)
display := s.resolveDisplayFromEnv()

// Build xrandr command - if refresh rate is specified, use the specific modeline
var xrandrCmd string
if refreshRate > 0 {
modeName := fmt.Sprintf("%dx%d_%d.00", width, height, refreshRate)
xrandrCmd = fmt.Sprintf("xrandr --output default --mode %s", modeName)
log.Info("using specific modeline", "mode", modeName)
} else {
xrandrCmd = fmt.Sprintf("xrandr -s %dx%d", width, height)
// The headful Xorg dummy driver exposes DUMMY0, not "default". The
// historical `xrandr --output default --mode ...` command exits 0 while
// silently doing nothing on this driver. Default to DUMMY0 and let an
// env var override it for any non-standard image layout.
output := strings.TrimSpace(os.Getenv("KERNEL_IMAGES_XRANDR_OUTPUT"))
if output == "" {
output = "DUMMY0"
}

// Per-output resizing requires --mode <name>; --size is a legacy global
// screen option that cannot be combined with --output. Always go through
// a named modeline. The schema enum prevents callers from sending zero,
// and getCurrentResolution falls back to 60 when xrandr is silent
// (Xvfb), but normalize defensively in case either guarantee changes.
if refreshRate <= 0 {
refreshRate = 60
}
modeName := fmt.Sprintf("%dx%d_%d.00", width, height, refreshRate)
xrandrCmd := fmt.Sprintf("xrandr --output %s --mode %s", output, modeName)
log.Info("using specific modeline", "output", output, "mode", modeName)

args := []string{"-lc", xrandrCmd}
env := map[string]string{"DISPLAY": display}
execReq := oapi.ProcessExecRequest{Command: "bash", Args: &args, Env: &env}
Expand Down Expand Up @@ -367,6 +394,81 @@ func (s *ApiService) backgroundResizeXvfb(ctx context.Context, width, height int
s.viewportMu.Unlock()
}

// setWindowMaximizedViaCDP re-asserts that the chromium OS window is in
// the "maximized" state via Browser.setWindowBounds, then waits until the
// window manager has actually reflowed the window onto the new X root.
// After a successful xrandr/Neko resize, mutter reflows a maximized
// (or fullscreen) window to fill the new root automatically — but that
// reflow is asynchronous to the CDP setWindowBounds acknowledgement, so
// the handler polls Browser.getWindowForTarget until the live bounds
// match the requested width/height before returning.
//
// This makes PATCH /display a synchronous contract: the API returns only
// once the window is at the new size, not just once the resize has been
// initiated. The previous approach of restarting chromium so it could
// re-apply --start-maximized had the same effective contract (the restart
// blocked the response) but cost ~9s per resize and wiped browser-side
// state (Emulation.* overrides, devtools sessions).
func (s *ApiService) setWindowMaximizedViaCDP(ctx context.Context, width, height int) error {
log := logger.FromContext(ctx)

upstreamURL := s.upstreamMgr.Current()
if upstreamURL == "" {
return fmt.Errorf("devtools upstream not available")
}

cdpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

client, err := cdpclient.Dial(cdpCtx, upstreamURL)
if err != nil {
return fmt.Errorf("failed to connect to devtools: %w", err)
}
defer client.Close()

if err := client.SetWindowBoundsMaximized(cdpCtx); err != nil {
return fmt.Errorf("CDP setWindowBoundsMaximized: %w", err)
}

if err := waitForWindowSize(cdpCtx, client, width, height, 3*time.Second); err != nil {
return fmt.Errorf("window did not reach %dx%d after CDP maximize: %w", width, height, err)
}

log.Info("re-asserted maximized window state via CDP", "width", width, "height", height)
return nil
}

// waitForWindowSize polls Browser.getWindowForTarget until the live OS
// window's width/height match the requested size, or the deadline expires.
// Typical convergence on docker+mutter is 20–50ms; pick a deadline that
// comfortably covers WM scheduling jitter without masking real bugs.
func waitForWindowSize(ctx context.Context, client *cdpclient.Client, width, height int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var last cdpclient.WindowBounds
var lastErr error
for {
b, err := client.GetWindowBounds(ctx)
lastErr = err
if err == nil {
last = b
if b.Width == width && b.Height == height {
return nil
}
}
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("last getWindowBounds error: %w", lastErr)
}
return fmt.Errorf("window is %dx%d state=%q", last.Width, last.Height, last.WindowState)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}

// setViewportViaCDP resizes the browser viewport using the CDP
// Emulation.setDeviceMetricsOverride command. This is near-instant and does
// not require restarting Chromium or Xvfb.
Expand Down
Loading
Loading