fix: harden standalone overlay plane path on real DRM

Replace the per-frame overlay layer churn with one persistent layer
per output (NULL buffer parks it), so libliftoff allocations settle
instead of re-solving around a fresh layer object every frame.

Repaint the held overlay from a per-frame content hash checked in the
main-loop bodies: any drift schedules a frame, clean walks cost one
cheap string pass.  Deliberately not from the output frame handler --
scheduling there re-emits the same event via idle and wedges the loop
at 100% CPU with dead stdin (caught via gdb).  Drop all scattered
explicit overlay refresh calls; the confirm-quit modal stays on its
explicit damage path.

Cover transient plane rejects with one composite frame then retry the
plane; lock software cursors while the overlay is held on DRM so the
menu layer can never trade planes with the cursor across re-solves.
Log the plane verdict and box (WxH+X+Y) every plane frame as the
off-process handle for VT verification.

Add tests/test_vt_plane.py: foot + Alt-held probe + Alt+Shift+T
alacritty on the real DRM backend, asserting the layer stays accepted
and the menu FB sits topmost at the logged geometry (libdrm plane
reads plus debugfs crtc-pos/zpos), with cursor warps and held-key
repeats churning the allocation.
This commit is contained in:
Andrew Hamilton 2026-09-23 10:07:00 +10:00
parent 137e4d0568
commit 9ea77fde6e
2 changed files with 738 additions and 68 deletions

216
kick.c
View file

