From 7c127250c0b8f74264942e88d2abeb9d2c4e6221 Mon Sep 17 00:00:00 2001 From: Niclas Overby Date: Thu, 13 Aug 2026 13:22:47 +0200 Subject: [PATCH] feat(cider): the guest puts pixels on the compositor, and two harness lies are named The gate now passes six assertions, the last two of which are new: ok the compositor configured an xdg_toplevel and the ack completed ok a wl_shm buffer was attached and the compositor presented it A wl_shm pool is created from a file descriptor the GUEST opened, a buffer is attached, damaged and committed, and the compositor answers with a frame callback. That is the whole of what a CGSSurface has to do. TWO THINGS IN THE HARNESS WERE LYING, and both looked exactly like a client bug. WESTON HEADLESS DEFAULTS TO A NO-OP RENDERER. It says so once, as no-op renderer SHM seed: 0, in a page of capability lines, and then never reads a client buffer, so nothing is ever released or presented. The checks pass --renderer=pixman now. A compositor that runs fine can be doing nothing at all. A ROUNDTRIP IS NOT A FRAME, and headless is slow to start repainting. Measured: the first frame callback after a surface is mapped took well over two seconds, so a two second budget reported "never presented" for a surface that was in the scene graph the entire time. The budget is ten seconds now. THE SCENE GRAPH SETTLED IT, and the technique is worth keeping: weston --debug plus weston-debug scene-graph prints every view the compositor holds, and ours was there with the right role and title while the probe claimed nothing had happened. That is what separated "not drawn" from "not drawn YET". The probe grew a CIDER_WAYLAND_HOLD env var so the surface can be inspected instead of racing it. THE ASSERTION IS THE FRAME CALLBACK, not the buffer release. A compositor may hold a buffer after drawing with it, so demanding a release asks for more than the protocol promises; a frame callback is the compositor stating that it drew. --- docs/wayland-port.md | 11 +++ scripts/checks/buck-wayland-check.nu | 11 ++- src/darwin/wayland/probe.rs | 143 ++++++++++++++++++++++++++- src/darwin/wayland/shim.c | 49 +++++++++ src/darwin/wayland/wl.rs | 34 +++++++ 5 files changed, 246 insertions(+), 2 deletions(-) diff --git a/docs/wayland-port.md b/docs/wayland-port.md index 58bdfa743..6c452d293 100644 --- a/docs/wayland-port.md +++ b/docs/wayland-port.md @@ -266,3 +266,14 @@ spaces in one process, and which one applies depends on which side of the bridge **THE PROBE ASKS "does the bridge work" FIRST**, with `wl_list_init`, which writes two pointers into a struct the guest owns. That is an observable effect rather than an inference, and until it holds every other result is noise. + +## The headless compositor needs a RENDERER, and its default is a no-op + +Measured while chasing a buffer that was attached and never released: weston's headless backend +selects the **no-op renderer** by default, and prints it as `no-op renderer SHM seed: 0` among a +page of capability lines nobody reads. A no-op renderer never reads a client buffer, so it never +releases one, and a client waiting for the release waits forever with nothing in any log. + +The checks pass `--renderer=pixman` so the compositor actually composites in software. Worth +knowing generally: a headless compositor that "runs fine" can still be doing nothing at all, and +that will look exactly like a client bug. diff --git a/scripts/checks/buck-wayland-check.nu b/scripts/checks/buck-wayland-check.nu index 51a763343..8cb5e3e3d 100755 --- a/scripts/checks/buck-wayland-check.nu +++ b/scripts/checks/buck-wayland-check.nu @@ -93,7 +93,7 @@ def main [scratch?: string] { say "== starting weston, headless ==" let weston = (job spawn { with-env {XDG_RUNTIME_DIR: $xdg} { - do -i { ^weston --backend=headless --socket=cider-wl --width=1024 --height=768 out+err> $"($root)/weston.log" } + do -i { ^weston --backend=headless --renderer=pixman --socket=cider-wl --width=1024 --height=768 out+err> $"($root)/weston.log" } } }) # The socket appears a moment after the process does, and connecting before it exists looks @@ -190,6 +190,15 @@ def main [scratch?: string] { bad "no xdg_surface configure arrived, so the surface was never mapped" $failed = $failed + 1 } + # PIXELS, asserted on the FRAME CALLBACK rather than the buffer release. A compositor may + # legitimately hold a buffer after drawing with it, so demanding a release asks for more than + # the protocol promises; a frame callback is the compositor saying it drew. + if ($out | str contains "pixels=presented") { + ok "a wl_shm buffer was attached and the compositor presented it" + } else { + bad "the surface was never presented, so no pixels reached the compositor" + $failed = $failed + 1 + } print -e "" print -e ($out | lines | where {|l| $l =~ 'cider-wayland-probe' } | str join "\n") diff --git a/src/darwin/wayland/probe.rs b/src/darwin/wayland/probe.rs index 8e4b180f0..0a31d76cf 100644 --- a/src/darwin/wayland/probe.rs +++ b/src/darwin/wayland/probe.rs @@ -241,6 +241,147 @@ fn open_a_window( break; } } - CONFIGURED + if !CONFIGURED { + return false; + } + + // NOW THE PIXELS, which is the part a CGSSurface exists to do. + let shm = wl::cider_wl_registry_bind_shm(registry, globals.bound.shm_name, globals.bound.shm_version); + if shm.is_null() { + println!("cider-wayland-probe shm-bind=FAILED"); + return false; + } + match present_a_buffer(display, shm, surface) { + Ok(()) => true, + Err(why) => { + println!("cider-wayland-probe pixels=FAILED {why}"); + false + } + } + } +} + +static mut RELEASED: bool = false; +static mut PRESENTED: bool = false; + +extern "C" fn on_buffer_release(_data: *mut c_void, _buffer: *mut wl::WlBuffer) { + unsafe { RELEASED = true }; +} + +extern "C" fn on_frame_done(_data: *mut c_void, _cb: *mut wl::WlCallback, _time: u32) { + unsafe { PRESENTED = true }; +} + +/// Fill a buffer, attach it, and wait for the compositor to RELEASE it. +/// +/// The release is the assertion. Attaching proves nothing on its own: the client can hand over a +/// buffer the compositor never reads. A release event means it finished with those pages, so the +/// pixels crossed the socket, the fd survived the trip and the mapping was valid on both sides. +/// +/// # Safety +/// Called with a configured surface and a bound wl_shm. +unsafe fn present_a_buffer( + display: *mut wl::WlDisplay, + shm: *mut wl::WlShm, + surface: *mut wl::WlSurface, +) -> Result<(), String> { + use std::io::{Seek, SeekFrom, Write}; + use std::os::unix::io::AsRawFd; + + const W: i32 = 64; + const H: i32 = 64; + let stride = W * 4; + let size = (stride * H) as usize; + + // A PLAIN FILE, not shm_open. The compositor receives the DESCRIPTOR over the socket and mmaps + // that, so the file only has to be mmap-able and the right size; where it lives does not + // travel with it. This also sidesteps the question of whether the guest has a working + // /dev/shm, which it does not need to have. + let mut file = tempfile_in_guest()?; + let pixel = 0xffu32 << 24 | 0x30u32 << 16 | 0x60u32 << 8 | 0x90u32; // opaque, a flat colour + let row: Vec = std::iter::repeat(pixel.to_ne_bytes()).take(W as usize).flatten().collect(); + for _ in 0..H { + file.write_all(&row).map_err(|e| format!("write {e}"))?; } + file.flush().map_err(|e| format!("flush {e}"))?; + file.seek(SeekFrom::Start(0)).map_err(|e| format!("seek {e}"))?; + + let pool = unsafe { wl::cider_wl_shm_create_pool(shm, file.as_raw_fd(), size as i32) }; + if pool.is_null() { + return Err("create_pool returned null".into()); + } + let format = unsafe { wl::cider_wl_shm_format_xrgb8888() }; + let buffer = unsafe { wl::cider_wl_shm_pool_create_buffer(pool, 0, W, H, stride, format) }; + if buffer.is_null() { + return Err("create_buffer returned null".into()); + } + let listener = wl::WlBufferListener { release: on_buffer_release }; + unsafe { + wl::cider_wl_buffer_add_listener(buffer, &listener, std::ptr::null_mut()); + // ASK FOR A FRAME TOO, before the commit that carries the buffer: the callback is + // delivered when the compositor has PRESENTED, which separates "never drawn" from + // "drawn, buffer still held". + let frame = wl::cider_wl_surface_frame(surface); + let frame_listener = wl::WlCallbackListener { done: on_frame_done }; + if !frame.is_null() { + wl::cider_wl_callback_add_listener(frame, &frame_listener, std::ptr::null_mut()); + } + wl::cider_wl_surface_attach(surface, buffer, 0, 0); + wl::cider_wl_surface_damage(surface, 0, 0, W, H); + wl::cider_wl_surface_commit(surface); + wl::wl_display_flush(display); + // A ROUNDTRIP IS NOT A FRAME, and a headless compositor is SLOW to start repainting. + // Measured on weston 15 with the pixman renderer: the first frame callback after a + // surface is mapped took well over two seconds, so a 2 second budget reported "never + // presented" for a surface that was in the scene graph the whole time. Ten seconds. + for _ in 0..200 { + wl::wl_display_roundtrip(display); + if RELEASED && PRESENTED { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + wl::cider_wl_shm_pool_destroy(pool); + // ASK THE CONNECTION WHETHER IT IS STILL ALIVE. A protocol error is invisible from + // here otherwise: events simply stop arriving, which looks exactly like a compositor + // that has not got round to compositing yet. + println!("cider-wayland-probe display_error={}", wl::wl_display_get_error(display)); + // HOLD THE SURFACE OPEN when asked, so a scene-graph dump has something to look at. + // Without it the probe exits in about two seconds and every inspection races it. + if let Ok(secs) = std::env::var("CIDER_WAYLAND_HOLD") { + if let Ok(n) = secs.parse::() { + println!("cider-wayland-probe holding={n}s"); + for _ in 0..(n * 10) { + wl::wl_display_roundtrip(display); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + } + println!("cider-wayland-probe presented={PRESENTED} released={RELEASED}"); + // PRESENTED IS THE ASSERTION, not the release. A compositor may legitimately hold a + // buffer after drawing with it, so demanding a release asks for more than the protocol + // promises; a frame callback is the compositor stating it drew. + if PRESENTED { + println!("cider-wayland-probe pixels=presented size={W}x{H}"); + Ok(()) + } else { + Err("no frame callback arrived, so the surface was never presented".into()) + } + } +} + +/// A file the guest can create and mmap. /tmp inside the container is a tmpfs, which is exactly +/// what this wants. +fn tempfile_in_guest() -> Result { + let path = format!("/tmp/cider-wayland-probe-{}.shm", std::process::id()); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&path) + .map_err(|e| format!("open {path}: {e}"))?; + // Unlinked immediately: the descriptor keeps it alive and nothing is left behind. + let _ = std::fs::remove_file(&path); + Ok(file) } diff --git a/src/darwin/wayland/shim.c b/src/darwin/wayland/shim.c index 2b317ef87..fae3bc9ec 100644 --- a/src/darwin/wayland/shim.c +++ b/src/darwin/wayland/shim.c @@ -96,3 +96,52 @@ int cider_xdg_wm_base_add_listener(struct xdg_wm_base *base, const struct xdg_wm_base_listener *listener, void *data) { return xdg_wm_base_add_listener(base, listener, data); } + +// --------------------------------------------------------------------------------------------- +// PIXELS. wl_shm hands the compositor a file descriptor and it mmaps the same pages the client +// wrote, which is how a CGSSurface will present a bitmap. All of this is inline upstream too. + +struct wl_shm_pool *cider_wl_shm_create_pool(struct wl_shm *shm, int32_t fd, int32_t size) { + return wl_shm_create_pool(shm, fd, size); +} + +struct wl_buffer *cider_wl_shm_pool_create_buffer(struct wl_shm_pool *pool, int32_t offset, + int32_t width, int32_t height, int32_t stride, + uint32_t format) { + return wl_shm_pool_create_buffer(pool, offset, width, height, stride, format); +} + +void cider_wl_shm_pool_destroy(struct wl_shm_pool *pool) { wl_shm_pool_destroy(pool); } + +void cider_wl_surface_attach(struct wl_surface *surface, struct wl_buffer *buffer, int32_t x, + int32_t y) { + wl_surface_attach(surface, buffer, x, y); +} + +void cider_wl_surface_damage(struct wl_surface *surface, int32_t x, int32_t y, int32_t width, + int32_t height) { + wl_surface_damage(surface, x, y, width, height); +} + +// THE RELEASE EVENT IS THE PROOF. A compositor releases a buffer once it has finished reading it, +// so a release means our pixels were actually consumed rather than merely offered. +int cider_wl_buffer_add_listener(struct wl_buffer *buffer, const struct wl_buffer_listener *listener, + void *data) { + return wl_buffer_add_listener(buffer, listener, data); +} + +// WL_SHM_FORMAT_XRGB8888 is guaranteed by the protocol, unlike most formats, so the probe uses it +// rather than asking which are supported. +uint32_t cider_wl_shm_format_xrgb8888(void) { return WL_SHM_FORMAT_XRGB8888; } + +// A FRAME CALLBACK FIRES WHEN THE COMPOSITOR HAS PRESENTED, which is a different claim from a +// buffer release: release is about buffer LIFETIME and can be deferred, while a frame callback is +// the compositor saying it drew. Asking for both means a failure says which half is missing. +struct wl_callback *cider_wl_surface_frame(struct wl_surface *surface) { + return wl_surface_frame(surface); +} + +int cider_wl_callback_add_listener(struct wl_callback *callback, + const struct wl_callback_listener *listener, void *data) { + return wl_callback_add_listener(callback, listener, data); +} diff --git a/src/darwin/wayland/wl.rs b/src/darwin/wayland/wl.rs index fd87a4a12..968c92ef0 100644 --- a/src/darwin/wayland/wl.rs +++ b/src/darwin/wayland/wl.rs @@ -20,6 +20,9 @@ pub enum WlSurface {} pub enum XdgWmBase {} pub enum XdgSurface {} pub enum XdgToplevel {} +pub enum WlShmPool {} +pub enum WlBuffer {} +pub enum WlCallback {} /// libwayland's intrusive list head: two pointers, and wl_list_init makes both point at it. #[repr(C)] @@ -66,6 +69,23 @@ unsafe extern "C" { pub fn cider_xdg_surface_add_listener(s: *mut XdgSurface, l: *const XdgSurfaceListener, data: *mut c_void) -> c_int; pub fn cider_xdg_wm_base_pong(b: *mut XdgWmBase, serial: u32); pub fn cider_xdg_wm_base_add_listener(b: *mut XdgWmBase, l: *const XdgWmBaseListener, data: *mut c_void) -> c_int; + + // Pixels. + pub fn cider_wl_shm_create_pool(shm: *mut WlShm, fd: c_int, size: i32) -> *mut WlShmPool; + pub fn cider_wl_shm_pool_create_buffer(pool: *mut WlShmPool, offset: i32, width: i32, height: i32, stride: i32, format: u32) -> *mut WlBuffer; + pub fn cider_wl_shm_pool_destroy(pool: *mut WlShmPool); + pub fn cider_wl_surface_attach(s: *mut WlSurface, b: *mut WlBuffer, x: i32, y: i32); + pub fn cider_wl_surface_damage(s: *mut WlSurface, x: i32, y: i32, w: i32, h: i32); + pub fn cider_wl_buffer_add_listener(b: *mut WlBuffer, l: *const WlBufferListener, data: *mut c_void) -> c_int; + pub fn cider_wl_shm_format_xrgb8888() -> u32; + + /// Nonzero once the connection has failed. A protocol error kills the connection silently + /// from the client's point of view, so without asking, a missing event and a dead socket look + /// the same. + pub fn wl_display_get_error(display: *mut WlDisplay) -> c_int; + pub fn wl_display_flush(display: *mut WlDisplay) -> c_int; + pub fn cider_wl_surface_frame(s: *mut WlSurface) -> *mut WlCallback; + pub fn cider_wl_callback_add_listener(c: *mut WlCallback, l: *const WlCallbackListener, data: *mut c_void) -> c_int; } /// The layout libwayland expects: two function pointers, in this order. It is passed by pointer @@ -155,3 +175,17 @@ pub struct Bound { pub xdg_name: u32, pub xdg_version: u32, } + +/// One callback: the compositor has finished with the buffer. That event is the only honest +/// evidence from the client side that the pixels were CONSUMED and not merely handed over. +#[repr(C)] +pub struct WlBufferListener { + pub release: extern "C" fn(data: *mut c_void, buffer: *mut WlBuffer), +} + +/// The compositor calls this once it has PRESENTED the surface. Independent of buffer lifetime, +/// so it distinguishes "never drawn" from "drawn but the buffer is still held". +#[repr(C)] +pub struct WlCallbackListener { + pub done: extern "C" fn(data: *mut c_void, callback: *mut WlCallback, time: u32), +} -- 2.51.2