8000 Conf::headless: run with no window on macOS and Linux by 16dprice · Pull Request #668 · not-fl3/miniquad · GitHub
[go: up one dir, main page]

Skip to content

Conf::headless: run with no window on macOS and Linux - #668

Draft
16dprice wants to merge 4 commits into
not-fl3:masterfrom
16dprice:upstream-headless
Draft

16dprice wants to merge 4 commits into
not-fl3:masterfrom
16dprice:upstream-headless

Conversation

@16dprice
@16dprice 16dprice commented Sep 18, 2026 •
Copy link
Copy Markdown

Problem

start creates a window unconditionally on every backend. A test suite or a CI
machine that wants to drive update/draw and read the framebuffer back has
no way to ask for anything else — so tests that draw take the screen and the
focus on a developer's machine, and on a CI executor with no display attached
they fail for the environment rather than for the code.

Working around it outside miniquad means a virtual X server (Xvfb plus a
software Mesa), which is heavier, only helps on Linux, and exercises the
windowed path rather than the offscreen one such a run actually wants.

Solution

Conf::headless (default false, so nothing changes unless it is asked for).
When set, the backend creates no window and draws into an offscreen framebuffer
of window_width x window_height at a dpi scale of 1. update and draw
run at roughly 60 Hz, no input or window event ever arrives, and
window::screen_size(), render passes and framebuffer readback all behave as
usual — the framebuffer object is bound before the event handler is built, so
GlContext::new adopts it as the default framebuffer and nothing above
miniquad has to know.

Two commits, one per platform, sharing that shape:

  • macOS, with AppleGfxApi::OpenGl: an NSOpenGLContext with no drawable
    is made current (Apple discards drawing to its framebuffer 0, but framebuffer
    objects work). No NSApplication, NSWindow or view is created, so nothing
    appears in the Dock and no focus moves.

  • Linux: an EGL pbuffer context in a new native::linux_headless. This
    needs no display server at all — neither DISPLAY nor
    WAYLAND_DISPLAY has to be set, and no X11 or Wayland package has to be
    installed. It is dispatched before Platform::linux_backend is consulted,
    because on such a machine both the X11 and the Wayland backends fail at
    connect time.

    The display comes from EGL_MESA_platform_surfaceless via
    eglGetPlatformDisplayEXT, falling back to EGL_DEFAULT_DISPLAY. That
    order matters and was found the hard way: EGL_DEFAULT_DISPLAY on a stock
    Linux image resolves to the X11 display, returns a non-null EGLDisplay,
    and then eglInitialize fails because no X server is listening — which
    reads like a broken driver rather than the absent window system it is.
    eglGetPlatformDisplayEXT is resolved through eglGetProcAddress rather
    than declared in LibEgl, since declare_module! unwraps every symbol it
    names and a driver without the extension would then fail to load libEGL at
    all instead of falling back.

    It does need an EGL implementation to render with; on a machine with no GPU,
    Mesa's software stack (libegl1, libegl-mesa0, libgles2,
    libgl1-mesa-dri) is enough.

sample_count is ignored for a headless run and the dpi scale is 1. Other
backends ignore the flag and open a window as before. The windowed X11 and
Wayland paths are untouched; egl.rs gains only the pbuffer constants and the
EGL null handles.

Testing

examples/headless.rs draws the triangle example into the offscreen
framebuffer, reads a pixel from the middle of the triangle and one from a
corner, checks both and exits — so it is a real assertion about what was
rendered, not just that the process started.

  • macOS: the example passes, and nothing appears on screen or in the Dock.
  • Linux: exercised on a CI executor (cimg/rust, no GPU, no display server)
    against Mesa 23.2.1's software EGL, running a downstream suite of scripted
    UI runs, layout dumps and screenshot comparisons against blessed snapshots.
    The executor log confirms the platform that answered:
    miniquad: headless EGL on EGL_PLATFORM_SURFACELESS_MESA. That line is
    behind MINIQUAD_HEADLESS_VERBOSE, because a headless run's output is
    usually some test's captured result and an unconditional line there
    displaces what the run was meant to print.

Opened as a draft: happy to split the two commits, rename anything, or adjust
where the Linux dispatch sits if you would rather it lived inside
linux_x11/linux_wayland than beside them.