@ -386,10 +386,11 @@ struct kick_server {
wlr_output_layer when the backend accepts it (libliftoff assigns it a
plane), so the main buffer never carries the overlay and direct scanout
of the focused client stays live underneath. */
struct wlr_output_layer *overlay_layer;
struct wlr_output_layer *overlay_layer; /* persistent, per output */
struct wlr_swapchain *overlay_swapchain;
/* Output-buffer-local box the overlay was last painted into on the plane.
Kept so a buffer exactly sized to it can be (re)created. */
/* Last committed state of the persistent layer. buffer is NULL while
the menu is hidden (the plane parked); the entry is described on
every commit so the backend's layer set never churns. */
struct wlr_output_layer_state overlay_layer_state;
/* Plane capability, probed once per output: the first overlay hold asks
the plane with a real commit and records whether libliftoff accepted
@ -401,6 +402,12 @@ struct kick_server {
OVERLAY_PLANE_OFF, /* hardware/knob: composite always */
} overlay_plane;
bool overlay_render_lock; /* attach-render lock held for the fallback */
bool overlay_cursor_lock; /* software-cursor lock held while up (DRM) */
/* One composite cover frame after a rejected plane frame (see
render_standalone): the next attempt retries the plane, so a
transient allocation shift costs one composited frame, not a stuck
menu. Cleared by the cover frame itself and on overlay hide. */
bool overlay_composite_next;
/* Standalone-only cursor: a real wlr_cursor so the pointer is visible
(hardware cursor plane on DRM, scene-composited on headless). */
struct wlr_cursor *cursor;
@ -1022,6 +1029,7 @@ static void set_active_tab(struct kick_tab *tab);
static void apply_window_priorities(struct kick_window *active);
static void render_and_present(void);
static void overlay_sync_render_lock(void);
static void overlay_sync_cursor_lock(void);
/* Backstop for overlay staleness (see overlay_poll_check): hash what the
tab overlay would draw, and record what was last painted. */
static uint64_t overlay_content_hash(void);
@ -3093,9 +3101,14 @@ static void apply_pending_output_resize(struct wlr_output_state *state) {
static void handle_output_frame(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
frame_pending = true;
/* Display-rate backstop on real outputs: notice overlay drift every
frame even if no mutation site scheduled a repaint. */
overlay_poll_check();
/* NOTE: no overlay_poll_check() here by design. kick_request_redraw()
schedules through wlr_output_schedule_frame, which re-emits this very
frame event via an idle source: polling from inside the handler would
re-arm idle sources faster than wl_event_loop_dispatch_idle drains
them while any drift persists (the render gate that would clear it
never runs), wedging the loop at 100% CPU with dead stdin and
broken client sockets. The backstop lives in the main-loop bodies
instead, where each check is followed by the render gate. */
}
/* How many standalone outputs have been seen since the last (re)connect.
@ -3114,12 +3127,18 @@ static void handle_output_destroy(struct wl_listener *listener, void *data) {
wl_list_remove(&server.frame.link);
server.output = NULL;
server.scene_output = NULL; /* wlroots frees it via its output addon */
/* The overlay layer dies with the output (wlroots frees output->layers);
the box-sized swapchain is ours and must go. Re-probe the plane on the
next output. */
server.overlay_layer = NULL;
/* The overlay layer dies with the output (wlroots frees output->layers,
so destroy our handle too); the box-sized swapchain is ours and must
go. Re-probe the plane on the next output. */
if (server.overlay_layer) {
wlr_output_layer_destroy(server.overlay_layer);
server.overlay_layer = NULL;
}
server.overlay_layer_state = (struct wlr_output_layer_state){0};
server.overlay_plane = OVERLAY_PLANE_UNKNOWN;
server.overlay_composite_next = false;
server.overlay_render_lock = false;
server.overlay_cursor_lock = false;
if (server.overlay_swapchain) {
wlr_swapchain_destroy(server.overlay_swapchain);
server.overlay_swapchain = NULL;
@ -3172,21 +3191,25 @@ server.output = output;
if (server.standalone && wlr_output_is_headless(output))
wlr_output_lock_software_cursors(output, true);
/* Standalone DRM output: the overlay gets its own wlr_output_layer on
demand. libliftoff assigns it a real hardware plane when the driver
has one spare; the headless backend would claim every layer accepted
but never draw it, so it gets no layer and stays on the composite
path. The layer is created per frame in render_standalone and
destroyed at the end of that frame, so a layer only exists in the
backend's layer set while the overlay is actually being presented --
hiding the menu destroys the layer and libliftoff must drop its
plane, which a NULL-buffer layer would silently keep alive. */
/* Standalone DRM output: the overlay gets one persistent wlr_output_layer
for the output's lifetime. libliftoff assigns it a real hardware
plane when the driver has one spare; keeping the same layer across
frames lets the allocation settle instead of re-solving on every
frame. The headless backend would claim every layer accepted but
never draw it, so it gets no layer and stays on the composite path.
Hiding the menu commits the layer with a NULL buffer, which clears
the plane (FB_ID 0); the layer object itself stays put. */
if (server.standalone && !server.standalone_headless) {
server.overlay_plane = OVERLAY_PLANE_UNKNOWN;
server.overlay_layer = wlr_output_layer_create(output);
server.overlay_layer_state = (struct wlr_output_layer_state){
.layer = NULL,
.layer = server.overlay_layer,
.buffer = NULL,
};
if (!server.overlay_layer) {
kick_log(KICK_LOG_DEBUG,
"[render] overlay layer create failed, compositing\n");
}
if (server.overlay_swapchain) {
wlr_swapchain_destroy(server.overlay_swapchain);
server.overlay_swapchain = NULL;
@ -3198,7 +3221,9 @@ server.output = output;
plane capability (UNKNOWN on a fresh output tries the plane, so no
lock) and covers both the tab list and the confirm-quit modal. */
server.overlay_render_lock = false;
server.overlay_cursor_lock = false;
overlay_sync_render_lock();
overlay_sync_cursor_lock();
/* Give the standalone cursor a place to live. Idempotent across VT
round-trips (wlr_output_layout_add reuses the existing entry), and the
@ -8794,6 +8819,7 @@ static void set_confirm_quit(bool on) {
tab list); on the composite path it must repaint into our own buffer,
so the scanout lock follows whether the plane is usable. */
overlay_sync_render_lock();
overlay_sync_cursor_lock();
if (server.standalone)
standalone_overlay_damage();
else if (server.scene_output)
@ -9479,14 +9505,20 @@ static void update_overlay_visible(bool alt) {
kick_log(KICK_LOG_DEBUG, "[render] overlay %s\n",
alt ? "show" : "hide");
/* A new hold paints fresh content on its first frame; until then the
poll backstop must not compare against a previous hold. */
poll backstop must not compare against a previous hold. A stale
composite cover flag dies here too so the hold starts on the plane. */
overlay_painted_valid = false;
server.overlay_composite_next = false;
/* Scanout lock ownership: when the overlay rides a hardware plane the lock
must stay off so direct scanout of the focused client keeps running
underneath; otherwise it must be held for the whole hold so the second
paint pass lands on our own composited buffer. Releasing it re-enables
scanout automatically (same pattern labwc uses for its panels). */
overlay_sync_render_lock();
/* The hardware cursor plane is parked for the hold as well, so the
overlay layer can never trade places with the cursor across
libliftoff re-solves (see overlay_sync_cursor_lock). */
overlay_sync_cursor_lock();
/* The overlay is baked into the scene output's swapchain buffers in both
modes now, so a transition must force those buffers to repaint from
clean scene content: standalone via a whole-output repaint, nested via
@ -12121,8 +12153,8 @@ static bool overlay_plane_knob(void) {
/* May this frame try the plane? USED holds are the settled path; UNKNOWN
means the very next overlay frame is the one-shot probe that decides.
OFF (headless, nested, knob turned down, or a failed probe) means
composite always. The layer itself is created and destroyed per frame
(in render_standalone), so the idempotent checks below never gate on it. */
composite always. Whether the persistent layer object exists never
gates this: creation failure simply degrades to composite. */
static bool overlay_plane_capable(void) {
return server.standalone && !server.standalone_headless &&
overlay_plane_knob() &&
@ -12149,6 +12181,29 @@ static void overlay_sync_render_lock(void) {
overlay_set_render_lock(up && !overlay_plane_capable());
}
/* Take or release the software-cursor lock while the overlay is up on a
real DRM output. With a hardware cursor plane in play, libliftoff is
free to trade planes across re-solves -- parking our menu layer on the
cursor plane (and the cursor on an overlay plane), where size limits
can crop it into invisibility. Locking software cursors removes the
cursor layer from the allocation entirely while held, so the only
layers left are the primary and ours: ours always lands on an overlay
plane. Idempotent; headless outputs are already locked permanently at
output init and never touched here. */
static void overlay_set_cursor_lock(bool want) {
if (want == server.overlay_cursor_lock) return;
if (server.output && server.standalone && !server.standalone_headless)
wlr_output_lock_software_cursors(server.output, want);
server.overlay_cursor_lock = want;
}
/* Recompute the cursor-lock state alongside the render lock: held whenever
the overlay (tab list or modal) is up on DRM. Called from the same
three places as overlay_sync_render_lock. */
static void overlay_sync_cursor_lock(void) {
overlay_set_cursor_lock(host.overlay_visible || confirm_quit_pending);
}
/* (Re)create the box-sized overlay swapchain. The overlay is opaque, so
XRGB8888 (no alpha) is the plane format; a plane can only ever use a
format it can actually scan out, and if none fits libliftoff reports the
@ -12205,51 +12260,53 @@ static void render_standalone(void) {
double t_build = 0, t_commit = 0;
bool frame_committed = false;
/* Plane path: paint the overlay into its own small buffer and ask the
backend (libliftoff, on DRM) to scan it out on a hardware plane for
this frame. The layer is created fresh for THIS frame only and
destroyed after wlr_output_state_finish(), so the backend's layer set
exactly mirrors what we presented. The first hold of each output is
the one-shot probe: the commit tells us whether libliftoff accepted
the layer, which becomes the session answer. Every later frame just
asks (layer with a buffer) or does not (no layer at all when the menu
is hidden; the layer_state array must stay valid until finish, so the
layer is destroyed only after finish). */
/* Plane path: paint the overlay into its own small buffer and present
it on the output's persistent layer, which libliftoff scans out on a
hardware plane. The layer lives as long as the output; every commit
carries its state (a NULL buffer disables it when the menu is
hidden), so the backend's allocation settles instead of re-solving
around a fresh layer object on every frame. The first hold of each
output is the one-shot probe: the commit tells us whether libliftoff
accepted the layer, which becomes the session answer. */
struct overlay_box box;
bool have_box = false;
struct wlr_buffer *plane_buf = NULL;
bool plane_attempt = overlay_up && overlay_plane_capable();
/* A rejected plane frame sets composite_next for one composite cover
frame, then retries the plane: with a stable allocation rejects are
transient, so flap back instead of giving up. */
bool plane_attempt = overlay_up && overlay_plane_capable() &&
server.overlay_layer && !server.overlay_composite_next;
if (kick_log_level() >= KICK_LOG_DEBUG && server.standalone)
kick_log(KICK_LOG_DEBUG, "[diag] frame start t=%.0f vis=%d plane=%d\n",
kick_monotonic_ms(), overlay_up, plane_attempt);
struct wlr_output_layer *frame_layer = NULL;
if (plane_attempt) {
int nrows = confirm_quit_pending ? overlay_confirm_box(&box)
: overlay_tab_box(&box);
if (nrows > 0) {
overlay_plane_resize(box.w, box.h);
plane_buf = overlay_render_plane(&box);
have_box = plane_buf != NULL;
}
plane_attempt = plane_buf != NULL;
plane_attempt = have_box;
}
if (plane_attempt) {
frame_layer = wlr_output_layer_create(server.output);
if (!frame_layer) {
plane_attempt = false;
wlr_buffer_unlock(plane_buf);
plane_buf = NULL;
} else {
server.overlay_layer = frame_layer;
server.overlay_layer_state.layer = frame_layer;
server.overlay_layer_state.buffer = plane_buf;
if (server.overlay_layer) {
/* Always describe the one persistent layer: with a buffer while
asking for the plane, NULL to park it while hidden (FB_ID 0
drops the plane). The array must stay valid until finish. */
server.overlay_layer_state.layer = server.overlay_layer;
server.overlay_layer_state.buffer = plane_buf;
if (have_box) {
server.overlay_layer_state.src_box = (struct wlr_fbox){
.x = 0, .y = 0, .width = box.w, .height = box.h };
server.overlay_layer_state.dst_box = (struct wlr_box){
.x = box.x, .y = box.y, .width = box.w, .height = box.h };
server.overlay_layer_state.damage = NULL;
} else {
server.overlay_layer_state.src_box = (struct wlr_fbox){0};
server.overlay_layer_state.dst_box = (struct wlr_box){0};
}
}
if (frame_layer)
server.overlay_layer_state.damage = NULL;
wlr_output_state_set_layers(&state, &server.overlay_layer_state, 1);
}
/* Composite path: the overlay joins the scene's own buffer. Its box is
invisible to the scene's damage ring, so while it is up every frame
@ -12274,19 +12331,15 @@ static void render_standalone(void) {
scans out (planes persist across frames); every overlay show/hide
forces a repaint through standalone_overlay_damage(), so a menu
appearing or clearing always goes out on a rendered frame.
Drop the plane resources we acquired for this frame before
finishing the (empty) state exactly once. */
Drop this frame's plane buffer before finishing the (empty)
state exactly once; the persistent layer entry stays described
(with this buffer still attached) so the next attempt reuses the
settled allocation. */
kick_log(KICK_LOG_DEBUG, "[render] build_state failed\n");
if (frame_layer) {
wlr_output_layer_destroy(frame_layer);
server.overlay_layer = NULL;
server.overlay_layer_state.layer = NULL;
server.overlay_layer_state.buffer = NULL;
frame_layer = NULL;
}
if (plane_buf) {
wlr_buffer_unlock(plane_buf);
plane_buf = NULL;
server.overlay_layer_state.buffer = NULL;
}
plane_attempt = false;
goto frame_done;
@ -12343,6 +12396,21 @@ static void render_standalone(void) {
}
output_mgmt_sync();
/* Witness for the overlay plane: whether libliftoff placed this frame's
layer, plus the box it was asked to scan out (WxH+X+Y in output
pixels). KMS offers no per-layer name, so the box -- which sizes the
plane's FB exactly -- is the off-process handle: a VT test matches
the live kernel plane by FB size and hold/release timing. */
if (plane_attempt) {
kick_log(KICK_LOG_DEBUG, "[render] overlay plane accepted=%d "
"box=%dx%d+%d+%d layer=%p buf=%dx%d\n",
server.overlay_layer_state.accepted ? 1 : 0,
box.w, box.h, box.x, box.y,
(void *)server.overlay_layer,
plane_buf ? plane_buf->width : 0,
plane_buf ? plane_buf->height : 0);
}
if (plane_attempt && server.overlay_plane == OVERLAY_PLANE_UNKNOWN) {
/* One-shot probe: the accepted flag is only populated by a real
commit -- libliftoff never writes it on a test-only path -- so this
@ -12351,6 +12419,7 @@ static void render_standalone(void) {
if (server.overlay_layer_state.accepted) {
kick_log(KICK_LOG_DEBUG, "[render] overlay plane available\n");
server.overlay_plane = OVERLAY_PLANE_USED;
overlay_note_painted();
} else {
kick_log(KICK_LOG_DEBUG,
"[render] overlay plane not available, compositing\n");
@ -12359,21 +12428,32 @@ static void render_standalone(void) {
the menu appear anyway by re-rendering it composited. */
kick_request_redraw();
}
} else if (plane_attempt) {
if (server.overlay_layer_state.accepted) {
overlay_note_painted();
} else {
/* Transiently rejected (allocation shifted under us): cover
this frame composited, then retry the plane next frame.
The lock must be held for the cover frame, and the next
attempt reuses the settled layer. */
kick_log(KICK_LOG_DEBUG,
"[render] overlay plane rejected, compositing once\n");
server.overlay_composite_next = true;
overlay_set_render_lock(true);
kick_request_redraw();
}
}
if (server.overlay_composite_next && composite_overlay) {
/* The cover frame above painted composited: retry the plane. */
server.overlay_composite_next = false;
}
frame_done:
/* Finish the output state first: the layers array must stay valid until
finish returns. Only then destroy the per-frame layer (removing the
Liftoff layer from the CRTC so the next allocation drops the plane)
and unlock the plane buffer that the commit just consumed. */
finish returns. The layer itself persists across frames (created with
the output); only unlock this frame's plane buffer, which the commit
just consumed. */
wlr_output_state_finish(&state);
if (frame_layer) {
wlr_output_layer_destroy(frame_layer);
server.overlay_layer = NULL;
server.overlay_layer_state.layer = NULL;
server.overlay_layer_state.buffer = NULL;
frame_layer = NULL;
}
if (plane_buf) {
wlr_buffer_unlock(plane_buf);
plane_buf = NULL;

590
tests/test_vt_plane.py Normal file
View file

@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""VT overlay-plane test: the menu must survive a client format switch.
Spawns kick on the real DRM backend with a `foot` tab, holds Alt (the
overlay plane is probed), then Alt+Shift+T's an `alacritty` tab while
still holding Alt. foot uses shm buffers, alacritty GL/dmabuf: if the
overlay layer stops being accepted when alacritty's buffers join the
libliftoff allocation, the menu vanishes over alacritty but not foot.
Every plane frame logs `[render] overlay plane accepted=0/1`, so the log
is the witness (plane content never reaches frame dumps).
Skips unless this process can grab DRM master on a card with a connected
connector (i.e. a live virtual console). Taking master steals the live
display for the duration; master is dropped on teardown.
"""
import fcntl
import glob
import ctypes
import os
import re
import subprocess
import time
import pytest
from test_smoke import ( # noqa: F401
ALT,
BIN,
DOWN,
KEY_PRESSED,
KEY_RELEASED,
SHIFT,
UP,
Session,
_assert_alive,
_fresh_log,
_mk_dump_dir,
_pick_font,
_preexec_death,
)
_DRM_SET_MASTER = 0x641E
_DRM_DROP_MASTER = 0x641F
_VT_LOCK = __file__ + ".lock"
re_frames = re.compile(
r"\[diag\] frame (?:start|result) .*?vis=(\d) plane=(\d)")
re_accept = re.compile(r"\[render\] overlay plane accepted=([01])")
def _drm_card_with_connected():
for card in sorted(glob.glob("/dev/dri/card[0-9]*")):
sysf = card.replace("/dev/dri/", "/sys/class/drm/")
for status in glob.glob(sysf + "/*/status"):
try:
if open(status).read().strip() == "connected":
return card
except OSError:
continue
return None
def _vt_drm_available():
card = _drm_card_with_connected()
if not card:
return False
try:
fd = os.open(card, os.O_RDWR)
except OSError:
return False
try:
fcntl.ioctl(fd, _DRM_SET_MASTER, 0)
except OSError:
return False
finally:
try:
fcntl.ioctl(fd, _DRM_DROP_MASTER, 0)
except OSError:
pass
os.close(fd)
return True
def _liftoff_excerpt(log, n=3000):
lines = [l for l in log.splitlines()
if "libliftoff" in l or "overlay plane" in l]
return "\n".join(lines[-80:]) + "\n...[last %d chars]\n%s" % (n, log[-n:])
def _drm_planes(card="/dev/dri/card1"):
"""Kernel-side plane state via libdrm (no master needed, read-only).
Returns [(id, type, crtc_id, fb_id, fb_w, fb_h, zpos)]. KMS has no
per-layer name, so the overlay menu is identified by what only it can
be while we hold Alt: an active overlay plane whose FB is exactly the
box size kick logged (`overlay plane box=WxH+X+Y`), timed to appear on
hold and vanish on release. Client buffers always go to the primary;
the hardware cursor lives on its own non-overlay plane.
"""
lib = ctypes.CDLL("libdrm.so.2")
class PlaneRes(ctypes.Structure):
_fields_ = [("count_planes", ctypes.c_uint32),
("planes", ctypes.POINTER(ctypes.c_uint32))]
class Plane(ctypes.Structure):
_fields_ = [("count_formats", ctypes.c_uint32),
("formats", ctypes.POINTER(ctypes.c_uint32)),
("plane_id", ctypes.c_uint32),
("crtc_id", ctypes.c_uint32),
("fb_id", ctypes.c_uint32),
("crtc_x", ctypes.c_int32), ("crtc_y", ctypes.c_int32),
("x", ctypes.c_uint32), ("y", ctypes.c_uint32),
("possible_crtcs", ctypes.c_uint32),
("gamma_size", ctypes.c_uint32)]
class PropPtrs(ctypes.Structure):
_fields_ = [("count_props", ctypes.c_uint32),
("props", ctypes.POINTER(ctypes.c_uint32)),
("prop_values", ctypes.POINTER(ctypes.c_uint64))]
class Prop(ctypes.Structure):
_fields_ = [("prop_id", ctypes.c_uint32), ("flags", ctypes.c_uint32),
("name", ctypes.c_char * 32),
("count_values", ctypes.c_uint32),
("values", ctypes.POINTER(ctypes.c_uint64))]
class FB(ctypes.Structure):
_fields_ = [("fb_id", ctypes.c_uint32),
("width", ctypes.c_uint32), ("height", ctypes.c_uint32),
("pitch", ctypes.c_uint32), ("bpp", ctypes.c_uint32),
("depth", ctypes.c_uint32), ("handle", ctypes.c_uint32)]
lib.drmModeGetPlaneResources.restype = ctypes.POINTER(PlaneRes)
lib.drmModeGetPlaneResources.argtypes = [ctypes.c_int]
lib.drmModeGetPlane.restype = ctypes.POINTER(Plane)
lib.drmModeGetPlane.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeObjectGetProperties.restype = ctypes.POINTER(PropPtrs)
lib.drmModeObjectGetProperties.argtypes = [ctypes.c_int, ctypes.c_uint32,
ctypes.c_uint32]
lib.drmModeGetProperty.restype = ctypes.POINTER(Prop)
lib.drmModeGetProperty.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeGetFB.restype = ctypes.POINTER(FB)
lib.drmModeGetFB.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeFreePlaneResources.argtypes = [ctypes.c_void_p]
lib.drmModeFreePlane.argtypes = [ctypes.c_void_p]
lib.drmModeFreeObjectProperties.argtypes = [ctypes.c_void_p]
lib.drmModeFreeProperty.argtypes = [ctypes.c_void_p]
lib.drmModeFreeFB.argtypes = [ctypes.c_void_p]
out = []
fd = os.open(card, os.O_RDWR)
try:
pres = lib.drmModeGetPlaneResources(fd)
ids = [pres.contents.planes[i]
for i in range(pres.contents.count_planes)]
lib.drmModeFreePlaneResources(pres)
for pid in ids:
pl = lib.drmModeGetPlane(fd, pid)
c = pl.contents
zpos = None
ptype = None
pp = lib.drmModeObjectGetProperties(fd, pid, 0xeeeeeeee)
if pp:
for j in range(pp.contents.count_props):
prid = pp.contents.props[j]
pr = lib.drmModeGetProperty(fd, prid)
name = pr.contents.name.decode()
if name == "zpos":
zpos = pp.contents.prop_values[j]
elif name == "type":
ptype = pp.contents.prop_values[j]
lib.drmModeFreeProperty(pr)
lib.drmModeFreeObjectProperties(pp)
fw = fh = None
if c.fb_id:
fb = lib.drmModeGetFB(fd, c.fb_id)
if fb:
fw, fh = fb.contents.width, fb.contents.height
lib.drmModeFreeFB(fb)
out.append((c.plane_id, ptype, c.crtc_id, c.fb_id, fw, fh,
zpos))
lib.drmModeFreePlane(pl)
finally:
os.close(fd)
return out
def _logged_box(s):
"""Latest `box=WxH+X+Y` from the overlay plane witness lines, or None."""
ms = re.findall(r"overlay plane accepted=[01] box=(\d+)x(\d+)\+(\d+)\+(\d+)",
s.read_log())
return tuple(map(int, ms[-1])) if ms else None
def _debugfs_planes(path="/sys/kernel/debug/dri/1/state"):
"""Kernel atomic state: {plane_id: {crtc, fb, cw, ch, cx, cy, zpos,
fb_w, fb_h}}. Unlike the ioctl queries this shows the PROGRAMMED
destination rectangle and z-order -- the ground truth for whether our
menu actually lands centered and on top."""
planes = {}
try:
text = open(path).read()
except OSError:
return planes
cur = None
import re as _re
def _ints(line):
return list(map(int, _re.findall(r"\d+", line)))
for line in text.splitlines():
m = _re.match(r"plane\[(\d+)\]:", line)
if m:
cur = int(m.group(1))
planes[cur] = {}
continue
if cur is None:
continue
line = line.strip()
if line.startswith("crtc="):
v = line[5:]
planes[cur]["crtc"] = None if v == "(null)" else v
elif line.startswith("fb="):
nums = _ints(line[3:])
planes[cur]["fb"] = nums[0] if nums else 0
elif line.startswith("crtc-pos="):
nums = _ints(line)
if len(nums) >= 4:
planes[cur]["cw"], planes[cur]["ch"], planes[cur]["cx"], \
planes[cur]["cy"] = nums[:4]
elif line.startswith("normalized-zpos="):
nums = _ints(line)
if nums:
planes[cur]["zpos"] = nums[0]
elif line.startswith("size=") and "fb_w" not in planes[cur]:
nums = _ints(line)
if len(nums) >= 2:
planes[cur]["fb_w"], planes[cur]["fb_h"] = nums[:2]
return planes
def _assert_menu_placed(card, box, ctx, samples=3):
"""The menu FB must be programmed at the logged box geometry on top:
same CRTC as the fullscreen primary, crtc-pos == box, zpos above the
primary's. Accepted/size checks can't see misplacement -- this can."""
if box is None:
raise AssertionError("no logged overlay box (%s)" % ctx)
bw, bh, bx, by_ = box
for _ in range(samples):
planes = _debugfs_planes()
prims = [p for p in planes.values()
if p.get("crtc") and p.get("fb")
and p.get("fb_w", 0) >= 1920 and p.get("zpos") == 0]
if not prims:
time.sleep(0.2)
continue
crtc = prims[0]["crtc"]
menu = [pid for pid, p in planes.items()
if p.get("crtc") == crtc and p.get("fb")
and p.get("fb_w") == bw and p.get("fb_h") == bh
and p.get("cx") == bx and p.get("cy") == by_
and p.get("zpos", 0) > 0]
if len(menu) == 1:
print("menu placed: plane %s at %dx%d+%d+%d zpos=%s (%s)"
% (menu[0], bw, bh, bx, by_,
planes[menu[0]].get("zpos"), ctx))
return menu[0]
time.sleep(0.2)
raise AssertionError(
"menu not placed at %dx%d+%d+%d (%s); planes:\n%s" % (
bw, bh, bx, by_, ctx,
"\n".join("%s: %s" % (pid, p)
for pid, p in sorted(planes.items())
if p.get("crtc"))))
def _menu_plane_sample(card, box):
"""One kernel snapshot: the plane id carrying the menu-size FB, or
None. libliftoff may place our layer on any compatible plane
(overlay 107, but also 83/657 in a pinch), so every candidate is
scanned: active CRTC, live FB exactly the logged box size, zpos >= 1.
Commits pipeline, so a single snapshot may catch an intermediate
state -- callers vote over several."""
bw, bh, _, _ = box
cands = []
for (pid, crtc, fb, fw, fh, zpos) in _all_planes(card).values():
if crtc and fb and fw == bw and fh == bh and zpos is not None \
and zpos >= 1:
cands.append(pid)
for (pid, typ, crtc, fb, fw, fh, zpos) in _drm_planes(card):
if typ == 0 and crtc and fb and fw == bw and fh == bh \
and zpos is not None and zpos >= 1:
cands.append(pid)
cands = sorted(set(cands))
return cands[0] if len(cands) == 1 else None
def _all_planes(card="/dev/dri/card1"):
"""Overlay planes plus the primary (83) and cursor (657) planes:
(id, crtc_id, fb_id, fb_w, fb_h, zpos). libliftoff may place any
layer on any compatible plane, so the menu FB can legally land
outside the overlay list -- what matters is that exactly one
topmost (zpos >= 1) plane carries it."""
import ctypes
lib = ctypes.CDLL("libdrm.so.2")
class Plane(ctypes.Structure):
_fields_ = [("count_formats", ctypes.c_uint32),
("formats", ctypes.POINTER(ctypes.c_uint32)),
("plane_id", ctypes.c_uint32),
("crtc_id", ctypes.c_uint32),
("fb_id", ctypes.c_uint32),
("crtc_x", ctypes.c_int32), ("crtc_y", ctypes.c_int32),
("x", ctypes.c_uint32), ("y", ctypes.c_uint32),
("possible_crtcs", ctypes.c_uint32),
("gamma_size", ctypes.c_uint32)]
class PropPtrs(ctypes.Structure):
_fields_ = [("count_props", ctypes.c_uint32),
("props", ctypes.POINTER(ctypes.c_uint32)),
("prop_values", ctypes.POINTER(ctypes.c_uint64))]
class Prop(ctypes.Structure):
_fields_ = [("prop_id", ctypes.c_uint32), ("flags", ctypes.c_uint32),
("name", ctypes.c_char * 32),
("count_values", ctypes.c_uint32),
("values", ctypes.POINTER(ctypes.c_uint64))]
class FB(ctypes.Structure):
_fields_ = [("fb_id", ctypes.c_uint32),
("width", ctypes.c_uint32), ("height", ctypes.c_uint32),
("pitch", ctypes.c_uint32), ("bpp", ctypes.c_uint32),
("depth", ctypes.c_uint32), ("handle", ctypes.c_uint32)]
lib.drmModeGetPlaneResources.restype = ctypes.c_void_p
lib.drmModeGetPlane.restype = ctypes.POINTER(Plane)
lib.drmModeGetPlane.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeObjectGetProperties.restype = ctypes.POINTER(PropPtrs)
lib.drmModeObjectGetProperties.argtypes = [ctypes.c_int, ctypes.c_uint32,
ctypes.c_uint32]
lib.drmModeGetProperty.restype = ctypes.POINTER(Prop)
lib.drmModeGetProperty.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeGetFB.restype = ctypes.POINTER(FB)
lib.drmModeGetFB.argtypes = [ctypes.c_int, ctypes.c_uint32]
lib.drmModeFreePlane.argtypes = [ctypes.c_void_p]
lib.drmModeFreeObjectProperties.argtypes = [ctypes.c_void_p]
lib.drmModeFreeProperty.argtypes = [ctypes.c_void_p]
lib.drmModeFreeFB.argtypes = [ctypes.c_void_p]
def one(fd, pid):
pl = lib.drmModeGetPlane(fd, pid)
if not pl:
return None
c = pl.contents
zpos = None
pp = lib.drmModeObjectGetProperties(fd, pid, 0xeeeeeeee)
if pp:
for j in range(pp.contents.count_props):
pr = lib.drmModeGetProperty(fd, pp.contents.props[j])
if pr.contents.name.decode() == "zpos":
zpos = pp.contents.prop_values[j]
lib.drmModeFreeProperty(pr)
lib.drmModeFreeObjectProperties(pp)
fw = fh = None
if c.fb_id:
fb = lib.drmModeGetFB(fd, c.fb_id)
if fb:
fw, fh = fb.contents.width, fb.contents.height
lib.drmModeFreeFB(fb)
lib.drmModeFreePlane(pl)
return (pid, c.crtc_id, c.fb_id, fw, fh, zpos)
out = {}
fd = os.open(card, os.O_RDWR)
try:
for pid in (83, 107, 657):
out[pid] = one(fd, pid)
finally:
os.close(fd)
return out
def _assert_menu_plane_on_top(card, box, ctx, samples=5):
"""The overlay menu must be scanned out on exactly one overlay plane:
active CRTC, live FB sized exactly like the logged box, zpos >= 1
(above the zpos-0 primary). Votes over several snapshots so one
pipelined-commit race cannot fail the run; reports every sample."""
if box is None:
raise AssertionError("no logged overlay box (%s)" % ctx)
seen = []
for _ in range(samples):
seen.append(_menu_plane_sample(card, box))
time.sleep(0.15)
good = sum(1 for m in seen if m is not None)
print("menu plane votes (%s): %s" % (ctx, seen))
assert good >= (samples // 2 + 1), \
"menu plane missing/not on top (%s): %d/%d samples, last planes:\n%s" % (
ctx, good, samples, _drm_planes(card))
return seen[-1] if seen[-1] is not None else seen[0]
def _assert_no_menu_plane(card, box, ctx):
"""After release no overlay plane may still carry the menu FB size."""
if box is None:
return
bw, bh, _, _ = box
stale = [p for p in _drm_planes(card)
if p[1] == 0 and p[2] and p[3] and p[4] == bw and p[5] == bh]
assert not stale, "menu plane stuck on screen (%s): %r" % (ctx, stale)
@pytest.fixture(scope="function")
def vt_foot_session():
if not _vt_drm_available():
pytest.skip("not on a DRM-capable VT (no card grants master)")
# Serialize against other workers: two kicks would fight for master.
with open(_VT_LOCK, "w") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
if not _vt_drm_available():
pytest.skip("DRM master went away before spawn")
for path in (BIN,):
if not os.access(path, os.X_OK):
pytest.skip("build first (DRM backend): %s" % path)
log = _fresh_log()
dump_dir = _mk_dump_dir()
env = dict(os.environ)
# Screen mode for the DRM output (KICK_TEST_SCREENMODE, default
# 1920x1080): also try the connector preferred mode (4K here) for
# HVS bandwidth pressure via KICK_TEST_SCREENMODE=3840x2160.
screen_mode = env.get("KICK_TEST_SCREENMODE", "1920x1080")
out_w, out_h = (int(x) for x in screen_mode.split("x"))
cmd = [
"env", "-i",
"PATH=" + env["PATH"],
"HOME=" + env.get("HOME", "/"),
"XDG_RUNTIME_DIR=" + env.get("XDG_RUNTIME_DIR", "/tmp"),
"XDG_DATA_HOME=" + env.get("XDG_DATA_HOME", ""),
"XDG_DATA_DIRS=" + env.get("XDG_DATA_DIRS", ""),
"KICK_LOG=2",
"KICK_MODE=standalone",
"WLR_BACKENDS=drm",
"KICK_FONT=" + (env.get("KICK_FONT") or _pick_font() or ""),
"KICK_DUMP_DIR=" + dump_dir,
"KICK_TEST_SCREENMODE=" + screen_mode,
"stdbuf", "-oL",
BIN, "--screen-mode", screen_mode, "foot",
]
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE,
stdout=open(log, "ab"), stderr=subprocess.STDOUT,
preexec_fn=_preexec_death(os.getpid()))
s = Session(proc, log, dump_dir)
try:
yield s
finally:
s.close()
def test_vt_overlay_survives_alacritty_format_switch(vt_foot_session):
"""foot (shm) + Alt-held plane probe, then Alt+Shift+T alacritty
(dmabuf) while held: the layer must stay accepted across the switch."""
s = vt_foot_session
_assert_alive(s, "kick exited early on the VT")
assert s.wait_any_active() == 0
s.wait_active_frame()
_assert_alive(s, "kick died after first frame")
pos = len(s.read_log())
s.mods(ALT)
s.wait_for_new_after(pos, r"\[render\] overlay show")
try:
s.wait_for_new_after(pos, r"\[render\] overlay plane available",
timeout=30.0)
except Exception:
pytest.fail("plane never became available (rejected at probe?):\n"
+ _liftoff_excerpt(s.read_log()))
s.wait_for_new_after(pos, r"\[diag\] frame (?:start|result) .*vis=1 plane=1")
_assert_alive(s, "kick died while Alt held over foot")
# Alt+Shift+T (T is keycode 20), keep Alt held throughout.
mark = len(s.read_log())
s.mods(ALT | SHIFT)
s.key(20, KEY_PRESSED)
s.key(20, KEY_RELEASED)
s.mods(ALT)
s.wait_for_new_after(mark, r"\[launch\] app=\d+ pid=\d+ cmd=alacritty",
timeout=30.0)
# The fresh terminal becomes active once it maps.
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
_assert_alive(s, "kick died waiting for alacritty")
try:
if s.active() == 1:
break
except Exception:
pass
time.sleep(0.2)
assert s.active() == 1, \
"alacritty never became active:\n%s" % s.read_log()[-1500:]
# Let several plane frames land over alacritty, then require every
# accepted sample since the switch chord to be 1. The offset predates
# the chord: the switch's own frames qualify, so there is no race with
# observing the effect first.
s.wait_for_new_after(
mark, r"\[render\] overlay plane accepted=1", timeout=30.0)
time.sleep(2.0)
tail = s.read_log()[mark:]
verdicts = re_accept.findall(tail)
assert verdicts, "no accepted samples over alacritty:\n%s" % tail[-1500:]
assert set(verdicts) == {"1"}, \
"overlay layer rejected over alacritty %r:\n%s" % (
verdicts, _liftoff_excerpt(s.read_log()))
# Cycle back and confirm the plane is still accepted. Offset again
# predates the key press for the same reason.
before = s.active()
cyc = len(s.read_log())
s.key(DOWN, KEY_PRESSED)
s.key(DOWN, KEY_RELEASED)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
_assert_alive(s, "kick died cycling")
try:
if s.active() != before:
break
except Exception:
pass
time.sleep(0.2)
assert s.active() != before, \
"never cycled away from %d:\n%s" % (before, s.read_log()[-1500:])
s.wait_for_new_after(
cyc, r"\[render\] overlay plane accepted=1", timeout=30.0)
# Kernel-side stacking check while held: the menu must be on a small
# overlay plane above the primary over BOTH tabs, with cursor motion
# and held-key repeats churning the libliftoff allocation in between.
# accepted=1 only means "placed somewhere" -- matching the logged box
# size proves it is ours, zpos >= 1 proves "on top".
card = _drm_card_with_connected()
assert card, "DRM card went away mid-test"
box = _logged_box(s)
_assert_menu_plane_on_top(card, box, "over foot")
_assert_menu_placed(card, box, "over foot")
for x, y in ((960, 540), (200, 200), (1700, 900), (960, 540)):
s.warp(x, y)
time.sleep(0.3)
time.sleep(3.0)
_assert_menu_plane_on_top(card, _logged_box(s),
"over foot after warps")
# Held Down at the production repeat rate walks foot<->alacritty many
# times; the stack must survive all of it.
s.key(DOWN, KEY_PRESSED)
time.sleep(2.0)
s.key(DOWN, KEY_RELEASED)
_assert_menu_plane_on_top(card, _logged_box(s),
"after held-Down repeats")
s.key(UP, KEY_PRESSED)
time.sleep(2.0)
s.key(UP, KEY_RELEASED)
held_at = len(s.read_log())
time.sleep(2.0)
_assert_menu_plane_on_top(card, _logged_box(s),
"after held-Up repeats")
_assert_menu_placed(card, _logged_box(s), "after held-Up repeats")
tail = s.read_log()[held_at:]
assert "overlay hide" not in tail, \
"overlay hid mid-hold:\n%s" % tail[-1500:]
bad = [v for v in re_accept.findall(tail) if v != "1"]
assert not bad, \
"overlay layer rejected mid-hold %r:\n%s" % (
bad, _liftoff_excerpt(s.read_log()))
pos = len(s.read_log())
s.mods(0)
s.wait_for_new_after(pos, r"\[render\] overlay hide\b")
s.wait_for_new_after(pos, r"\[diag\] frame (?:start|result) .*vis=0 plane=0")
box = _logged_box(s)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
_assert_no_menu_plane(card, box, "after release")
break
except AssertionError:
time.sleep(0.5)
_assert_no_menu_plane(card, box, "after release")
_assert_alive(s)