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:
- 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.
- Be the substrate CI runs the engine through.
The windowing rules in
docs/notes/backends.mdare explicit that this is not a stub: “CI andcrcbl screenshot/simrun the identical engine loop through it”. A render test pairs it withSurfaceTarget::Offscreen— whichsurface_targetreturns — so the golden-image path exercises the same acquire/present code a real window does. - 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_windowreturns aWindowIdand queues nothing. The firstResizedis delivered by a laterpump— by default the second one, andset_configure_delaymakes it later still. This models Wayland exactly: anxdg_surfaceis unsized and may not be given a buffer until the compositor answers with aconfigure, a round trip away. Consumers must loop onWindowState::sizebefore creating a swapchain, and here they are made to. (The same trick ascrcbl-hal’s injected readback latency, for the same reason.) set_modeandset_constraintsare requests, not commands. They record the request inWindowState::requested_mode/requested_constraintsand schedule a configure; the effective mode only appears inWindowConfiguration::modeonce that configure is delivered. A consumer that reads back its own request as fact fails here.- Constraints are enforced on delivered configures.
SizeConstraints::applyruns, 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 whyinjectexists 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:
- A first configure usually dictates no size at all. Headless always
delivers one.
xdg-shelldefines0 × 0as “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. - 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 separateScaleFactorChanged.WindowConfigurationgroups the two because they arrive in one message; it does not promise the first message is right about both.change_scale_factoris how a test reproduces the sequence. - 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 behaviour | Modelled by | What a consumer learns |
|---|---|---|
Wayland delivers wl_data_device.selection only to the focused client, asynchronously | set_clipboard_readable | a 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 writes | set_clipboard_stalled | the read ends as Unavailable — a failure, not an empty clipboard |
set_selection needs a serial from real user input | require_user_interaction | clipboard_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§
- Headless
Shell - A
Shellwith 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::newcreates. - 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.