Skip to content

Wayland on Kandelo - Fix desktop pointer-grab release routing, munmap page rounding, and browser reboot sizing#948

Open
mho22 wants to merge 13 commits into
explore-dri-sdl2from
explore-dri-wayland
Open

Wayland on Kandelo - Fix desktop pointer-grab release routing, munmap page rounding, and browser reboot sizing#948
mho22 wants to merge 13 commits into
explore-dri-sdl2from
explore-dri-wayland

Conversation

@mho22

@mho22 mho22 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

This branch stacks the full dri/wayland series (PR1–PR8) on top of explore-dri-sdl2; the reviewable work here is the tip commit f8cf509ee, a second devil's-advocate audit of the PR8 three-client Wayland desktop (2d02785e9). Draft until the series merge order is settled.

Problem

Driving the PR8 Wayland desktop (/?demo=wayland) as a real user surfaced defects that every marker-based gate passed straight over. A pointer drag whose release landed off the window left the client stuck mid-stroke. Unmapping a DRI buffer with an unrounded length leaked address-space pages the host kept mirroring. A never-mapped surface whose buffer was destroyed then committed hit freed memory. And a second Wayland boot in the same browser tab letterboxed at 1920×1080 instead of filling the pane. None of these were caught because the existing gates assert on printf markers, not on grab semantics, page accounting, or reboot state.

Root cause

  • Input: the compositor re-derived pointer focus on every motion, so a button release was delivered to whatever surface sat under the cursor rather than the surface that saw the press — Wayland's implicit grab was not honored. libkwl compounded it by consuming titlebar releases as toolkit clicks.
  • Address space: sys_munmap ran its fb0/DRI coverage sweeps against the raw length while MemoryManager::munmap rounds to the 64K page, so a map(aligned)/unmap(raw bo size) pair freed pages that stayed host-mirrored.
  • Lifetime: the compositor tracked only mapped/z-ordered surfaces, so a buffer destroy on a never-mapped surface was a use-after-free; a failed eglSwapBuffers was ignored and froze the canvas.
  • Reboot: detachKernel() never cleared the per-CRTC display-size map and the pane's ResizeObserver kept feeding the dead kernel, so the next boot skipped its sizing handshake.

Fix

  • Honor the implicit pointer grab: focus stays pinned to the pressed surface while any button is down, click-to-focus fires only on the first press, and the release always reaches the grabbed client. libkwl forwards every release to the app (content y goes negative over the titlebar).
  • Round the munmap length to the page up front, before the DRI sweeps, with checked_add closing the overflow edge.
  • Track every live surface so buffer-destroy is safe; degrade a failed GL present to CPU compositing instead of freezing; close the leaked keymap fd on wl_keyboard destroy.
  • Clear the display-size map on detachKernel() and disconnect the stale ResizeObserver once its kernel is gone, so a re-boot re-measures the pane and pushes a real mode.
  • Host-side robustness that rides along: move the imported-bo coherence copy out of the blocked-poll retry path, sanitize inherited GL state before a presenter rebuild, rebuild the presenter when a remounted pane brings a new canvas, restore the guest's UNPACK_ROW_LENGTH, delete a binding's foreign textures on unbind, and raise the scanout shader to highp for modes above 2048 px.

Both hosts

