Skip to content

Add ALSA audio backend and espeak-ng browser demo#698

Draft
mho22 wants to merge 43 commits into
mainfrom
explore-dri-evdev-and-alsa
Draft

Add ALSA audio backend and espeak-ng browser demo#698
mho22 wants to merge 43 commits into
mainfrom
explore-dri-evdev-and-alsa

Conversation

@mho22

@mho22 mho22 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Give Kandelo a working audio stack and prove it end-to-end with a real Wasm program. The kernel previously had no audio devices, so packages that rely on /dev/snd/* (espeak-ng via pcaudiolib, SDL audio, mpg123) could not run unmodified. This PR adds the ALSA subsystem on both hosts, ports espeak-ng with a kandelo pcaudiolib backend, and surfaces the result as a browser demo that plays a spoken greeting.

Summary

  • add a kernel ALSA subsystem under crates/kernel/src/audio/: /dev/snd/{pcmC0D0p, controlC0} devfs nodes, PCM ioctl dispatch, an mmap-able ring of pages backed by SharedArrayBuffer, a period-driven tick, poll readiness, and fork/exec state serialisation for both PCM and control fds
  • add three additive kernel-wasm exports — kernel_audio_init_sab, kernel_audio_period_tick, kernel_audio_get_appl_ptr — and the matching shared::abi constants; the existing audio.rs OSS-emulation module moves under audio/oss.rs
  • add a host AudioDriver interface plus BrowserAudioDriver (AudioWorklet + monotonic hwPtr + producer-pointer gate to prevent head truncation + drain-on-stop) and NodeAudioDriver (headless null sink so vitest exercises the same code paths)
  • wire both hosts in the same commit per CLAUDE.md §"Two hosts": host/src/{browser,node}-kernel-{host,protocol,worker-entry}.ts plus the shared kernel-worker.ts and kernel.ts
  • factor instrumentAudioDriver(inner, onFramesConsumed?) into host/src/audio/instrumented-audio-driver.ts so demos can observe framesConsumed totals without re-implementing the forwarding contract on every call site
  • vendor pcaudiolib with a new kandelo audio_object backend (audio_kandelo.c) and cross-compile espeak-ng with the English-only data dir for size; recipe + build script live under packages/registry/espeak-ng/
  • bake the espeak-ng binary and espeak-ng-data/ into shell.vfs.zst via a durable install_local_binary shell … step in the image builder, with a package-system test that pins the install
  • add a kandelo "ALSA – espeak-ng" demo preset and a Playwright spec that asserts the synthesized greeting plays end-to-end
  • vendor sound/asound.h under libc/musl-overlay/include/ so libc consumers see the same UAPI the kernel implements; regenerate abi/snapshot.json, host/src/generated/abi.ts, and libc/glue/abi_constants.h

Notes

  • ABI delta is additive-compatible: three new exports, two new virtual-device entries, no removed or renamed existing surface. ABI_VERSION is unchanged and scripts/check-abi-version.sh confirms the delta vs origin/main.
  • /dev/snd/controlC0 opens succeed through devfs but ioctls fall through to the default EINVAL. espeak-ng does not call them; the node exists so ALSA consumers that probe the control device before opening the PCM device see a real fd rather than ENOENT.
  • BrowserAudioDriver.stop() defers audioCtx.close() by (pending / sampleRate) * 1000 + 100 ms to drain the AudioContext output queue. The 100 ms margin is empirical and browser-dependent; the rationale is inline in host/src/audio/browser-audio-driver.ts.
  • New vitest coverage: audio-driver.test.ts, browser-audio-driver-drain.test.ts (synchronous close when pending == 0, deferred close path with exact drain time, clearInterval on stop), and instrumented-audio-driver.test.ts (forwarding all seven start() args + getApplPtr identity + framesConsumed accumulation + stop() delegation — pins the argument-dropping regression that previously silenced the demo).

Testing

  • cargo test -p kandelo --target aarch64-apple-darwin --lib → 1047/1047 pass
  • cargo test -p wasm-posix-shared --target aarch64-apple-darwin → 27/27 pass
  • npx vitest run host/test/audio-driver.test.ts host/test/browser-audio-driver-drain.test.ts host/test/instrumented-audio-driver.test.ts tests/package-system/ → 22/22 pass across 6 files
  • bash scripts/check-abi-version.sh → in sync; additive-compatible diff vs origin/main, no ABI_VERSION bump required
  • bash build.sh → fresh local-binaries/kernel.wasm exports kernel_audio_init_sab, kernel_audio_period_tick, kernel_audio_get_appl_ptr
  • browser demo: ./run.sh browser /?demo=espeak plays "Welcome to Kandelo, the WebAssembly POSIX kernel"
  • scripts/run-libc-tests.sh and scripts/run-posix-tests.sh not run in this checkout (submodules absent); the audio surface is purely additive and has no libc/POSIX touchpoints these suites exercise

mho22 and others added 30 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>
PAGE_FLIP now queues into pending_flips and the host's 60 Hz vblank
pump drains every process's queue into the per-fd event_ring,
stamped with the new sequence + host monotonic time. Replaces the
v1 synchronous in-ioctl drain so libdrm's PageFlip + drmHandleEvent
loop returns at monitor refresh, not ioctl rate.

Three issues had to land together:

* startVblankPump() now runs unconditionally at end of init() (was
  only started by attachKmsCanvas / attachKmsStats). Vitest fixtures
  exercising PAGE_FLIP without attaching a canvas would otherwise
  block on poll() indefinitely. tickVblank no-ops gracefully when
  no canvas or stats SAB is attached.

* poll_check(card0) gates POLLIN on event_ring being non-empty
  (mirrors /dev/input/mice). Without this a poll + read +
  drmHandleEvent loop races the pump: poll reports ready, read
  returns 0, drmHandleEvent reports a short read.

* libdrm_stub.drmHandleEvent polls before read. Real libdrm relies
  on Linux blocking-mode read on /dev/dri/cardN; sys_read on card0
  returns Ok(0) on an empty ring (centralized mode has no SYS_READ
  EAGAIN-retry plumbing), so a straight read would race the vblank
  tick and the caller's pending flip would stay queued, hitting
  EBUSY on the next PageFlip.

The PAGE_FLIP unit test in syscalls.rs is rewritten to drive the
drain via dri::drain_pending_flips_for_process (the per-process
variant the global pump invokes), keeping it free of
GLOBAL_PROCESS_TABLE side effects.

ABI: kernel_vblank's exported signature is unchanged (still () -> u32);
no new exports, no marshalled struct changes. Snapshot stays in sync.

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

First slice of plan 5 / evdev. Adds pub mod input as a sibling of
pub mod dri: WpkInputEvent (24-byte repr(C) with explicit _pad at
offset 12 so ev_type lands at offset 16 where C's struct timeval
puts it), WpkInputId, WpkInputAbsinfo, EV_/KEY_/BTN_/REL_/ABS_/SYN_
codes, BUS_VIRTUAL, EVIOCGVERSION/EVIOCGID/EVIOCGRAB plus the
variable-length nr bases for EVIOCGNAME / EVIOCGBIT / EVIOCGABS.

KEY_* covers 0..248 — the full Linux input-event-codes.h surface
that browsers can emit through KeyboardEvent.code, so Phase B2's
translation table is just a key-by-key lookup.

input_tests verifies struct sizes (24/8/24), field offsets (the
_pad is load-bearing; without it ev_type sits at offset 12 and
the C reader silently misreads every record), and re-derives each
EVIOC* number from (dir, magic, nr, size) so a copy-paste typo
cannot survive.

Purely additive — no ABI_VERSION bump; snapshot regen lands in A7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second slice of plan 5 / evdev. Extends VirtualDevice with the
InputEvent { device: u8 } struct variant (kbd / ptr; host_handle
-10 / -11), wires match_virtual_device to recognise event0 +
event1 (event2+ deliberately returns None), and adds the per-OFD
InputFdState sidecar: device + 24 KiB event ring + grabbed flag
+ dropped flag + ring_high_water diagnostic. Sidecar is parallel
to dri_state, not nested — disjoint state machines.

devfs lists event0 + event1 alongside the existing mice entry.

sys_read on an evdev fd is a placeholder Ok(0) for now — A5
lands the ring drain + SYN_DROPPED resync semantics. Fork
deserialise leaves input_state None with a TODO(A4/A5)
breadcrumb; no observable consumer exists yet.

Two pre-existing assertions adjusted:
  * match_virtual_device_recognizes_mice no longer claims
    /dev/input/event0 returns None.
  * test_virtual_device_roundtrip's "first sentinel past the
    range" moves from -10 to -12 since -10 / -11 are now kbd / ptr.

Purely additive — no ABI_VERSION bump.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sys_close snapshots OFD.input_state on last-ref and drops it via new
input_release_ofd_state helper (mirrors dri_release_ofd_state; v1 body
is a no-op drop, signature reserved for plan-9 grab-released hook).

Fork/exec serialise the per-OFD ring + grab + dropped flags + high
water mark across write_input_state / read_input_state (modelled on
write_dri_state / read_dri_state). Reader bounds-checks against
INPUT_RING_MAX_BYTES and rejects non-record-aligned lengths as EINVAL.

Tests:
- close_releases_grab_so_next_open_can_grab — OFD slot is freed, a
  fresh open on the same node comes up clean and re-grabbable.
- fork_then_close_in_child_keeps_grab_on_parent — child inherits grab
  + queued events; closing the child's fd doesn't touch parent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mho22 and others added 12 commits June 11, 2026 18:19
A4 added `kernel_input_event` (host → kernel event producer) and
`kernel_set_input_canvas_dims` (host → kernel pointer canvas geometry).
Both are pure additions to the kernel-wasm export set — no existing
entry changes — so per CLAUDE.md ABI policy this is additive-compatible
and `ABI_VERSION` stays at 14.

Note: `scripts/check-abi-version.sh` continues to flag
`kernel_reserve_host_region` + `kernel_reserve_host_region_at` as
"removed" and reports `host_adapter` + `process_memory_layout` as
reshaped vs `upstream/main`. That is the same pre-existing upstream
snapshot drift from PR #629 ("Make pthread control slots dynamic")
already documented in 00d123b — not introduced by this branch and
not addressed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A artifacts removed:

- ring_high_water field on InputFdState: debug-only counter no
  producer reads; carried 4 bytes per OFD across the fork wire.
- input::wait::wake_event_reader stub + was_empty/woken_ofds
  bookkeeping in push_event: empty no-op. Phase B will add real
  wake routing where it is actually wired.
- input_release_ofd_state + the sys_close snapshot block: 4-param
  helper whose body was `let _ = state;`. Slot Drop already
  releases the box on dec_ref — the take/helper pattern only
  exists for dri_state because that path calls into the host.
- VirtualDevice::InputEvent host_handle catchall returning -10:
  collapsed to `device => -10 - device as i64`.

Trim the matching test (push_event_tracks_high_water) and the
sys_read comment that referenced wake_event_reader by name. ABI
snapshot byte-identical; cargo test 983/983.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds host/src/input/input-source.ts with the InputEvent record
(device 0|1, ev_type, code, value) and the InputSource interface
(start(dispatch), stop()) that subsequent commits implement per host:
BrowserInputSource captures DOM events (keyboard/pointer/wheel) and
translates to evdev codes; NodeInputSource is a null-source for
headless test runs.

Convention encoded in the doc-comment: the source emits the
type-specific record then a SYN_REPORT to close the logical frame,
mirroring Linux evdev. Host wires dispatch to
kernel.exports.kernel_input_event at boot, after
kernel.exports.kernel_set_input_canvas_dims.

Vitest is import-and-instantiate sanity only — a StubSource records
two dispatches, stop() clears the dispatch handle, post-stop emits
are dropped.

No kernel or ABI surface change; pure-additive host module with no
consumers until B4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_* translation

Adds host/src/input/browser-input-source.ts (DOM keyboard/pointer/wheel
capture, evdev translation, SYN_REPORT framing) and
host/src/input/key-code-table.ts (~130-entry KeyboardEvent.code →
KEY_* lookup mirroring shared::input KEY_* values — Linux UAPI
verbatim, same numeric space SDL2's evdev backend consumes on real
Linux).

Coordinate convention: pointer-lock active → REL_X/REL_Y deltas
(movementX/Y); inactive → ABS_X/ABS_Y absolute (offsetX/Y). A bare
SYN_REPORT on pointerlockchange gives libinput / SDL2 a re-sync point
so a stale axis value doesn't carry across the lock-mode transition.

Wheel normalisation handles browser deltaMode quanta: PIXEL (Chromium
±100/±120 per notch, Safari ±1–10), LINE (Firefox ±3 per notch). A
small-but-nonzero delta clamps to ±1 tick so continuous-trackpad
scrolls still emit at least one record (otherwise Math.trunc(0.3 /
120) = 0 swallows the entire scroll).

Vitest covers every translation path with 17 specs: keydown
down/repeat/unknown, keyup, pointermove ABS vs REL with mocked
pointerLockElement, single-axis movement skip, three pointer buttons,
wheel PIXEL/LINE/HWHEEL/small-delta/zero-delta, lock-change SYN, and
stop() listener removal. Uses a FakeTarget EventTarget stub and
vi.stubGlobal('document', …) — no jsdom/happy-dom dependency.

The unused canvas constructor parameter is intentionally stashed for
B4, which calls kernel_set_input_canvas_dims off it.

No kernel or ABI surface change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds host/src/input/node-input-source.ts implementing InputSource as a
pair of no-ops. There's no DOM in Node, and the integration tests
drive evdev events directly via kernel.exports.kernel_input_event(…)
instead of synthesising KeyboardEvent / PointerEvent. The Node host
still registers an InputSource at boot so the init path is symmetric
with the browser-side one (CLAUDE.md §"Two hosts" — dual-host parity
is load-bearing).

Vitest: start() registers but emits no records; start()/stop() are
safe to call repeatedly.

No kernel or ABI surface change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the host-side plumbing for evdev: both BrowserKernel and
NodeKernelHost gain injectInputEvent / setInputCanvasDims raw entry
points plus an attachInputSource(source, dims) helper that mirrors
the boot pattern across the two hosts. CLAUDE.md §"Two hosts —
DUAL-HOST PARITY IS LOAD-BEARING": every layer gets a parallel diff
in the same commit (protocol message type, host class method, worker
entry switch case, shared kernel-worker.ts wrapper, shared kernel.ts
export wrapper).

Three layers, symmetric on both sides:

  1. host/src/kernel.ts (WasmPosixKernel) — calls the new
     kernel_input_event / kernel_set_input_canvas_dims exports from
     A4 + A7. Silent-drop pattern if the kernel module isn't yet
     instantiated, same as injectMouseEvent.

  2. host/src/kernel-worker.ts (CentralizedKernelWorker) — wraps the
     calls and schedules a blocked-reader wake on injectInputEvent
     so processes parked in sys_read on /dev/input/event{0,1} get
     re-poked. Picks "extend pendingPipeReaders" for the wake
     mechanism (handoff-28's B4-open choice) by reusing
     scheduleWakeBlockedRetries — same path mice uses.

  3. host/src/{browser,node}-kernel-protocol.ts +
     host/src/{browser,node}-kernel-worker-entry.ts +
     host/src/{browser,node}-kernel-host.ts — main↔worker message
     plumbing and public host-class API. attachInputSource(source,
     dims) sets canvas dims then starts the source with a dispatch
     callback that funnels each emitted record through
     injectInputEvent. Both hosts share the same method shape so
     callers see identical surface; only constructor patterns for
     the InputSource differ (BrowserInputSource on the browser,
     NodeInputSource null-source on Node).

Vitest:
  - input-attach-source.test.ts: attachInputSource posts dims-msg
    once, calls source.start once, routes dispatched records through
    injectInputEvent → input_event_inject. setInputCanvasDims and
    injectInputEvent also tested standalone. Bypasses init() (which
    spawns a real worker_thread) by monkey-patching sendToWorker;
    the constructor only stores options, so a bare new
    NodeKernelHost() is safe.
  - Browser-side end-to-end exercised at Phase C via Playwright per
    plan §C; pure-logic translation already covered by
    browser-input-source.test.ts (B2, 17 specs).

Symmetry sweep verified: every new symbol
(input_event_inject / set_input_canvas_dims / injectInputEvent /
setInputCanvasDims / attachInputSource) shows parallel hits in
browser-* and node-* trees at every layer.

No Rust changes; no ABI surface change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B5 closes Phase B with an end-to-end gate for the evdev path.
`input-evdev-smoke.c` opens /dev/input/event0 + event1, does
EVIOCGNAME / EVIOCGABS, and drains the per-OFD rings. Each phase
gates on a stdin byte so the host injects events AFTER the OFD
exists — `kernel_input_event` fans out at push time and a
pre-open injection would land nowhere.

The vitest drives `NodeKernelHost.injectInputEvent` (B4) directly
and asserts: EVIOCGNAME returns "wpk virtual keyboard";
EVIOCGABS(ABS_X).maximum reports canvas_w-1; KEY_A↓+SYN_REPORT
round-trip with monotonic-non-decreasing CLOCK_MONOTONIC stamps;
REL_X=+5 + SYN_REPORT; and overflow drains to 1025 records (1
synthesised SYN_DROPPED at index 0, then 1024 surviving ring
records) with the last record still an EV_KEY/KEY_A — i.e. the
incoming records were the ones discarded, Linux semantics.

`it.skipIf(!fixtureBinary)` keeps the suite green when the wasm
fixture isn't built locally, matching the dri-kms-pageflip pattern.

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

Closes the evdev plan's Phase C (PR #3 scope) plus a build-script
regression that blocked the demo from compiling.

- `libc/musl-overlay/include/linux/input.h` +
  `libc/musl-overlay/include/linux/input-event-codes.h` — vendored
  minimal subset (C1). `programs/evdev_demo.c` is the lone consumer
  in tree; its `_Static_assert(sizeof(struct input_event) == 24)`
  is the compile-time guard against wasm32 layout drift.
- `programs/evdev_demo.c` (~100 LoC) — opens /dev/input/event{0,1},
  prints EVIOCGNAME for both, then polls forever and logs every key
  and pointer event. Free-running (no stdin-barrier harness) so it
  works as the interactive Kandelo pane (C2).
- `apps/browser-demos/pages/kandelo/presets.ts` +
  `kernel-host/live-setup.ts` — wire an `evdev` preset that stages
  the binary into /usr/local/bin, attaches a BrowserInputSource to
  window so DOM key/pointer events reach `kernel_input_event`, and
  runs the demo through bash so its stdout lands in the Shell pane.
  Boot path mirrors the existing `modeset` preset.
- `apps/browser-demos/test/kandelo-evdev.spec.ts` — Playwright spec
  that drives KeyA + pointer moves and asserts the on-canvas log
  contains `key down: code=30` and `ptr (abs|rel) code=N value=N`.
  Proves the B4 dual-host parity claim end-to-end in a real browser.
- `scripts/build-musl.sh` steps 10-11 — restores libdrm.a + libgbm.a
  compile-and-archive steps that were lost when commit b25ef59
  ported the DRI sources forward but missed the build wiring from
  commits a091a5b and 2f7fead. Without these, programs/dri-modeset
  (and friends) fail to link with `no such file or directory: libgbm.a`.

Manual browser verification (CLAUDE.md item 6 / C4) passes: keystrokes
and pointer movement appear in the on-canvas log under
`./run.sh browser` → `?demo=evdev`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First pass dropped the unused `canvas` ctor arg on BrowserInputSource and
trimmed top-of-file what-narration in input.h / evdev_demo.c /
live-setup.ts / input/mod.rs.

Second pass strips the param-table doc on `kernel_input_event` +
`kernel_set_input_canvas_dims`, collapses the per-case EVIOCG* headers
and ioctl-decode preamble in `handle_input_ioctl`, tightens the
SYN_DROPPED / blocking-read narration in the evdev read drain and poll
gate, collapses the dispatch.rs file-doc, drops the three-phase prose
and ioctl-encoding paragraph from `input-evdev-smoke.c`, and trims
test-internal narration that restates test names across `syscalls.rs`,
`ofd.rs`, `input/mod.rs`, and `devfs.rs`.

Kept the load-bearing WHYs: ENOTTY-not-EINVAL for SDL2 probe walking,
SYN_DROPPED resync after overflow, blocking-read-returns-0 retry
contract, concurrency-justification for tests that mutate
canvas-dim globals.

Net across both passes: -184 LoC (12 files).

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

# Conflicts:
#	apps/browser-demos/pages/kandelo/panes/Display.tsx
#	apps/browser-demos/pages/kandelo/views/MachineView.tsx
#	crates/kernel/src/syscalls.rs
#	host/src/kernel-worker.ts
Wire a Kandelo ALSA subsystem (devfs nodes, PCM ioctl dispatch, SAB ring,
mmap pages, tick, poll, fork serialisation) and a host audio stack
(`AudioDriver`, Browser + Node drivers, AudioWorklet, drain-on-stop,
instrumented wrapper) on both Node and browser hosts. Port espeak-ng
with a vendored pcaudiolib `audio_kandelo` backend, bake the binary and
English data dir into `shell.vfs.zst`, and expose a kandelo
"ALSA – espeak-ng" demo preset with a Playwright spec. ABI delta is
additive (three new exports, two new virtual devices) and does not
bump `ABI_VERSION`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mho22 mho22 changed the base branch from main to explore-direct-rendering-infrastructure June 15, 2026 13:06
…ructure' into explore-dri-evdev-and-alsa

# Conflicts:
#	apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
@github-actions

Copy link
Copy Markdown

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

ABI v15. 62 built, 4 failed, 66 total.

Package Arch Status Sha
libcurl wasm32 built 05aae726
libcxx wasm32 built 7924fbe0
libcxx wasm64 built cfe94678
libpng wasm32 built e11caae0
libxml2 wasm32 built 6db06345
libxml2 wasm64 built bfabb4ff
openssl wasm32 built 2708b58d
openssl wasm64 built a1be8942
sqlite wasm32 built 0affae3a
sqlite wasm64 built 2638b6ad
zlib wasm32 built a74da2ed
zlib wasm64 built 76101599
bc wasm32 built 46973bfb
bzip2 wasm32 built ffa5f4c4
coreutils wasm32 built 4d8239d1
curl wasm32 built dca90723
dash wasm32 built ad59e65d
diffutils wasm32 built d67cb386
dinit wasm32 built 6780ca3d
fbdoom wasm32 built a4dcc46f
file wasm32 built d2312c00
findutils wasm32 built 3767b4e1
gawk wasm32 built faedad8e
git wasm32 built f50d2974
grep wasm32 built b9b6b1e6
gzip wasm32 built 539fee04
kandelo-sdk wasm32 built bdddb4fe
kernel wasm32 built 51062af5
less wasm32 built 5180eac9
lsof wasm32 built 96fa5be8
m4 wasm32 built 0aa7ce01
make wasm32 built 5cdd9bd8
mariadb wasm32 built 4c185e36
mariadb wasm64 built ec3ea5f8
msmtpd wasm32 built aa3de664
nano wasm32 built 4a52b067
ncurses wasm32 built ff1ca5a3
netcat wasm32 built 20b49fdf
nginx wasm32 built 5c15a366
php wasm32 built f1bfba4b
posix-utils-lite wasm32 built df2ce6e9
sed wasm32 built 80e62002
spidermonkey wasm32 built 0b3b3066
tar wasm32 built 0532dd66
tcl wasm32 built eba6fe94
unzip wasm32 built b4f32850
userspace wasm32 built 1d4c6a6f
vim wasm32 built 9895fd65
wget wasm32 built 6c82e2c4
xz wasm32 built 7bf9b496
zip wasm32 built 261d8148
zstd wasm32 built 0c16fdb0
bash wasm32 built 15fc009b
mariadb-test wasm32 built 884a67b1
mariadb-vfs wasm32 built dd1e248f
mariadb-vfs wasm64 built f3286762
nethack wasm32 built 73993e86
node wasm32 built 2e1b58a3
spidermonkey-node wasm32 built 22cfbf7c
vim-browser-bundle wasm32 built 0deeb2d2
nethack-browser-bundle wasm32 built 8561a152
rootfs wasm32 built 74c566e1
shell wasm32 failed
lamp wasm32 failed
node-vfs wasm32 failed
wordpress wasm32 failed

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

Base automatically changed from explore-direct-rendering-infrastructure to main June 16, 2026 15:18
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.

2 participants