summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-14 23:50:29 +0900
committerAdam Malczewski <[email protected]>2026-06-14 23:50:29 +0900
commited4938c52421fa7463557f0d7806235f2f496b37 (patch)
treeb87dcc2df2f500ca29759534e720b33c0ca917ab
parent4b568e52ac8f7c04b72692e619806431abd8d787 (diff)
downloadunbox-ed4938c52421fa7463557f0d7806235f2f496b37.tar.gz
unbox-ed4938c52421fa7463557f0d7806235f2f496b37.zip
spike(kernel): RML compositing live + interactive on the real DRM seat
Iterated the Phase-0 spike from "self-verified headless" to "works on the CF-AX3": a live, 3D-tilted client window you can type into, with a visible cursor and pointer/touch routed through the transform. Real-seat confirmed. Safety / escape (a black-screen lockout had forced reboots): - session escape hatch on --run (mirrors src/input.cpp): Esc + Ctrl+Alt+Backspace quit, Ctrl+Alt+F1..F12 VT-switch, SIGINT/SIGTERM clean-terminate. - 15s DEAD-MAN auto-exit, reset by pressing P (also a keyboard-liveness probe); UNBOX_SPIKE_TIMEOUT overrides. Guarantees the spike can never lock the machine. - persistent crash-survivable log at $HOME/rml-spike.log (UNBOX_SPIKE_LOG), fflush+fsync per line (was on tmpfs /tmp -> lost on reboot); loud diagnostics for backend/modeset/client-connect/map/import. Black screen / no window fixes: - present path works on real DRM+gles2 (blue bg + marker confirmed on panel); dark-blue clear + marker rect make "empty vs not-presenting" diagnosable. - xdg wired from new_toplevel/new_popup (was new_surface -> no role -> no configure -> client never mapped); foot now maps as a live surface element. - WAYLAND_DISPLAY exported so spawned clients connect. Interactivity: - keyboard focus enter + key forward to the focused client (was never sent without a pre-existing keyboard device); typing reaches foot. - visible wlr-plane cursor (xcursor theme load + set; never drawn into RmlUi). - pointer + touch hit-tested via RmlUi pick through the 3D transform, mapped to surface-local, forwarded via wl_seat (criterion-3 pointer confirmed at edges). Live update loop (was frozen on frame #1): - send wlr_surface_send_frame_done to all mapped surfaces+subsurfaces each frame (client lives as an imported texture, not a wlr_scene node). - re-import the surface's CURRENT buffer per commit, gated on the surface commit SEQUENCE not the buffer pointer (foot recycles a buffer pool -> same pointer, new contents); double-buffered wlr_buffer lock/release so the client is never starved. reimports now track commits ~1:1 (was capped 3:1). --verify ALL PASS (criteria 1-7), kernel suite green, build + build-asan clean. Spike target stays build_by_default:false, out of the shipped binary.
-rw-r--r--.gitignore3
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike.cpp19
-rw-r--r--packages/kernel/src/spike/rml_compositing_spike_run.cpp936
-rw-r--r--packages/kernel/src/spike/spike_gl.hpp75
4 files changed, 915 insertions, 118 deletions
diff --git a/.gitignore b/.gitignore
index a9d1f2f..72b9759 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ packaging/remote.local
subprojects/*
!subprojects/*.wrap
!subprojects/packagefiles/
+
+# local spike launcher (run-spike.sh)
+/run-spike.sh
diff --git a/packages/kernel/src/spike/rml_compositing_spike.cpp b/packages/kernel/src/spike/rml_compositing_spike.cpp
index 231d4bd..14884f5 100644
--- a/packages/kernel/src/spike/rml_compositing_spike.cpp
+++ b/packages/kernel/src/spike/rml_compositing_spike.cpp
@@ -181,17 +181,26 @@ auto run_verify() -> int {
wlr_render_pass_submit(pass);
}
gl.make_current();
- live_zero_copy = live.adopt(client_buf) && live.is_dmabuf;
+ live_zero_copy = live.adopt(client_buf, /*seq=*/1) && live.is_dmabuf;
}
check(client_buf != nullptr && live.tex != 0,
"criterion 1: live client buffer imported as a sampled texture");
check(live_zero_copy, "criterion 1: live import is ZERO-COPY dmabuf (not a CPU copy)");
+ // Re-adopting the SAME buffer at the SAME commit seq is the idle-gate case:
+ // no commit happened, so the seq is unchanged and we must NOT re-import.
const int reimports_before = live.reimports;
- live.adopt(client_buf);
- live.adopt(client_buf);
+ live.adopt(client_buf, /*seq=*/1);
+ live.adopt(client_buf, /*seq=*/1);
check(live.reimports == reimports_before,
- "criterion 1: unchanged buffer is NOT re-imported (cached)");
+ "criterion 1: unchanged surface state (same seq) is NOT re-imported (cached)");
+
+ // But a NEW commit (seq advances) of the SAME pooled buffer pointer with new
+ // contents MUST re-import — the frozen-frame fix. Proven here directly.
+ live.adopt(client_buf, /*seq=*/2);
+ check(live.reimports == reimports_before + 1,
+ "criterion 1: a new commit (seq++) of a reused buffer pointer DOES re-import "
+ "(frozen-frame fix: pool reuse no longer skips the update)");
std::string rml = kVerifyRmlTemplate;
rml.replace(rml.find("LIVE_URI"), 8, live.uri);
@@ -425,7 +434,7 @@ auto run_verify_surface_trees() -> int {
};
for (const Pair& p : {Pair{&wall, wall_buf}, Pair{&top, top_buf}, Pair{&sub, sub_buf},
Pair{&pop, pop_buf}}) {
- const bool ok = p.b != nullptr && p.t->adopt(p.b);
+ const bool ok = p.b != nullptr && p.t->adopt(p.b, /*seq=*/1);
zero_copy = zero_copy && ok && p.t->is_dmabuf;
}
check(zero_copy, "criterion 4/5: tree (toplevel+subsurface+popup) + wallpaper imported zero-copy");
diff --git a/packages/kernel/src/spike/rml_compositing_spike_run.cpp b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
index 0a6f78d..0aca44f 100644
--- a/packages/kernel/src/spike/rml_compositing_spike_run.cpp
+++ b/packages/kernel/src/spike/rml_compositing_spike_run.cpp
@@ -27,6 +27,8 @@
#include "spike_gl.hpp"
#include "spike_input_core.hpp"
+#include "../vt_core.hpp" // VT-switch decision core, mirrored from input.cpp
+
#include <RmlUi/Core/Context.h>
#include <RmlUi/Core/Core.h>
#include <RmlUi/Core/Element.h>
@@ -35,6 +37,7 @@
#include <algorithm>
#include <cmath>
+#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
@@ -49,6 +52,7 @@ extern "C" {
#include <xkbcommon/xkbcommon.h>
}
+#include <signal.h>
#include <unistd.h>
using unbox::kernel::Listener;
@@ -56,8 +60,67 @@ namespace spike = unbox::kernel::spike;
namespace {
+// ---- Verbose, crash-survivable diagnostic log --------------------------------
+//
+// The field failure was a BLACK SCREEN that forced a hard reboot — and the log
+// was on /tmp (tmpfs), so the reboot WIPED it. Now the log goes to a PERSISTENT
+// path that survives a reboot: $UNBOX_SPIKE_LOG if set, else $HOME/rml-spike.log
+// (NEVER /tmp). Every interesting step (backend/renderer pick, output modeset,
+// each commit heartbeat, client spawn/exec, EACH client connect, EACH surface
+// map/unmap, EACH live-texture import, scene insertion) is logged to BOTH stderr
+// AND that file. We FLUSH **and fsync()** after every line so a hard reboot (or
+// a freeze followed by a power-cycle) still preserves the log up to the freeze
+// point. Single-threaded event loop ⇒ no locking.
+FILE* g_log = nullptr;
+std::string g_log_path;
+
+void log_open() {
+ if (const char* env = getenv("UNBOX_SPIKE_LOG"); env != nullptr && env[0] != '\0') {
+ g_log_path = env;
+ } else if (const char* home = getenv("HOME"); home != nullptr && home[0] != '\0') {
+ g_log_path = std::string(home) + "/rml-spike.log";
+ } else {
+ // Last resort only (no HOME): the cwd, still NOT tmpfs by default.
+ g_log_path = "rml-spike.log";
+ }
+ g_log = std::fopen(g_log_path.c_str(), "w"); // truncate on start
+}
+void log_close() {
+ if (g_log != nullptr) {
+ std::fflush(g_log);
+ ::fsync(::fileno(g_log));
+ std::fclose(g_log);
+ g_log = nullptr;
+ }
+}
+[[gnu::format(printf, 1, 2)]] void slog(const char* fmt, ...) {
+ char buf[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ std::vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+ const double t = spike::now_sec();
+ std::fprintf(stderr, "[%.3f] %s\n", t, buf);
+ if (g_log != nullptr) {
+ std::fprintf(g_log, "[%.3f] %s\n", t, buf);
+ std::fflush(g_log); // push out of stdio's buffer...
+ ::fsync(::fileno(g_log)); // ...AND down to disk: a hard reboot keeps the tail
+ }
+}
+
struct Runner; // fwd
+// One keyboard device. MIRRORS the shipped kernel's src/input.cpp: every
+// keyboard from the seat gets its OWN key + modifiers + destroy listeners, held
+// in a list (NOT a single shared pointer that the last device clobbers). On a
+// real DRM seat there can be several keyboard devices; the escape hatch must
+// fire on a key from ANY of them, so EACH needs its own live key listener.
+struct Keyboard {
+ Runner* runner = nullptr;
+ wlr_keyboard* keyboard = nullptr;
+ Listener key_l, mods_l, destroy_l;
+};
+
// One live client surface presented as a surface element. Backed by a wlr
// xdg-toplevel (the spike maps exactly one toplevel + its popups/subsurfaces and
// one layer/wallpaper for the criteria; more would be the same loop). Holds the
@@ -96,13 +159,73 @@ struct Runner {
wlr_xcursor_manager* cursor_mgr = nullptr;
wlr_xdg_shell* xdg_shell = nullptr;
wlr_layer_shell_v1* layer_shell = nullptr;
- wlr_keyboard* keyboard = nullptr;
+ // Per-device keyboards (mirrors input.cpp's `keyboards` list). `focus_kb` is
+ // the one currently driving wlr_seat focus (the last to send a key), used to
+ // hand a newly-mapped toplevel keyboard focus. Never dereferenced for input
+ // routing — each device's own listener carries its own wlr_keyboard.
+ std::list<Keyboard> keyboards;
+ wlr_keyboard* focus_kb = nullptr;
+ // The toplevel wl_surface that currently holds keyboard focus on the seat
+ // (set via wlr_seat_keyboard_notify_enter). Tracking it lets us (a) re-assert
+ // focus idempotently if a key arrives before any enter landed (device/map
+ // ordering on a real DRM seat is not guaranteed), and (b) focus a toplevel
+ // that mapped BEFORE the first keyboard device appeared. Mirrors the shipped
+ // ext-xdg-shell discipline of holding the focused surface and re-entering.
+ wlr_surface* focused_surface = nullptr;
+ // The last toplevel that mapped — the focus candidate. Kept so a keyboard
+ // that is hot-plugged AFTER the toplevel mapped can still be handed focus.
+ wlr_surface* last_toplevel = nullptr;
+ // Per-key forward instrumentation: keys forwarded to the focused client so
+ // the log proves typing is reaching foot even though the agent cannot see it.
+ long keys_forwarded = 0;
+ // Cursor liveness: true once we have shown a default xcursor image on the
+ // wlr cursor plane. The cursor stays a wlr plane (NEVER drawn into RmlUi) per
+ // the plan; we just make sure it HAS an image so it is visible.
+ bool cursor_shown = false;
+ // Pointer/touch routing instrumentation (counts, not per-event spam after the
+ // first few): so the log shows events ARE landing on a client surface.
+ long pointer_enters = 0;
+ long pointer_motions = 0;
+ long pointer_misses = 0;
+ long touch_downs = 0;
+ // LIVE-UPDATE LOOP instrumentation (the field "stuck on a single frame" fix).
+ // The client lives as an imported texture (NOT a wlr_scene surface node), so
+ // the spike must DRIVE the client update loop itself: (a) send frame-done to
+ // every mapped client surface + its subsurfaces/popups each composited frame
+ // (without it the client draws ONCE and waits forever -> stuck frame), and
+ // (b) re-import the surface's CURRENT buffer on each commit (the buffer ptr
+ // changes per frame). These counters prove the loop is alive in the log.
+ long client_commits = 0; // per-surface wl_surface.commit count (all surfaces)
+ long live_reimports = 0; // live-texture re-imports (a real new buffer adopted)
+ long frame_done_sends = 0; // wlr_surface_send_frame_done calls (tree-walked)
+ double last_loop_report = 0.0;
// A fixed ~60Hz event-loop timer drives the composite/present clock
// independently of output `frame` damage semantics (which stall a static
// nested/DRM output and would freeze client progress). The dirty-gate still
// decides render-vs-skip; this only keeps the clock alive for the GO/NO-GO.
wl_event_source* tick = nullptr;
+ // SAFETY (criterion A): the guaranteed backstops that make a real DRM seat
+ // un-lockable. A self-timeout timer terminates the loop after N seconds; two
+ // signal sources turn SIGINT/SIGTERM into a CLEAN wl_display_terminate (so
+ // wlroots restores the VT to text mode — no hard reboot). VT switching +
+ // quit keys are handled inline in the keyboard handler, BEFORE any forward.
+ wl_event_source* safety_timer = nullptr;
+ wl_event_source* sigint_src = nullptr;
+ wl_event_source* sigterm_src = nullptr;
+ // The dead-man interval (ms). Pressing `P` re-arms safety_timer to this full
+ // interval — so holding the session open past the interval REQUIRES periodic
+ // P presses. That doubles as a real-seat keyboard-input liveness test: if the
+ // session survives past the interval, P is reaching the handler ⇒ input works.
+ int deadman_ms = 15000;
+
+ // A guaranteed-visible non-black background + test marker, composited UNDER
+ // the RmlUi present node as plain wlr_scene_rects. This makes "black screen"
+ // (nothing presenting at all) visibly different from "presenting but the
+ // RmlUi/client layer is empty" (dark blue + a marker square show through).
+ wlr_scene_rect* bg_rect = nullptr;
+ wlr_scene_rect* marker_rect = nullptr;
+
int out_w = 1920, out_h = 1080;
spike::GlBridge gl;
@@ -123,12 +246,28 @@ struct Runner {
int frames_skipped_idle = 0;
double last_report = 0.0;
+ // Commit/present heartbeat (criterion B): a count of output commits so the
+ // log shows the present loop is actually ticking even on a static scene.
+ long commits = 0;
+
+ // Client diagnosis: count connects, and a ~5s watchdog that screams if NO
+ // client surface ever maps (the "foot did not appear" field failure). Set
+ // true the first time ANY surface maps; the watchdog reads it.
+ int client_connects = 0;
+ bool any_surface_mapped = false;
+ wl_event_source* client_watchdog = nullptr;
+ wl_listener client_created_l{}; // raw: wl_display client-created is not a wlr signal
+ wl_global* compositor_global = nullptr;
+
// Server-level listeners.
Listener new_output_l, new_input_l, frame_l;
- Listener new_xdg_l, new_layer_l;
+ Listener new_toplevel_l, new_popup_l, new_layer_l;
Listener cursor_motion_l, cursor_motion_abs_l, cursor_button_l, cursor_axis_l, cursor_frame_l;
Listener touch_down_l, touch_up_l, touch_motion_l;
- Listener kb_key_l, kb_mods_l;
+ // Seat protocol glue (mirrors src/input.cpp::attach_seat_handlers): let a
+ // client set its own cursor over its surface, and restore the default
+ // xcursor when the pointer focus leaves all client surfaces.
+ Listener seat_request_cursor_l, seat_pointer_focus_change_l;
auto add_surface(wlr_surface* surf) -> LiveSurface* {
surfaces.emplace_back();
@@ -238,10 +377,23 @@ auto composite_frame(Runner& r, bool force) -> double {
buf = &s.surface->buffer->base;
}
if (buf != nullptr) {
- s.live.adopt(buf);
+ const int reimports_before = s.live.reimports;
+ // Gate the re-import on the surface's COMMIT SEQUENCE, not the buffer
+ // pointer: foot recycles a small buffer pool, so the SAME wlr_buffer
+ // pointer is re-committed with NEW contents. wlr_surface_state.seq
+ // advances on every commit regardless of pool reuse, so this re-imports
+ // the current buffer each new frame (the frozen-frame fix) while a
+ // static client (no commit => no seq change) still does zero work.
+ s.live.adopt(buf, s.surface->current.seq);
// Natural size from the surface's current state.
s.w = s.surface->current.width;
s.h = s.surface->current.height;
+ if (s.live.reimports != reimports_before) {
+ ++r.live_reimports;
+ slog("live-texture import: '%s' %dx%d dmabuf=%d tex=%u (reimport #%d)",
+ s.element_id.c_str(), s.live.width, s.live.height, s.live.is_dmabuf,
+ s.live.tex, s.live.reimports);
+ }
}
layout_surface_element(r, s);
}
@@ -259,6 +411,48 @@ auto composite_frame(Runner& r, bool force) -> double {
return dt_ms;
}
+// ---- The LIVE-UPDATE loop: frame callbacks to every client surface ----------
+//
+// THE STUCK-FRAME FIX. The shipped kernel's output frame handler (server.cpp)
+// ends each frame with wlr_scene_output_send_frame_done(scene_output, now),
+// which walks every SCENE surface node and completes its frame callbacks so the
+// client is told "now is a good time to draw your next frame". But in this spike
+// the client surfaces are NOT scene nodes — they live as imported live textures
+// inside RmlUi, and the scene holds only our background rects + the single
+// composited present buffer. So wlr_scene_output_send_frame_done NEVER reaches
+// foot: the client draws its first buffer, its frame callback is never completed,
+// and it waits forever -> the window is stuck on one frame (no typing/output/
+// cursor-blink). We must drive the callbacks ourselves.
+//
+// This walks EVERY mapped client surface tree (toplevel + its subsurfaces, each
+// popup + its subsurfaces, the wallpaper) — exactly what wlr_scene_output_send_
+// frame_done does for scene nodes — and calls wlr_surface_send_frame_done on
+// every mapped surface in the tree. wlr_surface_for_each_surface visits the
+// surface and all its subsurfaces (root -> leaves), so subsurface callbacks are
+// covered; xdg popups are tracked as their OWN LiveSurface (added in
+// new_popup), so their tree is walked here too. Mirrors the SHIPPED behaviour.
+void send_frame_done_to_clients(Runner& r) {
+ timespec now{};
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ struct WalkData {
+ Runner* r;
+ timespec* now;
+ } wd{&r, &now};
+ for (LiveSurface& s : r.surfaces) {
+ if (!s.mapped || s.surface == nullptr) {
+ continue;
+ }
+ wlr_surface_for_each_surface(
+ s.surface,
+ [](wlr_surface* surf, int /*sx*/, int /*sy*/, void* data) {
+ auto* w = static_cast<WalkData*>(data);
+ wlr_surface_send_frame_done(surf, w->now);
+ ++w->r->frame_done_sends;
+ },
+ &wd);
+ }
+}
+
// ---- Input: RmlUi pick -> surface-local -> wl_seat --------------------------
//
// Feed the screen point to the RmlUi context; RmlUi's transform-aware hover pick
@@ -320,28 +514,108 @@ auto route_point(Runner& r, double screen_x, double screen_y) -> Routed {
return out;
}
+// Make the wlr cursor VISIBLE by giving its plane a default xcursor image. The
+// cursor stays a wlr plane (hardware/output cursor), NEVER drawn into RmlUi —
+// exactly as the plan requires. Called once a pointer/touch device exists and
+// re-asserted whenever the pointer is not over a client surface (a client may
+// have set its own cursor surface; when it leaves we restore the default).
+// MIRRORS the shipped kernel (input.cpp seat_pointer_focus_change -> "default").
+void show_default_cursor(Runner& r) {
+ if (r.cursor == nullptr || r.cursor_mgr == nullptr) {
+ return;
+ }
+ wlr_cursor_set_xcursor(r.cursor, r.cursor_mgr, "default");
+ if (!r.cursor_shown) {
+ r.cursor_shown = true;
+ slog("CURSOR shown: default xcursor set on the wlr cursor plane (visible, hardware plane)");
+ }
+}
+
void notify_pointer_motion(Runner& r, double sx, double sy, std::uint32_t time, Routed& rt) {
if (rt.s == nullptr || rt.s->surface == nullptr) {
+ // No client surface under the cursor (over the document body / wallpaper
+ // gap / a tilt's empty corner): clear client pointer focus and make sure
+ // OUR default cursor is showing (the client can't have set one here).
wlr_seat_pointer_notify_clear_focus(r.seat);
+ show_default_cursor(r);
+ ++r.pointer_misses;
+ if (r.pointer_misses <= 4 || (r.pointer_misses % 240) == 0) {
+ slog("pointer motion: NO surface under point (%.0f,%.0f) — misses=%ld "
+ "(cursor over document/empty area; default cursor shown)",
+ sx, sy, r.pointer_misses);
+ }
return;
}
wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
wlr_seat_pointer_notify_motion(r.seat, time, rt.sx, rt.sy);
wlr_seat_pointer_notify_frame(r.seat);
- (void)sx;
- (void)sy;
+ ++r.pointer_motions;
+ if (r.pointer_motions <= 4 || (r.pointer_motions % 240) == 0) {
+ slog("pointer -> client surface '%s' at surface-local (%.1f,%.1f) [screen (%.0f,%.0f) "
+ "through the 3D transform] motions=%ld",
+ rt.s->element_id.c_str(), rt.sx, rt.sy, sx, sy, r.pointer_motions);
+ }
}
// ---- xdg-shell ---------------------------------------------------------------
void on_surface_commit(Runner& r, LiveSurface& s) {
// A client buffer commit is THE dirty source (criterion 6): a new frame is
- // scheduled only here (plus input/animation).
+ // scheduled only here (plus input/animation). This is ALSO the second half of
+ // the stuck-frame fix: the client's per-frame buffer POINTER changes on each
+ // commit, and LiveTexture::adopt early-returns when the buffer is unchanged —
+ // so a real new buffer must be RE-IMPORTED. We mark the scene dirty so the
+ // dirty-gate actually renders the updated texture this frame (composite_frame
+ // re-adopts s.surface->buffer for every mapped surface). Marking dirty here
+ // is what stops the idle gate from suppressing a REAL client update: a static
+ // client (no commits) => no extra renders; an updating client (a commit per
+ // frame) => one render per committed frame. Covers the whole surface tree:
+ // the toplevel commit AND each subsurface/popup commit (each its own
+ // LiveSurface with its own commit listener) both land here.
+ ++r.client_commits;
r.dirty = true;
if (r.output != nullptr) {
wlr_output_schedule_frame(r.output);
}
- (void)s;
+ (void)s; // re-import happens in composite_frame (re-adopts s.surface->buffer)
+}
+
+// Give `surface` keyboard focus on the seat: set the active keyboard (so the
+// client receives the keymap) and send the enter with the keyboard's current
+// pressed keys + modifiers. Idempotent — calling it again for the already-
+// focused surface is harmless and just re-asserts. MIRRORS input.cpp's
+// wlr_seat_set_keyboard discipline + the ext-xdg-shell notify_enter on focus.
+//
+// CRITICAL FIX (real-seat "cannot type into foot"): the previous code only
+// entered focus at MAP and ONLY if a keyboard already existed (r.focus_kb !=
+// nullptr). On a real DRM seat the keyboard device and the client map can land
+// in EITHER order, and wlr_seat_set_keyboard had not necessarily run for the
+// keyboard that ends up sending keys — so the client never got an `enter` and
+// every wlr_seat_keyboard_notify_key fell on a surface with no keyboard focus.
+// Routing it through this helper, called on map AND on keyboard-add AND lazily
+// on the first key, guarantees the focused client actually receives keys.
+void focus_toplevel(Runner& r, wlr_surface* surface) {
+ if (surface == nullptr) {
+ return;
+ }
+ // Need a keyboard set on the seat so the enter ships the keymap. Prefer the
+ // last device that drove the seat; else any device we have; else bail (we
+ // will retry from new_keyboard once a device exists).
+ wlr_keyboard* kb = r.focus_kb;
+ if (kb == nullptr && !r.keyboards.empty()) {
+ kb = r.keyboards.back().keyboard;
+ }
+ if (kb == nullptr) {
+ slog("focus deferred: toplevel mapped but NO keyboard device yet "
+ "(will enter on keyboard-add)");
+ return;
+ }
+ wlr_seat_set_keyboard(r.seat, kb);
+ wlr_seat_keyboard_notify_enter(r.seat, surface, kb->keycodes, kb->num_keycodes,
+ &kb->modifiers);
+ r.focused_surface = surface;
+ slog("KEYBOARD FOCUS ENTER -> client surface %p (kb='%s') — keys now route to this client",
+ static_cast<void*>(surface), kb->base.name != nullptr ? kb->base.name : "?");
}
void on_xdg_map(Runner& r, LiveSurface& s) {
@@ -355,55 +629,89 @@ void on_xdg_map(Runner& r, LiveSurface& s) {
s.x = (r.out_w - s.w) / 2;
s.y = (r.out_h - s.h) / 2;
s.transform3d = true;
- // Give the toplevel keyboard focus.
- if (r.keyboard != nullptr && s.surface != nullptr) {
- wlr_seat_keyboard_notify_enter(r.seat, s.surface, r.keyboard->keycodes,
- r.keyboard->num_keycodes, &r.keyboard->modifiers);
- }
+ // Give the toplevel keyboard focus (robust helper — handles the case where
+ // no keyboard device exists yet by deferring to new_keyboard).
+ r.last_toplevel = s.surface;
+ focus_toplevel(r, s.surface);
r.dirty = true;
- std::fprintf(stderr, "[run] toplevel mapped %dx%d at (%d,%d) as surface element '%s'\n", s.w,
- s.h, s.x, s.y, s.element_id.c_str());
+ r.any_surface_mapped = true;
+ slog("CLIENT SURFACE MAP: toplevel %dx%d at (%d,%d) -> added to scene as live surface "
+ "element '%s' (it WILL be composited as a live texture this frame)",
+ s.w, s.h, s.x, s.y, s.element_id.c_str());
}
-void handle_new_xdg(Runner& r, wlr_xdg_surface* xdg) {
- if (xdg->role == WLR_XDG_SURFACE_ROLE_POPUP) {
- // Popups are surface elements too — answering criterion 4: each
- // subsurface/popup is its OWN element sampling its OWN live texture,
- // positioned at the popup's offset under its parent. The per-subsurface
- // approach (vs per-window RTT) is what we exercise here.
- LiveSurface* s = r.add_surface(xdg->surface);
- s->xdg = xdg;
- s->map_l.connect(xdg->surface->events.map, [&r, s](void*) {
- s->mapped = true;
- // Position the popup relative to the output (its geometry carries the
- // offset from the parent in surface coords; for the spike we place it
- // near the toplevel center + popup geometry).
- const wlr_box geo = s->xdg->geometry;
- s->w = geo.width > 0 ? geo.width : 200;
- s->h = geo.height > 0 ? geo.height : 100;
- s->x = (r.out_w) / 2 + s->xdg->popup->scheduled.geometry.x;
- s->y = (r.out_h) / 2 + s->xdg->popup->scheduled.geometry.y;
- r.dirty = true;
- std::fprintf(stderr, "[run] popup mapped as surface element '%s'\n",
- s->element_id.c_str());
- });
- s->unmap_l.connect(xdg->surface->events.unmap,
- [&r, s](void*) { s->mapped = false; r.dirty = true; });
- s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) { on_surface_commit(r, *s); });
- s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
- return;
- }
- if (xdg->role != WLR_XDG_SURFACE_ROLE_TOPLEVEL) {
- return;
- }
+// MIRRORS the shipped ext-xdg-shell: wire from the xdg_shell's `new_toplevel` /
+// `new_popup` signals, NOT `new_surface`. CRITICAL: on `new_surface` the surface
+// has NO role yet (the client has not called get_toplevel/get_popup), so the old
+// `xdg->role == TOPLEVEL` test was ALWAYS false there and the spike wired NOTHING
+// — no commit handler, so the initial-commit `configure` was never sent, so the
+// client (foot) waited forever for a configure and NEVER mapped. That is exactly
+// the "foot did not appear" field failure. These signals fire with the role
+// assigned, so the handshake completes and the client maps.
+void handle_new_toplevel(Runner& r, wlr_xdg_toplevel* toplevel) {
+ wlr_xdg_surface* xdg = toplevel->base;
LiveSurface* s = r.add_surface(xdg->surface);
s->xdg = xdg;
+ slog("xdg TOPLEVEL created (app_id='%s' title='%s') — awaiting initial commit -> configure",
+ toplevel->app_id != nullptr ? toplevel->app_id : "?",
+ toplevel->title != nullptr ? toplevel->title : "?");
s->map_l.connect(xdg->surface->events.map, [&r, s](void*) { on_xdg_map(r, *s); });
- s->unmap_l.connect(xdg->surface->events.unmap,
- [&r, s](void*) { s->mapped = false; r.dirty = true; });
+ s->unmap_l.connect(xdg->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ // Drop keyboard focus if this was the focused toplevel (mirrors ext-xdg-
+ // shell: an unmapped surface must not keep the seat's keyboard focus).
+ if (r.focused_surface == s->surface) {
+ wlr_seat_keyboard_notify_clear_focus(r.seat);
+ r.focused_surface = nullptr;
+ }
+ if (r.last_toplevel == s->surface) {
+ r.last_toplevel = nullptr;
+ }
+ slog("client surface UNMAP: toplevel element '%s'", s->element_id.c_str());
+ });
s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) {
+ // The initial commit REQUIRES a configure reply before the client may
+ // attach a buffer + map. 0x0 size lets the client pick its own dims
+ // (tinywl/ext-xdg-shell discipline); set_size schedules the configure.
if (s->xdg != nullptr && s->xdg->initial_commit) {
- wlr_xdg_toplevel_set_size(s->xdg->toplevel, 0, 0); // let the client choose
+ slog("toplevel '%s' initial commit -> sending 0x0 configure (client picks size)",
+ s->element_id.c_str());
+ wlr_xdg_toplevel_set_size(s->xdg->toplevel, 0, 0);
+ }
+ on_surface_commit(r, *s);
+ });
+ s->destroy_l.connect(xdg->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
+}
+
+void handle_new_popup(Runner& r, wlr_xdg_popup* popup) {
+ // Popups are surface elements too — answering criterion 4: each
+ // subsurface/popup is its OWN element sampling its OWN live texture,
+ // positioned at the popup's offset under its parent.
+ wlr_xdg_surface* xdg = popup->base;
+ LiveSurface* s = r.add_surface(xdg->surface);
+ s->xdg = xdg;
+ s->map_l.connect(xdg->surface->events.map, [&r, s](void*) {
+ s->mapped = true;
+ const wlr_box geo = s->xdg->geometry;
+ s->w = geo.width > 0 ? geo.width : 200;
+ s->h = geo.height > 0 ? geo.height : 100;
+ s->x = (r.out_w) / 2 + s->xdg->popup->scheduled.geometry.x;
+ s->y = (r.out_h) / 2 + s->xdg->popup->scheduled.geometry.y;
+ r.dirty = true;
+ r.any_surface_mapped = true;
+ slog("CLIENT SURFACE MAP: popup -> added to scene as surface element '%s'",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(xdg->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ slog("client surface UNMAP: popup element '%s'", s->element_id.c_str());
+ });
+ s->commit_l.connect(xdg->surface->events.commit, [&r, s](void*) {
+ // A popup also needs its initial configure before it can map.
+ if (s->xdg != nullptr && s->xdg->initial_commit) {
+ wlr_xdg_surface_schedule_configure(s->xdg);
}
on_surface_commit(r, *s);
});
@@ -428,11 +736,15 @@ void handle_new_layer(Runner& r, wlr_layer_surface_v1* layer) {
s->map_l.connect(layer->surface->events.map, [&r, s](void*) {
s->mapped = true;
r.dirty = true;
- std::fprintf(stderr, "[run] layer-shell wallpaper mapped as surface element '%s'\n",
- s->element_id.c_str());
+ r.any_surface_mapped = true;
+ slog("CLIENT SURFACE MAP: layer-shell wallpaper -> added to scene as surface element '%s'",
+ s->element_id.c_str());
+ });
+ s->unmap_l.connect(layer->surface->events.unmap, [&r, s](void*) {
+ s->mapped = false;
+ r.dirty = true;
+ slog("client surface UNMAP: layer-shell wallpaper element '%s'", s->element_id.c_str());
});
- s->unmap_l.connect(layer->surface->events.unmap,
- [&r, s](void*) { s->mapped = false; r.dirty = true; });
s->commit_l.connect(layer->surface->events.commit, [&r, s](void*) { on_surface_commit(r, *s); });
s->destroy_l.connect(layer->surface->events.destroy, [&r, s](void*) { r.remove_surface(s); });
}
@@ -441,6 +753,15 @@ void handle_new_layer(Runner& r, wlr_layer_surface_v1* layer) {
void on_frame(Runner& r) {
const double dt = composite_frame(r, /*force=*/false);
+ ++r.commits;
+ // Heartbeat (criterion B): prove the present/commit loop is alive even on a
+ // static scene. First few commits are logged individually (catches an early
+ // freeze); after that, once a second via the [perf] line below.
+ if (r.commits <= 5) {
+ slog("output commit heartbeat #%ld (rendered=%d skipped_idle=%d present_node=%p)",
+ r.commits, r.frames_rendered, r.frames_skipped_idle,
+ static_cast<void*>(r.present_node));
+ }
if (!wlr_scene_output_commit(r.scene_output, nullptr)) {
// Nothing changed for wlr_scene to commit (static scene). The nested /
// DRM backend only emits the next `frame` after a successful output
@@ -459,6 +780,15 @@ void on_frame(Runner& r) {
clock_gettime(CLOCK_MONOTONIC, &now);
wlr_scene_output_send_frame_done(r.scene_output, &now);
+ // THE STUCK-FRAME FIX: drive the client update loop. The client surfaces are
+ // imported live textures, NOT scene nodes, so the wlr_scene_output_send_frame_
+ // done above never reaches them. Walk every mapped client surface tree and
+ // complete its frame callbacks ourselves — without this foot draws ONCE and
+ // waits forever (the field "stuck on a single frame"). This tells every
+ // mapped surface + subsurface + popup "now is a good time to draw the next
+ // frame", so typing/output/cursor-blink advance. Mirrors server.cpp.
+ send_frame_done_to_clients(r);
+
// Animation dirty source: RmlUi's GetNextUpdateDelay() (finite => animating,
// +inf => at rest) — exactly the design's gate signal.
bool anim = false;
@@ -493,51 +823,202 @@ void on_frame(Runner& r) {
}
const double avg = sum / v.size();
const double p95 = v[static_cast<std::size_t>(v.size() * 0.95)];
- std::fprintf(stderr,
- "[perf] frames=%d skipped_idle=%d avg=%.2fms p95=%.2fms max=%.2fms "
- "(~%.0f fps budget)\n",
- r.frames_rendered, r.frames_skipped_idle, avg, p95, v.back(),
- avg > 0 ? 1000.0 / avg : 0.0);
+ slog("[perf] frames=%d skipped_idle=%d commits=%ld avg=%.2fms p95=%.2fms max=%.2fms "
+ "(~%.0f fps budget)",
+ r.frames_rendered, r.frames_skipped_idle, r.commits, avg, p95, v.back(),
+ avg > 0 ? 1000.0 / avg : 0.0);
r.frame_ms.clear();
r.last_report = t;
(void)dt;
}
+
+ // Periodic LIVE-UPDATE-LOOP heartbeat (~1s): proves the client update loop is
+ // ALIVE — commits coming in, buffers re-imported, and frame-done sent back so
+ // the client keeps producing frames. On the user's next run a CLIMBING
+ // frame_done (with client_commits + reimports climbing as they type) means
+ // foot is no longer stuck on one frame: typing/output/cursor-blink advance.
+ // (Counts only, not per-event spam, per the brief.)
+ if (t - r.last_loop_report > 1.0) {
+ slog("[live-loop] client commits=%ld reimports=%ld frame_done=%ld (mapped surfaces "
+ "are being told to draw their next frame -> live update)",
+ r.client_commits, r.live_reimports, r.frame_done_sends);
+ r.last_loop_report = t;
+ }
}
// ---- input devices -----------------------------------------------------------
-void handle_new_input(Runner& r, wlr_input_device* dev) {
- if (dev->type == WLR_INPUT_DEVICE_KEYBOARD) {
- r.keyboard = wlr_keyboard_from_input_device(dev);
- xkb_context* xkb = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
- xkb_keymap* km = xkb_keymap_new_from_names(xkb, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
- wlr_keyboard_set_keymap(r.keyboard, km);
- xkb_keymap_unref(km);
- xkb_context_unref(xkb);
- wlr_keyboard_set_repeat_info(r.keyboard, 25, 600);
- wlr_seat_set_keyboard(r.seat, r.keyboard);
- r.kb_key_l.connect(r.keyboard->events.key, [&r](void* data) {
- auto* ev = static_cast<wlr_keyboard_key_event*>(data);
- wlr_seat_set_keyboard(r.seat, r.keyboard);
- wlr_seat_keyboard_notify_key(r.seat, ev->time_msec, ev->keycode, ev->state);
- });
- r.kb_mods_l.connect(r.keyboard->events.modifiers, [&r](void*) {
- wlr_seat_set_keyboard(r.seat, r.keyboard);
- wlr_seat_keyboard_notify_modifiers(r.seat, &r.keyboard->modifiers);
- });
- } else if (dev->type == WLR_INPUT_DEVICE_POINTER) {
- wlr_cursor_attach_input_device(r.cursor, dev);
- } else if (dev->type == WLR_INPUT_DEVICE_TOUCH) {
- wlr_cursor_attach_input_device(r.cursor, dev);
+// Re-arm the dead-man self-timeout to its full interval. Called when it is first
+// armed and EVERY time `P` is pressed; if the session outlives the interval, P
+// reached the handler ⇒ real-seat keyboard input is alive (the liveness test).
+void deadman_rearm(Runner& r) {
+ if (r.safety_timer != nullptr && r.deadman_ms > 0) {
+ wl_event_source_timer_update(r.safety_timer, r.deadman_ms);
+ }
+}
+
+// The kernel-hardwired escape-hatch + dead-man check, run on EVERY key event
+// from EVERY keyboard device, BEFORE anything else. Returns true if the key was
+// CONSUMED here (so it must NOT be forwarded to a client). MIRRORS the shipped
+// kernel's src/input.cpp keysym-resolution + VT-switch discipline.
+auto handle_escape_keys(Runner& r, Keyboard& kb, wlr_keyboard_key_event* ev) -> bool {
+ const std::uint32_t keycode = ev->keycode + 8; // libinput keycode -> xkb
+ const xkb_keysym_t* syms = nullptr;
+ const int nsyms = xkb_state_key_get_syms(kb.keyboard->xkb_state, keycode, &syms);
+ const std::uint32_t mods = wlr_keyboard_get_modifiers(kb.keyboard);
+ const bool pressed = ev->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+ const bool ctrl_alt = (mods & (WLR_MODIFIER_CTRL | WLR_MODIFIER_ALT)) ==
+ (WLR_MODIFIER_CTRL | WLR_MODIFIER_ALT);
+
+ for (int i = 0; i < nsyms; ++i) {
+ const xkb_keysym_t sym = syms[i];
+
+ // `P` (with NO ctrl/alt) -> reset the dead-man timer AND prove input is
+ // live. This is the keep-alive: holding the session open past the
+ // dead-man interval REQUIRES pressing P periodically. We do NOT consume
+ // P — let it pass through to the client too (it is a normal letter); the
+ // keep-alive is a side-effect, not a grab.
+ if (pressed && !ctrl_alt && (sym == XKB_KEY_p || sym == XKB_KEY_P)) {
+ deadman_rearm(r);
+ slog("P pressed -> dead-man reset to %ds (real-seat keyboard input is LIVE)",
+ r.deadman_ms / 1000);
+ // fall through: do not consume.
+ }
+
+ // Esc OR Ctrl+Alt+Backspace -> terminate the session cleanly.
+ if (sym == XKB_KEY_Escape || (ctrl_alt && sym == XKB_KEY_BackSpace) ||
+ (ctrl_alt && sym == XKB_KEY_Terminate_Server)) {
+ if (pressed) {
+ slog("QUIT KEY pressed (Esc / Ctrl+Alt+Backspace) -> terminating");
+ wl_display_terminate(r.display);
+ }
+ return true; // consume press AND release; never forward
+ }
+
+ // Ctrl+Alt+F1..F12 -> switch the Linux VT (escape to a console).
+ // vt_for_keysym() is the SAME decision core input.cpp uses.
+ if (const std::optional<unsigned> vt = unbox::kernel::vt_for_keysym(sym)) {
+ if (pressed) {
+ if (r.session != nullptr) {
+ slog("VT-SWITCH key -> wlr_session_change_vt(%u)", *vt);
+ wlr_session_change_vt(r.session, *vt);
+ } else {
+ slog("VT-SWITCH key but no session (nested/headless) -> no-op");
+ }
+ }
+ return true; // consume: no client forward (press or release)
+ }
}
- std::uint32_t caps = WL_SEAT_CAPABILITY_POINTER;
- if (r.keyboard != nullptr) {
+ return false;
+}
+
+void update_seat_caps(Runner& r) {
+ std::uint32_t caps = WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_TOUCH;
+ if (!r.keyboards.empty()) {
caps |= WL_SEAT_CAPABILITY_KEYBOARD;
}
- caps |= WL_SEAT_CAPABILITY_TOUCH;
wlr_seat_set_capabilities(r.seat, caps);
}
+// MIRRORS src/input.cpp::new_keyboard — per-device key/modifiers/destroy
+// listeners, default XKB keymap, repeat info, seat keyboard set. The previous
+// spike kept ONE shared listener that the last device clobbered; on a real DRM
+// seat with several keyboard devices that could leave keys arriving on a device
+// with no live listener — which is exactly the "no key events reached the
+// handler" field failure. Per-device listeners fix that by construction.
+void new_keyboard(Runner& r, wlr_input_device* dev) {
+ wlr_keyboard* wlr_kb = wlr_keyboard_from_input_device(dev);
+
+ r.keyboards.emplace_back();
+ Keyboard& kb = r.keyboards.back();
+ kb.runner = &r;
+ kb.keyboard = wlr_kb;
+
+ xkb_context* xkb = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
+ xkb_keymap* km = xkb_keymap_new_from_names(xkb, nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
+ wlr_keyboard_set_keymap(wlr_kb, km);
+ xkb_keymap_unref(km);
+ xkb_context_unref(xkb);
+ wlr_keyboard_set_repeat_info(wlr_kb, 25, 600);
+
+ kb.key_l.connect(wlr_kb->events.key, [&r, &kb](void* data) {
+ auto* ev = static_cast<wlr_keyboard_key_event*>(data);
+ // Escape hatch + dead-man FIRST, kernel-hardwired, before any forward.
+ if (handle_escape_keys(r, kb, ev)) {
+ return;
+ }
+ r.focus_kb = kb.keyboard;
+ wlr_seat_set_keyboard(r.seat, kb.keyboard);
+ // Lazily (re)assert keyboard focus on the mapped toplevel if the seat is
+ // not already focused on a client surface — covers the real-seat case
+ // where the FIRST key arrives before any enter landed (e.g. keyboard
+ // hot-plugged after map, or map/enter raced). Without this the notify_key
+ // below would fall on a surface with no keyboard focus and never reach
+ // the client (the field "cannot type into foot" symptom).
+ if (wlr_seat_get_keyboard(r.seat) == nullptr || r.focused_surface == nullptr) {
+ if (r.last_toplevel != nullptr) {
+ focus_toplevel(r, r.last_toplevel);
+ }
+ }
+ wlr_seat_keyboard_notify_key(r.seat, ev->time_msec, ev->keycode, ev->state);
+ ++r.keys_forwarded;
+ if (r.keys_forwarded <= 8 || (r.keys_forwarded % 64) == 0) {
+ slog("key FORWARDED to client (keycode=%u state=%u) — total forwarded=%ld%s",
+ ev->keycode, static_cast<unsigned>(ev->state), r.keys_forwarded,
+ r.focused_surface == nullptr ? " [WARN: no focused surface!]" : "");
+ }
+ });
+ kb.mods_l.connect(wlr_kb->events.modifiers, [&r, &kb](void*) {
+ r.focus_kb = kb.keyboard;
+ wlr_seat_set_keyboard(r.seat, kb.keyboard);
+ wlr_seat_keyboard_notify_modifiers(r.seat, &kb.keyboard->modifiers);
+ });
+ kb.destroy_l.connect(dev->events.destroy, [&r, &kb](void*) {
+ slog("keyboard device REMOVED: '%s'", kb.keyboard->base.name ? kb.keyboard->base.name : "?");
+ if (r.focus_kb == kb.keyboard) {
+ r.focus_kb = nullptr;
+ }
+ Keyboard* self = &kb;
+ r.keyboards.remove_if([self](const Keyboard& e) { return &e == self; });
+ update_seat_caps(r);
+ });
+
+ r.focus_kb = wlr_kb;
+ wlr_seat_set_keyboard(r.seat, wlr_kb);
+ slog("keyboard device ADDED: '%s' (escape-hatch + P-keepalive listener attached)",
+ dev->name != nullptr ? dev->name : "?");
+ // If a toplevel mapped BEFORE this keyboard appeared, its focus enter was
+ // deferred (no keyboard then) — hand it focus now so keys reach the client.
+ if (r.focused_surface == nullptr && r.last_toplevel != nullptr) {
+ focus_toplevel(r, r.last_toplevel);
+ }
+}
+
+void handle_new_input(Runner& r, wlr_input_device* dev) {
+ switch (dev->type) {
+ case WLR_INPUT_DEVICE_KEYBOARD:
+ new_keyboard(r, dev);
+ break;
+ case WLR_INPUT_DEVICE_POINTER:
+ slog("pointer device ADDED: '%s'", dev->name != nullptr ? dev->name : "?");
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ // Make the cursor visible immediately (a default xcursor image on the
+ // wlr plane) so it shows even before the first motion event.
+ show_default_cursor(r);
+ break;
+ case WLR_INPUT_DEVICE_TOUCH:
+ slog("touch device ADDED: '%s'", dev->name != nullptr ? dev->name : "?");
+ wlr_cursor_attach_input_device(r.cursor, dev);
+ show_default_cursor(r);
+ break;
+ default:
+ slog("input device ADDED (other type=%d): '%s'", static_cast<int>(dev->type),
+ dev->name != nullptr ? dev->name : "?");
+ break;
+ }
+ update_seat_caps(r);
+}
+
void attach_input(Runner& r) {
r.cursor_motion_l.connect(r.cursor->events.motion, [&r](void* data) {
auto* ev = static_cast<wlr_pointer_motion_event*>(data);
@@ -562,6 +1043,14 @@ void attach_input(Runner& r) {
wlr_seat_pointer_notify_enter(r.seat, rt.s->surface, rt.sx, rt.sy);
wlr_seat_pointer_notify_button(r.seat, ev->time_msec, ev->button, ev->state);
wlr_seat_pointer_notify_frame(r.seat);
+ ++r.pointer_enters;
+ slog("pointer BUTTON %u state=%u -> client surface '%s' at surface-local (%.1f,%.1f) "
+ "[screen (%.0f,%.0f) through the 3D transform]",
+ ev->button, static_cast<unsigned>(ev->state), rt.s->element_id.c_str(), rt.sx,
+ rt.sy, r.cursor->x, r.cursor->y);
+ } else {
+ slog("pointer BUTTON %u state=%u: NO surface under cursor (%.0f,%.0f) — not forwarded",
+ ev->button, static_cast<unsigned>(ev->state), r.cursor->x, r.cursor->y);
}
});
r.cursor_axis_l.connect(r.cursor->events.axis, [&r](void* data) {
@@ -581,6 +1070,13 @@ void attach_input(Runner& r) {
if (rt.s != nullptr) {
wlr_seat_touch_notify_down(r.seat, rt.s->surface, ev->time_msec, ev->touch_id, rt.sx,
rt.sy);
+ ++r.touch_downs;
+ slog("TOUCH DOWN id=%d -> client surface '%s' at surface-local (%.1f,%.1f) [screen "
+ "(%.0f,%.0f) through the 3D transform] downs=%ld",
+ ev->touch_id, rt.s->element_id.c_str(), rt.sx, rt.sy, lx, ly, r.touch_downs);
+ } else {
+ slog("TOUCH DOWN id=%d: NO surface under point (%.0f,%.0f) — not forwarded",
+ ev->touch_id, lx, ly);
}
r.dirty = true;
wlr_output_schedule_frame(r.output);
@@ -597,7 +1093,28 @@ void attach_input(Runner& r) {
r.touch_up_l.connect(r.cursor->events.touch_up, [&r](void* data) {
auto* ev = static_cast<wlr_touch_up_event*>(data);
wlr_seat_touch_notify_up(r.seat, ev->time_msec, ev->touch_id);
+ slog("TOUCH UP id=%d -> client", ev->touch_id);
});
+
+ // Seat protocol glue (mirrors src/input.cpp::attach_seat_handlers). A client
+ // (foot) may request its OWN cursor surface (e.g. the text I-beam) while the
+ // pointer is over it; honor that only for the currently pointer-focused
+ // client. When the pointer focus leaves all client surfaces, restore OUR
+ // default xcursor so the cursor never goes invisible over the document.
+ r.seat_request_cursor_l.connect(r.seat->events.request_set_cursor, [&r](void* data) {
+ auto* ev = static_cast<wlr_seat_pointer_request_set_cursor_event*>(data);
+ if (r.seat->pointer_state.focused_client == ev->seat_client) {
+ wlr_cursor_set_surface(r.cursor, ev->surface, ev->hotspot_x, ev->hotspot_y);
+ slog("client set its own cursor surface (e.g. text I-beam over foot)");
+ }
+ });
+ r.seat_pointer_focus_change_l.connect(
+ r.seat->pointer_state.events.focus_change, [&r](void* data) {
+ auto* ev = static_cast<wlr_seat_pointer_focus_change_event*>(data);
+ if (ev->new_surface == nullptr) {
+ show_default_cursor(r);
+ }
+ });
}
// ---- output bring-up ---------------------------------------------------------
@@ -611,25 +1128,62 @@ void handle_new_output(Runner& r, wlr_output* out) {
wlr_output_state st;
wlr_output_state_init(&st);
wlr_output_state_set_enabled(&st, true);
- if (wlr_output_mode* mode = wlr_output_preferred_mode(out)) {
+ wlr_output_mode* mode = wlr_output_preferred_mode(out);
+ if (mode != nullptr) {
wlr_output_state_set_mode(&st, mode);
}
- wlr_output_commit_state(out, &st);
+ const bool modeset_ok = wlr_output_commit_state(out, &st);
wlr_output_state_finish(&st);
if (out->width > 0) {
r.out_w = out->width;
r.out_h = out->height;
}
+ slog("output ADDED + MODESET: name='%s' %dx%d refresh=%dmHz preferred_mode=%d commit=%s",
+ out->name, r.out_w, r.out_h, mode != nullptr ? mode->refresh : 0, mode != nullptr,
+ modeset_ok ? "OK" : "FAILED");
+ if (!modeset_ok) {
+ slog("WARNING: modeset commit FAILED — the panel will likely stay black. "
+ "Check WLR_RENDERER=gles2 and DRM permissions.");
+ }
wlr_output_layout_output* lo = wlr_output_layout_add_auto(r.output_layout, out);
r.scene_output = wlr_scene_output_create(r.scene, out);
wlr_scene_output_layout_add_output(r.scene_layout, lo, r.scene_output);
+ // Load the xcursor theme for this output's scale BEFORE we ever set an
+ // xcursor image (wlr_cursor_set_xcursor needs the theme loaded at the right
+ // scale to produce a buffer for the plane). Without this the cursor plane
+ // has no image => the "no mouse cursor visible" field symptom. Mirrors the
+ // shipped kernel, which loads the theme on output bring-up.
+ if (r.cursor_mgr != nullptr) {
+ wlr_xcursor_manager_load(r.cursor_mgr, out->scale);
+ slog("xcursor theme loaded for output scale %.2f (cursor can now show an image)",
+ out->scale);
+ }
+ // If a pointer/touch device already exists, show the default cursor now that
+ // the theme is loaded (device-add may have run before the output came up).
+ show_default_cursor(r);
+
+ // Guaranteed-visible NON-BLACK background + a test marker (criterion C),
+ // created in the scene tree FIRST so they sit UNDER the RmlUi present node.
+ // If the RmlUi/dmabuf present path works, the opaque composite covers these
+ // (you see the tilted window on the document's own dark-blue body). If the
+ // present path is BROKEN (no buffer reaches present_node), wlr_scene still
+ // paints these — so "totally black" (nothing presents / modeset failed) is
+ // visibly distinct from "dark blue + marker" (presenting, but the RmlUi
+ // layer is empty). Dark blue: an unmistakable "the spike is alive" signal.
+ const float kBlue[4] = {0.05f, 0.08f, 0.20f, 1.0f};
+ const float kAmber[4] = {1.0f, 0.65f, 0.0f, 1.0f};
+ r.bg_rect = wlr_scene_rect_create(&r.scene->tree, r.out_w, r.out_h, kBlue);
+ r.marker_rect = wlr_scene_rect_create(&r.scene->tree, 64, 64, kAmber);
+ wlr_scene_node_set_position(&r.marker_rect->node, 24, 24);
+
// Build the present target + RmlUi document sized to the output, then a
- // single full-output scene_buffer node to present it (criterion 7).
+ // single full-output scene_buffer node to present it (criterion 7). Created
+ // AFTER the background rects so it renders ON TOP of them.
r.gl.make_current();
- r.present.init(&r.gl, r.allocator, r.out_w, r.out_h);
+ const bool present_ok = r.present.init(&r.gl, r.allocator, r.out_w, r.out_h);
r.present_node = wlr_scene_buffer_create(&r.scene->tree, nullptr);
r.present.scene_buffer = r.present_node;
r.ctx = Rml::CreateContext("run", Rml::Vector2i(r.out_w, r.out_h), r.gl.render);
@@ -638,39 +1192,125 @@ void handle_new_output(Runner& r, wlr_output* out) {
r.doc->Show();
}
r.gl.restore_current();
+ slog("present target init=%d dmabuf=%d; RmlUi document=%s; background+marker rects placed",
+ present_ok, r.present.dmabuf, r.doc != nullptr ? "loaded" : "FAILED");
r.frame_l.connect(out->events.frame, [&r](void*) { on_frame(r); });
wlr_output_schedule_frame(out);
- std::fprintf(stderr, "[run] output %s up at %dx%d; present node + RmlUi document built\n",
- out->name, r.out_w, r.out_h);
+ slog("output '%s' up at %dx%d; present node + RmlUi document built", out->name, r.out_w,
+ r.out_h);
}
Runner* g_runner = nullptr;
+// EACH client connect: wl_display's client-created signal is a raw wl_listener
+// (not a wlr signal, so no RAII Listener wraps it). For a single-TU throwaway
+// spike this is in-bounds; we never let it outlive the display (removed in
+// teardown). Loud per-connect logging answers "did foot even connect?".
+void on_client_created(wl_listener* l, void* data) {
+ auto* client = static_cast<wl_client*>(data);
+ pid_t pid = 0;
+ uid_t uid = 0;
+ gid_t gid = 0;
+ wl_client_get_credentials(client, &pid, &uid, &gid);
+ ++g_runner->client_connects;
+ slog("CLIENT CONNECT #%d: a wl_client connected (pid=%d uid=%d) — now waiting for it to "
+ "create + MAP a surface",
+ g_runner->client_connects, static_cast<int>(pid), static_cast<int>(uid));
+ (void)l;
+}
+
} // namespace
auto run_real_seat(const char* startup_cmd) -> int {
+ log_open();
wlr_log_init(WLR_INFO, nullptr);
+ slog("=== rml-compositing-spike --run START (persistent log: %s) ===", g_log_path.c_str());
+ slog("env: WLR_BACKENDS=%s WLR_RENDERER=%s", getenv("WLR_BACKENDS") ? getenv("WLR_BACKENDS") : "(auto)",
+ getenv("WLR_RENDERER") ? getenv("WLR_RENDERER") : "(auto)");
Runner r;
g_runner = &r;
r.display = wl_display_create();
r.loop = wl_display_get_event_loop(r.display);
+ // Loudly log EACH client connect (diagnose "did foot connect?"). Raw
+ // wl_listener — removed in teardown before the display dies.
+ r.client_created_l.notify = on_client_created;
+ wl_display_add_client_created_listener(r.display, &r.client_created_l);
r.backend = wlr_backend_autocreate(r.loop, &r.session);
if (r.backend == nullptr) {
- std::fprintf(stderr, "[run] failed to create backend\n");
+ slog("FATAL: failed to create backend");
+ log_close();
return 1;
}
+ slog("backend created: session=%s (NULL session => nested/headless, no VT switching)",
+ r.session != nullptr ? "present (real seat)" : "NULL");
r.renderer = wlr_renderer_autocreate(r.backend);
wlr_renderer_init_wl_display(r.renderer, r.display);
r.allocator = wlr_allocator_autocreate(r.backend, r.renderer);
+ slog("renderer selected: gles2=%d (RML compositing requires gles2)",
+ wlr_renderer_is_gles2(r.renderer));
if (!wlr_renderer_is_gles2(r.renderer)) {
- std::fprintf(stderr, "[run] renderer is not gles2 — RML compositing needs the GL path. "
- "Set WLR_RENDERER=gles2.\n");
+ slog("FATAL: renderer is not gles2 — RML compositing needs the GL path. "
+ "Set WLR_RENDERER=gles2.");
+ log_close();
return 1;
}
+ // SAFETY (criterion A) — signal handlers FIRST, so even an early hang during
+ // bring-up can be killed cleanly. wl_event_loop_add_signal turns the signal
+ // into a normal event-loop dispatch on the single thread: SIGINT/SIGTERM ->
+ // wl_display_terminate -> wl_display_run returns -> clean wlroots/session
+ // teardown restores the VT to text mode. This is what lets `kill`/`timeout`/
+ // an SSH `pkill` exit WITHOUT a hard reboot.
+ r.sigint_src = wl_event_loop_add_signal(r.loop, SIGINT, [](int, void* data) {
+ slog("SIGINT received -> wl_display_terminate (clean exit)");
+ wl_display_terminate(static_cast<wl_display*>(data));
+ return 0;
+ }, r.display);
+ r.sigterm_src = wl_event_loop_add_signal(r.loop, SIGTERM, [](int, void* data) {
+ slog("SIGTERM received -> wl_display_terminate (clean exit)");
+ wl_display_terminate(static_cast<wl_display*>(data));
+ return 0;
+ }, r.display);
+
+ // SAFETY — the GUARANTEED backstop: a DEAD-MAN self-timeout that terminates
+ // the session no matter what, so the machine can NEVER be locked again.
+ // DEFAULT 15s (was 120). Override with UNBOX_SPIKE_TIMEOUT seconds (0 =
+ // disabled, for a deliberate long real-seat session once you trust the key
+ // escapes). Pressing `P` re-arms it to the FULL interval (see handle_escape_
+ // keys) — so keeping the session alive past 15s REQUIRES periodic P presses,
+ // which doubles as the real-seat keyboard-input liveness test.
+ int timeout_s = 15;
+ if (const char* env = getenv("UNBOX_SPIKE_TIMEOUT")) {
+ timeout_s = std::atoi(env);
+ }
+ r.deadman_ms = timeout_s * 1000;
+ if (timeout_s > 0) {
+ // The timer fires against the Runner so it can log + re-arm. It is a
+ // ONE-SHOT (we never re-arm it ourselves on expiry): when it fires, the
+ // session dies — UNLESS a `P` press re-armed it first.
+ r.safety_timer = wl_event_loop_add_timer(r.loop, [](void* data) {
+ auto* rr = static_cast<Runner*>(data);
+ slog("DEAD-MAN FIRED (no `P` press within %ds) -> wl_display_terminate. "
+ "If you expected the session to stay open, real-seat keyboard input is DEAD "
+ "(P never reached the handler).",
+ rr->deadman_ms / 1000);
+ wl_display_terminate(rr->display);
+ return 0;
+ }, &r);
+ deadman_rearm(r);
+ slog("DEAD-MAN self-timeout armed: %ds (press P to reset; UNBOX_SPIKE_TIMEOUT=0 to "
+ "disable). Survival past %ds == keyboard input WORKS.",
+ timeout_s, timeout_s);
+ } else {
+ slog("DEAD-MAN self-timeout DISABLED (UNBOX_SPIKE_TIMEOUT=0) — rely on Esc / "
+ "Ctrl+Alt+Backspace / Ctrl+Alt+F-key / signals to exit");
+ }
+ slog("ESCAPE HATCH: Esc or Ctrl+Alt+Backspace = quit; Ctrl+Alt+F1..F12 = switch VT; "
+ "SIGINT/SIGTERM = clean quit; P = reset dead-man (keyboard liveness test)");
+
r.compositor = wlr_compositor_create(r.display, 5, r.renderer);
wlr_subcompositor_create(r.display);
wlr_data_device_manager_create(r.display);
@@ -684,8 +1324,13 @@ auto run_real_seat(const char* startup_cmd) -> int {
r.seat = wlr_seat_create(r.display, "seat0");
r.xdg_shell = wlr_xdg_shell_create(r.display, 3);
- r.new_xdg_l.connect(r.xdg_shell->events.new_surface, [&r](void* data) {
- handle_new_xdg(r, static_cast<wlr_xdg_surface*>(data));
+ // Wire from new_toplevel/new_popup (role assigned) — NOT new_surface (no role
+ // yet). This is what makes the configure handshake complete so clients map.
+ r.new_toplevel_l.connect(r.xdg_shell->events.new_toplevel, [&r](void* data) {
+ handle_new_toplevel(r, static_cast<wlr_xdg_toplevel*>(data));
+ });
+ r.new_popup_l.connect(r.xdg_shell->events.new_popup, [&r](void* data) {
+ handle_new_popup(r, static_cast<wlr_xdg_popup*>(data));
});
r.layer_shell = wlr_layer_shell_v1_create(r.display, 4);
r.new_layer_l.connect(r.layer_shell->events.new_surface, [&r](void* data) {
@@ -703,35 +1348,108 @@ auto run_real_seat(const char* startup_cmd) -> int {
// the import path only needs the display).
EGLDisplay egl = wlr_egl_get_display(wlr_gles2_renderer_get_egl(r.renderer));
if (!r.gl.init(egl)) {
- std::fprintf(stderr, "[run] GL bridge init failed — NO-GO on this hardware\n");
+ slog("FATAL: GL bridge init failed — NO-GO on this hardware");
+ log_close();
return 1;
}
const char* socket = wl_display_add_socket_auto(r.display);
if (socket == nullptr) {
- std::fprintf(stderr, "[run] failed to add wayland socket\n");
+ slog("FATAL: failed to add wayland socket");
+ log_close();
return 1;
}
+ // Export WAYLAND_DISPLAY in OUR environment so EVERY child (the spawn below
+ // AND anything it forks) inherits our socket, not the stale parent value.
+ // Mirrors the shipped kernel (server.cpp): without this the client connects
+ // to the WRONG compositor and nothing shows — a black-screen cause.
setenv("WAYLAND_DISPLAY", socket, 1);
+ slog("WAYLAND_DISPLAY=%s (exported into process env; children inherit it)", socket);
if (!wlr_backend_start(r.backend)) {
- std::fprintf(stderr, "[run] failed to start backend\n");
+ slog("FATAL: failed to start backend");
+ log_close();
return 1;
}
- std::fprintf(stderr, "[run] up on WAYLAND_DISPLAY=%s — spawning client: %s\n", socket,
- startup_cmd);
+ slog("backend started; up on WAYLAND_DISPLAY=%s", socket);
if (startup_cmd != nullptr && startup_cmd[0] != '\0') {
- if (fork() == 0) {
+ const pid_t pid = fork();
+ if (pid == 0) {
setenv("WAYLAND_DISPLAY", socket, 1);
execl("/bin/sh", "/bin/sh", "-c", startup_cmd, static_cast<char*>(nullptr));
+ // Only reached if exec failed.
+ std::fprintf(stderr, "[run] exec of client failed: %s\n", startup_cmd);
_exit(127);
}
+ if (pid > 0) {
+ slog("client SPAWN: pid=%d cmd='%s' (watch for a 'client surface MAP' line; if it "
+ "never comes, the client could not connect/render)",
+ static_cast<int>(pid), startup_cmd);
+ } else {
+ slog("WARNING: fork() failed; no client spawned");
+ }
+ } else {
+ slog("no startup command — connect your own client to WAYLAND_DISPLAY=%s", socket);
}
+ // NO-CLIENT watchdog (~5s): if nothing maps a surface by then, scream loudly
+ // in the log so the "background+marker but no foot" case is unambiguous.
+ r.client_watchdog = wl_event_loop_add_timer(r.loop, [](void* data) {
+ auto* rr = static_cast<Runner*>(data);
+ if (!rr->any_surface_mapped) {
+ slog("*** NO CLIENT MAPPED — foot did not connect/render within ~5s. ***");
+ slog(" connects-so-far=%d. If 0: the client could NOT connect (wrong "
+ "WAYLAND_DISPLAY, client crash, or missing binary). If >0: it connected but "
+ "produced no buffer (missing fonts, GL/shm failure). Only background+marker "
+ "will show.",
+ rr->client_connects);
+ } else {
+ slog("client-mapped check OK: at least one surface mapped within ~5s.");
+ }
+ return 0; // one-shot
+ }, &r);
+ wl_event_source_timer_update(r.client_watchdog, 5000);
+
+ slog("entering event loop (wl_display_run)");
wl_display_run(r.display);
+ slog("event loop exited — tearing down cleanly (wlroots restores the VT to text mode)");
+
+ // Teardown. Disconnect every RAII Listener bound to a wlr signal BEFORE the
+ // wlr objects (cursor/backend/seat) are destroyed — a still-linked listener
+ // trips wlr_cursor_destroy's `wl_list_empty(listener_list)` assertion (the
+ // Runner's Listener members would otherwise unsubscribe only at Runner's
+ // destruction, AFTER these destroys). Also drop per-surface listeners.
+ for (LiveSurface& s : r.surfaces) {
+ s.map_l.disconnect();
+ s.unmap_l.disconnect();
+ s.commit_l.disconnect();
+ s.destroy_l.disconnect();
+ }
+ r.new_output_l.disconnect();
+ r.new_input_l.disconnect();
+ r.frame_l.disconnect();
+ r.new_toplevel_l.disconnect();
+ r.new_popup_l.disconnect();
+ r.new_layer_l.disconnect();
+ r.cursor_motion_l.disconnect();
+ r.cursor_motion_abs_l.disconnect();
+ r.cursor_button_l.disconnect();
+ r.cursor_axis_l.disconnect();
+ r.cursor_frame_l.disconnect();
+ r.touch_down_l.disconnect();
+ r.touch_up_l.disconnect();
+ r.touch_motion_l.disconnect();
+ r.seat_request_cursor_l.disconnect();
+ r.seat_pointer_focus_change_l.disconnect();
+ for (Keyboard& kb : r.keyboards) {
+ kb.key_l.disconnect();
+ kb.mods_l.disconnect();
+ kb.destroy_l.disconnect();
+ }
+ // The raw client-created wl_listener must not outlive the display.
+ wl_list_remove(&r.client_created_l.link);
- // Teardown.
const bool cur = r.gl.make_current();
for (LiveSurface& s : r.surfaces) {
s.live.destroy();
@@ -762,6 +1480,20 @@ auto run_real_seat(const char* startup_cmd) -> int {
if (r.backend != nullptr) {
wlr_backend_destroy(r.backend);
}
+ if (r.safety_timer != nullptr) {
+ wl_event_source_remove(r.safety_timer);
+ }
+ if (r.client_watchdog != nullptr) {
+ wl_event_source_remove(r.client_watchdog);
+ }
+ if (r.sigint_src != nullptr) {
+ wl_event_source_remove(r.sigint_src);
+ }
+ if (r.sigterm_src != nullptr) {
+ wl_event_source_remove(r.sigterm_src);
+ }
wl_display_destroy(r.display);
+ slog("=== rml-compositing-spike --run EXIT 0 (VT restored) ===");
+ log_close();
return 0;
}
diff --git a/packages/kernel/src/spike/spike_gl.hpp b/packages/kernel/src/spike/spike_gl.hpp
index 8f7cfd8..6efef85 100644
--- a/packages/kernel/src/spike/spike_gl.hpp
+++ b/packages/kernel/src/spike/spike_gl.hpp
@@ -222,23 +222,57 @@ struct GlBridge {
};
// --- A LIVE surface element: a client buffer imported zero-copy as a sampled
-// texture, registered under a URI, re-imported ONLY on a new buffer commit. ---
+// texture, registered under a URI, re-imported on each NEW surface commit. ---
+//
+// FROZEN-FRAME FIX. The re-import was gated on the wlr_buffer POINTER changing
+// (`buf == current`). That is WRONG for real clients: Wayland clients (foot)
+// recycle a SMALL POOL of buffers, and wlroots re-uses the SAME wlr_client_buffer
+// for a re-attached wl_buffer — so the identical pointer is re-committed with
+// BRAND-NEW contents. The pointer-equality early-return then wrongly skipped the
+// update and the displayed texture stayed stuck on buffer #1 (`commits=3` but
+// `reimports=1` in the headless log). The correct dirty signal is the surface's
+// COMMIT SEQUENCE (`wlr_surface_state.seq`), which increments on EVERY commit
+// regardless of pool reuse. We re-import whenever the seq advances, re-binding
+// the EGLImage to the current buffer (a live dmabuf view => new pixels) or
+// re-uploading for shm, so new contents show even on a reused buffer pointer.
+//
+// BUFFER LIFECYCLE. `surface->buffer` is a wlr_client_buffer (the renderer-side
+// import); wlroots has ALREADY released the client's underlying wl_buffer back
+// to its pool, so reading it never starves the client. We still LOCK the buffer
+// we are importing (so its dmabuf FDs stay valid while we build the EGLImage and
+// sample it) and UNLOCK the PREVIOUS one once the new import is live — a
+// double-buffered lock that mirrors wlroots' consumer lock/release discipline
+// and guarantees we never pin more than one buffer at a time.
struct LiveTexture {
GlBridge* gl = nullptr;
std::string uri;
int width = 0, height = 0;
- wlr_buffer* current = nullptr;
+ wlr_buffer* current = nullptr; // the buffer currently imported + LOCKED
+ std::uint32_t current_seq = 0; // surface commit seq of `current`
+ bool have_seq = false; // false until the first adopt()
EGLImageKHR image = EGL_NO_IMAGE_KHR;
GLuint tex = 0;
bool is_dmabuf = false;
int reimports = 0;
int commits_seen = 0;
- auto adopt(wlr_buffer* buf) -> bool {
+ // Re-import the surface's CURRENT committed buffer for commit sequence `seq`.
+ // `seq` MUST be the surface's wlr_surface_state.seq (advances every commit) —
+ // NOT the buffer pointer, which a pooled client recycles. Returns true if the
+ // sampled texture reflects the current buffer afterwards.
+ auto adopt(wlr_buffer* buf, std::uint32_t seq) -> bool {
++commits_seen;
- if (buf == current && tex != 0) {
- return true; // unchanged buffer: zero re-import, zero copy
+ // Idle gate: a static client never commits, so its seq never advances and
+ // we do zero work (the dirty-gate stays intact). A re-committed buffer —
+ // even the SAME pointer with new contents — bumps seq and re-imports.
+ if (have_seq && seq == current_seq && buf == current && tex != 0) {
+ return true; // truly unchanged surface state: zero re-import, zero copy
}
+ // Lock the buffer we are about to sample so its storage (dmabuf FDs / shm)
+ // stays valid for the whole import+sample; unlock the PREVIOUS one once the
+ // new import is live (double-buffered: at most one buffer pinned).
+ wlr_buffer* prev = current;
+ wlr_buffer_lock(buf);
wlr_dmabuf_attributes attrs{};
if (gl->dmabuf_ok && wlr_buffer_get_dmabuf(buf, &attrs) && attrs.n_planes >= 1) {
EGLint ia[] = {EGL_WIDTH,
@@ -270,18 +304,18 @@ struct LiveTexture {
width = attrs.width;
height = attrs.height;
is_dmabuf = true;
- current = buf;
- ++reimports;
+ adopt_commit(prev, buf, seq);
register_uri();
return true;
}
}
- // Fallback: one CPU upload for an shm client (still only on a new buffer).
+ // Fallback: one CPU upload for an shm client.
void* data = nullptr;
std::uint32_t fmt = 0;
std::size_t stride = 0;
if (!wlr_buffer_begin_data_ptr_access(buf, WLR_BUFFER_DATA_PTR_ACCESS_READ, &data, &fmt,
&stride)) {
+ wlr_buffer_unlock(buf); // import failed: drop the lock we just took
return false;
}
release_gl();
@@ -302,12 +336,26 @@ struct LiveTexture {
width = buf->width;
height = buf->height;
is_dmabuf = false;
- current = buf;
- ++reimports;
+ adopt_commit(prev, buf, seq);
register_uri();
return true;
}
+ // Commit a successful import: adopt `buf` (already locked) at sequence `seq`
+ // and release the PREVIOUSLY-locked buffer (double-buffered lock). Counts a
+ // reimport. NB: prev may equal buf when a pooled client re-commits the same
+ // pointer with new contents — lock/unlock balance still holds (net +1 then
+ // -1 => the single live lock we took above for THIS adopt).
+ void adopt_commit(wlr_buffer* prev, wlr_buffer* buf, std::uint32_t seq) {
+ current = buf;
+ current_seq = seq;
+ have_seq = true;
+ ++reimports;
+ if (prev != nullptr) {
+ wlr_buffer_unlock(prev);
+ }
+ }
+
void register_uri() {
gl->render->register_preview_texture(uri, tex, Rml::Vector2i(width, height));
}
@@ -326,7 +374,12 @@ struct LiveTexture {
gl->render->unregister_preview_texture(uri);
}
release_gl();
- current = nullptr;
+ if (current != nullptr) {
+ wlr_buffer_unlock(current); // release the buffer we held locked
+ current = nullptr;
+ }
+ have_seq = false;
+ current_seq = 0;
}
};