Skip to content

Add Direct Rendering Infrastructure (DRI/KMS) and Kandelo Modeset pane#678

Draft
mho22 wants to merge 23 commits into
mainfrom
explore-direct-rendering-infrastructure
Draft

Add Direct Rendering Infrastructure (DRI/KMS) and Kandelo Modeset pane#678
mho22 wants to merge 23 commits into
mainfrom
explore-direct-rendering-infrastructure

Conversation

@mho22

@mho22 mho22 commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Why

The kernel had no graphics surface. User-space programs that want a framebuffer — fluid simulations, GUI demos, future GL-backed tools — had no path to a display. This PR ports a minimal but real DRI/KMS stack: /dev/dri/card0 + renderD128, dumb buffers, PRIME handles, page-flip with vblank events, and a GLES2 session backed by the host's WebGL2 context. A Kandelo browser pane (Modeset) renders the result into an OffscreenCanvas on the worker, with a ported WebGL fluid simulation as the first client.

Summary

  • add /dev/dri/card0 + /dev/dri/renderD128 open surface with DriOfdState / DriFdState / KmsFdState scaffolding, fork/exec inheritance, and close-release
  • wire DRM ioctls: dumb-buffer alloc/map/destroy, PRIME HANDLE_TO_FD / FD_TO_HANDLE, KMS master grab, modeset, page-flip, vblank
  • wire GLES2 session ioctls + command-buffer mmap + flex-sized BO mmap for shader uploads
  • retire queued DRM_IOCTL_MODE_PAGE_FLIP synchronously into the card0 read queue so drmHandleEvent returns at ioctl rate; program-level 60 Hz pacing handles cadence (architectural follow-up sketched in docs/plans/2026-06-10-dri-q4-vblank-gating-plan.md)
  • add host imports host_gbm_*, host_kms_*, host_gl_*, host_proc_* and route them through to the host TypeScript via the shared kernel-worker (Node + Browser parity)
  • add attachKmsCanvas / attachKmsStats host APIs with auto-mode (KMS canvas) and explicit mode: "2d" | "auto", plus GL auto-attach and BO PRIME-sync on both Node and Browser hosts
  • add BrowserKernel.getProcessMemory(pid) so the browser framebuffer renderer can read pixel SAB
  • add programs/modeset.c with the ported Pavel WebGL fluid simulation, 60 Hz frame pacing, and a dri-modeset CLI fixture
  • link libdrm/libgbm into DRI programs and auto-link EGL/GLES2 where used
  • add Kandelo Modeset React pane (full-width canvas + slim header chip), wire it into MachineView via a new modeset preset, and expose KandeloHost.attachKmsDisplay
  • regenerate abi/snapshot.json for the additive kernel exports (no ABI_VERSION bump — additions only)

Verification

  • cargo test -p kandelo --target aarch64-apple-darwin --lib932 ✓ (0 failures)
  • cd host && npx vitest run test/dri-*.test.ts30 ✓ across 10 files (KMS stats SAB, registry, page-flip, prime, modeset spec, browser PAGE_FLIP regression)
  • scripts/run-libc-tests.sh — 0 unexpected failures (one timing-sensitive pthread_cond_wait-cancel_ignored flake on first run, clean on re-run)
  • scripts/run-posix-tests.sh — 0 FAIL (3 XFAIL: mlock/12-1, munmap/1-1, munmap/1-2 — Wasm linear-memory limits; 2 SKIP)
  • bash scripts/check-abi-version.sh — snapshot in sync with sources; additive-compatible
  • manual browser stutter-when-idle verification on the Modeset pane via ./run.sh browser — confirmed gone

Notes

  • The kernel-wasm imports host_gbm_bo_create_gpu and host_gl_bind_foreign_texture from earlier GPU-tier scaffolding were removed in the final cleanup commit. Import removals don't shift kernel exports, so the snapshot stays in sync without an ABI_VERSION bump.
  • Q4 (kernel-side vblank gating) shipped as the v1 synchronous-retire shape. The architectural fix needs a new process_table::with_processes accessor so kernel_vblank can drain pending_flips for every open card0 fd; sketched in docs/plans/2026-06-10-dri-q4-vblank-gating-plan.md.
  • Dual-host parity (per CLAUDE.md): all kms_attach_canvas / attachKmsCanvas / getKmsCanvas / markKmsCanvasGlOwned / primeBindFromSab / getProcessMemory paths are mirrored on Node and Browser hosts. BrowserKernel.getProcessMemory(pid) is browser-only by design (the canvas renderer reads pixel SAB through it; Node framebuffer demos render in-worker).
  • 47 pre-existing cargo build warnings and a carry-forward of ~142 full-sweep vitest failures (exnref / instrumentation drift in unrelated packages) are not addressed here — none of them sit in DRI-touched files. The DRI subset is fully green.