16dprice and others added 4 commits September 18, 2026 11:13
A test suite or a CI machine wants to drive `update`/`draw` and read the
framebuffer back without a window appearing, stealing focus, or needing a
display at all. There was no way to ask for that: `start` on every backend
creates a window unconditionally.

`Conf::headless` (default `false`) asks for one. On macOS with the OpenGL
backend it now:

- creates no `NSApplication`, `NSWindow` or view, so nothing shows in the
  Dock and no focus moves;
- makes an `NSOpenGLContext` with no drawable current — Apple discards
  drawing to its framebuffer 0, but framebuffer objects work — and binds
  an RGBA8 + depth24/stencil8 framebuffer object of `window_width` x
  `window_height` before the event handler is built, so `GlContext::new`
  adopts it as the default framebuffer. Default passes, `screen_size()`,
  render textures and readback all work unchanged above miniquad;
- runs `update`/`draw` at ~60 Hz with no event queue, honours
  `set_window_size` by re-specifying the renderbuffers in place, and quits
  when the handler orders it (or requests it and does not cancel).

The dpi scale of a headless run is 1 and `sample_count` is ignored. Other
backends ignore the flag and open a window as before.

`examples/headless.rs` draws the triangle example into the offscreen
framebuffer, reads two pixels back, checks them and exits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The flag added for macOS is honored on Linux too, so a test suite or a CI
machine can drive `update`/`draw` and read the framebuffer back with no
window, no focus moved, and — the part X11 and Wayland cannot give — no
display server running at all.

EGL can hand out a context without a window system: `EGL_DEFAULT_DISPLAY`
plus a pbuffer surface. `native::linux_headless` does that and nothing else.
It runs before `Platform::linux_backend` is consulted, because on a machine
with neither an X11 nor a Wayland socket both of those backends fail at
connect time, and it keeps the shape of the macOS path:

- an RGBA8 + depth24/stencil8 framebuffer object of `window_width` x
  `window_height` is bound before the event handler is built, so
  `GlContext::new` adopts it as the default framebuffer and default passes,
  `screen_size()`, render textures and readback all work unchanged. The
  pbuffer exists only because `eglMakeCurrent` needs a surface; nothing is
  drawn to it and it is never swapped;
- `update`/`draw` run at ~60 Hz with no event queue, `set_window_size`
  re-specifies the renderbuffers in place, and the run quits when the handler
  orders it (or requests it and does not cancel).

The dpi scale is 1 and `sample_count` is ignored, as on macOS. The windowed
X11 and Wayland backends are untouched; `egl.rs` gains only the pbuffer
constants and the EGL null handles.

Needs a software EGL to run against where there is no GPU: Mesa's
`libegl-mesa0` and `libgl1-mesa-dri` are enough, and no X11 or Wayland
package is required.
`eglInitialize` failed on a CI executor with no display server, so every
headless run died before drawing:

    headless EGL backend failed: InitializeFailed

`EGL_DEFAULT_DISPLAY` is the wrong question to ask on a stock Linux image.
The driver reads it as "the X11 display", hands back a non-null `EGLDisplay`,
and initialisation then fails because no X server is listening — which looks
like a broken driver rather than the absent window system it is.

`EGL_MESA_platform_surfaceless` names a platform that is not a window system
at all, which is exactly the situation. `open_display` asks for that first,
through `eglGetPlatformDisplayEXT`, and falls back to `EGL_DEFAULT_DISPLAY`
for a driver that does not offer the extension. The chosen route is printed,
so a log says which platform answered rather than leaving a future Mesa
change to be guessed at.

`eglGetPlatformDisplayEXT` is resolved through `eglGetProcAddress` rather
than declared in `LibEgl`, because `declare_module!` unwraps every symbol it
names: declaring an extension there would make a driver without it fail to
load libEGL at all, instead of falling back.

Also stop requiring 24-bit depth and 8-bit stencil of the pbuffer config.
Nothing is ever drawn to the pbuffer — the depth and stencil that get used
are the offscreen framebuffer's own renderbuffer — so those constraints only
narrowed the candidate set, and a driver offering no pbuffer config with 24/8
would have failed for a buffer this run does not touch.
A headless run is normally a child process whose stdout and stderr some
test is reading as the run's result. An unconditional line naming the EGL
platform is indistinguishable there from what the run was meant to print,
and it displaced the real assertion output of a failing scripted test.

Gate it on MINIQUAD_HEADLESS_VERBOSE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0