The kernel and host/src/{kernel-worker,webgl/*}.ts changes are shared code and take effect on Node and the browser together. The browser-only surface is the kandelo-session reboot fix and the new Playwright gate 1e (the GPU compositing path exists only in the browser; the Node smokes assert the inverse, WLC_RENDERER cpu). New regression coverage is added on the side it exercises: wldesktop-smoke (Node) gains toolbar-routing and two off-window grab-release gates, kandelo-wayland.spec (Chromium) asserts the GPU-path glReadPixels COMPOSITE_SAMPLE is present and non-black, and kandelo-session gets two unit tests for the detach/reboot display-size lifecycle. Kernel changes carry two new unit tests.

Validation

  • Kernel unit suite: 1098 passed, 0 failed.
  • ABI snapshot/version check: passed, no drift — the kernel edits are behavioral only.
  • Full host Vitest (solo): 897 passed, 3 failed — exactly the pre-existing baseline (wasm-binary-parse, fork-dlopen-replay-e2e, spidermonkey cowsay).
  • Six Wayland Node smokes, each run solo: all green.
  • Playwright wayland + modeset + sdl2: 3/3.
  • musl libc-test: exit 0, XFAIL only (baseline FLAKE-PASS pthread_cond-smasher, TIME raise-race).
  • Open POSIX suite: exit 0, 0 FAIL (3 XFAIL, 2 SKIP).

Deliberately left as-is (with reasons in the commit body): the guest sees EIO for all host bind-foreign-texture failures, select/pselect completions have no import-coherence sync (no in-tree consumer), and the single-sample bilinear degrade is kept pending a follow-up consecutive-overrun counter.

Scope

No ABI_VERSION, package recipe, package artifact, VFS image, or binary-index changes. One planning doc is included — docs/plans/2026-07-14-build-hyprland-class-compositor-plan.md, the roadmap the follow-up Hyprland PR will stack on this branch; the transient handoff docs are left untracked.

🤖 Generated with Claude Code

mho22 and others added 10 commits July 8, 2026 12:23
test-gate-prepare failed because fetch-binaries could not resolve
espeak-ng (wasm32): its build.toml lists gitignored vendored sources
(pcaudiolib-src/src/audio*.c) as cache-key inputs, so the resolver
cannot even hash them on a clean checkout, and no archive is published
for it in any binaries-abi-v* index. The base branch masked this
because its last staging run skipped test-gate-prepare entirely.

espeak-ng's port is still WIP (placeholder all-zero source sha, vendored
pcaudiolib-src not committed) and unpublishable, so treat it like the
other temporarily-disabled packages: add it to disabled_pkgs (matrix
exclusion) and WASM_POSIX_FETCH_SKIP_PKGS (fetch gate) in both
staging-build.yml and prepare-merge.yml, keeping the two lists mirrored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vendored libffi replacement providing a call_indirect trampoline for
wl_closure_invoke's ffi_call across arities 0..22, so libwayland can
dispatch decoded requests without a full libffi wasm32 port. Ships the
recipe/build state, ffi.h, the shim source, and a native unit test that
exercises every arity's argument marshalling.

Opens the Wayland compositor stack; see
docs/plans/2026-07-08-dri-wayland-compositor-plan.md (§4, the libffi
de-risk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(PR2)

Adds pkgs.wayland-scanner (the split -bin derivation — generator only,
darwin+linux clean, unlike the Linux-only full library) to the dev
shell, and a wayland-protocols package vendoring wayland.xml +
xdg-shell.xml pinned to the same wayland version. The scanner turns
protocol XML into C marshalling glue that consumers compile to wasm32.
A generate-and-verify test drives the scanner over the vendored XML.

Part of the Wayland compositor stack; see
docs/plans/2026-07-08-dri-wayland-compositor-plan.md (§3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pport (PR3)

Ports libwayland (client + server) to wasm32 linked against the PR1
libffi shim, and adds the three kernel primitives it needs:

- SO_PEERCRED getsockopt: returns struct ucred{pid,uid,gid} for a
  connected AF_UNIX socket. wl_client_create refuses a client on error.
  Single-user model reports the caller's own credentials (exact for the
  canonical socketpair / same-process accept). +4 unit tests.
- epoll_pwait finite-timeout fix: persist a per-channel deadline so
  wakeup-driven retries (retrySyscall) reuse it instead of resetting the
  timeout on every readiness poke. A permanently-drained fd set now
  parks the full timeout and returns 0 rather than retrying forever.
  Host-side only, so dual-host parity holds.
- Drop the generic sendmsg/recvmsg arg-descriptors: arg 2 is flags, not
  a length, so the generic pointer-copy path would splat ~flags bytes of
  scratch over the caller's msghdr on the EAGAIN retry. Both syscalls are
  hand-marshalled by dedicated host handlers.

Proven end-to-end by programs/wl_smoke.c — one process hosting a
libwayland server AND client over a kernel AF_UNIX socketpair: real
wl_marshal -> recvmsg -> demarshal -> wl_closure_invoke through the ffi
shim (create_surface + damage(7,11,100,200), all four i32 args checked),
plus wl_event_loop epoll park (~60ms) and prompt wake on a readable fd.
libwayland-smoke.test.ts gates it.

ABI: removes SYSCALL_ARG_DESCRIPTORS entries 137/138 from the snapshot
without an ABI_VERSION bump — the change is runtime-compatible (the host
already hand-marshals these). The vs-origin/main reconciliation bump is
deferred to stack integration.

Verified: 1084 kernel unit tests pass; libc-test 0 unexpected fail;
POSIX 0 fail; wl_smoke green against the rebuilt kernel.

Part of the Wayland compositor stack; see
docs/plans/2026-07-08-dri-wayland-compositor-plan.md (§5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port libxkbcommon 1.7.0 to wasm32 using the libwayland pattern: bypass
meson, compile the core TUs against a hand-curated config.h, with bison
generating the xkbcomp parser. Gated by host/test/libxkbcommon-smoke.test.ts
driving programs/xkb_smoke.c (keymap compile + keycode/modifier -> keysym/UTF-8
translation) under the kernel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…input 1.25.0 (PR5)

Ports the real libinput input stack for the Wayland compositor, replacing
the libinput-lite no-op stub as the compositor's input library. Lands as
one commit spanning the bottom-up sub-stack (PR5a+PR5b+PR5c), since real
libinput builds on all three lower layers. See
docs/plans/2026-07-08-dri-wayland-compositor-plan.md §3 (PR5a/5b/5c result
notes) and §5.

PR5a — libevdev 1.13.3 (packages/registry/libevdev/, libevdev.a). Wraps the
/dev/input/event* fd: owns the fd, does EVIOCG* capability probing, turns
the raw input_event stream into a resyncable per-slot state machine. Meson
bypassed; two core TUs compiled against a hand-curated config.h + a
python-generated event-name table from the bundled full linux UAPI headers.
Gate: host/test/libevdev-smoke.test.ts.

PR5b — the link + classification layers, plus the kernel st_rdev fix:
- mtdev (libmtdev.a) — link-only stub of mtdev's public ABI. libinput
  references five mtdev_* symbols but calls them only for legacy protocol-A
  multitouch (evdev_need_mtdev()); the kernel's single-touch virtual
  devices never enter that path, so each entry point aborts if ever called.
- libudev (libudev.a) — PATH-mode shim reimplementing systemd v255 input_id
  classification (test_pointers + test_key + EV_SW/scrollwheel fallbacks)
  over EVIOCGBIT/EVIOCGPROP, synthesizing the ID_INPUT* tags libinput's
  evdev core requires to accept a device. Other 13 udev fns are thin no-ops.
- ABI v16→v17: WasmStat grows 88→96 with a trailing st_rdev, and virtual
  evdev nodes now stat as Linux char major 13, minor 64+N (event0 = 13:64).
  Load-bearing for libinput's path backend, which drops the devnode and
  hands udev_device_new_from_devnum only the st_rdev; the shim recovers the
  node by scanning /dev/input/event* for the matching devnum.
  Gates: host/test/{mtdev-smoke,libudev-input-id-smoke}.test.ts.

PR5c — real libinput 1.25.0 core (packages/registry/libinput/, libinput.a).
The 35 path-backend TUs (src_libfilter + src_libinput core + util +
quirks), meson bypassed, compiled against a hand-curated config.h +
sed-substituted version headers, linking libevdev + the libudev/mtdev
shims. src/udev-seat.c is dropped (the udev enumerate/monitor backend; a
symbol audit confirmed no in-set TU references it) — the compositor drives
the path backend only. The bundled full linux UAPI wins over the sysroot's
minimal <linux/input.h> via -Iinclude/linux + a <linux/types.h> shim.
Gate: host/test/libinput-smoke.test.ts drives the full chain —
libinput_path_add_device(event0) runs stat → st_rdev recovery →
udev_device_new_from_devnum → input_id → evdev_device_new → libevdev probe
→ device accepted, then a host-injected EV_KEY is read off the evdev ring
by libinput's epoll loop (sys_epoll_pwait → sys_poll) and decoded into a
LIBINPUT_EVENT_KEYBOARD_KEY.

Dual-consumer decision: SDL2 keeps depending on libinput-lite, not the real
port. SDL2 2.30 references zero libinput symbols (verified over the source
+ configure) — it uses libinput only as an optional-detection stub — so the
real 35-TU library would only bloat its dep graph for no gain. The real
libinput is a distinct consumer (this smoke + the PR6/PR7 compositor); its
archive is linked from the resolver cache prefix by full path, never
through $SYSROOT/lib/libinput.a (which stays the lite stub), so the two
never collide.

Verification: kernel unit 1087/0, libc-test 302/0 FAIL, POSIX 174/0 FAIL,
ABI check exit 0 (v17 consistent). Vitest: the four new smokes pass; the
remaining failures are ABI-16 package binaries (php/spidermonkey/wordpress)
awaiting the v17 release-tag matrix — not logic regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ell/seat/output) (PR6)

Adds a standalone libwayland *server* (wlcompositor) plus a raw
libwayland-client test client, wiring wl_shm / xdg_shell / wl_seat /
wl_output over real libinput (PR5) + libxkbcommon (PR4). Getting
injected input to flow end-to-end surfaced a latent kernel+host bug:

- Root-caused the epoll_event wasm32 struct-layout mismatch. The kernel
  and host marshalled epoll_event as packed 12 bytes / data@4, but on
  wasm32 the struct is unpacked → 16 bytes: events@0, pad@4, data@8
  (__packed__ applies only on x86_64). At a single ready fd the offset-4
  read/write accidentally cancel, so socket epoll worked all the way to
  CLIENT_READY; at i>=1 the 12-vs-16 stride desyncs and the 2nd event's
  data comes back 0. libinput registers 3 fds (timer + 2 evdev), so
  injected input was the first workload with 2 ready at once —
  libinput_dispatch read a NULL source and call_indirect'd on garbage.
  Fixed across all 4 marshalling sites: kernel_epoll_ctl,
  kernel_epoll_pwait (crates/kernel/src/wasm_api.rs) and handleEpollCtl,
  handleEpollPwait (host/src/kernel-worker.ts). kernel-worker.ts is the
  authoritative host path, shared by Node + browser — dual-host parity.
- Nested-epoll readiness: poll_check → poll_check_depth with
  POLL_CHECK_MAX_DEPTH and a FileType::Epoll arm, so an epoll fd nested
  in another epoll reports POLLIN when its inner interest is ready.
- SCM_RIGHTS prime-bo sidecar (InFlightFd.prime_bo in pipe.rs +
  extract_scm_rights/install_scm_rights_fds in wasm_api.rs) so a
  cross-process wl_shm buffer composites correctly.

Regression coverage: test_epoll_pwait_multiple_events_data (each ready
fd returns its own data, none 0) + test_epoll_nested_readiness in
crates/kernel/src/syscalls.rs, and host/test/wlcompositor-smoke.test.ts
which exercises the host epoll path end-to-end (GOT_KEY + GOT_BTN +
CLIENT_OK). epoll marshalling is not ABI surface, nested-epoll is
behavioral, SCM_RIGHTS is kernel-internal — ABI_VERSION stays 17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rument frame-address fix (PR7)

PR7 Phase 4 lands the userspace terminal for the Wayland stack and fixes a
latent fork-instrumentation bug that its larger memory footprint exposed.

New userspace:
- examples/libs/wpkdraw — software 2D draw lib (clear/pixel/rect + an
  stb_truetype font engine over bundled Inconsolata) for client-side
  framebuffer rendering.
- examples/libs/libkwl — minimal Wayland client toolkit (wl_shm/xdg_shell/seat)
  wrapping wpkdraw surfaces.
- programs/wlterm — libkwl VT100 terminal (vt100.c parser) running a forkpty'd
  dash, composited by wlcompositor on /dev/dri/card0 via KMS.
- programs/wpkdraw_smoke, programs/kwldemo — smoke drivers.
- host/test/{wpkdraw,libkwl,wlterm}-smoke.test.ts — Node smoke gates.
- apps/browser-demos/test/kandelo-wayland.spec.ts — end-to-end browser gate
  (compositor presents a non-blank frame; typed input routes to the grid).

Kernel: PTY master read() now drains buffered slave output BEFORE reporting
EOF when the slave has closed — a shell writing its final bytes and exiting
in one go must not lose them (regression test
test_pty_master_read_drains_output_after_slave_close).

fork-instrument fix (crates/fork-instrument):
- Frames are stored at `_wpk_fork_buf + current_pos` where `current_pos` is a
  buffer-relative offset. The postamble (write) and preamble (read) helpers
  used `current_pos` directly as an absolute address, dropping the
  `+ _wpk_fork_buf` add, so every fork save-frame was written to absolute low
  memory (addr 16..). Small programs survived by luck; wlterm's larger memory
  collided with live data, corrupting a call_indirect funcidx and trapping with
  "table index out of bounds" during the child rewind replay.
- Convert `current_pos` -> absolute pointer at both access sites; keep cursor
  advance in offset space. Frame layout / header / wpk_fork_* exports unchanged
  — not an ABI change (snapshot unchanged, no ABI_VERSION bump). Updated 3 unit
  tests that pinned the exact instruction sequences.
- docs/fork-instrumentation.md documents the `frame @ buf + current_pos`
  invariant and the historical absolute-addressing bug.

Verification: cargo fork-instrument + kernel (1090) pass; wayland smokes pass;
libc-test 0 unexpected FAIL; POSIX 0 FAIL; check-abi-version.sh exit 0 no bump;
browser kandelo-wayland.spec.ts passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… WPK_BIND_FOREIGN_TEXTURE, WebGL2 scanout presenter, wpkdraw AA, kernel + host fixes (PR8)

Boots a full Wayland desktop in the browser (`/?demo=wayland`) and under
node: wlcompositor composites wlclock (animated analog clock), wlpaint
(pointer painting), and wlterm (VT100 terminal over a forkpty'd dash)
onto /dev/dri/card0, with typing, titlebar window drags, and
drag-painting routed end-to-end through libinput + wl_seat. In the
browser the compositor now COMPOSITES ON THE GPU: client wl_shm buffers
are imported as WebGL textures through the newly-dispatched
DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE and rendered as GLES quads straight
to the display canvas (first slice of the wayland plan's §7 F′ GPU
tier). Headless hosts (node smokes) keep the CPU blit + the new WebGL2
scanout presenter path. wpkdraw gains anti-aliased line/disc primitives
and wlclock uses them — hands, ticks and hub render smooth at 1:1.

GPU compositing (plan §8.10; kernel + host + glue + compositor):
- kernel: DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE dispatched for CPU-tier
  bos — resolves the caller's fd-local GEM handle, validates the GL
  session on the SAME fd, and forwards to the new
  host gl_bind_foreign_texture import; host failure surfaces EIO so
  callers degrade. Unit test drm_bind_foreign_texture_uploads_and_
  writes_texture_id (happy path + ENOENT/EINVAL/EIO). Additive ioctl +
  import: no ABI-surface change (snapshot unchanged).
- host (shared by BOTH hosts, kernel.ts + webgl/foreign-texture.ts):
  uploads the bo's canonical SAB pixels into a per-binding WebGLTexture
  (stable id from 0x40000000, mirrored into the cmdbuf texture table so
  glBindTexture resolves it; UNPACK_ROW_LENGTH honors the bo stride;
  creator's live mmap flushed to the SAB first via syncCreatorToSab;
  texSubImage2D reuse on steady state; save/restore of the shared
  context's texture/unpack state since this runs outside the muxer).
  Texture lifetime is tied to the bo: gbm_bo_destroy drops it across
  all bindings (dropForeignTexturesForBo).
- libEGL glue: wpkEglImportDmabufHandle / wpkEglBindBoTexture /
  wpkEglCloseBoHandle (stand-in for EGL_EXT_image_dma_buf_import), and
  eglCreateWindowSurface now honors EGL_WIDTH/EGL_HEIGHT attribs (the
  host resizes the canvas drawing buffer — a compositor creates its
  surface before its first ADDFB, when the fb-based resize has nothing
  to size against).
- wlcompositor: probes GL at boot by compiling its quad shader (sync
  queries fail cleanly on headless hosts → CPU path; WLC_NO_GPU=1
  forces it; one-shot WLC_RENDERER gpu|cpu marker). GPU frames render
  wallpaper texture + z-ordered window quads + focus border in ONE
  cmdbuf flush (the canvas transitions atomically between complete
  frames — the flicker gate stays meaningful), with per-commit dirty
  tracking so only changed buffers re-upload. PAGE_FLIPs continue as
  the frame clock (frame callbacks, flip counters). COMPOSITE_SAMPLE
  comes from a 1×1 glReadPixels on the GL path. A runtime GL failure
  tears down EGL and falls back to the CPU blit permanently.
- canvas ownership hand-off (kernel-worker.ts + kernel.ts):
  markKmsCanvasGlOwned now fires only after getContext succeeds,
  disposes the pump's webgl2-scanout presenter (same underlying
  context) and reports stats slot 7 = 3 (Modeset chip "webgl2-gl");
  new markKmsCanvasGlReleased (fired from context destroy / session
  teardown via the binding's claimedKmsCrtc) restores the pre-claim
  presenter mode so a degrading compositor never freezes the canvas.
  The presenter's context is created with preserveDrawingBuffer so the
  inheriting compositor's cross-task readbacks survive the present.
- spec gate 1d asserts WLC_RENDERER gpu in the browser.

wpkdraw anti-aliasing (the "jagged clock hands" fix):
- wpk_line_aa (capsule distance field, round caps, per-pixel coverage
  scales the colour's alpha) + wpk_disc_aa; __builtin_sqrtf lowers to
  wasm f32.sqrt so consumers stay libm-free. wlclock hands/ticks/hub
  now draw with them (one AA capsule per hand instead of stamped
  squares). wpkdraw build.toml revision 2; smoke program + vitest
  assert full-alpha cores and partially-covered AA fringes.

Client-GPU evaluation (plan §7.1, new): do NOT port the wpkdraw
clients to GL; finish the GPU tier for already-GL apps instead —
PR10 WPK_CREATE_GPU_BO as WebGLTexture-backed bos rendered into FBOs
on ONE muxer-shared context (bind-foreign-texture then degenerates to
a texture-id lookup, true zero-copy), PR11 zwp_linux_dmabuf_v1 (thin
now that the compositor texture path exists), PR12 SDL2 wayland
backend, PR13 wlcube.

The desktop is otherwise presented through the session-6 WebGL2 scanout
path (GPU swizzle + scaling) rather than the 2D putImageData blit.

New programs and demo wiring:
- programs/wlclock.c, programs/wlpaint.c — libkwl floating-window
  clients (poll-paced animation; wl_pointer painting).
- libkwl: CSD titlebar buttons, pointer enter/leave/motion plumbing,
  close-box exit (kwl.h/kwl.c).
- wlcompositor: placement rules for the three clients, focus border,
  MOVE_GRAB/MOVE_END + WLPAINT_STROKE/COMPOSITE_SAMPLE markers, loud
  stderr on gbm_bo_map/drmModeAddFB failure, software cursor removed
  (the browser host pointer is mapped absolutely — a sprite would sit
  exactly under it; hover no longer schedules no-op repaints).
  Input-batch repaint coalescing + peg suppression: the browser bridge
  emulates each absolute pointer move as an EV_REL peg-to-(0,0) +
  jump pair, and per-event synchronous repaints rendered every drag
  update with the grabbed window teleported to the top-left corner.
  Repaints now defer to the end of the libinput drain loop, and a peg
  frame (REL ≤ −2048 on both axes — impossible from real hardware)
  updates the cursor without delivering motion to grabs or clients.
- Modeset pane: the canvas renders with object-fit: contain instead
  of stretching — a 16:9 desktop distorted into a wider pane smeared
  every 1-px glyph non-uniformly ("everything looks pixelated").
  Pointer mapping and the spec's desktopPoint helper map through the
  fitted (letterboxed) content box, using the kernel-reported scanout
  dims (stats slots 2/3) rather than canvas.width — Chrome reflects
  the committed OffscreenCanvas bitmap size back into the placeholder
  attributes, which under the display-sized presenter is NOT the fb.
  The status chip appends the active renderer (stats slot 7).
- wlterm: 960×540 window sized for the three-client desktop layout.
- live-setup.ts: stages the four binaries, attaches BrowserInputSource
  (keyboard → event0; pointer owned by the Modeset pane → event1), and
  opts the Modeset canvas into the vblank pump's WebGL2 scanout
  presenter (setKmsDisplayMode "webgl2-scanout") — the compositor
  renders CPU-side, so the program-owned WebGL2 bridge never fires
  for it.
- kernel-host.ts (kandelo-session): setKmsDisplayMode lets boot flows
  pick the canvas paint mode before the pane mounts; attachKmsDisplay
  no longer hardcodes the webgl2 default, and for webgl2-scanout
  canvases a ResizeObserver (devicePixelContentBoxSize) streams the
  pane's device-pixel size to the presenter via the new dual-host
  kms_set_display_size message.

WebGL2 scanout presenter (kernel-worker.ts, mode "webgl2-scanout",
new; the legacy "2d" blit remains):
- The vblank pump owns a WebGL2 context on the CRTC canvas and draws
  the scanout bo as a texture: fragment-shader XRGB8888→RGB swizzle
  (replaces the per-pixel CPU loop), GL-viewport letterbox (same
  contain math as the pane pointer mapping), and GPU scaling at the
  embedder-reported display resolution with trilinear-over-mipmap
  minification — a 1920×1080 desktop shown smaller no longer shimmers.
- Presents are change-driven, not unconditional 60 Hz: kernel commit
  count (PAGE_FLIP) + fb id + target size, with a ~15 Hz strided-
  checksum content probe as backstop for scanout mutations that never
  flip. The probe is load-bearing for the flicker gate's detection
  power: flip-synced presents alone sample a latch-pinned bo BEFORE
  its repaint and would hide the PAGE_FLIP-latch regression (negative
  control re-verified under WebGL: latch removed → 2/120 dropouts,
  median 19.2 KB; fixed kernel → 0/120).
- Software-GL hosts (headless Chromium/SwiftShader, where mipmap +
  trilinear cost ~100 ms/frame and would starve the kernel worker)
  auto-degrade to bilinear when a steady-state present exceeds the
  16 ms frame budget; hardware GL never trips it. WebGL2 acquisition
  failure (node, no polyfill) is cached and the CRTC degrades to
  stats-only.
- Stats slot 7 reports the active presenter (1=2d, 2=webgl2-scanout);
  protocol + host wiring (kms_set_display_size, mode union) landed on
  BOTH hosts (browser-/node-kernel-protocol.ts, both worker entries,
  both kernel hosts).

Dynamic connector mode — the desktop fills the pane (no letterbox):
- host_kms_mode_info derives the PREFERRED mode from the embedder-
  reported display size (buildVirtualConnectorMode: round(1080×aspect)
  × 1080, width clamped [1440, 3840], even-aligned; 1920×1080 fallback
  for Node/headless — node smokes and GL demos are unaffected).
- live-setup.ts feeds the pane's device-pixel size to the kernel
  BEFORE spawning wlcompositor (waits ≤1.5 s for the pane's
  ResizeObserver; measures the canvas directly as a fallback).
- wlcompositor placement rules are edge-anchored (negative x = offset
  from the right edge: wlclock width-680, wlpaint width-840); resolved
  coordinates are byte-identical to the old fixed layout at 1920, and
  wider modes spread the demo across the full width. The mode is fixed
  at boot; post-boot resizes letterbox rather than re-mode.
- kandelo-wayland.spec.ts parses the live mode from the Modeset chip
  (readDesktopDims) and derives all window geometry (clock titlebar,
  wlterm region, paint strokes) from the anchors.
- Unit tests: buildVirtualConnectorMode aspect/clamp/even-align/
  fallback (dri-kms-registry.test.ts).

Kernel fixes (shared by BOTH hosts):
- PAGE_FLIP now latches the host scanout fb (host.kms_set_fb) at ioctl
  time. Previously only SETCRTC latched, so the host's 60 Hz 2D blit
  scanned out the first bo forever — the double-buffered compositor's
  BACK buffer every other frame — and the desktop flickered randomly
  with windows missing mid-composite. Unit test
  kms_page_flip_latches_host_scanout_fb; browser flicker gate verified
  to fail on the pre-fix kernel.
- munmap length now rounds up to the 64 KB wasm page (Linux semantics).
  The compositor maps/unmaps its scanout bo every frame; the literal-
  length free stranded a tail remnant per cycle (~8.3 MB address space
  per flip), hitting ENOMEM and a silent desktop freeze at ~124 frames.
  Two unit tests (memory.rs).
- TFD_TIMER_ABSTIME timerfd targets now compare against the timerfd's
  own clock_id — libinput arms CLOCK_MONOTONIC absolute timers, and
  comparing them against CLOCK_REALTIME made every monotonic timer
  look already-expired on hosts where the two epochs differ.

Host runtime fixes (kernel-worker.ts — shared by BOTH hosts):
- Finite poll/select/pselect timeouts never expired while the system
  was busy: every EAGAIN retry re-entered the handler and recomputed
  the deadline. New blockingWaitDeadlines map persists the first-block
  deadline across retries (same pattern as epollWaitDeadlines);
  cleared on completion/abandon/process cleanup. wlclock ran at
  ~0.5 fps and froze at idle without this.
- Blocking-retry keys are pid-qualified (retryKey pid:channelOffset)
  and per-pid import sync (syncImportsForPid) fixed for multi-process
  KMS demos.
- The 2D KMS blit swizzles DRM XRGB8888 → RGBA (R and B were swapped:
  wlpaint's blue painted orange). Under the webgl2-scanout presenter
  the same swizzle happens in the fragment shader.

Tests:
- host/test/wldesktop-smoke.test.ts (node): two clients composite,
  per-client input routing, titlebar move grabs.
- host/test/wldesktop-liveness-smoke.test.ts (node): PAGE_FLIP commits
  keep advancing at idle and across 4 drag-paint strokes (caught the
  poll-deadline and munmap bugs at 100% pre-fix).
- kandelo-wayland.spec.ts (browser): gate 1c asserts the webgl2
  renderer is active (Modeset chip, stats slot 7), gate 3 samples the
  desktop's top-left corner per drag step (teleport guard), + gate 4
  drag-paint liveness via the Modeset flips chip, + gate 5 flicker
  stability (120 rapid canvas screenshots; no frame < 90% of median
  PNG size — fails on the pre-latch kernel under BOTH the 2d blit and
  the WebGL presenter, passes post-fix), syslogStream joins .ksys-msg
  spans so chunk-split markers match.
- host/test/dri-kms-stats-sab.test.ts: webgl2-scanout coverage — fake
  GL context (upload/texSubImage2D reuse, letterbox viewport math,
  slot 7, present-on-change skip, content-probe re-present),
  no-WebGL2 graceful degradation with cached getContext failure,
  the 2d-path XRGB→RGBA swizzle at pixel level, the GL claim/release
  canvas handoff (slot 7 = 3, pump stand-down, presenter rebuild on
  release), and the software-GL bilinear degrade.
- host/test/webgl-foreign-texture.test.ts: BIND_FOREIGN_TEXTURE TS
  upload path — id allocation from 0x40000000, texImage2D→
  texSubImage2D reuse, stride-aware upload with GL state
  save/restore, EINVAL/EIO, dropForeignTexturesForBo cleanup.
- host/test/dri-registry.test.ts: GbmBoRegistry coherence helpers —
  dims() without pid membership, syncCreatorToSab flush direction,
  hasStaleableImports gate, syncImportsForPid delivery.
- wlcompositor/libkwl smokes extended for the new client behaviors,
  incl. shifted-key modifiers delivery (GOT_TEXT "A") and the
  WLC_RENDERER cpu probe fallback on Node.

Docs: browser-support.md (modeset + wayland demo rows, Wayland desktop
demo section incl. GPU compositing + the WebGL2 scanout presenter,
boot-pattern legend), architecture.md (PAGE_FLIP latch semantics,
BIND_FOREIGN_TEXTURE + wpkEgl* extension, GPU-first compositing,
blocking-wait deadline persistence, 1920×1080 preferred mode,
three-client demo + webgl2-scanout presenter), posix-status.md (munmap
rounding, card0 + renderD128 entries), wayland compositor plan §7
(F′ partial landing + §7.1 client-GPU evaluation) and §8 post-PR7 gaps
(items 1-10, incl. the WebGL flip, its negative-control
re-verification, the dynamic connector mode, and GPU compositing).

Both hosts verified: node via the five wl smokes + full vitest
(3 pre-existing baseline failures only: wasm-binary-parse,
fork-dlopen-replay-e2e, spidermonkey-node-compat); browser via
Playwright kandelo-wayland (incl. new gate 1d WLC_RENDERER gpu) +
kandelo-modeset + kandelo-sdl2, plus a visual screenshot check of the
GPU-composited desktop and the AA clock. Cargo lib 1096 passed /
0 failed. libc-test and Open POSIX suites at baseline (0 unexpected
FAIL). check-abi-version.sh exit 0 — the new ioctl + kernel-wasm
import are additive; no ABI surface change, no further ABI_VERSION
bump beyond the branch's existing v17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unding unbind, implicit pointer grab, compositor UAF + present fallback, presenter/foreign-texture fixes, reboot display sizing, new gates

Second devil's-advocate audit of the PR8 three-client Wayland desktop
(2d02785): fix real bugs in the new code, remove stale code, give
every new behavior a dedicated test, trim WHAT-comments (keep WHY).

Kernel (crates/kernel/src/syscalls.rs):
- sys_munmap rounds len up to the 64K page BEFORE the fb0/DRI coverage
  sweeps (they used raw len while MemoryManager::munmap rounds — a
  map(aligned)/unmap(raw bo size) pair freed pages the host kept
  mirroring); checked_add on the rounded len fixes the overflow edge.
  Test: munmap_dri_raw_len_unbinds_page_aligned_binding.
- timerfd lazy-recompute deduped into timerfd_refresh(), used by both
  the sys_read and poll arms. Test:
  test_timerfd_expiry_surfaces_through_epoll (libinput's epoll shape).

Host (kernel-worker.ts + webgl/, shared by BOTH hosts):
- imported-bo coherence hook moved AFTER the EAGAIN branch, gated
  (POLL||PPOLL) && retVal > 0 — it listed 4 unreachable syscalls and
  burned MB-scale copies on every blocked-poll retry.
- buildKmsGlPresenter sanitizes inherited GL state at entry (the
  release-path rebuild runs on the dead compositor's dirty context).
- attachKmsCanvas rebuilds the presenter when a remounted pane brings
  a new canvas for the same CRTC (was painting the orphaned canvas).
- foreign-texture.ts queries + restores UNPACK_ROW_LENGTH (not shadow-
  tracked; the guest-set value was clobbered to 0).
- registry.ts unbind() deletes the binding's foreign textures
  deterministically (the shared context outlives the binding).
- KMS scanout shader mediump -> highp (fp16 texel addressing breaks
  above 2048 px; modes go to 3840).
- removed a stale selEntry.channel identity guard in the select-retry
  cancel branch (retryKey is pid-qualified).

wlcompositor + libkwl + wlpaint (C):
- implicit pointer grab: focus stays pinned to the pressed surface
  while any button is down; click-to-focus only on the first press;
  releases always delivered; focus follows the cursor again after the
  last release.
- libkwl consumes only titlebar PRESSES; every RELEASE is forwarded to
  the app (content y < 0) — fixes stuck drags ending over the titlebar.
- all live surfaces tracked (not just mapped/z-ordered): never-mapped
  surface attach -> wl_buffer.destroy -> commit was a use-after-free;
  create_surface posts no_memory when full instead of partial tracking.
- failed eglSwapBuffers degrades to CPU compositing like a failed
  texture bind (was ignored -> frozen canvas).
- keymap fd leak: fd stored in the wl_keyboard resource user data and
  closed on resource destroy.
- wlpaint: #include <linux/input-event-codes.h> replaces a wrong
  #ifndef BTN_LEFT fallback; new WLPAINT_COLOR/WLPAINT_CLEAR/
  WLPAINT_STROKE_END markers for the smoke gates.
- wpk_disc_aa delegates to wpk_line_aa (byte-equivalent); kwldemo/
  wlclock/wlpaint link recipes folded into one loop; wpkdraw build.toml
  inert revision reverted.

kandelo-session (web-libs, browser reboot bug):
- detachKernel() clears kmsDisplaySizes and the attachKmsDisplay
  ResizeObserver disconnects once its kernel is detached — a second
  wayland boot in the same page read the dead session's size, never
  pushed a mode to the new kernel, and letterboxed at 1920x1080 while
  the stale observer kept feeding the shared map. 2 unit tests.

New gates:
- wldesktop-smoke: swatch click -> WLPAINT_COLOR i=1, clear click ->
  WLPAINT_CLEAR, and two implicit-grab gates (release over wallpaper /
  over wlpaint's own titlebar -> WLPAINT_STROKE_END).
- kandelo-wayland.spec gate 1e: GPU-path COMPOSITE_SAMPLE (glReadPixels
  readback) present + non-black — previously asserted by no test.
- wldesktop-liveness-smoke stats SAB 7*4 -> 8*4 (slot 7 = renderer).

Docs/comments: GPU-compositing-is-steady-state corrections in
live-setup.ts + kernel-host.ts; Modeset.tsx pointer-scaler bullet
(stats slots 2/3, not canvas.width); libkwl-smoke sample point is the
CSD titlebar; architecture.md node-gate list includes both wldesktop
smokes; wlcompositor-smoke socket path /tmp/wayland-0; WLC_NO_GPU
described as a manual debug escape hatch.

Deliberately not fixed: guest sees EIO for bind-foreign-texture host
failures (host distinctions TS-tested); select/pselect import-coherence
gap (pre-existing, no in-tree consumer); single-sample bilinear degrade
kept (recommend a 2-consecutive-overrun counter).

Tests: cargo 1098/0; ABI check exit 0 (no snapshot drift); vitest full
solo 897 pass / 3 fail = pre-existing baseline (wasm-binary-parse,
fork-dlopen-replay-e2e, spidermonkey cowsay); 6 wl node smokes green
solo; Playwright wayland+modeset+sdl2 3/3; libc-tests exit 0 (XFAIL
only, baseline FLAKE-PASS pthread_cond-smasher, TIME raise-race);
posix-tests exit 0, 0 FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mho22 mho22 changed the title Harden the Wayland desktop (PR8 audit): implicit pointer grabs, munmap page rounding, compositor UAF + present fallback, reboot display sizing wayland: fix desktop pointer-grab release routing, munmap page rounding, and browser reboot sizing Jul 14, 2026
@mho22
mho22 changed the base branch from main to explore-dri-sdl2 July 14, 2026 20:07
@mho22 mho22 changed the title wayland: fix desktop pointer-grab release routing, munmap page rounding, and browser reboot sizing Wayland on Kandelo - Fix desktop pointer-grab release routing, munmap page rounding, and browser reboot sizing Jul 14, 2026
Resolves the kernel-worker.ts conflict between two independent fixes for
the same finite-timeout-starvation bug:

- base (d2f79e1 "vblank select starvation"): threads a deadlineOverride
  parameter through handleSelect/handlePselect6 to persist the absolute
  deadline across re-dispatches, plus a separate vblankWakeCarry gate so
  the pump only broad-wakes on an actual KMS flip.
- ours (wayland series): a blockingWaitDeadlines map keyed by the
  pid-qualified retryKey, wired through pid teardown, covering select,
  pselect6, AND poll.

Kept ours for the deadline-persistence half (it subsumes deadlineOverride
— covers all three paths, persists via the map so no arg threading is
needed, and carries the pid-qualified keying audit fix) and removed the
now-dead deadlineOverride parameter + its call-site args. Kept the base's
vblankWakeCarry flip-gating verbatim (orthogonal optimization) and merged
the pump comment to reflect that both the 2d and webgl2-scanout presenter
paths run. Host TS build/typecheck clean; dri-kms-stats-sab, wldesktop
and wldesktop-liveness smokes pass solo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Phase B-1 matrix build status — pr-948-staging

ABI v17. 77 built, 0 failed, 77 total.

Package Arch Status Sha
alsa-lib wasm32 built 76a1d382
libcurl wasm32 built 4f9b5a1f
libcxx wasm32 built ec27aa51
libcxx wasm64 built 8f4ee8d0
libdrm wasm32 built 1d5db816
libevdev wasm32 built 9333885b
libffi wasm32 built e3940282
libinput-lite wasm32 built 1c625bbd
libinput wasm32 built a60361d6
libpng wasm32 built 9689c61e
libudev wasm32 built 30ef5cef
libwayland wasm32 built 6f1dfcf1
libxkbcommon wasm32 built f2d812a7
libxml2 wasm32 built e75d97c9
libxml2 wasm64 built 32bd5ba0
mtdev wasm32 built 5abcd835
openssl wasm32 built 04ac59f8
openssl wasm64 built 049550f6
sdl2 wasm32 built 90b6724c
sqlite wasm32 built 68933c56
sqlite wasm64 built 88bbf041
zlib wasm32 built 073eb2db
zlib wasm64 built 0d12bab1
bc wasm32 built 324366af
bzip2 wasm32 built 1eed3d03
coreutils wasm32 built 72e54e1f
curl wasm32 built bdcbcc48
dash wasm32 built 46e890ed
diffutils wasm32 built 08846c06
dinit wasm32 built c6b49d0d
fbdoom wasm32 built f29b87e9
file wasm32 built e75acd51
findutils wasm32 built 2359e785
gawk wasm32 built a1f49aa3
git wasm32 built 0be6d225
grep wasm32 built ee2307ef
gzip wasm32 built d87a65c0
kandelo-sdk wasm32 built 327eb2ca
kernel wasm32 built a25b079e
less wasm32 built 2707cc56
lsof wasm32 built 85b55778
m4 wasm32 built 1cade1f2
make wasm32 built bd353850
mariadb wasm32 built d485f8ad
mariadb wasm64 built 79043662
msmtpd wasm32 built 8876faf5
nano wasm32 built 6d2d0e7f
ncurses wasm32 built dcd4f1be
netcat wasm32 built 38dfa0f9
nginx wasm32 built 8165bdc3
php wasm32 built ef94bc44
posix-utils-lite wasm32 built 7f8aa0f1
sed wasm32 built d39bedcf
spidermonkey wasm32 built e3c4dd41
tar wasm32 built df3a2b98
tcl wasm32 built 26cd0438
unzip wasm32 built 69dc6810
userspace wasm32 built 758ef3be
vim wasm32 built 8cab3cab
wget wasm32 built ac5f4481
xz wasm32 built f371fe0e
zip wasm32 built d0a493c6
zstd wasm32 built 97996306
bash wasm32 built 66c00206
mariadb-test wasm32 built 1651df80
mariadb-vfs wasm32 built 1f6769f5
mariadb-vfs wasm64 built 575a5172
nethack wasm32 built 99bf9067
node wasm32 built dbee0563
spidermonkey-node wasm32 built 659020ab
vim-browser-bundle wasm32 built e51bb29e
nethack-browser-bundle wasm32 built 9a7373e0
rootfs wasm32 built 470d7e0a
shell wasm32 built e5ef64d2
lamp wasm32 built bcf07ede
node-vfs wasm32 built 2ceb0b40
wordpress wasm32 built 1a3268f3

Auto-generated; replaced on each push. Raw data in the publish-status workflow artifact.

Tiered plan extending the dri-wayland compositor plan past its §7
post-v1 milestones. PR10–PR13 (GPU tier, zwp_linux_dmabuf_v1, SDL2
wayland backend, wlcube) stay the Tier 0 prerequisite; GTK/glib moves
to Tier 2. Groundwork for the follow-up Hyprland PR, which stacks on
this Wayland branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mho22
mho22 force-pushed the explore-dri-wayland branch from ffb750a to 4440a9d Compare July 16, 2026 08:44
bash's `$(...)` command-substitution fork pushes a frame stack past the
old 16 KB fork-save buffer, spilling into the adjacent syscall channel
and trapping the child on rewind with a cryptic `call_indirect`
signature mismatch (surfaced by the browser merge-gate shell demo).

- crates/shared: FORK_SAVE_BUFFER_SIZE 16 KB → 48 KB. The buffer sits at
  the top of a dedicated 64 KB fork-save page, so it grows in-page
  without moving any other region. Instrumented binaries take the buffer
  address at runtime and never bake the size in, so this needs no
  ABI_VERSION bump (stays 17).
- xtask/dump_abi: classify_process_memory_layout treats an in-page
  increase of fork_save_buffer_size as additive (shrink / over-page /
  any other field change stays breaking), so the snapshot change passes
  the ABI gate without a version bump. +6 unit tests.
- host/worker-main: assertForkBufferWithinBounds reads current_pos after
  unwind at both fork sites and throws — naming the peak — turning
  silent channel corruption into a diagnostic. Shared file, both hosts.
  +dedicated vitest (wasm32 + wasm64).
- Consumers that hand-roll onFork and derived forkBufAddr from a
  hardcoded 16384 (nginx.test.ts, nginx-wrapper.ts, mariadb run-tests.ts)
  now import FORK_SAVE_BUFFER_SIZE. With the 48 KB kernel a stale literal
  pointed the child's fork replay at the wrong address and hung the
  worker — nginx's HTTP test timed out at 60s. Host source already
  imported the constant; only these three lagged.
- docs/fork-instrumentation: buffer capacity, overflow guard, sizing
  history.

Also folds in the fetch-binaries kind=source CI fix (squashed):
fetch-binaries.sh skips `kind = "source"` packages by kind, not just by
build.toml absence — wayland-protocols carries a build.toml for its
inputs cache-key yet must not resolve under --fetch-only (test-gate-
prepare). +dedicated regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant