Skip to main content

Module headless

Module headless 

Source
Expand description

HeadlessShell — a complete Shell with a virtual monitor, scripted event injection, and no OS calls anywhere.

It is the windowing counterpart of crcbl-hal’s NullBackend, and it has the same three jobs, in increasing order of usefulness:

  1. Prove the seam is implementable without platform types. If a trait method could not be written without naming a compositor, this module would not compile.
  2. Be the substrate CI runs the engine through. The windowing rules in docs/notes/backends.md are explicit that this is not a stub: “CI and crcbl screenshot/sim run the identical engine loop through it”. A render test pairs it with SurfaceTarget::Offscreen — which surface_target returns — so the golden-image path exercises the same acquire/present code a real window does.
  3. Make window behaviour testable. Resize storms, scale-factor changes mid-session, aspect-lock clamping, focus loss: all scriptable, all deterministic, none needing a nested compositor.

§Deterministic by construction

Nothing here reads a clock, a socket or an environment variable. Time comes from an internal ManualTime, so a test that drives both the shell and a FrameClock from the same schedule gets a session that is byte-identical on every machine — the property docs/notes/process.md demands of every nondeterminism source. HeadlessShell::set_time exists so the shell’s clock can be slaved to a test’s own ManualTime rather than drifting alongside it.

§It is stricter than a compositor, on purpose

The temptation with a test double is to make every call succeed immediately. That is the shape that proves nothing: a consumer written against it grows an assumption the real backend then breaks, all at once, long after the code was written. So where headless can be the harsher of the two, it is.

  • A new window has no size. create_window returns a WindowId and queues nothing. The first Resized is delivered by a later pump — by default the second one, and set_configure_delay makes it later still. This models Wayland exactly: an xdg_surface is unsized and may not be given a buffer until the compositor answers with a configure, a round trip away. Consumers must loop on WindowState::size before creating a swapchain, and here they are made to. (The same trick as crcbl-hal’s injected readback latency, for the same reason.)
  • set_mode and set_constraints are requests, not commands. They record the request in WindowState::requested_mode / requested_constraints and schedule a configure; the effective mode only appears in WindowConfiguration::mode once that configure is delivered. A consumer that reads back its own request as fact fails here.
  • Constraints are enforced on delivered configures. SizeConstraints::apply runs, so aspect-locked and min/max behaviour is exercised. A tiling window manager enforces none of it — which is why in-renderer letterboxing is the universal fallback, and why inject exists to feed sizes that violate the constraints outright.

Where it is still easier than a compositor, and knowingly so: the injection helpers (resize, change_scale_factor) apply at once, because they are the compositor speaking rather than the client asking. They are also the escape hatch for a test that wants a configured window in one line.

§Where the model is now known to be wrong (P0.5a)

The Wayland backend’s end-to-end suite is the first thing that could check any of this against a real compositor. Three differences came back, and they are recorded here rather than only in the backend, because this is the file consumers are written against:

  1. A first configure usually dictates no size at all. Headless always delivers one. xdg-shell defines 0 × 0 as “you choose”, and it is what sway sends for a freshly created toplevel — so a real backend supplies the fallback and the requested size stands. A consumer is unaffected (both shapes are “a size arrived”), but a backend author reading only this file would not expect it.
  2. Size arrives before scale. A window’s scale factor comes from wl_surface.enter, which only fires once the surface is mapped — which needs a buffer, which needs the size. So the first configure is at scale 1.0 and the true scale follows as a separate ScaleFactorChanged. WindowConfiguration groups the two because they arrive in one message; it does not promise the first message is right about both. change_scale_factor is how a test reproduces the sequence.
  3. Configured is not the same as managed. A Wayland window that has never been given a buffer is answered once and then ignored: no compositor-chosen geometry, no fullscreen configure, not even present in the window manager’s tree. Headless has no such state, and nothing in the seam expresses it. It is not a defect in the seam — the sequence a consumer must write (configure → swapchain → present → then expect real geometry) is the same either way — but a test that resizes a headless window before its first present is exercising something a compositor would not have done.

§The clipboard’s hard cases are settable (P0.5c)

P0.5c found the same “test double only knows the easy path” problem in the clipboard that P0.4 had fixed for window sizes: a real compositor’s clipboard is not always readable and not always claimable, and a shell that always answered instantly would let a consumer grow exactly the assumption Wayland then breaks. All three cases are now scriptable, and none of them needs a compositor:

Real behaviourModelled byWhat a consumer learns
Wayland delivers wl_data_device.selection only to the focused client, asynchronouslyset_clipboard_readablea read is held until it can be answered, and is never answered Empty for “I have not looked yet”
A peer takes the transfer descriptor and never writesset_clipboard_stalledthe read ends as Unavailable — a failure, not an empty clipboard
set_selection needs a serial from real user inputrequire_user_interactionclipboard_offer can fail with ShellError::NeedsUserInteraction, and that is a policy rather than a bug

The deadline a held read gives up after is clipboard_deadline, counted in pumps rather than milliseconds so a test asserting the timeout is not a flake. Defaults are the permissive ones — readable, not stalled, no interaction required — because most platforms have no such rules and a test that does not care about them should not have to say so.

§Capabilities are settable, which is the point

HeadlessShell::new reports ShellCaps::DESKTOP — everything. But with_caps lets a test report a degraded set: a Wayland-shaped shell with no POINTER_WARP and no ASPECT_HINT_HONORED, say. That is how the caps system gets tested before a second backend exists — a consumer that branches on the platform instead of on caps fails against a headless shell configured to look like the platform it forgot.

Structs§

HeadlessShell
A Shell with no window system behind it.

Constants§

DEFAULT_CLIPBOARD_DEADLINE
Pumps a held clipboard read waits before giving up.
DEFAULT_CONFIGURE_DELAY
Pumps that must complete before a newly requested configure is delivered.
DEFAULT_MONITOR_SIZE
Resolution of the virtual monitor HeadlessShell::new creates.
DEFAULT_REFRESH_MILLIHERTZ
Refresh rate of the virtual monitor, in millihertz — exactly 60 Hz.
VIRTUAL_DEVICE
The device id every injected input event carries unless a helper says otherwise.