mho22 and others added 23 commits June 8, 2026 16:33
Initial port of the DRI v2 work (PRs #58/#61#66 against mho22/wasm-posix-kernel)
onto current upstream/main. This commit covers:

  - **shared ABI**: append `pub mod gl` (cmdbuf opcodes + GLES2 sync-query tags
    + marshalled ioctl arg structs) and `pub mod dri` (DRM ioctl numbers,
    fourcc constants, KMS struct definitions) to `crates/shared/src/lib.rs`,
    plus the matching unit tests. No `ABI_VERSION` bump — additive-only.
  - **kernel `dri/` module**: bo registry + global master tracking
    (`crates/kernel/src/dri/{mod,bo,master}.rs`), 17 unit tests pass.
  - **HostIO trait extensions**: gbm_bo_*, gl_*, kms_*, proc_read_bytes,
    proc_write_bytes all added with no-op / -ENOSYS default impls so
    existing host adapters compile without changes.
  - **libc stubs**: full `libdrm`, `libgbm`, `libegl`, `libglesv2` stubs;
    `gl_abi.h` shared header.
  - **musl-overlay headers**: drm, GLES2, EGL, KHR, gbm + sys/ioccom.h.
  - **example programs**: cube, cube_pyramid, dri-smoke, dri_paint,
    dumb_roundtrip, kms-pageflip-smoke, libdrm-kms-smoke, modeset.
  - **build script**: scripts/build-gles-stubs.sh.
  - **design docs**: webgl-gles2 + dri-v2 plans.
  - **host TS surface** (`host/src/dri/`, `host/src/webgl/`) and the matching
    `host/test/{dri,webgl}-*.test.ts` files copied for the next pass to
    integrate against upstream's evolved `kernel.ts`/`kernel-worker.ts`.

Next commits: wire DRI ioctls into syscalls.rs ioctl dispatch + devfs.rs
+ ofd.rs + fork.rs + wasm_api.rs, then integrate the host TS surface,
then build the Kandelo React UI pane.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Context: scope of work to land mho22/wasm-posix-kernel PRs #58/#61-66
as one PR against Automattic/kandelo:main, including the in-flight
state of the kernel/host integration and the test-gate plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two VirtualDevice variants for /dev/dri/{card0,renderD128} with
negative host_handle sentinels (-8/-9). sys_open and sys_openat route
both through the existing CharDevice branch — no single-owner claim
since DRI is multi-process by design.

Minimal first-pass DRI ioctl handler implements only the two ioctls
libdrm's drmOpen() probe issues:

- DRM_IOCTL_VERSION returns 1.0.0 with zero-length name/date/desc
  strings (user-supplied pointers echoed back so libdrm's
  buffer-length round-trip stays clean).
- DRM_IOCTL_GET_CAP: DUMB_BUFFER → 1, PRIME → IMPORT|EXPORT,
  unknown caps → 0 (matches Linux's value=0,errno=0 behavior, not
  EINVAL).

Anything else returns ENOSYS so libdrm reports "feature unsupported"
rather than silently succeeding with a bogus result. CREATE_DUMB,
SET_MASTER, PAGE_FLIP, etc. land in the next pass alongside
DriFdState/KmsFdState and Process::dri_handles.

devfs lists /dev/dri as a directory under /dev, and getdents64 on
/dev/dri returns card0 and renderD128 as DT_CHR.

Adds three optional kernel-wasm exports for the host:
kernel_vblank (advances the global vblank sequence used by
WAIT_VBLANK responders), kernel_kms_commit_count(crtc), and
kernel_kms_last_frame_us(crtc). All three are additive — they're not
in HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS so the host adapter manifest
shape is unchanged and no ABI_VERSION bump is needed.

890 kernel unit tests pass (881 prior + 9 new DRI tests covering
match_virtual_device, multi-process open, ioctl VERSION, ioctl
GET_CAP for known and unknown caps, ioctl ENOSYS on unimplemented,
read-returns-0, devfs listing).
Adds the per-fd DRI sidecar that subsequent commits hang real ioctl
behavior off. Three OFD flavors share one Option<Box<DriOfdState>>:

- PrimeBo: created by DRM_IOCTL_PRIME_HANDLE_TO_FD; binds the new
  fd to a global BoId via a per-fd cookie that PRIME_FD_TO_HANDLE
  validates before bumping the bo's refcount.
- RenderNode: open("/dev/dri/renderD128"). Carries a per-fd
  GEM-handle namespace plus a future GLES2 cmdbuf binding
  (CmdbufBinding + GlState — kept default-None here; the actual
  GLIO_* dispatch lands later).
- Card { dri, kms }: open("/dev/dri/card0"). Same handle namespace
  as a render node plus KmsFdState (holds_master flag, per-fd
  fb_id -> KmsFb map, pending PAGE_FLIP queue, DRM event ring).

OpenFileDesc gains dri_state: Option<Box<DriOfdState>> — boxed so
non-DRI OFDs pay just one pointer slot. Accessor helpers (dri /
dri_mut / kms / kms_mut / prime_bo / take_prime_bo) thin the
ioctl-path boilerplate and prevent double-release of prime-bo
cookies.

OfdTable picks up iter_mut() so the upcoming DRI cleanup paths
(close-final-release-master, exec-clears-handles) can walk every
live OFD once.

fork.rs picks up dri_state: None on the two OpenFileDesc constructors
in the fork/exec deserialization path. Fork-time DRI state inheritance
(clone handles, drop master on parent-exit, etc.) is a separate
commit alongside Process state.

896 kernel unit tests pass (890 prior + 6 new covering default
sidecar absence, variant-routing through accessors, mutable handle
registration, take_prime_bo idempotence and non-prime safety, and
iter_mut visiting every live slot).
sys_open/sys_openat now install the DRI sidecar on /dev/dri/* opens:
RenderNode for renderD128, Card { dri, kms } for card0. Non-DRI
virtual devices skip the helper, so the call is unconditional from
the existing CharDevice branch.

handle_dri_ioctl gains the four ioctls libgbm and libdrm use to
allocate, mmap, and free CPU-shared bos via the per-fd DriFdState:

- DRM_IOCTL_MODE_CREATE_DUMB allocates a bo in the global registry,
  asks the host to back it with a SAB (HostIO::gbm_bo_create),
  registers a fresh per-fd handle, and writes (handle, pitch, size)
  back to the caller. Three rollback edges are covered: registry
  alloc OK + host fail → registry decref + ENOMEM; host OK + handle
  overflow → registry decref + host gbm_bo_destroy + EMFILE; host OK
  + dri_state_mut fail → same rollback.
- DRM_IOCTL_MODE_MAP_DUMB encodes the BoId into a stable page-aligned
  mmap offset (BoId << 12). The mmap path will decode it back later;
  no Linux-style vma_offset_manager.
- DRM_IOCTL_MODE_DESTROY_DUMB / DRM_IOCTL_GEM_CLOSE share a release
  path: drop the handle from the fd namespace, decref the bo, free
  host backing on the last refcount drop. Second close returns
  ENOENT.

Validates v1 limits: bpp must be 32 (ARGB8888-only); width/height
must be non-zero; flags must be 0 (no GBM_BO_USE_*).

sys_ioctl picks up &mut dyn HostIO so the dumb-buffer path can call
into gbm_bo_create / gbm_bo_destroy. The signature change ripples
to kernel_ioctl (wasm_api) and every test caller. Tests that didn't
have a MockHostIO in scope get one inline.

MockHostIO overrides gbm_bo_create → 0 and gbm_bo_destroy → no-op so
unit tests can exercise the happy-path bookkeeping without a real
host adapter. The default HostIO trait still returns -ENOSYS, which
is what production hosts need to override.

PRIME (HANDLE_TO_FD / FD_TO_HANDLE) and the WPK GPU-bo extensions
(CREATE_GPU_BO / BIND_FOREIGN_TEXTURE) land in the next commit
alongside the second-fd-creation plumbing for prime-bo OFDs.

905 kernel unit tests pass (896 prior + 9 new covering open-installs
state for render-node and card variants, CREATE_DUMB happy path +
EINVAL on bpp/dim/flags violations, MAP_DUMB offset encoding +
ENOENT on unknown handle, DESTROY_DUMB + GEM_CLOSE release path +
double-close ENOENT, and per-fd handle namespace isolation across
two opens of the same node).
…ease

Adds the two ioctls libdrm uses to share bos across DRI fds (e.g.
between a renderD128 client and a card0 KMS scanout fd):

- DRM_IOCTL_PRIME_HANDLE_TO_FD looks up the bo by handle in the
  fd's namespace, materialises a per-bo prime cookie (idempotent —
  re-export reuses the existing cookie, Linux-shape), bumps the bo's
  refcount, and allocates a fresh OFD carrying PrimeBo sidecar
  state. The new fd's OFD uses host_handle = -200 — outside the
  VirtualDevice sentinel range (-1..=-9) — so it won't be
  misrouted by the DRI ioctl dispatcher. O_CLOEXEC on the request
  flags propagates to FD_CLOEXEC on the new fd.

- DRM_IOCTL_PRIME_FD_TO_HANDLE looks up the prime-fd's OFD, clones
  its PrimeBoState, verifies the cookie still matches the bo's
  current cookie (a stale cookie — bo destroyed + new bo took its id —
  fails with EACCES, matching Linux), bumps the bo refcount, and
  registers a fresh handle in the destination fd's namespace.

Both paths cover their rollback edges: registry incref on success
followed by per-fd handle-alloc failure decrefs the bo back to its
pre-ioctl refcount; OFD-create-success followed by fd_table.alloc
failure releases both the OFD slot and the bo.

sys_close picks up DRI cleanup so closing the last fd that
references a bo decrefs it to zero and asks the host to destroy
its SAB backing. The mechanic:

- Before dec_ref, peek the OFD's ref_count. If ref_count == 1 (this
  close will free the OFD), `take()` the dri_state so close-on-exec
  and process exit can't double-release. Otherwise leave the sidecar
  in place — dup-shared OFDs must keep their DRI state until every
  fd closes.
- After dec_ref returns true, dri_release_ofd_state walks the
  per-fd handle map (RenderNode / Card variants) or unwraps the
  PrimeBoState. Each bo is decref'd; bo_destroy fires on the last
  drop.

dri::bo gains pub(crate) `next_id_for_test()` so syscall-layer
tests can identify the most recently allocated bo (ioctls return a
per-fd handle, not the global BoId). Also marks `reset_registry`
as pub(crate) for the same reason.

910 kernel unit tests pass (905 prior + 5 new covering PRIME
export's new-fd allocation with PrimeBo state + O_CLOEXEC
propagation, PRIME import roundtrip aliasing the same bo across
fds, PRIME import rejecting a non-prime fd with EINVAL, close
releasing the last bo reference and destroying host backing, and
multi-fd refcount tracking through the prime export+close
sequence).
Splits the DRI ioctl dispatch by node: renderD128 stays on
handle_dri_ioctl, card0 routes to handle_dri_card_ioctl which
fall through to handle_dri_ioctl for the shared probe + dumb-buffer
+ prime surface.

Adds the full minimum KMS surface modeset clients need:

- SET_MASTER / DROP_MASTER. Single global master enforced by
  crate::dri::master; re-set by the same (pid, ofd) is idempotent,
  anyone else gets EBUSY. The KmsFdState `holds_master` flag tracks
  ownership for the modeset gates below.
- MODE_GETRESOURCES. Reports one virtual {crtc=1, connector=1,
  encoder=1} plus 1..16384 dimension bounds. The caller-supplied
  count/ptr arrays are populated via HostIO::proc_write_bytes;
  a write failure surfaces as EFAULT.
- MODE_GETCRTC / MODE_GETENCODER / MODE_GETCONNECTOR. Return sane
  defaults for the one slot we expose; mismatched id → ENOENT. The
  connector reports VIRTUAL + CONNECTED + the host's preferred
  mode (HostIO::kms_mode_info(1)).
- MODE_ADDFB2. Looks up the bo, validates pixel_format
  (ARGB8888/XRGB8888/RGB565), enforces stride == bo stride for
  CPU-shared bos (GPU-tier bos skip the check — stride is 0 in
  the registry), registers a per-fd fb_id, bumps the bo refcount,
  asks the host to bind via HostIO::kms_addfb. Host-fail rollback
  releases both the fb slot and the bo.
- MODE_RMFB. Drops the fb slot, decrefs the bo, frees host backing
  on the last drop (gbm_bo_destroy) and asks the host to drop the
  fb via HostIO::kms_rmfb. Second RMFB on the same id → ENOENT.
- MODE_SETCRTC. Master-gated (EACCES otherwise). crtc_id==1 only;
  fb_id must be either 0 (unset) or a previously registered fb_id
  on this fd. Delegates to HostIO::kms_set_fb.
- MODE_PAGE_FLIP. Same gates. EBUSY if a flip on the same crtc is
  already pending. Queues the flip on `kms.pending_flips` and bumps
  the global commit counter via dri::record_kms_commit so the host
  UI (kernel_kms_commit_count) sees progress immediately. The
  clock read is best-effort — a CLOCK_MONOTONIC failure leaves the
  flip queued and just skips the counter bump.
- WAIT_VBLANK. Returns a best-effort reply with the current
  monotonic time; sequence=0. The full vblank handshake (queued
  waiters drained by the kernel_vblank tick) lands when the host
  pump is wired in a later commit.

sys_close DRI release path picks up KMS state:
dri_release_ofd_state now drops every fb (each held an extra
bo refcount and an extra host kms_rmfb call), releases master via
crate::dri::master::release_if_held if the closing OFD held it,
and walks the GEM handle namespace like a render node. The
ofd_idx is now passed in so the master release can match (pid,
ofd_idx) — a release that doesn't match the current holder is a
no-op.

Adds shared helpers `kms_state` / `kms_state_mut` next to the
existing `dri_state` / `dri_state_mut`. The dispatch in sys_ioctl
routes card0 to handle_dri_card_ioctl and renderD128 to
handle_dri_ioctl directly.

Existing `dri_ioctl_unknown_returns_enosys` rewritten to use
0xdead_beef instead of MODE_GETRESOURCES (now implemented), so
the negative-path assertion still holds.

918 kernel unit tests pass (910 prior + 8 new covering SET/DROP
master roundtrip + holds_master flag tracking, second-pid SET
returning EBUSY, close releasing master, GETRESOURCES count
output, GETCRTC id==1 OK and id!=1 ENOENT, the full
ADDFB2+SETCRTC+PAGE_FLIP happy path + commit counter bump +
second-flip EBUSY, SETCRTC without master returning EACCES, and
RMFB decrementing both the per-fd fb slot and the bo refcount
plus double-RMFB ENOENT).
…dState

Picks up the per-fd DRI state that landed in earlier commits and
connects it to the two paths user space actually needs to share a bo
across processes: mmap of a /dev/dri/{renderD128,card0} fd, and fork
of a process that already holds DRI handles, fbs, or prime imports.

mmap → gbm_bo_bind:

- sys_mmap now matches a third per-OFD branch alongside fb0: if the
  OFD has DriOfdState::RenderNode or DriOfdState::Card and the caller
  provides the offset from DRM_IOCTL_MODE_MAP_DUMB (BoId << 12), the
  kernel decodes the BoId, validates the bo is live and CPU-shared
  in dri::bo's registry, page-aligns the requested length, allocates
  wasm pages, then calls HostIO::gbm_bo_bind(pid, bo_id, addr, len)
  so the host can point that region at the bo's SAB slice. On
  gbm_bo_bind failure the wasm pages are returned via munmap so the
  caller sees ENOMEM with no half-bound state.
- The active mappings are recorded on Process::dri_bindings so
  sys_munmap can find them. munmap drops every binding fully covered
  by [addr, addr+len) and asks the host to gbm_bo_unbind each one
  before the wasm pages return to the anonymous pool, mirroring the
  fb_binding teardown for /dev/fb0.
- A GPU-tier bo (from gbm_bo_create_gpu — backed by a WebGLTexture,
  not a SAB) is not CPU-mappable and rejects mmap with EINVAL.
- The mmap path uses libgbm-shape geometry: the requested length must
  match the bo's size rounded up to a wasm page (64 KiB), matching
  what libgbm actually asks the kernel for. Wrong length → EINVAL,
  no host call.

Fork (and exec) inheritance of DriOfdState:

- FORK_VERSION bumps 8 → 9. Every per-OFD record now carries a one-
  byte variant tag (None / RenderNode / Card / PrimeBo) plus the
  payload bytes — handle map and next_handle for renderD128, plus
  the kms fb map / next_fb_id / pending_flips for card0, or the
  (bo_id, cookie) pair for a prime-fd OFD.
- On deserialize, every BoId restored on a handle, an fb, or a
  prime-bo gets an extra with_registry(|r| r.incref(_)) so the new
  process owns its own refcount on every bo it inherits. The
  parent's eventual close-path decref balances against its original
  alloc/ioctl-bumped refcount; the child's against the incref made
  here.
- Fork clears KmsFdState::holds_master in the child — the global
  master is a singleton and the child must SET_MASTER itself if it
  wants the lease. Exec preserves holds_master: the process
  identity is unchanged across the image swap, so an inherited
  card0 OFD legitimately keeps its KMS lease.
- The mmap-side host bindings on Process::dri_bindings are NOT
  inherited (the child gets an empty Vec). The child's wasm memory
  is a fresh region the host has not been told about; the child
  must re-mmap to re-establish bindings, mirroring fb_binding.

MockHostIO grows two tracking vecs (gbm_bo_bind_calls /
gbm_bo_unbind_calls) and a configurable gbm_bo_bind_rc so the new
mmap tests can assert the host was actually told about the binding
and exercise the rollback edge when gbm_bo_bind returns an errno.

929 kernel unit tests pass (918 prior + 11 new covering: mmap on
renderD128 binding the host + recording the per-process binding,
mmap on card0 doing the same via the Card variant, mmap with wrong
length / unknown bo / host gbm_bo_bind failure all surfacing errnos
with no half-state, munmap unbinding the host and clearing the
binding; fork preserving renderD128 handles and incref'ing every
bo, fork preserving card0 fbs and incref'ing those bos, fork
preserving a PrimeBo state's (bo_id, cookie) and incref'ing the
shared bo, fork clearing holds_master in the child, and exec
preserving holds_master in the post-image OFD).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bridges the kernel-side HostIO trait methods (gbm_bo_*, kms_*, gl_*,
proc_read/write_bytes) added in commits 1-6 to a fresh batch of wasm
imports, and supplies the matching JS-side host functions on the
WasmPosixKernel env import object.

Kernel side (crates/kernel/src/wasm_api.rs):
- New `host_*` extern declarations: host_gbm_bo_{create,destroy,
  create_gpu,bind,unbind}, host_gl_{bind,unbind,create_context,
  destroy_context,create_surface,destroy_surface,make_current,
  submit,present,query,bind_foreign_texture}, host_kms_{set_master,
  drop_master,mode_info,addfb,rmfb,set_fb}, host_proc_{read,write}_bytes.
- WasmHostIO impls forward each HostIO trait method to the corresponding
  extern. The trait's default no-op / -ENOSYS impls remain in place for
  test mocks; only the production wasm path now actually calls into the
  host.
- All additive — no existing extern, impl, or signature changed.

Host TS side (host/src/kernel.ts):
- Imports the existing host/src/dri/{registry,kms-registry} and
  host/src/webgl/{registry,bridge,query,submit-queue,muxer,
  submit-drain} surfaces (copied wholesale in b25ef59 but not yet
  wired into kernel.ts).
- WasmPosixKernel instance fields bos / kms / gl / foreignTextures /
  gl_submit_queue / gl_muxers track per-pid GBM bos, KMS state, GLES
  bindings, foreign-texture handles, and the master-prioritized GL
  submit lanes.
- KernelCallbacks gains `getProcessMemory?: (pid) => WebAssembly.Memory`,
  threaded into host_gl_submit / host_gl_query / host_proc_* so the GL
  bridge can decode cmdbuf bytes out of the calling process's wasm
  Memory SAB.
- buildImportObject grows host_gbm_bo_*, host_gl_*, host_kms_*,
  host_proc_read_bytes, host_proc_write_bytes — wired straight through
  to the registries above.
- New private writeKernelBytes mirror of readKernelBytes for the query
  / mode-info / proc-read return paths.

The embedder (node/browser kernel-host) still needs to supply
`getProcessMemory` and `kmsAttachCanvas`/`kmsAttachStats` plumbing —
that's commit 9. Until then GL bindings stay null-canvas, host_gl_submit
silently no-ops, and the existing FB / channel paths are untouched.

Kernel tests: 929 passing (unchanged from prior tip).
ABI snapshot: not regenerated here; the kernel_vblank /
kernel_kms_commit_count / kernel_kms_last_frame_us export additions
from commit 1 will be rolled into the closing snapshot commit per
the session-3 handoff plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the 60 Hz vblank pump and OffscreenCanvas blit path that turn the
kernel-side KMS ioctls landed in commits 1-7 into actual on-screen
pixels.

CentralizedKernelWorker gains:
- `getProcessMemory` callback wired into the kernel's `KernelCallbacks`
  so `host_gl_submit` / `host_gl_query` / `host_proc_{read,write}_bytes`
  can reach the per-pid wasm `Memory` registered in `this.processes`.
- `attachKmsCanvas(crtc_id, canvas, statsSab?)` to register an
  `OffscreenCanvas` (and optional stats SAB) as the scanout target for
  a CRTC. Starts the pump on first attach.
- `attachKmsStats(crtc_id, statsSab)` for the GL-rendered case that
  wants page-flip telemetry without a 2D blit canvas.
- `startVblankPump()` / `tickVblank()` running on a 16.67 ms interval:
    * Calls `kernel_vblank()` to drain pending page-flips.
    * For each registered canvas: pulls `kms.currentFb` + `kms.scanoutBytes`,
      blits opaque RGBA8888 into a per-CRTC cached `Uint8ClampedArray`,
      `putImageData` to the canvas, and writes (frame count, ts, width,
      height, blit µs) into stats slots 0..4 atomically.
    * For every stats SAB (canvas-bound or not), writes
      `kernel_kms_commit_count` and `kernel_kms_last_frame_us` into
      slots 5/6 so demos can show real PAGE_FLIP rate without coupling
      to the blit cadence.
- `get bos()` / `get gl()` / `get kms()` accessors so demos and presenter
  code can reach the registries that now live on `WasmPosixKernel`.

The pump auto-stops nothing once attached — the timer uses `.unref()` so
Node process exit isn't blocked, and the OffscreenCanvas/stats maps
empty out naturally on `terminate()` since the entire worker tears down.

Scratch buffer is explicitly `Uint8ClampedArray<ArrayBuffer>` (not the
default `ArrayBufferLike`) so `new ImageData(scratch, w, h)` accepts it
under TS's stricter typed-array generic.

Tests:
- Cargo: 929 passing (unchanged).
- Host TS: build clean; no new tsc errors. The browser-host vitest /
  Playwright suites that exercise OffscreenCanvas paths land in
  commit 10/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the KMS presenter plumbing landed in commit 8 through to both
host adapters at parity (CLAUDE.md §"Two hosts" — neither host follows
the other; both go in the same commit). Without this commit a caller
can construct a `CentralizedKernelWorker` inside the kernel worker but
cannot reach `attachKmsCanvas`/`attachKmsStats` from the main thread,
so OffscreenCanvas + stats SAB never make it into the pump.

Protocol (host/src/{browser,node}-kernel-protocol.ts):
- New `KmsAttachCanvasMessage { type: "kms_attach_canvas", crtcId,
  canvas: OffscreenCanvas, stats?: SharedArrayBuffer }`.
- New `KmsAttachStatsMessage { type: "kms_attach_stats", crtcId,
  stats: SharedArrayBuffer }` for the GL-rendered case that wants
  page-flip telemetry but no 2D blit canvas.
- Both unions extended.

Main-thread adapter (browser-kernel-host.ts / node-kernel-host.ts):
- `kmsAttachCanvas(crtcId, canvas, stats?)` — on browser, transfers
  the canvas (browser refuses to share it); on Node passes it raw
  (Node lacks a native `OffscreenCanvas`; the worker no-ops if no
  polyfill is wired, but the wire shape stays parallel).
- `kmsAttachStats(crtcId, stats)` — plain forwarding both ways.

Worker entry (browser-kernel-worker-entry.ts / node-kernel-worker-entry.ts):
- New `case "kms_attach_canvas"` / `case "kms_attach_stats"` in the
  top-level main→worker message switch. Forwards to the worker
  instance's existing `attachKmsCanvas`/`attachKmsStats` methods,
  which start the vblank pump on first attach.

These are singleton kernel-worker messages (the OffscreenCanvas /
stats SAB are owned by the worker, not per-process), so they do NOT
need parallel wiring inside `handleSpawn`/`handleFork`/`handleExec` —
no risk of the PR #410 dual-host-asymmetry regression here.

Symmetry check (CLAUDE.md): `grep kms_attach_canvas host/src/` shows
parallel structure on both trees; the matching consumer demo lives
in commit 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the in-Kandelo UI surface for the DRI/KMS stack: a `<Modeset>`
pane that hands an OffscreenCanvas to the kernel-worker and shows the
stats SAB the vblank pump writes into. Mirrors `Framebuffer` in
shape (canvas + per-bound-process status), so whoever runs `modeset`
(or any other CRTC-driving program) from the shell drives the pixels —
the pane itself never spawns the renderer.

`web-libs/kandelo-session/src/kernel-host.ts`:
- `KernelLike` gains optional `kmsAttachCanvas` / `kmsAttachStats`
  methods so a `LiveKernelHost` wrapping a `BrowserKernel` (or the
  parallel `NodeKernelHost`) can reach the host-host's new commit-9
  API surface.
- New `KmsDisplayHandle` and `attachKmsDisplay(canvas, crtcId?)` on
  `KernelHost`. Default `crtcId = 1` matches the single CRTC the
  kernel currently advertises via `MODE_GETRESOURCES`.
- `LiveKernelHost.attachKmsDisplay` lazy-allocates a 7-slot stats SAB
  (64 bytes, aligned for `Atomics.*`), transfers the canvas via
  `transferControlToOffscreen`, and forwards to `kmsAttachCanvas`.
  Returns null when the wrapped kernel lacks the method (older ABI)
  or the canvas can't be transferred (Node without polyfill).
- Stats slot layout documented on `KmsDisplayHandle`: 0-4 from the
  blit pump, 5-6 from the kernel-side `kernel_kms_commit_count` /
  `kernel_kms_last_frame_us` exports.

`apps/browser-demos/pages/kandelo/panes/Modeset.tsx`:
- New pane following Framebuffer's pattern. Attaches the canvas on
  status === "running" mount, polls the stats SAB at 4 Hz for the
  status bar (frame count, scanout WxH, blit µs, PAGE_FLIP commits,
  last flip µs). The CRTC id is a prop (defaults to 1) so a future
  multi-CRTC layout can mount multiple instances.
- Surfaces an error if the kernel ABI doesn't expose the new
  `attachKmsCanvas`, rather than silently appearing blank.
- The legacy `apps/browser-demos/pages/modeset/` standalone page
  mentioned in the v2 handoff is already absent from the branch tip
  (nothing to drop here — the v2 doc was working against an older
  tree state).

Layout integration (adding a "modeset" `PrimarySurface` to
`MachineView` + `SurfaceAvailability` plumbing in `LiveKernelHost`) is
deferred to a follow-up commit so the new pane can be reviewed
independently of the demo-presentation logic.

Build: host TS clean; apps/browser-demos tsc shows 8 fewer errors
than tip (the new interface fills a previously-`any` hole).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a "kms" PrimarySurface so the Modeset pane is reachable from the
Kandelo UI without conflating it with /dev/fb0. LiveKernelHost flips the
kms availability bit based on `kmsAttachCanvas` presence; Display routes
the demo surface slot to <Modeset/> when the active demo declares kms.

The new "modeset" preset boots the shell image, declares the kms
runtime feature, and host-side stages /usr/local/bin/modeset from
binaries/programs/wasm32/modeset.wasm — mirroring the fbtest staging
path so the binary doesn't have to live inside the shell VFS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DRI port's build script came forward from mho22 with a uniform
build_program call that no longer linked libdrm.a / libgbm.a into
DRI programs. Without those archives the linker silently emitted
drmSetMaster / drmModeGetResources / gbm_create_device / ... as
`env.*` undefined imports, and any program that touched libdrm
crashed at instantiation with `Unimplemented import: env.drmSetMaster`.

Restore the per-program extra-libs path that lived in the original
DRI build script:

  - Split LINK_FLAGS into LINK_PRE_LIBS (syscall glue + crt1) and
    LINK_POST_LIBS (libc.a + -Wl,...), so extra archives can be
    spliced BEFORE libc.a — required for the wrappers' internal
    references (mmap, ioctl, calloc, ...) to resolve in a single
    wasm-ld pass.
  - build_program now accepts extra archives after the standard
    two args, and grep-detects `#include <EGL/...>` / `<GLES{2,3}/...>`
    to auto-append libEGL.a + libGLESv2.a (no-op for non-GL programs).
  - The per-program case block routes modeset / dri_paint /
    dumb_roundtrip to libgbm.a + libdrm.a, and libdrm-kms-smoke to
    libdrm.a alone.

Effect on the vitest gauntlet: 36 failing tests → 7 (the latter set
is unrelated to linkage — DRI runtime / fixture mismatches that
predate this branch's bring-forward and need investigation per-test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full GLES2 port of github.com/PavelDoGreat/WebGL-Fluid-Simulation:
9 shader passes (curl, vorticity, divergence, pressure decay + 20×
Jacobi, gradient subtract, advect velocity/dye), bloom (prefilter +
7-level Gaussian pyramid + final), sunrays (mask + 16-step radial
sweep + separable blur), shading + gamma. Pavel "Quality High"
config: SIM_RES=128 → 228×128, DYE_RES=1024 → 1820×1024, BLOOM/
SUNRAYS resolutions per getResolution(N). DEN_DISSIPATION bumped to
2.0 to reduce color saturation buildup on long drags.

dt is now a per-frame wall-clock delta from CLOCK_MONOTONIC, capped
at 1/15 s so a stalled frame can't blow up the sim. Replaces the
hardcoded 1/60 — at the actual loop cadence the old constant caused
~30× sim-step-per-real-frame over-dissipation.

A program-level 60 Hz throttle sleeps until the next 1/60 s tick
after kms_pageflip_wait returns. The kernel-side vblank pump
currently delivers PAGE_FLIP_EVENTs at ~2 kHz instead of 60; until
that's fixed (Q4), the program needs to throttle itself or burn the
GPU running bloom + sunrays + display 33× per real frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canvas style is now width:100%, height:auto, maxHeight:100% — the
1920×1080 drawing buffer keeps its intrinsic aspect ratio so the
mouse-coord mapping (rect.width vs canvas.width) stays correct on
both axes; the canvas fills the pane width and caps at the pane
body height. The bottom stats grid (scanout / blit / pump / commits
/ last flip / crtc) is gone; the header chip carries the load:
"1920×1080 · N flips · Nµs". KmsStats slimmed to just the three
fields still rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier branch commits 9575d78 (kernel_vblank +
kernel_kms_commit_count + kernel_kms_last_frame_us via the dri probe
surface) and the KMS card0 ioctl pass added three new optional kernel
exports without regenerating `abi/snapshot.json`. This commit captures
them.

Per CLAUDE.md ABI policy: additive kernel-wasm exports do not require
an `ABI_VERSION` bump as long as existing entries are unchanged.
`ABI_VERSION` stays at 14.

Note: `scripts/check-abi-version.sh` also flags `kernel_reserve_host_region`
+ `kernel_reserve_host_region_at` as "removed" and reports `host_adapter`
+ `process_memory_layout` as reshaped vs `upstream/main`. Those are
pre-existing upstream snapshot drift from PR #629 ("Make pthread
control slots dynamic"), not changes introduced on this branch — the
upstream snapshot was never regenerated when those source-level changes
landed. No action taken here; documenting for the next person.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the GLES2 cmdbuf state machine that libEGL / libGLESv2 stubs
drive on /dev/dri/renderD128:

- `GLIO_INIT`         — version-checks the op-table; installs GlState.
                        Mismatch fails ENOSYS at first contact so a
                        future op-table bump can't silently decode wrong.
- `GLIO_TERMINATE`    — tears the binding down + calls `host.gl_unbind`.
- `GLIO_CREATE_CONTEXT` / `GLIO_DESTROY_CONTEXT` — one context per fd
                        (v1 limit). Duplicate `CREATE_CONTEXT` fails
                        EBUSY until destroyed.
- `GLIO_CREATE_SURFACE` / `GLIO_DESTROY_SURFACE` — DEFAULT + PBUFFER
                        only.
- `GLIO_MAKE_CURRENT` — both context and surface must be present.
- `GLIO_SUBMIT`       — validates `[offset, offset+length) <= cmdbuf.len`,
                        bumps `submit_seq`, forwards to `host.gl_submit`.
- `GLIO_PRESENT`      — initialised-only gate, forwards to
                        `host.gl_present`.
- `GLIO_QUERY`        — bounds-checks `out_buf_len <= MAX_QUERY_OUT_LEN`,
                        round-trips an opaque in/out buffer through
                        `host.gl_query` via the proc_read_bytes /
                        proc_write_bytes bridge.

`sys_mmap` on a renderD128 fd recognises `offset == 0` (the cmdbuf
slot — bo mmaps always encode `bo_id >= 1` into the offset via the
`bo_id << 12` MAP_DUMB convention) and:

- Requires `len == gl::CMDBUF_LEN`.
- Allocates an anonymous wasm region.
- Records the `CmdbufBinding { addr, len, submit_seq: 0 }` on the
  fd's `GlState`.
- Calls `host.gl_bind(pid, addr, len)` so the host muxer can mirror
  the encoder region into its own memory view.

The bo-mmap path is loosened to accept either the raw bo size returned
by `DRM_IOCTL_MODE_CREATE_DUMB` or the wasm-page-aligned size some
callers (libgbm's stub on the eager path) round to. `mmap_anonymous`
maps the same number of pages either way; only the binding length we
record differs.

Also fixes the channel SYS_MMAP dispatch path in `wasm_api.rs` to
call `syscalls::sys_mmap` directly instead of going through
`kernel_mmap`. The wrapper collapses every `Errno` to `MAP_FAILED`
(`usize::MAX`); the channel dispatcher then sees `-1` and reports
`-EPERM`. Going direct preserves `-ENOMEM`, `-EINVAL`, `-EBADF`, etc.
This was already wrong for non-DRI callers — the GLES2 cmdbuf path
just made it visible.

Tests cover: GLIO_INIT version skew + matching version + double-init
EBUSY; CREATE_CONTEXT double-attach EBUSY + destroy-then-recreate;
SUBMIT out-of-range EINVAL + valid range bumps submit_seq; cmdbuf
mmap length validation + binding recorded; second mmap-at-offset-0
falls through to the bo path and EINVALs.

934 kernel unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`drmModePageFlip` previously pushed onto `KmsFdState::pending_flips`
and returned, leaving the host's 60 Hz vblank pump as the sole driver
of `DRM_EVENT_FLIP_COMPLETE` delivery. That worked for stats
counters but blocked anything that issued the PR-standard
`drmModePageFlip(EVENT) → drmHandleEvent` round-trip — vitest
fixtures and a freshly-spawned client both stalled until the next
pump tick, and a back-to-back second flip on the same crtc failed
EBUSY because the pump hadn't drained the first.

Solution (v1, marked as such): inside `handle_dri_card_ioctl` after
the PAGE_FLIP accepts, push into `pending_flips`, then immediately
pop and serialise as a 32-byte DRM event record into the per-fd
`event_ring`. `crate::dri::vblank_tick()` supplies the sequence;
`host_clock_gettime` supplies tv_sec/tv_usec (a clock-read failure
falls back to zeros, no flip is lost).

`sys_read` on a DriCard0 fd now drains the event_ring byte-at-a-time
into the caller's buffer (libdrm sets a 32-byte buffer for one
event; honour shorter buffers by leaving the remainder queued).
Empty ring + O_NONBLOCK → EAGAIN; empty + blocking → 0 (drmHandleEvent
treats that as "no events this round" rather than a hard error,
preserving the read-loop shape).

This unblocks programs/dri-modeset.c (the kms-pageflip vitest
fixture) and unsticks back-to-back flips on the same crtc — both
now succeed instead of hitting EBUSY at the second push.

Test updates: the kms-pageflip test asserts `pending_flips` is
empty and `event_ring` holds exactly 32 bytes of a well-formed
DRM_EVENT_FLIP_COMPLETE record; second flip succeeds and extends
event_ring to 64 bytes. EBUSY case is now the in-flight-on-same-crtc
case (preserved upstream of the push).

This is still Q4 (host-side vblank gating) territory long-term —
real refresh-rate pacing belongs in `kernel_vblank()` draining a
pending queue, not in PAGE_FLIP. Marked v1 in the inline comment.

934 kernel unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the host-side half of the modeset surface so a libdrm/libgbm/EGL
program (e.g. programs/modeset.c — Pavel's fluid sim) can drive the
Modeset React pane end-to-end. Mirrored across Node + Browser hosts
per CLAUDE.md dual-host parity rule.

KMS canvas ownership mode
-------------------------
`attachKmsCanvas` now takes `opts.mode`:
- `"auto"` (default) — no context is grabbed up front. If the
  DRM-master pid later calls `eglCreateContext`, the GL bridge's
  auto-attach path (see below) claims the canvas for WebGL2. Slots
  5/6 (kernel-side commit count, last frame µs) still tick from
  PAGE_FLIP regardless.
- `"2d"` — legacy CPU-blit path. Pump eagerly acquires a 2D context
  and copies the kernel's scanout BO into the canvas each frame.
- `"webgl2"` — declared GL-owned up front. Pump stays hands-off.

The 2D-blit branch in `tickVblank` now only fires for CRTCs whose
`kmsContextMode === "2d"`. Touching an OffscreenCanvas with
`getContext("2d")` claims it for life — calling that on a canvas the
embedder later hands to WebGL2 used to break Modeset silently. Now
it doesn't.

Slots 2/3 (current scanout width/height) move out of the 2D blit
branch and into a sourced-from-kernel-FB block that runs for every
stats SAB. The Modeset pane uses (width > 0 && height > 0) as its
"scanout active" predicate; tying that to the blit branch broke the
auto/webgl2 modes.

GL auto-attach
--------------
`host_gl_create_context` now grows a fall-through: when `b.canvas`
is null but the pid holds DRM master on a CRTC the embedder has
registered, fetch the canvas through `callbacks.getKmsCanvas`,
resize its drawing buffer to match the kernel-side FB
(otherwise eglMakeCurrent draws into the default 300×150 corner),
`gl.attachCanvas`, and call `markKmsCanvasGlOwned` so the pump stays
off. Without this, modeset.c — which never calls a TS API — would
silently no-op every shader compile/link/draw against `null`.

`KmsRegistry.masterCrtcForPid` is the lookup the auto-attach path
needs.

bo prime SAB→Memory sync
------------------------
When `sys_mmap` on a /dev/dri/* fd returns, the kernel side has
already called `host.gbm_bo_bind` to record metadata, but the actual
SAB→wasm Memory copy is deferred until the anonymous-mmap zero-fill
is in place. `kernel-worker.ts` finalises that copy here. Without
it, a child that imported a PRIME fd from a forked parent saw zeros
instead of the parent's writes.

`bos.setProcessMemoryResolver` (driven by the new
`KernelCallbacks.getProcessMemory`) is what `primeBindFromSab` calls
to find the right `Memory` object — one per pid.

Kandelo-session
---------------
`attachKmsDisplay` grows `opts.mode` (default `"webgl2"` for the
Modeset pane) and a `sendMouseEvent` member on the returned handle.
React 18 StrictMode double-invokes effects and
`transferControlToOffscreen` can only run once per canvas, so the
handle is memoised on a per-canvas WeakMap.

Tests
-----
host/test/dri-kms-stats-sab.test.ts passes `{ mode: "2d" }` so it
keeps exercising the CPU-blit branch (default "auto" wouldn't blit
and slots 0/1/4 would be 0).

All 32 DRI vitests pass on Node host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`programs/modeset.c` became Pavel's WebGL fluid sim in commit
52a1022. The vitest at `host/test/dri-modeset.test.ts` still wants
a short-lived libdrm/libgbm CLI that runs the PR-standard
SetMaster → ADDFB2 → SETCRTC → PageFlip loop and exits with a
"modeset OK" summary, so add a separate `programs/dri-modeset.c`
trimmed-down fixture (155 lines, behaviour matches the pre-fluid
modeset.c described in §C2 of docs/plans/2026-06-08-dri-kms-plan.md).

`scripts/build-programs.sh` adds dri-modeset.c to the libdrm/libgbm
link group alongside modeset.c, dri_paint.c, dumb_roundtrip.c.

`host/test/dri-modeset.test.ts` is repointed at the new
`programs/dri-modeset.wasm`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Playwright spec that boots the Kandelo browser demo with
`?demo=modeset`, waits for the Modeset pane to be visible, and
asserts:

  1. The header chip ticks to "N flips" (with N ≥ 1), proving
     PAGE_FLIP ioctls actually reach the kernel through the host
     bridge.
  2. A canvas screenshot is materially larger than a uniform-color
     baseline (≥ 5 KiB; blank 800×600 PNGs are < 2 KiB), proving
     WebGL2 acquired the scanout canvas and Pavel's fluid sim is
     producing pixels. Without this clause a regression that
     silently fails to compile the splat shader against a null
     `b.gl` would still tick the flip counter.

Required infra changes:

- `playwright.config.ts` switches the chromium project to
  `channel: "chromium"` (new headless mode). The default
  chromium-headless-shell silently returns null for
  `getContext("webgl2")` on a transferred OffscreenCanvas inside
  a Web Worker — the entire path Modeset relies on.

- `host/test/dri-smoke.test.ts` bumps the timeout to 20 s. The DRI
  smoke run can spend 5–15 s in the WordPress-style sysroot warmup
  on CI runners and was tripping the default 10 s ceiling
  intermittently.

programs/modeset.c cleanups (devil's-advocate pass):

- `DT_FALLBACK` macro was only used to initialise `g_dt`. Inlined
  to `static float g_dt = 1.0f / 60.0f;` and drop the macro.

- `splat_radius_sq` renamed to `splat_radius`. The variable holds
  `SPLAT_RADIUS_BASE * aspect` — Pavel's `correctRadius` output,
  not a squared distance. The shader uniform is already named
  `radius` and `dot(p, p) / radius` does the squaring at sample
  time. The `_sq` suffix was a name from an earlier iteration that
  never matched the maths. Function parameters renamed to match.

- The two long block comments documenting the user-space 60 Hz
  throttle (one before the loop, one inside it) are condensed.
  The "why" — kernel-side vblank gating is Q4 and the pump retires
  PAGE_FLIP at ~2 kHz, so we throttle ourselves — is preserved in
  one short sentence each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ims + auto-mode test

Pre-push devil's-advocate pass on `explore-direct-rendering-infrastructure`
before publishing to `Automattic/kandelo`. Every added line on the branch
re-walked against its parent; everything that wasn't load-bearing for the
modeset.c demo target is removed.

GPU-tier infrastructure (forward-ported in b25ef59, never wired in):

- Kernel: `BoRegistry::alloc_gpu`, `BoTier` enum + `tier` field on `GbmBo`,
  `gbm_bo_create_gpu` + `gl_bind_foreign_texture` HostIO trait methods
  (default impls + WasmHostIO impls), the matching `host_gbm_bo_create_gpu`
  and `host_gl_bind_foreign_texture` import declarations. The two
  vacuous `if tier == CpuShared` / `if tier != CpuShared` checks in
  `syscalls.rs` (MODE_ADDFB2 stride validation, sys_mmap renderD128
  path) collapse to the unconditional check.
- Host: `WasmPosixKernel.foreignTextures`, the `host_gbm_bo_create_gpu`
  + `host_gl_bind_foreign_texture` handlers, `GbmBoRegistry.createGpu`,
  `GbmBoGpuCreateInput`, the `"gpu"`/`"cpu_shared"` tier discriminator,
  `format` + `texture` fields on `GbmBoEntry`, `ForeignTextureRegistry`.
- Tests: GPU-tier `createGpu` tests in `dri-registry.test.ts`,
  full `webgl-foreign-texture.test.ts` (67 LOC) deleted.

No production syscall path constructs a GPU-tier bo today, and no
handler ever calls `host.gbm_bo_create_gpu` or
`host.gl_bind_foreign_texture`. The infra was speculative scaffolding
for a future plan-3 §B6/§B7 milestone; reintroducing it when that
milestone actually ships is mechanical. Pulling it out now keeps the
branch's "every character has to be needed" invariant honest.

Orphan example programs:

- `programs/cube.c` (364 LOC, brought forward in b25ef59 as a planned
  fork+pipe spinning-cube demo, never wired into any test or
  browser-demo). The only references were in design-plan docs.
- `programs/dri_paint.c` (161 LOC, brought forward as a planned
  PRIME-export visualisation, never wired into any test). The build
  script case in `scripts/build-programs.sh` drops it from the
  libgbm/libdrm link group.

`programs/cube_pyramid.c` stays — `dri-cube-pyramid.test.ts` exercises
it. `programs/dri-smoke.c`, `programs/dumb_roundtrip.c`,
`programs/kms-pageflip-smoke.c`, `programs/libdrm-kms-smoke.c`,
`programs/modeset.c`, `programs/dri-modeset.c` all have tests and stay.

Oversized doc comments in `syscalls.rs`:

- `handle_dri_ioctl` had a 27-line `///` block enumerating every ioctl
  it handles (VERSION, GET_CAP, MODE_CREATE_DUMB, MODE_MAP_DUMB,
  MODE_DESTROY_DUMB, GEM_CLOSE). Trimmed to a 4-line summary: the
  enumeration is what the `match request` body literally shows.
- `handle_dri_card_ioctl` had a 41-line `///` block enumerating each
  KMS ioctl with multi-line per-entry semantics. Trimmed to a 3-line
  summary; fall-through to `handle_dri_ioctl` is the load-bearing
  architectural fact.
- Three obvious one-liners dropped: `// Roll back: drop the OFD and
  decref the bo.`, `// Bump refcount for the new local handle.`,
  `// Then the GEM handle namespace, like a render node.` — each
  restated the code below it.

UI / test comment trims:

- `apps/browser-demos/pages/kandelo/panes/Modeset.tsx` — the 15-line
  block-comment header (component summary + stats-slot layout
  enumeration) trimmed to a 3-line pointer that the layout lives in
  `tickVblank`. Reference-doc text moved to where the reader actually
  needs it — at the `kernel-worker.ts` source.
- `apps/browser-demos/test/kandelo-modeset.spec.ts` — the 8-line
  "PNG IDAT chunk explanation" before the screenshot size assertion
  trimmed to one line. The assertion's intent (`>5KiB threshold`
  proves a real render, not just a blank canvas) survives; the PNG
  format walk-through reads like documentation aimed at the wrong
  audience.

Test coverage gap closed:

- `host/test/dri-kms-stats-sab.test.ts` now asserts that slots 2/3
  (scanout width / height) are populated for a CRTC whose canvas was
  attached in the default `auto` mode — i.e. without the legacy
  `mode: "2d"` CPU-blit branch firing. This is the handoff-18 §4
  regression risk: when slots 2/3 moved out of the 2D blit branch
  and into the unconditional stats block, only the `mode: "2d"` path
  had test coverage for them.

Q4 follow-up:

- `docs/plans/2026-06-10-dri-q4-vblank-gating-plan.md` documents the
  v1 simplification still in `5e0c15f1d`: PAGE_FLIP events retire
  immediately into the per-fd `event_ring` from
  `handle_dri_card_ioctl`, so `drmHandleEvent` returns at ioctl rate
  (~2 kHz) instead of monitor refresh. modeset.c masks this with a
  program-level 60 Hz throttle. Plan describes the architectural fix
  (drain pending_flips from `kernel_vblank()` instead, gated on a
  `process_table::with_processes`-style accessor) without blocking
  the push.

Test verification (all 5 suites per CLAUDE.md):

- cargo `-p kandelo --target aarch64-apple-darwin --lib`: 932 passed
  (lost 2 from the removed `alloc_gpu_sets_tier_and_zero_stride` +
  `alloc_marks_cpu_shared_tier` tests).
- DRI vitest (`test/dri-*.test.ts`): 30 passed across 10 files —
  modeset, cube-pyramid, kms-pageflip, libdrm-kms, registry, kms
  registry, kms-stats-sab (+1 new), multiplex, smoke,
  dumb-roundtrip. Full vitest run shows 142 unrelated exnref
  failures (spidermonkey/php/coreutils/bash/dash/wordpress/
  fork-instrument-coverage) which match the v18 carry-forward count.
- libc-test (`scripts/run-libc-tests.sh`): 0 unexpected failures on
  re-run. First run flaked `regression/pthread_cond_wait-cancel_ignored`
  (timing-sensitive; unrelated to anything in this diff); second run
  clean.
- POSIX (`scripts/run-posix-tests.sh`): 0 FAIL. XFAIL × 3
  (mlock/12-1, munmap/1-1, munmap/1-2 — Wasm linear-memory
  limitations) + SKIP × 2 (sched_get_priority_*).
- ABI snapshot (`scripts/check-abi-version.sh`): snapshot is in sync
  with sources. The breaking-diff vs origin/main is pre-existing
  upstream drift from PR #629 (pthread control slots dynamic) and
  PR #630 (wasm32 for Safari) which never regenerated the snapshot;
  documented in 00d123b and unchanged here. No `ABI_VERSION` bump
  needed — kernel exports are unchanged by this commit (the GPU-tier
  imports being removed are kernel `host_*` imports, not exports;
  ABI snapshot tracks exports only).

Dual-host parity grep over `kms_attach_canvas`, `attachKmsCanvas`,
`getKmsCanvas`, `markKmsCanvasGlOwned`, `primeBindFromSab`,
`getProcessMemory` across `host/src/` and `apps/browser-demos/`
remains clean: both `node-kernel-host` and `browser-kernel-host`
forward `kms_attach_canvas` / `kms_attach_stats` messages; both
worker entries dispatch them; the `attachKmsCanvas` / `attachKmsStats`
implementations live in shared `kernel-worker.ts`. The
`BrowserKernel.getProcessMemory(pid)` exposure is by-design (browser
framebuffer renderer reads pixel SAB through it; Node's framebuffer
demos render in-worker and don't need the bridge).

Co-Authored-By: Claude Opus 4.7 (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