Skip to content

Evdev on Kandelo - /dev/input keyboard and pointer, plus an espeak-ng speech demo - #698

Open
mho22 wants to merge 26 commits into
mainfrom
explore-dri-evdev-and-alsa
Open

Evdev on Kandelo - /dev/input keyboard and pointer, plus an espeak-ng speech demo#698
mho22 wants to merge 26 commits into
mainfrom
explore-dri-evdev-and-alsa

Conversation

@mho22

@mho22 mho22 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Why

Kandelo had no input devices: nothing existed under /dev/input, so evdev consumers — SDL2 (#709), Wayland (#948), Hyprland (#976) up the stack — had no way to read a keyboard or a pointer. This PR is the base layer of the five-PR DRI stack (#698#709#948#976#1241). It adds a kernel evdev subsystem fed by host input sources on both hosts, and proves the audio path end-to-end by porting espeak-ng against main's existing OSS /dev/dsp device.

Earlier revisions of this PR added a kernel ALSA subsystem instead. The review recommended against carrying a narrow subset of the ALSA contract under standard ALSA names, and main's /dev/dsp already covers the PCM needs — the ALSA surface is gone from this branch (see Notes).

What changed

  • kernel evdev subsystem (crates/kernel/src/input/): /dev/input/event0 (keyboard) and /dev/input/event1 (pointer) devfs nodes with per-OFD InputFdState; EVIOCG* ioctl dispatch, with the length-encoded EVIOC ioctls resolved through a family table; a kernel_input_event export that fans events out to every open fd, with ring overflow handled by SYN_DROPPED resync; sys_read drains the ring and sys_poll gates POLLIN; state is released on close and serialised across fork/exec
  • shared ABI: struct input_event plus EV_*/KEY_*/BTN_*/REL_*/ABS_*/EVIOC* constants; linux/input.h and linux/input-event-codes.h vendored into the musl overlay; abi/snapshot.json, host/src/generated/abi.ts, and libc/glue/abi_constants.h regenerated — additive-only, no ABI_VERSION bump
  • host input, both hosts: an InputSource interface; BrowserInputSource captures DOM keyboard/pointer events and translates them through a key-code table; NodeInputSource is the headless null source so vitest exercises the same path; ingress routes through the kernel entry gate
  • espeak-ng port (packages/registry/espeak-ng/): cross-build recipe with a cmake toolchain file, linked against upstream pcaudiolib 1.3 built with only its OSS backend — that backend opens /dev/dsp, so no pcaudiolib source is patched and no kernel audio code is added; the English-only voice data ships as a resolver-published runtime file; the image builder stages /usr/bin/espeak-ng, /usr/share/espeak-ng-data, and /usr/local/bin/evdev_demo at image-compose time
  • demos: kandelo presets "Evdev input log" and "OSS - Espeak-NG" with Playwright specs kandelo-evdev.spec.ts / kandelo-espeak.spec.ts; programs/evdev_demo.c and programs/input-evdev-smoke.c; an evdev-demo package owning evdev_demo.wasm

Notes

  • The kernel ALSA surface from earlier revisions (/dev/snd/{pcmC0D0p,controlC0}, the mmap ring, the three kernel_audio_* exports) is removed in cfa1d51. main's audio.rs already answers the OSS quality checklist (blocking write, EAGAIN, GETOSPACE, audio-clock-driven consumption), and this branch's copy was byte-identical to it. The net kernel audio delta vs main is zero.
  • host/src/audio/ is also unchanged vs main — the PCM drivers the demo plays through (BrowserPcmDriver, NodePcmDriver) already landed there.

Both hosts

Browser: BrowserInputSource, wired through browser-kernel-host.ts, browser-kernel-protocol.ts, browser-kernel-worker-entry.ts. Node: NodeInputSource, wired through the node counterparts in the same commits. Shared: kernel-worker.ts carries the input fan-in for both. Vitest covers the Node path end-to-end (input-evdev.test.ts: key + pointer + ring overflow) and the browser translation layer (browser-input-source.test.ts); the Playwright specs cover the browser path.

Verification

The stack was verified at its tip 272be070a (explore-dri-omarchy), which contains all 26 commits here:

  • cargo test -p kandelo --target aarch64-apple-darwin --lib → 1665 pass, 0 fail
  • host typecheck clean; all 48 stack vitest gate files pass, including the seven input/ioctl test files this PR adds
  • scripts/run-libc-tests.sh → 0 unexpected failures
  • scripts/run-posix-tests.sh → FAIL: 0
  • bash scripts/check-abi-version.sh → consistent; this layer is additive at ABI 43, the wayland layer up-stack bumps to 44
  • browser-demo verification of the espeak and evdev presets on the rebased branch is pending: it needs a shell.vfs.zst built for the stack's ABI, which is not yet published. The Playwright specs are in the tree but have not run against this rebase.

@mho22
mho22 changed the base branch from main to explore-direct-rendering-infrastructure June 15, 2026 13:06
@github-actions

Copy link
Copy Markdown
Contributor

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

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review

This feedback is from Codex (OpenAI), reviewing at Brandon's request.

Summary

I do not recommend adopting this kernel ALSA surface as Kandelo's sound API for the stated SDL goal. The browser AudioWorklet, shared-ring transport, dual-host AudioDriver abstraction, and end-to-end eSpeak demonstration are useful engineering work. However, the demo proves a Kandelo-specific pcaudiolib backend and the host playback path; it does not demonstrate compatibility with SDL's ALSA backend, alsa-lib, or ordinary ALSA applications.

ALSA is a large Linux kernel/userspace contract, not simply a PCM node plus several ioctl numbers. Implementing /dev/snd/* usefully requires the control plane, device/card enumeration, hardware and software parameter negotiation, state and XRUN behavior, poll semantics, read/write and mmap paths, and the ABI details expected by alsa-lib. Carrying a small custom subset under ALSA names risks freezing an interface that looks compatible but is not.

For Kandelo's immediate objective, I recommend a smaller implementation-neutral PCM core with a correct OSS-compatible /dev/dsp frontend.

Completeness concerns

  1. This does not establish SDL ALSA compatibility. SDL calls a broad libasound API; it does not normally issue these raw kernel ioctls itself. This PR does not port or test alsa-lib, and eSpeak uses a new Kandelo-specific pcaudiolib backend instead. The resulting demo is valuable, but it should not be treated as evidence that SDL or other ALSA software will run unmodified.

  2. The control device is intentionally a stub. The PR description says /dev/snd/controlC0 opens but all ioctls fail. A node that passes an existence probe but cannot enumerate or describe a card is not a complete ALSA control implementation and can cause consumers to fail later in less obvious ways.

  3. The advertised PCM ABI is only a narrow subset. Capture is absent, the full control/device discovery contract is absent, and compatibility with real alsa-lib read/write and mmap behavior has not been demonstrated. Unsupported functionality should not be advertised under standard ALSA device names unless the supported conformance boundary is explicit and tested.

  4. The proof is application-specific. The custom eSpeak backend is coupled to exactly the implemented subset, so it cannot reveal missing behavior that a general ALSA client would exercise. At minimum, a claim of ALSA compatibility would require an unmodified alsa-lib plus representative clients and negative/state-transition tests.

  5. Required verification is incomplete. The PR says the musl libc-test and Open POSIX Test Suite were not run. Repository policy requires both before completion, even for an additive change. The full Vitest suite also needs to run, not only selected audio and package-system files.

  6. The size cost is not isolated. This PR is merged with large DRI/input work, so its source diff and final kernel artifact do not show the incremental cost of ALSA. Before choosing this API, measure the audio-only delta in raw and compressed kernel Wasm, host/worklet JavaScript, steady-state ring memory, and userspace artifacts such as libraries and test/demo binaries.

Direction Brandon is exploring

Brandon is exploring deeper /dev/dsp support on a clean branch, with the goal of satisfying SDL without implementing the Linux ALSA kernel ABI.

There are strong reasons for that direction:

  • Upstream SDL2 and SDL3 already contain an OSS dsp backend. SDL2 primarily relies on blocking write() for pacing; SDL3 additionally uses SNDCTL_DSP_GETOSPACE.
  • Kandelo already has /dev/dsp, its SDK header, and an existing non-SDL consumer, so this is an incremental compatibility improvement rather than a second sound ABI.
  • FreeBSD's native PCM framework exposes an OSS-compatible /dev/dsp API rather than kernel ALSA. FreeBSD supplies ALSA compatibility in userspace over OSS, which is also a viable later path for Kandelo if Linux-specific applications become important.
  • POSIX does not standardize audio. A small Unix device extension does not make Kandelo less POSIX, while ALSA does not make it more POSIX.
  • SDL performs application-side conversion and resampling, so the kernel does not need ALSA's full negotiation and plugin model merely to provide useful playback.

The /dev/dsp work should be source/API compatible rather than claiming host binary compatibility. Kandelo should own and ABI-snapshot its wasm32 ioctl values and fixed-width structures such as audio_buf_info.

For SDL-quality playback, the current /dev/dsp implementation still needs real backpressure: blocking writes, O_NONBLOCK/EAGAIN, truthful POLLOUT with wakeups, GETOSPACE, actual drain/reset behavior, audio-clock-driven consumption, and deterministic open-file-description ownership. It must stop dropping the oldest queued audio when full. Playback-only is a reasonable first boundary, but capture opens should then fail rather than advertise a recording device that returns EOF.

Suggested disposition

I would treat this PR as a valuable audio-transport exploration and reuse the sound parts selectively:

  • retain the AudioDriver separation and dual-host wiring;
  • retain the AudioWorklet-driven browser consumption concept;
  • retain deterministic Node pacing and host instrumentation where useful;
  • move those pieces behind a generic PCM stream rather than ALSA state/mmap structures;
  • do not merge /dev/snd and the partial ALSA UAPI as Kandelo's public sound contract solely for SDL.

If broad compatibility with unmodified Linux ALSA applications later becomes an explicit product requirement, evaluate a userspace alsa-lib OSS/native PCM adapter first. A kernel ALSA implementation should only be undertaken with a defined compatibility target and conformance suite.

Codex (OpenAI), at Brandon's request

mho22 and others added 24 commits August 19, 2026 15:42
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>
…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>
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>
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>
Five call sites the 647-commit rebase left dangling.

`with_processes` came from a DRI commit main already carries through
#678, so replaying it was a no-op — but the evdev fan-out and the audio
period tick both call it. Restored on its own.

`OpenFileDesc`'s SCM_RIGHTS construction site is main's; it never
carried the three sidecar fields. The two test helpers passed a pid to
`create_process`, which main now allocates itself, so they insert the
process at their chosen pid directly.

`/dev/snd/pcmC0D0c` reported ENOENT instead of ENODEV. It is
deliberately absent from the synthetic tree, and main's path resolver
now rejects a missing node before `sys_open` reaches the
disabled-device check. The resolver's ENOENT is translated back.

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

Codex's review of #698 recommended against adopting `/dev/snd` as
Kandelo's sound API: ALSA is a large kernel/userspace contract, this
was a narrow subset of it, and carrying it under standard ALSA names
freezes an interface that looks compatible but is not. The reviewer's
alternative — a correct OSS-compatible `/dev/dsp` — is already on main.

`crates/kernel/src/audio/oss.rs` was byte-identical to main's
`crates/kernel/src/audio.rs`; this branch only renamed it. Main's
version already answers every item on the review's SDL-quality list:
blocking `write`, `set_nonblock`/EAGAIN, `poll_writable`,
`output_space` (GETOSPACE), `output_delay`, `output_pointer`, `sync`,
`reset_stream`, `claim_transport`/`clock_update` for audio-clock-driven
consumption, and `open_stream`/`preflight_close` for deterministic OFD
ownership. So the file moves back and the ALSA siblings go.

Removed: `audio/{pcm_ioctl,mmap,sab,tick,wait}.rs`, the `/dev/snd`
devfs directory with `controlC0` + `pcmC0D0p`, the `AlsaPcm` /
`AlsaControl` virtual devices, the `pcmC0D0c` disabled-node path, the
`audio` / `audio_ctl` OFD sidecars and their fork/exec serialisation,
the `kernel_audio_{init_sab,period_tick,get_appl_ptr}` exports, the
`shared::audio` ALSA ABI, and `sound/asound.h`.

Host side loses the ALSA-shaped driver — `audio/{audio-driver,
browser-audio-driver,node-audio-driver,instrumented-audio-driver}.ts`
and `wpk-audio-worklet.js` — plus `attachAudioDriver` and the three
ring/tick/appl-ptr protocol messages on both hosts. Main's PCM stack
(`audio/{pcm-driver,browser-pcm-driver,node-pcm-driver,pcm-transport}.ts`
+ `pcm-audio-worklet.js`) already serves `/dev/dsp` and stays. The
espeak demo now rides that path instead of attaching its own driver.

Kernel: 1635 tests pass. Shared: 53 pass.

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

`IOCTL_REQUESTS` is keyed by exact request number, which the evdev
surface does not fit. `EVIOCGNAME(len)` and `EVIOCGBIT(ev, len)` encode
a caller-chosen buffer length in the request itself, so every length is
a distinct number; `EVIOCGABS(axis)` keeps one structure across 64 axes.
None of the three can be enumerated.

`IOCTL_REQUEST_FAMILIES` names the `nr` ranges instead. A family fixes
either the exact structure size (`EVIOCGABS`, 24 bytes) or a bound on
the caller's length (256 bytes, wide enough for the `EV_KEY` bitmap),
and a request whose encoded size falls outside stays unresolved rather
than staging a wrong buffer. `request_contract` consults the sorted
table first and the families second.

`host/src/ioctl-contract.ts` mirrors that order for both hosts, so the
kernel and the two host runtimes agree on the staged byte count. The
table itself is generated into `host/src/generated/abi.ts` by
`dump_abi`, alongside a snapshot section.

`EVIOCGVERSION`, `EVIOCGID` and `EVIOCGRAB` have fixed numbers and join
the sorted table directly.

ABI: additive only. A request absent from the table resolved to
"unknown" before, so adding one cannot change how an older program
marshals a call it already made; `classify_compat_change` treats added
`ioctl_request_contracts` entries and the new `ioctl_request_families`
section as compatible. Changing or removing an entry stays breaking.
`ABI_VERSION` stays 43.

Shared: 60 pass. xtask: 787 pass. Host: 7 new cases in
`host/test/ioctl-contract.test.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`injectInputEvent` and `setInputCanvasDims` called into the kernel
instance directly. Both are host ingress: the browser pushes a key or
pointer record whenever the DOM fires, with no regard for whether the
kernel is mid-export. Calling straight through re-enters the kernel and
trips `KernelReentrantEntryError`, or corrupts state that an in-flight
export owns.

`injectMouseEvent` already solved this — it runs under
`#runOrDeferKernelEntry`, which executes immediately when the gate is
open and queues the call when it is not. The two evdev entry points now
do the same, and the record wakes blocked readers through the same
`scheduleWakeBlockedRetries(entry)` the mouse path uses.

`WasmPosixKernel` carried its own copy of both methods. Nothing called
them: `CentralizedKernelWorker` reaches the exports itself, the same
shape `injectMouseEvent` has on main. Deleted rather than gated.

The new test proves both calls defer out of a detached host callback
and then run in order. `kernel_input_event` and
`kernel_set_input_canvas_dims` join the test instance's export
signatures so the fixture builds a genuine Wasm function for each.

Host: 6 pass in `kernel-clone-exit-entry.test.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`432d4c4d1` brought forward `programs/cube.c`, `programs/dri_paint.c`
and `host/test/webgl-foreign-texture.test.ts` from the pre-rebase
branch. Main carries the DRI/WebGL surface these were written against,
so all three arrived as additions with no consumer.

`scripts/build-programs.sh` globs `programs/*.c`, so both sources were
compiled on every build and neither binary is named by any demo, test
or image. Only two plan documents mention them, as history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`scripts/browser-binary-package-roots.mjs` requires every `@binaries`
import in the browser app to name a registry package, and the audit at
`tests/package-system/browser-binary-dependencies.test.ts` enforces it.
`live-setup.ts` reaches `evdev_demo.wasm` through `import.meta.glob`,
and nothing owned it.

`packages/registry/evdev-demo/` follows `packages/registry/modeset/`,
the precedent for a `programs/*.c` binary that drives a Kandelo demo
pane: the source stays in `programs/`, and the package builds it. The
output name is `evdev_demo`, which keeps the flat mirror path
`programs/wasm32/evdev_demo.wasm` that `live-setup.ts` already names,
so no consumer changed. The package name needs the hyphen — the
package-name regex in `browser-binary-package-roots.mjs` rejects the
underscore.

`build-programs.sh` now skips the source, exactly as it skips
`modeset.c`: once a package owns a mirror path, the resolver publishes
a generation-backed symlink there and the compiler must not write to it.

`docs/posix-status.md` claimed there was no `evdev`. It now carries a
`/dev/input/event{0,1}` row written from `handle_input_ioctl` and the
1024-record per-OFD ring.

No package test: `packages/registry/modeset/` has none either, and the
generic audits under `tests/package-system/` already cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image builder read the voice data from
`packages/registry/espeak-ng/espeak-ng-install/share/espeak-ng-data`,
a build-tree path that exists on no fresh checkout and no CI runner.
`docs/package-management.md` names that exact pattern as forbidden:
repo-side builders query the resolver, they do not scan build caches.

The data is now a closure member, following `packages/registry/cpython`.
`build-espeak-ng.sh` packs the tree into `espeak-ng-data.zip` —
stored-only, sorted, fixed timestamp and mode, so the archive bytes
follow the voice data alone and the cache key stays stable — and
publishes it with `install_local_runtime_file`. Verified byte-identical
across two builds from the same tree: 251 entries, 919 KB.

Adding a runtime file makes espeak-ng multi-member, so the resolver
moves its whole closure under the package directory:
`espeak-ng/espeak-ng.wasm` and `espeak-ng/espeak-ng-data.zip`.
`build.toml` revision goes 2 -> 3, which invalidates every cached
archive and makes CI rebuild it.

The branch had also added `install_local_binary shell` to
`images/vfs/scripts/build-shell-vfs-image.sh`, overwriting the
resolver-owned `shell` mirror with a foreign image and failing the
ownership audit. That script and its `.ts` sibling are both on main's
retirement list and `./run.sh browser` never runs them — the shell
image comes from `packages/registry/shell/build-shell.sh`. Both files
go back to main and the test asserting the added line goes with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mho22 and others added 2 commits August 19, 2026 15:42
Neither demo could run. Both staged their binary after boot through
`kernel.fs`, and `BrowserKernel` has no `fs` member — the kernel worker
owns the VFS exclusively and the main thread holds no VFS
SharedArrayBuffer. `live-setup.ts` says so itself: post-boot
main-thread staging was removed, and a binary has to be part of the
image bytes. `tsc` surfaced it; no test did.

Both now stage while the image is composed, in the same block that
already writes `profile.init.programUrl`. `stageEspeakRuntime` fetches
both espeak-ng closure members, writes `/usr/bin/espeak-ng`, and
unpacks the zip into `/usr/share/espeak-ng-data` — the tree must be
unpacked because libespeak-ng's `PATH_ESPEAK_DATA` is fixed to
`/usr/share` by `CMAKE_INSTALL_PREFIX=/usr` at build time.
`stageEvdevDemo` writes `/usr/local/bin/evdev_demo`. The post-boot
blocks now only run the command; the evdev one still attaches
`BrowserInputSource` first, because the binary polls as soon as it
starts.

A second copy of the `ensureServiceWorkerReady` import made the whole
file unparseable — Babel raised `Identifier 'ensureServiceWorkerReady'
has already been declared`, which failed five tests and masked the
package-ownership defects behind them. The added block goes; its other
specifier had no use anywhere in the file.

`optionalBinaryUrl` told the reader to run `./run.sh build programs`.
That is now wrong for package-owned binaries, which the script
deliberately skips, so the message names the resolver too.

The espeak spec dropped its `__alsaFramesConsumed` assertion with the
counter itself. It now clicks for a trusted gesture and waits for
`data-audio-state="running"`, the same signal the doom demo's spec
uses; pcaudiolib aborts when it cannot open `/dev/dsp`, so reaching the
prompt with a running sink proves the OSS backend negotiated the device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Derived from the two package changes ahead of it: the new `evdev-demo`
recipe, and espeak-ng becoming multi-member, which moves its closure
under the package directory as `espeak-ng/espeak-ng.wasm` and
`espeak-ng/espeak-ng-data.zip`.

Editing anything a package lists in `build.toml` `inputs` invalidates
this file, and every `resolveBinary()` then throws `Program package
source projection is not current`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mho22
mho22 force-pushed the explore-dri-evdev-and-alsa branch from d1b1156 to e439b86 Compare August 21, 2026 10:02
@mho22 mho22 changed the title Add ALSA audio backend and espeak-ng browser demo Evdev on Kandelo - /dev/input keyboard and pointer, plus an espeak-ng speech demo Aug 21, 2026
@mho22
mho22 marked this pull request as ready for review August 21, 2026 14:15
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