Render-node headless compositor backend
Status: accepted, 2026-10-20.
compd has three backends: kms-live owns physical scanout, nested draws
inside a host compositor, and headless draws a virtual output using only
a render node. Headless is the offscreen test and future recording target;
hardware behaviour continues to be tested on KMS.
The headless backend lives beside nested at compd layer 4. GBM/EGL opens
the selected render node and verifies the EGL device identity. There is no
DRM device, master, connector, libseat, udev or libinput. The virtual output
is headless-0; mode and scale are explicit. Two offscreen GLES textures
alternate, with full repaints after scene damage. A one-shot software timer
caps frames at 60 Hz and stops while idle. Software presentation uses
CLOCK_MONOTONIC and claims no hardware completion or vblank.
COMPD_HEADLESS_REFRESH_HZ may select a lower rate from 30 through 60 Hz;
the virtual mode and frame timer use the same rate. The default is 60 Hz.
The binding profile is headless; the default Bus service is
comp-headless, with comp-<name> available through --bus-service for
instances. Headless never requests realtime priority or negative nice,
starts no XWayland, and leaves the desktop session environment alone.
Headless owns agent input authority without a VT; session locks still clear
agent input, while KMS and nested retain their session-pause rules. seat0
is advertised with no capabilities, and human input refuses seat_unavailable.
The agent pointer starts at output centre; absolute headless-0 moves clamp
to the mode's logical bounds. Each Bus service has its own observation topics,
window registry and presentation statistics.
Screencopy and Bus captures run after completed offscreen rendering, including on idle outputs. The normal picture excludes cursor elements; a second pass is made only when a capture requests them. Cursor inclusion is chosen per capture, including mixed requests in one frame, and an unavailable picture fails its capture rather than substituting another cursor policy. Capturing unchanged content does not count as a new presentation.
Deployment and recording
An instance is a headless desk: its own nspawn machine, noded, session bus,
PipeWire and portald, with only the GPU render node bound and no VT, tty or
seatd. compd-headless@.service uses the session slot %i, just as compd@
does: a console desk uses a VT number; a headless desk can use rec. Both
read /etc/mixos/session/%i.env. Inside the desk, the compositor's Bus service
is comp; each desk has its own node. The standalone CLI default remains
comp-headless.
The component owns mixos-headless.slice with CPUQuota=200%; the desk's
compositor runs there at nice 10. Desk provisioning places its portal,
PipeWire, WirePlumber and encoder in that slice too. The container has its
own quota, so the slice does not need a per-instance name.
COMPD_HEADLESS_REFRESH_HZ passes through the session environment file.
portald's serve --screencast-max-framerate N sets the cast ceiling in
1..=120 Hz. It defaults to 30, preserving console sessions; a headless desk
can request 60 through its portal unit drop-in. This ceiling does not change
the compositor's refresh rate. These templates do not provision or start a desk.
Phase 4b acceptance measures presentation cadence: presented_hz is the
presented-frame count divided by the paced probe's elapsed time, and
cadence_missed counts presentation gaps greater than 1.5 requested intervals.
Captured-frame uniqueness is measured from the probe's pixel frame IDs.
The client-deadline missed count remains a regression guard only: two
free-running 60 Hz clocks beat against that deadline without establishing
a compositor cadence defect.
The Phase 2 plain Wayland EGL client probe (weston-simple-egl, with
WAYLAND_DEBUG=client) created its first wl_buffer through wl_shm_pool.
Its rendered pixels survived composition and screencopy, but this was a
wl_shm fallback. Client DMA-BUF negotiation remains unproven; this probe
does not establish the GPU-client DMA-BUF path.
Phase 5b publishes the validated headless render node in the kernel registry
as the composite device for backends without a scanout binding. The DMA-BUF
advertiser uses the native GPU binding first, as before, otherwise this node,
otherwise the v3 flat format list (nested). Headless therefore advertises
zwp_linux_dmabuf_v1 v5-capable feedback with main_device equal to the render
node's dev_t, using the existing narrowed import format set. Mesa can select its
hardware EGL driver from that feedback without a legacy wl_drm global.
The GPU acceptance tests read the default feedback on a real Wayland client,
compare its main device against the selected node's st_rdev, and require a
tranche carrying valid format-table indices. The plain EGL client must create
linux-dmabuf buffers and pass the existing rendered-pixel readback checks;
this establishes GPU EGL client allocation, import and composition. The ced
probe continues to record its actual wgpu adapter and attached buffer type.
This does not establish hardware Vulkan WSI on the build workers: their only
Vulkan ICD is llvmpipe and they lack vulkan-intel. On a headless desk with
vulkan-intel installed, ced chose Vulkan on the Intel GPU and presented
through linux-dmabuf even against the earlier v3 global (2026-10-21,
recorded in the private journal); the Vulkan path depends on the ICD being
in the desk image, which the image builder now includes.
The Phase 5b Intel render-node run observed a version 6 global from the current
Smithay feedback helper. Mesa bound version 4. Default feedback named
0xe280, equal to the selected node's (/dev/dri/renderD128) st_rdev, with
one tranche of 224 format indices.
weston-simple-egl created linux-dmabuf buffers; screencopy retained its dark
background and bright bar. Ced chose Gl, IntegratedGpu,
Mesa Intel(R) Iris(R) Xe Graphics (RPL-P), Mesa 26.2.4, and attached
linux-dmabuf buffers. Idle SSIM was 1.0 and typed SSIM 0.980828380141, with
exact Bus text readback. No console reference was supplied for this run.
Phase 5 uses ced's real egui/wgpu window with a private noded and editd, so
ced.state {text:true} can confirm agent-seat typing independently of pixels.
The reference helper services/compd/crates/testkit/scripts/app_reference.mix
creates a fresh document and places an 800×560 window at (80,60), scale 1,
in either session; the console-desk reference is captured separately with
that same helper. Before the first GPU run, the acceptance threshold is fixed
at grayscale SSIM ≥ 0.99 for repeated idle captures and the console/headless
comparison, allowing minor rasterisation differences while rejecting material
layout changes. The known typed line must produce SSIM < 0.999 against the
blank document, alongside exact Bus text readback. SSIM uses uniform sliding
8×8 valid windows, population covariance, Rec.601 luma, K1=0.01, K2=0.03 and
L=255. A test-only ced launcher installs a log subscriber around the unchanged
entry point to record the chosen wgpu adapter; Wayland client tracing records
the actual buffer creation request. A shm result is reported as a weaker
client-buffer claim, even when wgpu renders on a GPU. Software wgpu adapters
are recorded explicitly and establish no GPU-client claim.
What the headless-only run proves, and does not (review, 2026-10-21): with no
console reference supplied, the test shows a large, stable rendered change
after typing plus exact model text; it does not check glyph correctness
(tofu or a wrong font fallback would still pass). Glyphs are only pinned by
the console comparison. SSIM at 0.99 tolerates uniform brightness drift: a
+10 luma shift scores about 0.9955 at mid grey and 0.9988 at bright grey, so
a brightness regression is within the threshold by design and must be
caught elsewhere. The tested artefact is the test-only launcher, which
compiles the shipped entry point from the same source; drift is a compile
error, never silent. The first Phase 5 run chose a software Vulkan adapter
(llvmpipe) with wl_shm; Phase 5b's device feedback enables the hardware GL
path recorded above. Hardware Vulkan WSI is unproven on the workers and
observed on a desk with the Intel ICD. Open follow-ups
are the test's fixed post-typing sleep (retry once on an unchanged capture),
the helper's leftover .session directory on
failure, and its reliance on spawn merging the parent environment.
Wayland conformance (Phase 6)
canonical/wlcs exercises protocol behaviour:
buffers, pointer and touch delivery, constraints, clipboard and selection,
subsurfaces, shell roles and popups, output descriptions and supported extensions.
It does not establish KMS, physical input or hardware presentation correctness.
The baseline pins v1.8.1, commit
2b385328366de5db19521b9b3eef81861430d984.
The private wlcs crate produces libcompd_wlcs.so. It implements the C ABI
independently, with the callback structure informed by
Smithay 0.7's wlcs_anvil.
Small client/surface lookup fragments in the compd adapter follow that reference.
The C ABI shim and owned-child lifecycle were implemented for compd. The /256
conversion implements Wayland's signed 24.8 fixed-point ABI; touch_destroy
auto-up is local lifetime handling (wlcs_anvil's touch destroy is empty).
Both source headers and NOTICE credit the reference; its MIT licence remains
in vendor/smithay/LICENSE.txt.
It owns a fresh compd --headless child for each server, isolating process-global
configuration, logging and GPU resources across tests. An inherited socket carries
test requests and client descriptors directly to compd's event loop. That control
socket has no listening endpoint; the child also listens on --socket wayland-wlcs
inside its private runtime directory. Pointer events call the agent seat's existing
policy-host injection path; touch uses that host's agent hit test on down and
Smithay's held focus for motion and up. Refused delivery returns an explicit error
on the control socket and is printed with the compositor log path. Window placement
uses the native policy-host placement executor with a test-only entry point:
it retains redraw, pointer retargeting and interactive-grab completion while
permitting deliberately off-output positions. All new
entry points are behind wlcs. Callbacks wait for completion on the compositor loop.
The extension descriptor is read from a live Wayland registry, including the
advertised versions. Creation probes a short-lived child and reaps it to cache
the descriptor before wlcs asks for it. The descriptor callback only reads the
cache, including after stop; only explicit start retains a test compositor.
Stop kills and reaps the owned child,
and parent death also kills it after a wlcs abort. The adapter is compiled only
with compd's wlcs feature and installed only on the headless backend.
Placement uses the requested wl_surface origin at screen (x, y). The shim
converts through the native camera transform (world (0, 0) is screen centre),
adds the committed xdg geometry offset to obtain the Space element location,
then reads the public window row and checks that its surface origin projects
back to (x, y). Successful checks appear in the gate output. Absolute pointer
and touch coordinates use the same conversion, avoiding a half-screen shift.
There is a contract discrepancy in the pinned wlcs tests. display_server.h
describes the window's left/top; both pointer_respects_window_geom_offset and
touch_respects_window_geom_offset in tests/xdg_toplevel_stable.cpp:131,162
(and their v6 equivalents at 103,134) expect local input coordinates
input - placement + geometry_offset, which positions the window geometry
origin. They use offset (35, 12) and input delta (20, 30), expecting
(55, 42). Under the explicitly selected surface-origin convention, the correct
local point would be (20, 30). Smithay's reference adapter also uses the geometry
origin: vendor/smithay/wlcs_anvil/src/main_loop.rs:213 maps the element at the
wlcs location without adding the geometry offset. The pinned tests and that
reference agree; this adapter's selected surface-origin convention differs.
This contract mismatch is established from source, not inferred from a failed
run. No test source or normal compositor behaviour is changed to conceal it.
The fresh recording reports the four named cases as follows (coordinates below are pixels, converted from Wayland 24.8 values):
| Case | Observed result |
|---|---|
XdgToplevelStableTest.pointer_respects_window_geom_offset | Fails the coordinate assertion: (2.5, 24) observed, (55, 42) expected by wlcs. Placement self-check reports (200, 280). |
XdgToplevelStableTest.touch_respects_window_geom_offset | touch_down refuses touch_no_target; the touched-window assertion sees NULL, before the coordinate assertion. Placement self-check reports (200, 280). |
XdgToplevelV6Test.pointer_respects_window_geom_offset | Skipped: missing zxdg_shell_v6 >= 1. |
XdgToplevelV6Test.touch_respects_window_geom_offset | Skipped: missing zxdg_shell_v6 >= 1. |
The stable pointer result also differs from (20, 30), so the convention mismatch
alone does not explain that failure. The native coordinate/focus path still needs
tracing; the stable touch case stops at native hit testing. Neither stable case
reports unconfigured_buffer in this recording, and neither v6 case tests input.
Build on a GPU worker, keeping wlcs outside the MixOS checkout:
git clone --depth 1 --branch v1.8.1 https://github.com/canonical/wlcs /build/wlcs
cmake -S /build/wlcs -B /build/wlcs/build -DCMAKE_BUILD_TYPE=Release \
-DBOOST_ROOT=/build/wlcs/deps/usr \
-DCMAKE_CXX_FLAGS=-Wno-error=deprecated-declarations
cmake --build /build/wlcs/build --target wlcs -j4
cargo build -p wlcs --locked
cargo build -p compd --no-default-features --features wlcs --locked
cargo clippy -p wlcs --all-targets --no-deps --locked -- -D warnings
cargo --config 'env.COMPD_WLCS_BINARY="/build/mixos/target/debug/compd"' \
test -p wlcs --locked -- --ignored --nocapture
/opt/mixos/bin/mix services/compd/tests/wlcs_gate.mix \
--wlcs /build/wlcs/build/wlcs \
--shim /build/mixos/target/debug/libcompd_wlcs.so \
--compd /build/mixos/target/debug/compd \
--output-dir /build/wlcs/run-1
Wayland development files, gtest/gmock and Boost headers are required. The first
build used Boost 1.92.0 unpacked under deps, without a system install. The
compiler flag permits wlcs's deprecated gmock Invoke calls with gmock 1.18.0.
WLCS may replace --wlcs; COMPD_TEST_RENDER_NODE selects the render node.
The ignored shim lifecycle test needs an absolute COMPD_WLCS_BINARY because
Cargo runs it from the package directory. It checks cached descriptors before
start and after stop, including stable backing storage. --no-deps scopes
Clippy's warning gate to the shim rather than inherited legacy dependency lints.
The gate enumerates every test and runs four isolated processes at a time, each with a 20-second
deadline, preserving JSON, process results and compositor logs in the output
directory. It checks every name and outcome against
services/compd/tests/wlcs_expectations.conf.mix; additions, removals and changed
outcomes fail with a diff. The first observation uses --record --expectations <new-file>; recording refuses to overwrite a file. Disabled and unsupported
tests are recorded as skipped. The flaky list must be empty: exemptions are
refused, and every observed outcome must match. The control socket's 10-second
read/write timeout is half the process deadline; transport errors name the
compositor log before aborting. Conformance failures are
recorded for later work; Phase 6 does not change compositor policy to fix them.
The first complete observation (2026-10-21) recorded 1,228 tests: 361 passed,
563 failed, 304 skipped, 0 classified flaky. Every failed case produced a
complete JSON report; no case hit the gate's process deadline. Unsupported
protocols use wlcs's wlcs-skip-test JSON property, even though gtest also
records an exception for them. These are skips, alongside disabled cases.
After the shim review, a fresh recording from ab43adf4 on 2026-10-21 produced
the same inventory and outcomes. The expectation file was removed before
recording; its data was regenerated rather than preserving any exemptions.
| Outcome or failure class | Before review | Fresh recording |
|---|---|---|
| Passed | 361 | 361 |
| Failed | 563 | 563 |
| Skipped | 304 | 304 |
| Flaky | 0 | 0 |
xdg_surface 3, unconfigured_buffer | 319 | 319 |
zwlr_layer_surface_v1 0, invalid_surface_state | 79 | 79 |
| Input-region assertions | 58 | 58 |
Every one of the 1,228 cases produced complete JSON; none hit a process deadline,
terminated by signal or reported an adapter error. 237 cases passed the
surface-origin self-check. Unchanged outcomes do not validate the old shim:
the named stable touch_and_drag_on_surface_seen and
touch_drag_outside_of_surface_and_back_not_lost cases still fail with
unconfigured_buffer before reaching input injection, so they cannot prove
touch continuity. Fixture source (src/surface_builder.cpp) shows that these
cases use XdgStableSurfaceBuilder, which assigns the xdg role, sets window
geometry and calls attach_visible_buffer without the initial bufferless
commit/configure/ack handshake. Their errors are consistent with enforcing
that handshake; their current outcomes do not validate touch delivery.
Two subsequent complete gate runs against committed baseline de856d52 passed
the name-by-name outcome comparison. Each again recorded 361 passed, 563 failed,
304 skipped, 0 flaky, with 237 successful placement checks and complete JSON
for all 1,228 cases. Neither run hit a process deadline, terminated by signal or
reported an adapter error. The expectation data and both observed inventories
were also compared structurally and matched exactly.
The original three largest classes were xdg unconfigured-buffer errors (319), layer-surface invalid-state errors (79), and input-region assertions (58). The remaining original classes were 38 event timeouts, 24 missing-notification cases, 7 keyboard-focus cases and 38 other assertions or protocol errors. The interface name is part of every protocol error's identity:
xdg_surface, error 3 =unconfigured_buffer. Stable xdg-shell.xml requires an initial bufferless commit, the firstconfigureevent and itsack_configurebefore attaching a buffer; earlier attaches are errors. The client trace ofForeignToplevelManagerTest.detects_toplevel_closedshows xdg surface/toplevel creation followed directly by buffer attach and commit, without that handshake. Compd's error 3 is correct enforcement for this fixture, which bypassescreate_visible_surface's handshake. This trace does not classify the other 318 cases; per-case traces are required before assigning ownership across the class. Separately,XdgSurfaceStableTest.attaching_buffer_to_unconfigured_xdg_surface_is_an_errorattaches afterget_xdg_surfacewithout a role or commit; its trace shows no error where error 3 is required in the original recording. Smithay's role pre-commit checks atvendor/smithay/src/wayland/shell/xdg/mod.rs:1673(posted at1721) and2067(posted at2116), installed byxdg/handlers/surface.rs:130,208, do not cover this attach-only sequence. The native follow-up below fixes this missing attach-time validation; it does not establish a configure-state defect.zwlr_layer_surface_v1, error 0 =invalid_surface_state. The 79-case baseline class is on this interface, notzwlr_layer_shell_v1:Layer/LayerSurfaceLayerTest.surface_on_lower_layer_is_initially_placed_below/0reportsmust ack the initial configure before attaching buffer, raised invendor/smithay/src/wayland/shell/wlr_layer/mod.rs:576. Its client trace showsget_layer_surfaceandset_size, then buffer attach, frame and commit, without an initial bufferless commit, configure or acknowledgement. Layer-shell XML requires that handshake before attaching a buffer. The rejection is correct for this fixture: its configure listener can acknowledge events, but no initial bufferless commit requests one before attachment. The remaining 78 cases need individual attribution. The XML defineszwlr_layer_shell_v1, error 0 =roleseparately: another role is already assigned. That check is atvendor/smithay/src/wayland/shell/wlr_layer/handlers.rs:84,85, with the role stamp inwayland/compositor/tree.rs:170. An erroneous early or stale assignment on a fresh surface would be a compd defect, but the original logs contain zero failures with that interface/error pair. Calling the observed 79 failures role errors would misattribute the protocol object.- Input-region targeting and coordinates: the shim's surface placement
and screen/world conversion are corrected above. Remaining input-region
assertions exercise compd's native hit testing and are compd defects;
for example,
MultiRectEdges/RegionSurfaceInputCombinations.input_inside_region_seen/8verifies surface origin[64, 49], completes absolute motion, then reportsno_pointer_targeton the button operation. Its paired touch case reportstouch_no_targeton down. These are native hit misses after verified placement, not silently successful adapter deliveries. No protocol error code is raised. Start atservices/compd/crates/world/src/surface/interface/core/hit.rs:478(fitted surface coordinates) andvendor/smithay/src/desktop/wayland/window.rs(surface_under). The stable geometry-offset cases have the observed native coordinate/hit failures recorded above, in addition to the source-level wlcs/shim placement-contract mismatch. - Event timeouts, missing notifications and keyboard focus: these are compd conformance gaps in the native event, constraints, layout and focus paths. They have no protocol error code unless the individual report names one. In particular, pointer constraints miss expected locked/unlocked events; exclusive-zone layout can time out. The adapter's device refusals are explicit in the case logs and must be considered when tracing each case.
- Other assertions or protocol errors: retained per case, including missing mandated errors for invalid buffers and unconfigured surfaces, popup placement/lifecycle and output metadata assertions. These are compd conformance defects; the geometry-offset cases also have the stated contract mismatch, which does not fully explain their observed failures. They are not evidence of a protocol-permitted refusal of a valid client.
All failed cases remain in the expectation file. This phase fixes the shim and records evidence for follow-up without changing normal compd policy. Failure class counts alone do not establish that every case is a compositor defect.
Native xdg attach validation follow-up
The native frontend now checks non-null wl_surface.attach requests before
changing pending buffer state. A live xdg wrapper must have its initial
configure sent and acknowledged; otherwise the wrapper receives
xdg_surface.unconfigured_buffer immediately, including when no role or
commit follows. The check reads current role state and preserves NULL attaches,
ordinary surfaces, valid toplevel and popup handshakes, wrapper recreation and
the existing retention of pending configures across unmap. Healthy attaches
use one surface-state lock without collecting wrapper handles; errors are
posted after locks are released.
Ten raw-wire regression guards cover refusal and acceptance, stale ACKs after
unmap, proper remapping, wrapper recreation and both popup outcomes. They live
with the existing xdg wrapper guards in vendored Smithay and are registered in
tools/smithay_guards.mix and vendor/smithay/PATCHES.md.
The unchanged canonical v1.8.1 suite now records 362 passed, 562 failed, 304
skipped, 0 flaky. Only
XdgSurfaceStableTest.attaching_buffer_to_unconfigured_xdg_surface_is_an_error
changed from failed to passed; all other 1,227 outcomes are unchanged. The
complete run retained 1,228 JSON reports and 237 placement checks, with no
process deadline or signal. No canonical fixture or exemption was changed.