From ec102cb88aba36298e938468fd6c5dd1aeb1c7e8 Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 8 Jun 2026 16:33:23 +0200 Subject: [PATCH 01/27] dri+wpk: bring forward DRI/WebGL surface against kandelo:main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/test/webgl-foreign-texture.test.ts | 67 +++++ programs/cube.c | 364 ++++++++++++++++++++++++ programs/dri_paint.c | 161 +++++++++++ 3 files changed, 592 insertions(+) create mode 100644 host/test/webgl-foreign-texture.test.ts create mode 100644 programs/cube.c create mode 100644 programs/dri_paint.c diff --git a/host/test/webgl-foreign-texture.test.ts b/host/test/webgl-foreign-texture.test.ts new file mode 100644 index 0000000000..3f751d5421 --- /dev/null +++ b/host/test/webgl-foreign-texture.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { ForeignTextureRegistry } from "../src/webgl/registry.js"; + +class FakeGl { + TEXTURE_2D = 0x0de1; + RGBA = 0x1908; + UNSIGNED_BYTE = 0x1401; + + created = 0; + deleted: object[] = []; + texImage2DArgs: unknown[][] = []; + + createTexture(): object { return { id: ++this.created }; } + bindTexture(_t: number, _tex: object): void {} + texImage2D(...a: unknown[]): void { this.texImage2DArgs.push(a); } + deleteTexture(t: object): void { this.deleted.push(t); } +} + +const asGl = (g: FakeGl) => g as unknown as WebGL2RenderingContext; + +describe("ForeignTextureRegistry", () => { + it("allocate creates one texture sized w×h", () => { + const reg = new ForeignTextureRegistry(); + const gl = new FakeGl(); + reg.allocate(42, 64, 32, asGl(gl)); + expect(gl.created).toBe(1); + const [, , , w, h] = gl.texImage2DArgs[0]; + expect(w).toBe(64); + expect(h).toBe(32); + }); + + it("bind on unknown bo returns -1", () => { + const reg = new ForeignTextureRegistry(); + expect(reg.bind(99, 1)).toBe(-1); + }); + + it("two ctx_ids resolve back to the same WebGLTexture", () => { + const reg = new ForeignTextureRegistry(); + const gl = new FakeGl(); + reg.allocate(7, 16, 16, asGl(gl)); + const id_a = reg.bind(7, 100); + const id_b = reg.bind(7, 200); + expect(id_a).toBeGreaterThan(0); + expect(id_b).toBeGreaterThan(0); + expect(gl.created).toBe(1); + expect(reg.resolve(100, id_a)).toBe(reg.resolve(200, id_b)); + }); + + it("synthetic ids are independent per ctx_id", () => { + const reg = new ForeignTextureRegistry(); + const gl = new FakeGl(); + reg.allocate(1, 4, 4, asGl(gl)); + reg.allocate(2, 4, 4, asGl(gl)); + expect(reg.bind(1, 50)).toBe(1); + expect(reg.bind(2, 50)).toBe(2); + expect(reg.bind(1, 51)).toBe(1); + }); + + it("free deletes the texture and drops the entry", () => { + const reg = new ForeignTextureRegistry(); + const gl = new FakeGl(); + reg.allocate(5, 8, 8, asGl(gl)); + reg.free(5, asGl(gl)); + expect(gl.deleted.length).toBe(1); + expect(reg.bind(5, 1)).toBe(-1); + }); +}); diff --git a/programs/cube.c b/programs/cube.c new file mode 100644 index 0000000000..114ed789cd --- /dev/null +++ b/programs/cube.c @@ -0,0 +1,364 @@ +/* + * Spinning colored cube on wasm-posix-kernel — fork(2)+pipe(2) two-process demo. + * + * Architecture: + * + * parent ── pipe[0] ── read frames ── upload VBO ── glDrawArrays + * │ │ + * └── fork(2) ────────────────────────┐ │ + * ▼ ▼ + * child ── compute rotation matrix ── project 3D ── write pipe[1] + * + * The parent owns the GLES2 context (eglInitialize → eglMakeCurrent), so + * forking after EGL setup would clone the cmdbuf fd and confuse the + * host registry (one canvas, two cmdbufs). We fork *before* any GL + * call, then only the parent enters the GL path. + * + * Per-frame: child computes a tumbling rotation from clock_gettime, + * applies it + a perspective projection to the 8 cube vertices, + * expands to 36 triangle vertices (6 faces × 2 tris × 3 verts), and + * writes one frame's worth of (x,y,z,r,g,b) floats to the pipe. Frame + * size is 36 * 24 = 864 bytes — well under PIPE_BUF, so each write is + * atomic and the parent reads exactly one frame per render. + * + * The vertex shader is a pass-through: projection is already done CPU-side + * because the GLES2 stub doesn't carry uniforms. Depth testing in the + * GPU does the occlusion (24-bit depth requested in the EGL config). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CANVAS_W 768 +#define CANVAS_H 768 +#define VERTS 36 +#define VERT_SZ (6 * sizeof(float)) /* x,y,z,r,g,b */ +#define FRAME_SZ (VERTS * VERT_SZ) /* 864 bytes — atomic over pipe */ +#define FRAME_USEC 16000 /* ~60 Hz */ + +/* The 8 corners of a unit cube centred on the origin. */ +static const float cube_v[8][3] = { + {-1, -1, -1}, { 1, -1, -1}, { 1, 1, -1}, {-1, 1, -1}, + {-1, -1, 1}, { 1, -1, 1}, { 1, 1, 1}, {-1, 1, 1}, +}; + +/* 6 faces, each two triangles, indexing into cube_v. + * Order chosen so the outward normal points away from the centre when + * traversed counter-clockwise — matters for any future face culling but + * not for the depth-test path used here. */ +static const int faces[6][6] = { + {0,1,2, 0,2,3}, /* -Z */ + {4,6,5, 4,7,6}, /* +Z */ + {0,4,5, 0,5,1}, /* -Y */ + {3,2,6, 3,6,7}, /* +Y */ + {0,3,7, 0,7,4}, /* -X */ + {1,5,6, 1,6,2}, /* +X */ +}; + +/* Classic 6-color cube palette (red, green, blue, yellow, cyan, magenta). */ +static const float face_col[6][3] = { + {1.00, 0.20, 0.20}, + {0.20, 0.85, 0.30}, + {0.25, 0.45, 1.00}, + {1.00, 0.85, 0.20}, + {0.20, 0.85, 0.95}, + {0.95, 0.30, 0.85}, +}; + +static double monotonic_seconds(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; +} + +/* SIGUSR1 toggles the paused flag in the *child*. The browser's + * Stop/Resume buttons send the signal directly to the child pid + * (parsed from the parent's stdout banner) via kernel.sendSignal. + * + * Why the child and not the parent: the child parks in usleep(2) + * between frames (pendingSleeps in centralized mode), which + * sendSignalToProcess wakes via completeSleepWithSignalCheck — the + * EINTR path delivers the signal cleanly and the user-space handler + * runs. The parent parks in read(2) on the pipe (pendingPipeReaders), + * which sendSignalToProcess does NOT wake, so signalling it would + * leave the signal queued indefinitely. */ +static volatile sig_atomic_t paused = 0; + +static void on_pause_toggle(int sig) { + (void)sig; + paused = !paused; +} + +/* ──────────────────────────────────────────────────────────────────── + * Child process: simulate rotation, project to clip space, ship frame. + * ──────────────────────────────────────────────────────────────────── */ + +/* 3x3 rotation: rotate `v` around X by ax, then around Y by ay. */ +static void rotate(const float v[3], float ax, float ay, float out[3]) { + float cx = cosf(ax), sx = sinf(ax); + float cy = cosf(ay), sy = sinf(ay); + /* Rx then Ry: out = Ry · Rx · v. */ + float y1 = cx * v[1] - sx * v[2]; + float z1 = sx * v[1] + cx * v[2]; + float x2 = cy * v[0] + sy * z1; + float z2 = -sy * v[0] + cy * z1; + out[0] = x2; + out[1] = y1; + out[2] = z2; +} + +/* Pull the cube back from the camera and apply a simple perspective: + * x' = x * f / (z + d), y' = y * f / (z + d). z' encodes the post-translate + * depth in [-1, 1]ish — only the relative ordering matters for the + * GPU depth test. */ +static void project(const float v[3], float out[3]) { + const float dist = 4.0f; + const float focal = 1.1f; + float zc = v[2] + dist; + if (zc < 0.1f) zc = 0.1f; + out[0] = v[0] * focal / zc; + out[1] = v[1] * focal / zc; + /* Map z roughly into clip space: closer = smaller (renders in front + * with default GL_LESS depth func). The constants are picked so all + * 8 corners stay in the [-0.95, 0.95] range. */ + out[2] = (zc - dist) * 0.25f; +} + +static void child_loop(int write_fd) { + /* Parent died → write returns EPIPE → exit cleanly without a signal. */ + signal(SIGPIPE, SIG_IGN); + /* SIGUSR1 (browser Stop/Resume) flips `paused` and interrupts usleep. */ + signal(SIGUSR1, on_pause_toggle); + + float frame[VERTS * 6]; + /* Pause-aware clock: `t0` is the monotonic instant the cube would + * have started at if it had been running continuously. While + * paused we keep advancing it forward so the un-pause picks up at + * the same angle the pause hit. */ + double t0 = monotonic_seconds(); + double pause_started = 0; + + for (;;) { + if (paused) { + if (pause_started == 0) pause_started = monotonic_seconds(); + usleep(FRAME_USEC); + continue; + } + if (pause_started != 0) { + t0 += monotonic_seconds() - pause_started; + pause_started = 0; + } + double t = monotonic_seconds() - t0; + float ax = (float)(t * 0.7); + float ay = (float)(t * 0.9); + + /* Transform all 8 cube corners once per frame. */ + float xv[8][3]; + for (int i = 0; i < 8; i++) { + float r[3]; + rotate(cube_v[i], ax, ay, r); + project(r, xv[i]); + } + + /* Expand to 36 triangle vertices, attaching the face colour. */ + float *p = frame; + for (int f = 0; f < 6; f++) { + const int *idx = faces[f]; + for (int j = 0; j < 6; j++) { + const float *v = xv[idx[j]]; + *p++ = v[0]; *p++ = v[1]; *p++ = v[2]; + *p++ = face_col[f][0]; + *p++ = face_col[f][1]; + *p++ = face_col[f][2]; + } + } + + const char *buf = (const char *)frame; + size_t left = FRAME_SZ; + while (left > 0) { + ssize_t w = write(write_fd, buf, left); + if (w < 0) { + if (errno == EINTR) continue; + _exit(0); /* parent gone — quietly exit */ + } + buf += w; + left -= (size_t)w; + } + + usleep(FRAME_USEC); + } +} + +/* ──────────────────────────────────────────────────────────────────── + * Parent process: GLES2 setup, per-frame VBO upload, draw, present. + * ──────────────────────────────────────────────────────────────────── */ + +static const char vs_src[] = + "attribute vec3 a_pos;\n" + "attribute vec3 a_col;\n" + "varying vec3 v_col;\n" + "void main() { gl_Position = vec4(a_pos, 1.0); v_col = a_col; }\n"; + +static const char fs_src[] = + "precision mediump float;\n" + "varying vec3 v_col;\n" + "void main() { gl_FragColor = vec4(v_col, 1.0); }\n"; + +/* Read exactly `n` bytes or return -1 on EOF/error. Frames are + * single-write atomic on the producer side (FRAME_SZ < PIPE_BUF), but + * the consumer can still see split reads if it races the writer mid- + * write — loop just in case. */ +static int read_full(int fd, void *buf, size_t n) { + char *p = (char *)buf; + while (n > 0) { + ssize_t r = read(fd, p, n); + if (r < 0) { + if (errno == EINTR) continue; + return -1; + } + if (r == 0) return -1; /* child exited */ + p += r; + n -= (size_t)r; + } + return 0; +} + +static int parent_loop(int read_fd, pid_t child_pid) { + (void)child_pid; /* only used for logging (FPS line); browser drives pause */ + EGLDisplay dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); + EGLint maj = 0, min = 0; + if (!eglInitialize(dpy, &maj, &min)) return 1; + + EGLint cfg_attribs[] = { + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_NONE, + }; + EGLConfig cfg; + EGLint num_cfg = 0; + if (!eglChooseConfig(dpy, cfg_attribs, &cfg, 1, &num_cfg) || num_cfg < 1) return 2; + if (!eglBindAPI(EGL_OPENGL_ES_API)) return 3; + + EGLint ctx_attribs[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; + EGLContext ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, ctx_attribs); + if (ctx == EGL_NO_CONTEXT) return 4; + + EGLSurface surf = eglCreateWindowSurface(dpy, cfg, 0, 0); + if (surf == EGL_NO_SURFACE) return 5; + if (!eglMakeCurrent(dpy, surf, surf, ctx)) return 6; + + GLuint vs = glCreateShader(GL_VERTEX_SHADER); + const char *vs_p = vs_src; glShaderSource(vs, 1, &vs_p, 0); glCompileShader(vs); + GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); + const char *fs_p = fs_src; glShaderSource(fs, 1, &fs_p, 0); glCompileShader(fs); + + GLuint prog = glCreateProgram(); + glAttachShader(prog, vs); + glAttachShader(prog, fs); + glBindAttribLocation(prog, 0, "a_pos"); + glBindAttribLocation(prog, 1, "a_col"); + glLinkProgram(prog); + glUseProgram(prog); + + GLuint vbo; + glGenBuffers(1, &vbo); + glBindBuffer(GL_ARRAY_BUFFER, vbo); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, (GLsizei)VERT_SZ, (const void *)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, (GLsizei)VERT_SZ, (const void *)(3 * sizeof(float))); + + glViewport(0, 0, CANVAS_W, CANVAS_H); + glEnable(GL_DEPTH_TEST); + + float frame[VERTS * 6]; + double last_fps_at = monotonic_seconds(); + unsigned frames = 0; + int rc = 0; + + /* Plain blocking read on the pipe — when the child is paused it + * stops writing, so this read parks until the child resumes. */ + for (;;) { + if (read_full(read_fd, frame, FRAME_SZ) < 0) break; + + glClearColor(0.05f, 0.06f, 0.10f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)FRAME_SZ, frame, GL_DYNAMIC_DRAW); + glDrawArrays(GL_TRIANGLES, 0, VERTS); + + if (!eglSwapBuffers(dpy, surf)) { rc = 7; break; } + + frames++; + double now = monotonic_seconds(); + if (now - last_fps_at >= 1.0) { + printf("cube: %u fps (child pid %d)\n", frames, (int)child_pid); + fflush(stdout); + frames = 0; + last_fps_at = now; + } + } + + glDeleteShader(vs); + glDeleteShader(fs); + glDeleteProgram(prog); + eglDestroySurface(dpy, surf); + eglDestroyContext(dpy, ctx); + eglTerminate(dpy); + return rc; +} + +/* ──────────────────────────────────────────────────────────────────── + * main: pipe + fork. Parent owns GL, child owns the math. + * ──────────────────────────────────────────────────────────────────── */ + +int main(void) { + int fds[2]; + if (pipe(fds) != 0) { + perror("pipe"); + return 10; + } + + pid_t k = fork(); + if (k < 0) { + perror("fork"); + return 11; + } + + if (k == 0) { + close(fds[0]); + child_loop(fds[1]); + _exit(0); + } + + /* Parent. No SIGUSR1 handler here — the browser sends Stop/Resume + * SIGUSR1s directly to the child pid (via kernel.sendSignal), + * never to the parent. Default-action SIGUSR1 on a process with + * no handler is Terminate, but since nothing signals the parent + * that's fine. */ + close(fds[1]); + printf("cube: forked child pid %d, parent pid %d\n", + (int)k, (int)getpid()); + fflush(stdout); + + int rc = parent_loop(fds[0], k); + + /* Best-effort tidy: child will EPIPE-out on next write once we + * close the read end. */ + close(fds[0]); + int status = 0; + waitpid(k, &status, WNOHANG); + return rc; +} diff --git a/programs/dri_paint.c b/programs/dri_paint.c new file mode 100644 index 0000000000..4f51938e8f --- /dev/null +++ b/programs/dri_paint.c @@ -0,0 +1,161 @@ +/* + * dri_paint — visible browser demo for milestone (A). + * + * Same flow as dumb_roundtrip (programs/dumb_roundtrip.c), but the + * child writes the verified buffer bytes to /tmp/dri-paint.raw before + * exiting. Pages can read that file via kernel.fs and paint the bytes + * onto a canvas, proving visually that the parent's gradient survived + * PRIME export → fork → PRIME import → mmap on the imported handle. + * + * parent + * 1. open /dev/dri/renderD128 + * 2. gbm_create_device(fd) + * 3. gbm_bo_create(W×H, ARGB8888, LINEAR) + * 4. gbm_bo_map → write a deterministic gradient + * 5. gbm_bo_get_fd → PRIME export the bo + * 6. fork(); prime fd inherited by the child via fd table + * child + * 7. gbm_create_device(fd) on the inherited fd + * 8. gbm_bo_import(GBM_BO_IMPORT_FD, prime_fd) + * 9. gbm_bo_map → MAP_DUMB + mmap on the imported handle + * 10. verify every pixel matches the parent's gradient + * 11. write the verified buffer to /tmp/dri-paint.raw + * 12. _exit(0) on full success + * parent + * 13. waitpid the child; print sentinel; exit 0 + * + * Defense in depth: Playwright asserts both the exit-0 + sentinel + * (program completed) AND samples canvas pixels (the bytes on disk + * really are the gradient — catches a future SAB-sync regression + * that produces zeros without crashing). + * + * Stride is queried via the &stride out-param of gbm_bo_map. Buffer + * length on disk is `stride * H` so the page knows what to read + * even if stride > W*4 (libgbm shim returns W*4 today, but the + * convention survives a future driver that pads rows). + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define W 256 +#define H 256 + +#define DUMP_PATH "/tmp/dri-paint.raw" + +int main(void) { + int fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + if (fd < 0) { perror("open /dev/dri/renderD128"); return 1; } + + struct gbm_device *dev = gbm_create_device(fd); + if (!dev) { perror("gbm_create_device"); return 1; } + + struct gbm_bo *bo = gbm_bo_create(dev, W, H, + DRM_FORMAT_ARGB8888, + GBM_BO_USE_LINEAR); + if (!bo) { perror("gbm_bo_create"); return 1; } + + uint32_t stride = 0; + void *map_data = NULL; + uint32_t *px = gbm_bo_map(bo, 0, 0, W, H, 0, &stride, &map_data); + if (!px) { perror("gbm_bo_map (parent)"); return 1; } + if (stride == 0 || (stride % 4) != 0) { + fprintf(stderr, "FAIL: parent stride bogus (%u)\n", stride); + return 1; + } + + const uint32_t stride_px = stride / 4; + for (uint32_t y = 0; y < H; y++) { + for (uint32_t x = 0; x < W; x++) { + px[y * stride_px + x] = (0xFFu << 24) | (x << 16) | (y << 8); + } + } + + int prime = gbm_bo_get_fd(bo); + if (prime < 0) { perror("gbm_bo_get_fd"); return 1; } + + pid_t pid = fork(); + if (pid < 0) { perror("fork"); return 1; } + + if (pid == 0) { + struct gbm_device *cdev = gbm_create_device(fd); + if (!cdev) { perror("gbm_create_device (child)"); _exit(2); } + + struct gbm_import_fd_data ifd = { + .fd = prime, + .width = W, + .height = H, + .stride = stride, + .format = DRM_FORMAT_ARGB8888, + }; + struct gbm_bo *cbo = gbm_bo_import(cdev, GBM_BO_IMPORT_FD, &ifd, 0); + if (!cbo) { perror("gbm_bo_import"); _exit(2); } + + uint32_t cstride = 0; + void *cmap_data = NULL; + uint32_t *cpx = gbm_bo_map(cbo, 0, 0, W, H, 0, &cstride, &cmap_data); + if (!cpx) { perror("gbm_bo_map (child)"); _exit(2); } + if (cstride != stride) { + fprintf(stderr, "FAIL: child stride %u != parent %u\n", + cstride, stride); + _exit(3); + } + const uint32_t cstride_px = cstride / 4; + for (uint32_t y = 0; y < H; y++) { + for (uint32_t x = 0; x < W; x++) { + uint32_t want = (0xFFu << 24) | (x << 16) | (y << 8); + uint32_t got = cpx[y * cstride_px + x]; + if (got != want) { + fprintf(stderr, + "FAIL: child pixel (%u,%u) = 0x%08x; want 0x%08x\n", + x, y, got, want); + _exit(4); + } + } + } + + /* /tmp is created by both host memfs init paths (browser-kernel-host.ts + * and the Node rootfs.vfs default mounts), but mkdir-with-EEXIST keeps + * the demo robust against a future VFS shape that ships without it. */ + if (mkdir("/tmp", 0755) != 0 && errno != EEXIST) { + perror("FAIL: mkdir /tmp"); _exit(5); + } + int of = open(DUMP_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (of < 0) { perror("FAIL: open " DUMP_PATH); _exit(5); } + size_t total = (size_t)cstride * H; + const uint8_t *bytes = (const uint8_t *)cpx; + size_t written = 0; + while (written < total) { + ssize_t n = write(of, bytes + written, total - written); + if (n < 0) { + if (errno == EINTR) continue; + perror("FAIL: write " DUMP_PATH); _exit(6); + } + written += (size_t)n; + } + if (close(of) != 0) { perror("FAIL: close " DUMP_PATH); _exit(7); } + + _exit(0); + } + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "FAIL: child exited abnormally (status=0x%x)\n", status); + return 1; + } + + static const char ok[] = "milestone (A) PAINT\n"; + write(1, ok, sizeof ok - 1); + return 0; +} From bdbc3fbd74f91c6c338ba0ec40e818e397a5a441 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 16:13:19 +0200 Subject: [PATCH 02/27] =?UTF-8?q?kernel(input):=20shared=20ABI=20=E2=80=94?= =?UTF-8?q?=20struct=20input=5Fevent=20+=20EV=5F*/KEY=5F*/EVIOC*=20constan?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/shared/src/lib.rs | 398 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index d5e6d517ac..66c5372e93 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -4985,6 +4985,332 @@ pub mod dri { } } +/// evdev — `/dev/input/event*` UAPI exposed to user programs. +/// +/// Mirror image of [`dri`] above: the kernel synthesises records and +/// user programs drain them through `read()` / `poll()`. The struct +/// layouts, ioctl numbers, and code points here are Linux-verbatim so +/// libinput / SDL2 / X11 evdev paths can be ported without an +/// abstraction layer. +/// +/// **Additive only.** Adding new `EV_*` / `KEY_*` / `EVIOC*` is +/// allowed without bumping [`ABI_VERSION`]; changing the layout of +/// [`input::WpkInputEvent`] or the value of any existing constant is +/// not. +pub mod input { + // --- Event types (struct input_event.type) --------------------------- + + /// `EV_SYN` = 0. End-of-logical-event sentinel; readers use this + /// to coalesce a (REL_X, REL_Y) pair into one cursor move. + pub const EV_SYN: u16 = 0x00; + /// `EV_KEY` = 1. Press / release / autorepeat. Value is + /// 0 = release, 1 = press, 2 = repeat. + pub const EV_KEY: u16 = 0x01; + /// `EV_REL` = 2. Relative axis (pointer dx/dy/wheel). + pub const EV_REL: u16 = 0x02; + /// `EV_ABS` = 3. Absolute axis (pointer position when not locked, + /// joystick, touch coords). + pub const EV_ABS: u16 = 0x03; + /// `EV_MSC` = 4. Misc events (scancode, timestamp). Not produced + /// in v1. + pub const EV_MSC: u16 = 0x04; + + // --- SYN codes (struct input_event.code when type == EV_SYN) --------- + + /// `SYN_REPORT` = 0. End-of-frame; readers should treat + /// everything since the previous `SYN_REPORT` as atomic. + pub const SYN_REPORT: u16 = 0x00; + /// `SYN_DROPPED` = 3. Posted when the ring overflowed and the + /// oldest record was dropped; userspace should resynchronise + /// (re-query EVIOCG* state). + pub const SYN_DROPPED: u16 = 0x03; + + // --- KEY_* codes (verbatim from linux/input-event-codes.h) ----------- + // + // Range 0..248 covers every code Chrome / Firefox / WebKit emit + // through `KeyboardEvent.code`; values >248 (KEY_BUTTONCONFIG, the + // KEY_VENDOR range, etc.) are not browser-reachable. + + pub const KEY_RESERVED: u16 = 0; + pub const KEY_ESC: u16 = 1; + pub const KEY_1: u16 = 2; + pub const KEY_2: u16 = 3; + pub const KEY_3: u16 = 4; + pub const KEY_4: u16 = 5; + pub const KEY_5: u16 = 6; + pub const KEY_6: u16 = 7; + pub const KEY_7: u16 = 8; + pub const KEY_8: u16 = 9; + pub const KEY_9: u16 = 10; + pub const KEY_0: u16 = 11; + pub const KEY_MINUS: u16 = 12; + pub const KEY_EQUAL: u16 = 13; + pub const KEY_BACKSPACE: u16 = 14; + pub const KEY_TAB: u16 = 15; + pub const KEY_Q: u16 = 16; + pub const KEY_W: u16 = 17; + pub const KEY_E: u16 = 18; + pub const KEY_R: u16 = 19; + pub const KEY_T: u16 = 20; + pub const KEY_Y: u16 = 21; + pub const KEY_U: u16 = 22; + pub const KEY_I: u16 = 23; + pub const KEY_O: u16 = 24; + pub const KEY_P: u16 = 25; + pub const KEY_LEFTBRACE: u16 = 26; + pub const KEY_RIGHTBRACE: u16 = 27; + pub const KEY_ENTER: u16 = 28; + pub const KEY_LEFTCTRL: u16 = 29; + pub const KEY_A: u16 = 30; + pub const KEY_S: u16 = 31; + pub const KEY_D: u16 = 32; + pub const KEY_F: u16 = 33; + pub const KEY_G: u16 = 34; + pub const KEY_H: u16 = 35; + pub const KEY_J: u16 = 36; + pub const KEY_K: u16 = 37; + pub const KEY_L: u16 = 38; + pub const KEY_SEMICOLON: u16 = 39; + pub const KEY_APOSTROPHE: u16 = 40; + pub const KEY_GRAVE: u16 = 41; + pub const KEY_LEFTSHIFT: u16 = 42; + pub const KEY_BACKSLASH: u16 = 43; + pub const KEY_Z: u16 = 44; + pub const KEY_X: u16 = 45; + pub const KEY_C: u16 = 46; + pub const KEY_V: u16 = 47; + pub const KEY_B: u16 = 48; + pub const KEY_N: u16 = 49; + pub const KEY_M: u16 = 50; + pub const KEY_COMMA: u16 = 51; + pub const KEY_DOT: u16 = 52; + pub const KEY_SLASH: u16 = 53; + pub const KEY_RIGHTSHIFT: u16 = 54; + pub const KEY_KPASTERISK: u16 = 55; + pub const KEY_LEFTALT: u16 = 56; + pub const KEY_SPACE: u16 = 57; + pub const KEY_CAPSLOCK: u16 = 58; + pub const KEY_F1: u16 = 59; + pub const KEY_F2: u16 = 60; + pub const KEY_F3: u16 = 61; + pub const KEY_F4: u16 = 62; + pub const KEY_F5: u16 = 63; + pub const KEY_F6: u16 = 64; + pub const KEY_F7: u16 = 65; + pub const KEY_F8: u16 = 66; + pub const KEY_F9: u16 = 67; + pub const KEY_F10: u16 = 68; + pub const KEY_NUMLOCK: u16 = 69; + pub const KEY_SCROLLLOCK: u16 = 70; + pub const KEY_KP7: u16 = 71; + pub const KEY_KP8: u16 = 72; + pub const KEY_KP9: u16 = 73; + pub const KEY_KPMINUS: u16 = 74; + pub const KEY_KP4: u16 = 75; + pub const KEY_KP5: u16 = 76; + pub const KEY_KP6: u16 = 77; + pub const KEY_KPPLUS: u16 = 78; + pub const KEY_KP1: u16 = 79; + pub const KEY_KP2: u16 = 80; + pub const KEY_KP3: u16 = 81; + pub const KEY_KP0: u16 = 82; + pub const KEY_KPDOT: u16 = 83; + pub const KEY_ZENKAKUHANKAKU: u16 = 85; + pub const KEY_102ND: u16 = 86; + pub const KEY_F11: u16 = 87; + pub const KEY_F12: u16 = 88; + pub const KEY_RO: u16 = 89; + pub const KEY_KATAKANA: u16 = 90; + pub const KEY_HIRAGANA: u16 = 91; + pub const KEY_HENKAN: u16 = 92; + pub const KEY_KATAKANAHIRAGANA: u16 = 93; + pub const KEY_MUHENKAN: u16 = 94; + pub const KEY_KPJPCOMMA: u16 = 95; + pub const KEY_KPENTER: u16 = 96; + pub const KEY_RIGHTCTRL: u16 = 97; + pub const KEY_KPSLASH: u16 = 98; + pub const KEY_SYSRQ: u16 = 99; + pub const KEY_RIGHTALT: u16 = 100; + pub const KEY_LINEFEED: u16 = 101; + pub const KEY_HOME: u16 = 102; + pub const KEY_UP: u16 = 103; + pub const KEY_PAGEUP: u16 = 104; + pub const KEY_LEFT: u16 = 105; + pub const KEY_RIGHT: u16 = 106; + pub const KEY_END: u16 = 107; + pub const KEY_DOWN: u16 = 108; + pub const KEY_PAGEDOWN: u16 = 109; + pub const KEY_INSERT: u16 = 110; + pub const KEY_DELETE: u16 = 111; + pub const KEY_MACRO: u16 = 112; + pub const KEY_MUTE: u16 = 113; + pub const KEY_VOLUMEDOWN: u16 = 114; + pub const KEY_VOLUMEUP: u16 = 115; + pub const KEY_POWER: u16 = 116; + pub const KEY_KPEQUAL: u16 = 117; + pub const KEY_KPPLUSMINUS: u16 = 118; + pub const KEY_PAUSE: u16 = 119; + pub const KEY_SCALE: u16 = 120; + pub const KEY_KPCOMMA: u16 = 121; + pub const KEY_HANGEUL: u16 = 122; + pub const KEY_HANJA: u16 = 123; + pub const KEY_YEN: u16 = 124; + pub const KEY_LEFTMETA: u16 = 125; + pub const KEY_RIGHTMETA: u16 = 126; + pub const KEY_COMPOSE: u16 = 127; + pub const KEY_STOP: u16 = 128; + pub const KEY_AGAIN: u16 = 129; + pub const KEY_PROPS: u16 = 130; + pub const KEY_UNDO: u16 = 131; + pub const KEY_FRONT: u16 = 132; + pub const KEY_COPY: u16 = 133; + pub const KEY_OPEN: u16 = 134; + pub const KEY_PASTE: u16 = 135; + pub const KEY_FIND: u16 = 136; + pub const KEY_CUT: u16 = 137; + pub const KEY_HELP: u16 = 138; + pub const KEY_MENU: u16 = 139; + pub const KEY_CALC: u16 = 140; + pub const KEY_SLEEP: u16 = 142; + pub const KEY_WAKEUP: u16 = 143; + pub const KEY_PLAYPAUSE: u16 = 164; + pub const KEY_PREVIOUSSONG: u16 = 165; + pub const KEY_STOPCD: u16 = 166; + pub const KEY_NEXTSONG: u16 = 163; + pub const KEY_EJECTCD: u16 = 161; + pub const KEY_REFRESH: u16 = 173; + pub const KEY_F13: u16 = 183; + pub const KEY_F14: u16 = 184; + pub const KEY_F15: u16 = 185; + pub const KEY_F16: u16 = 186; + pub const KEY_F17: u16 = 187; + pub const KEY_F18: u16 = 188; + pub const KEY_F19: u16 = 189; + pub const KEY_F20: u16 = 190; + pub const KEY_F21: u16 = 191; + pub const KEY_F22: u16 = 192; + pub const KEY_F23: u16 = 193; + pub const KEY_F24: u16 = 194; + pub const KEY_PLAYCD: u16 = 200; + pub const KEY_PAUSECD: u16 = 201; + pub const KEY_BRIGHTNESSDOWN: u16 = 224; + pub const KEY_BRIGHTNESSUP: u16 = 225; + pub const KEY_MICMUTE: u16 = 248; + + // --- BTN_* codes (button class; reuse the EV_KEY event type) --------- + + pub const BTN_LEFT: u16 = 0x110; + pub const BTN_RIGHT: u16 = 0x111; + pub const BTN_MIDDLE: u16 = 0x112; + pub const BTN_SIDE: u16 = 0x113; + pub const BTN_EXTRA: u16 = 0x114; + + // --- REL_* codes (relative axes; EV_REL records carry these) --------- + + pub const REL_X: u16 = 0x00; + pub const REL_Y: u16 = 0x01; + pub const REL_HWHEEL: u16 = 0x06; + pub const REL_WHEEL: u16 = 0x08; + + // --- ABS_* codes (absolute axes; EV_ABS records carry these) --------- + + pub const ABS_X: u16 = 0x00; + pub const ABS_Y: u16 = 0x01; + + // --- BUS_* constants (subset) ---------------------------------------- + + /// `BUS_VIRTUAL` = 0x06 — closest match for a kernel-synthesised + /// device (Linux uses this for `uinput`-backed devices). + pub const BUS_VIRTUAL: u16 = 0x06; + + // --- ioctl numbers ('E' magic, Linux UAPI verbatim) ------------------ + // + // Encoding: `(dir << 30) | (size << 16) | (magic << 8) | nr`. + // `_IOR` = dir 2 (kernel writes back to userland buffer), + // `_IOW` = dir 1 (kernel reads from userland buffer). The + // `evioc_numbers_match_linux_uapi` test below re-derives each one + // through `ioc(...)` so a copy-paste typo cannot survive. + + /// `_IOR('E', 0x01, int)` = `0x8004_4501`. + pub const EVIOCGVERSION: u32 = 0x8004_4501; + + /// `_IOR('E', 0x02, WpkInputId)` = `0x8008_4502`. + pub const EVIOCGID: u32 = 0x8008_4502; + + /// `_IOC(_IOC_READ, 'E', 0x06, len)` — `EVIOCGNAME(len)` in C. + /// `len` is caller-supplied; A3 matches on `(dir, magic, nr)` + /// and recomputes the buffer size from the encoded `size` field + /// at dispatch time (1 ≤ size ≤ 256). + pub const EVIOCGNAME_NR: u32 = 0x06; + + /// `EVIOCGBIT(ev_type, len)` — same variable-length shape as + /// `EVIOCGNAME`. `nr = 0x20 + ev_type`. + pub const EVIOCGBIT_NR_BASE: u32 = 0x20; + + /// `EVIOCGABS(axis)` — `_IOR('E', 0x40 + axis, WpkInputAbsinfo)`. + /// `axis` is a small integer (`ABS_X = 0`, `ABS_Y = 1`, …). + pub const EVIOCGABS_NR_BASE: u32 = 0x40; + + /// `_IOW('E', 0x90, int)` = `0x4004_4590`. + pub const EVIOCGRAB: u32 = 0x4004_4590; + + // --- marshalled structs ---------------------------------------------- + + /// `struct input_event` on wasm32-musl (`time_t = int64_t`, + /// `suseconds_t = int32_t`, `__u16` + `__u16` + `__s32`). + /// Total = 24 bytes. + /// + /// The explicit `_pad: i32` at byte 12 is **load-bearing**. + /// `repr(C)` would otherwise place `ev_type` at offset 12 (no + /// interior padding between the `i32 tv_usec` and the `u16 + /// ev_type`), but C's `struct timeval` substruct is itself 16 + /// bytes on wasm32-musl: the `int64_t tv_sec` forces 8-byte + /// alignment of the substruct, and the trailing `int32_t + /// tv_usec` is padded to 16 to satisfy that alignment. So the + /// C reader expects `ev_type` at offset 16 while the + /// pad-less Rust writer would put it at offset 12 — silent + /// corruption on every record. The `input_event_field_offsets` + /// test below gates the layout; if `ev_type` ever drifts back + /// to offset 12, restore `_pad`. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputEvent { + pub tv_sec: i64, // 0 CLOCK_MONOTONIC seconds since kernel boot + pub tv_usec: i32, // 8 microseconds; matches musl suseconds_t + pub _pad: i32, // 12 pad so the trailing union 8-aligns with C + pub ev_type: u16, // 16 EV_KEY / EV_REL / EV_ABS / EV_SYN / EV_MSC + pub code: u16, // 18 KEY_* / BTN_* / REL_* / ABS_* / SYN_* + pub value: i32, // 20 press/release/repeat; delta; absolute pos + // total: 24 + } + + /// `struct input_id` — 8 bytes (4 × u16). Returned by `EVIOCGID`. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputId { + pub bustype: u16, // 0 BUS_VIRTUAL = 0x06 + pub vendor: u16, // 2 + pub product: u16, // 4 0x0001 = kbd, 0x0002 = ptr + pub version: u16, // 6 + // total: 8 + } + + /// `struct input_absinfo` — 24 bytes (6 × i32). Returned by + /// `EVIOCGABS(axis)`. Used for `ABS_X` / `ABS_Y` on the pointer + /// device when pointer lock is not active. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkInputAbsinfo { + pub value: i32, // 0 current value + pub minimum: i32, // 4 + pub maximum: i32, // 8 canvas width-1 / height-1 + pub fuzz: i32, // 12 + pub flat: i32, // 16 + pub resolution: i32,// 20 1 unit per pixel + // total: 24 + } +} + #[cfg(test)] mod dri_tests { use super::dri::*; @@ -5323,3 +5649,75 @@ mod gl_tests { } } } + +#[cfg(test)] +mod input_tests { + use super::input::*; + use core::mem::size_of; + + // Linux's `_IOC` packs (dir, size, magic, nr) into a u32. + // Mirrors include/uapi/asm-generic/ioctl.h. `IOC_READ = 2` + // (`_IOR`); `IOC_WRITE = 1` (`_IOW`). + const fn ioc(dir: u32, magic: u32, nr: u32, size: u32) -> u32 { + (dir << 30) | (size << 16) | (magic << 8) | nr + } + const IOC_READ: u32 = 2; + const IOC_WRITE: u32 = 1; + + #[test] + fn input_struct_sizes_match_wasm32_repr_c() { + assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 8); + assert_eq!(size_of::(), 24); + } + + #[test] + fn input_event_field_offsets() { + // The 24-byte layout is load-bearing — every reader walks + // the ring 24 bytes at a time. Lock the offsets explicitly. + let e = WpkInputEvent::default(); + let base = (&e as *const _) as usize; + assert_eq!((&e.tv_sec as *const _ as usize) - base, 0); + assert_eq!((&e.tv_usec as *const _ as usize) - base, 8); + assert_eq!((&e.ev_type as *const _ as usize) - base, 16); + assert_eq!((&e.code as *const _ as usize) - base, 18); + assert_eq!((&e.value as *const _ as usize) - base, 20); + } + + #[test] + fn evioc_numbers_match_linux_uapi() { + assert_eq!( + EVIOCGVERSION, + ioc(IOC_READ, 'E' as u32, 0x01, 4) + ); + assert_eq!( + EVIOCGID, + ioc(IOC_READ, 'E' as u32, 0x02, size_of::() as u32) + ); + assert_eq!( + EVIOCGRAB, + ioc(IOC_WRITE, 'E' as u32, 0x90, 4) + ); + // EVIOCGABS(ABS_X) — exercises both the variable nr base + // and the absinfo struct size. + assert_eq!( + ioc( + IOC_READ, + 'E' as u32, + EVIOCGABS_NR_BASE + ABS_X as u32, + size_of::() as u32 + ), + 0x8018_4540 + ); + } + + #[test] + fn evioc_nr_bases_match_linux_uapi() { + // Spot-check the variable-length / per-axis bases used by + // A3's dispatch; the precise number is only known once the + // size field is filled in at ioctl time. + assert_eq!(EVIOCGNAME_NR, 0x06); + assert_eq!(EVIOCGBIT_NR_BASE, 0x20); + assert_eq!(EVIOCGABS_NR_BASE, 0x40); + } +} From b6a2205c058e0759ea19f6ca53880eaba962d309 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 16:28:06 +0200 Subject: [PATCH 03/27] kernel(input): add /dev/input/event{0,1} + InputFdState on OFD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kernel/src/devfs.rs | 22 +++++- crates/kernel/src/fork.rs | 8 +++ crates/kernel/src/ofd.rs | 101 ++++++++++++++++++++++++++ crates/kernel/src/syscalls.rs | 130 +++++++++++++++++++++++++++++++++- 4 files changed, 257 insertions(+), 4 deletions(-) diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index f6b862331c..91b1a55e4e 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -166,8 +166,11 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec { // /dev/input/mice — Linux-compatible PS/2 mouse stream. - // No /dev/input/eventN evdev nodes yet (mousedev surface only). entries.push((b"mice".into(), DT_CHR, devfs_ino(b"/dev/input/mice"))); + // /dev/input/event0 — keyboard evdev (plan 5). + // /dev/input/event1 — pointer evdev. + entries.push((b"event0".into(), DT_CHR, devfs_ino(b"/dev/input/event0"))); + entries.push((b"event1".into(), DT_CHR, devfs_ino(b"/dev/input/event1"))); } DevfsEntry::DriDir => { // /dev/dri/card0 — KMS / display side. @@ -523,4 +526,21 @@ mod tests { Err(Errno::EINVAL) ); } + + #[test] + fn event0_and_event1_listed_in_dev_input_dir() { + let proc = crate::process::Process::new(1); + let entries = dir_entries(&proc, &DevfsEntry::InputDir); + let names: Vec<&[u8]> = entries.iter().map(|(n, _, _)| n.as_slice()).collect(); + assert!(names.iter().any(|n| *n == b"event0"), "event0 missing: {:?}", names); + assert!(names.iter().any(|n| *n == b"event1"), "event1 missing: {:?}", names); + // event2 deliberately NOT synthesised. + assert!(!names.iter().any(|n| *n == b"event2")); + // Both must be char devices. + for (name, dtype, _) in entries.iter() { + if name.as_slice() == b"event0" || name.as_slice() == b"event1" { + assert_eq!(*dtype, DT_CHR); + } + } + } } diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 46dee82332..6ffe261712 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -1277,6 +1277,11 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er dir_position_generation: 0, dir_pending_entry: None, dri_state, + // TODO(plan-5 A4/A5): once the host produces events and + // sys_read drains the ring, serialise input_state through + // fork the same way dri_state is. A2 leaves it None + // because no consumer exists yet. + input_state: None, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -1952,6 +1957,9 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { dir_position_generation: 0, dir_pending_entry: None, dri_state, + // TODO(plan-5 A4/A5): see same TODO in the other + // OpenFileDesc reconstruction site above. + input_state: None, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index f3afdd3c81..1ec9ddc3a2 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -274,6 +274,52 @@ pub enum DriOfdState { Card { dri: DriFdState, kms: KmsFdState }, } +/// Per-OFD ring cap: 1024 `struct input_event` records (24 KiB). +pub const INPUT_RING_MAX_RECORDS: usize = 1024; +/// Per-OFD ring cap in bytes — `INPUT_RING_MAX_RECORDS * size_of::()`. +pub const INPUT_RING_MAX_BYTES: usize = INPUT_RING_MAX_RECORDS * 24; + +/// Per-fd state for `/dev/input/event{0,1}` opens. +/// +/// Disjoint from [`DriOfdState`] — input fds carry no DRI bo state. +/// We keep `input_state` as a separate `Option>` field on the +/// OFD rather than folding it into `DriOfdState` because the two +/// state machines have no shared invariants. +/// +/// Linux semantics replicated: +/// * The ring is per-OFD, not per-process. `dup` / fork-inherit share +/// one ring; a fresh `open()` gets a new one. +/// * On overflow, the **new** event is discarded and `dropped` is set +/// (mirrors `drivers/input/evdev.c::evdev_pass_values`). The next +/// `read()` synthesises a `SYN_DROPPED` record at the head of the +/// returned buffer and clears `dropped`; userspace is expected to +/// resynchronise by re-querying state via `EVIOCG*`. +/// * `grabbed` is recorded but NOT enforced in v1 — plan 9 +/// (wpkcompositor) adds the cross-OFD focus-routing layer. +#[derive(Default, Clone, Debug)] +pub struct InputFdState { + /// Which device this fd is bound to (0 = kbd, 1 = ptr). Cached + /// to avoid a second `VirtualDevice` lookup on read / poll. + pub device: u8, + + /// Ring of 24-byte `WpkInputEvent` records. Bounded at + /// [`INPUT_RING_MAX_BYTES`]. + pub event_ring: VecDeque, + + /// `EVIOCGRAB` ownership flag. v1 records the flag but doesn't + /// gate event delivery on it. + pub grabbed: bool, + + /// Set when an event push found the ring full; cleared on the + /// next `read()` *after* a `SYN_DROPPED` synthetic record is + /// delivered at the head of that read's output. + pub dropped: bool, + + /// Peak record count seen on this ring — debug-only, not exposed + /// to userspace. + pub ring_high_water: u32, +} + #[derive(Clone)] pub struct OpenFileDesc { /// Machine-wide identity of this open file description. Independent @@ -312,6 +358,10 @@ pub struct OpenFileDesc { /// DRI sidecar; see [`DriOfdState`]. Boxed so non-DRI OFDs pay /// only one pointer slot. pub dri_state: Option>, + /// evdev sidecar for `/dev/input/event{0,1}` OFDs; see + /// [`InputFdState`]. Boxed so non-evdev OFDs pay only one pointer + /// slot. Parallel to [`Self::dri_state`] (disjoint state machines). + pub input_state: Option>, } struct SharedOfdStateInner { @@ -536,6 +586,16 @@ impl OpenFileDesc { _ => None, } } + + /// Borrow the `InputFdState` for `/dev/input/event{0,1}` OFDs. + /// Returns `None` for any other OFD. + pub fn input(&self) -> Option<&InputFdState> { + self.input_state.as_deref() + } + + pub fn input_mut(&mut self) -> Option<&mut InputFdState> { + self.input_state.as_deref_mut() + } } #[derive(Clone)] @@ -573,6 +633,7 @@ impl OfdTable { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, }; self.insert(ofd) @@ -962,6 +1023,7 @@ mod tests { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, }); } @@ -1164,6 +1226,45 @@ mod tests { assert!(table.get(render).unwrap().dri_state.is_some()); } + #[test] + fn ofd_default_has_no_input_state() { + let mut table = OfdTable::new(); + let idx = table.create(FileType::CharDevice, O_RDONLY, -10, b"/dev/input/event0".to_vec()); + let ofd = table.get(idx).unwrap(); + assert!(ofd.input_state.is_none()); + assert!(ofd.input().is_none()); + } + + #[test] + fn input_accessors_route_to_attached_state() { + let mut table = OfdTable::new(); + let idx = table.create(FileType::CharDevice, O_RDONLY, -10, b"/dev/input/event0".to_vec()); + table.get_mut(idx).unwrap().input_state = Some(Box::new(InputFdState { + device: 0, + ..Default::default() + })); + + let st = table.get(idx).unwrap().input().unwrap(); + assert_eq!(st.device, 0); + assert!(!st.grabbed); + assert!(!st.dropped); + assert_eq!(st.ring_high_water, 0); + assert!(st.event_ring.is_empty()); + + // input_mut lets us mutate the ring. + let st = table.get_mut(idx).unwrap().input_mut().unwrap(); + st.event_ring.push_back(0xab); + assert_eq!(table.get(idx).unwrap().input().unwrap().event_ring.len(), 1); + } + + #[test] + fn input_ring_cap_bytes_is_24_kib() { + // Lock the ring cap so the per-fd memory budget cannot drift + // without a deliberate edit + review. + assert_eq!(INPUT_RING_MAX_RECORDS, 1024); + assert_eq!(INPUT_RING_MAX_BYTES, 24 * 1024); + } + #[test] fn iter_mut_visits_every_live_ofd() { let mut table = OfdTable::new(); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 729d01ef2e..82087371b6 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -159,6 +159,10 @@ pub enum VirtualDevice { Dsp, // /dev/dsp host_handle = -7 DriRenderD128, // /dev/dri/renderD128 host_handle = -8 DriCard0, // /dev/dri/card0 host_handle = -9 + /// `/dev/input/event{0,1}`. `device = 0` → kbd (host_handle -10), + /// `device = 1` → ptr (host_handle -11). v1 exposes exactly these + /// two; `/dev/input/eventN` for N≥2 is not synthesised. + InputEvent { device: u8 }, } impl VirtualDevice { @@ -174,6 +178,11 @@ impl VirtualDevice { VirtualDevice::Dsp => -7, VirtualDevice::DriRenderD128 => -8, VirtualDevice::DriCard0 => -9, + VirtualDevice::InputEvent { device: 0 } => -10, + VirtualDevice::InputEvent { device: 1 } => -11, + // Any other device byte is unreachable — match_virtual_device + // only constructs InputEvent{0} or InputEvent{1}. + VirtualDevice::InputEvent { .. } => -10, } } @@ -189,6 +198,8 @@ impl VirtualDevice { -7 => Some(VirtualDevice::Dsp), -8 => Some(VirtualDevice::DriRenderD128), -9 => Some(VirtualDevice::DriCard0), + -10 => Some(VirtualDevice::InputEvent { device: 0 }), + -11 => Some(VirtualDevice::InputEvent { device: 1 }), _ => None, } } @@ -205,6 +216,7 @@ impl VirtualDevice { VirtualDevice::Dsp => 7, VirtualDevice::DriRenderD128 => 8, VirtualDevice::DriCard0 => 9, + VirtualDevice::InputEvent { device } => 10 + device as u64, } } } @@ -226,6 +238,8 @@ fn match_virtual_device(path: &[u8]) -> Option { b"/dev/dsp" => Some(VirtualDevice::Dsp), b"/dev/dri/renderD128" => Some(VirtualDevice::DriRenderD128), b"/dev/dri/card0" => Some(VirtualDevice::DriCard0), + b"/dev/input/event0" => Some(VirtualDevice::InputEvent { device: 0 }), + b"/dev/input/event1" => Some(VirtualDevice::InputEvent { device: 1 }), _ => None, } } @@ -728,6 +742,19 @@ fn install_dri_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualDev } } +/// Install the evdev sidecar on a freshly-allocated OFD for a +/// `/dev/input/event{0,1}` open. No-op for any other virtual device. +fn install_input_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualDevice) { + if let VirtualDevice::InputEvent { device } = dev { + if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { + ofd.input_state = Some(alloc::boxed::Box::new(crate::ofd::InputFdState { + device, + ..Default::default() + })); + } + } +} + /// Borrow the `DriFdState` hung off the OFD at `ofd_idx`, returning /// `EBADF` if the OFD doesn't have one or is a prime-bo. Used by /// renderD128- and card0-targeted ioctls that manipulate per-fd GEM @@ -3070,6 +3097,7 @@ pub fn sys_open( resolved, ); install_dri_state_on_open(proc, ofd_idx, dev); + install_input_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -4539,6 +4567,12 @@ pub fn sys_read( VirtualDevice::Null | VirtualDevice::Fb0 | VirtualDevice::DriRenderD128 => 0, // Real DSP descriptors use PcmPlayback and O_WRONLY. VirtualDevice::Dsp => return Err(Errno::EBADF), + // A2 placeholder — A5 replaces with the ring + // drain. Returning 0 here is "no events + // pending", which is what reads against a + // freshly-opened evdev fd see before any + // host-pushed events arrive. + VirtualDevice::InputEvent { .. } => 0, VirtualDevice::DriCard0 => { // Drain queued DRM events (DRM_EVENT_FLIP_COMPLETE) // into the caller buffer, one byte at a time so a @@ -13925,6 +13959,7 @@ pub fn sys_openat( resolved, ); install_dri_state_on_open(proc, ofd_idx, dev); + install_input_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -34835,6 +34870,8 @@ mod tests { VirtualDevice::Dsp, VirtualDevice::DriRenderD128, VirtualDevice::DriCard0, + VirtualDevice::InputEvent { device: 0 }, + VirtualDevice::InputEvent { device: 1 }, ] { assert_eq!( VirtualDevice::from_host_handle(dev.host_handle()), @@ -34843,7 +34880,8 @@ mod tests { } assert_eq!(VirtualDevice::from_host_handle(0), None); // First sentinel past the allocated range — must not roundtrip. - assert_eq!(VirtualDevice::from_host_handle(-10), None); + // -10 and -11 are now allocated for InputEvent{0,1}. + assert_eq!(VirtualDevice::from_host_handle(-12), None); } // ===== Loopback socket tests ===== @@ -40230,8 +40268,6 @@ mod tests { match_virtual_device(b"/dev/input/mice"), Some(VirtualDevice::Mice) ); - // No /dev/input/event0 — evdev is out of scope for v1. - assert_eq!(match_virtual_device(b"/dev/input/event0"), None); } #[test] @@ -45028,4 +45064,92 @@ mod tests { assert_eq!(proc.exec_generation, 0); assert_eq!(host.closed_handles, vec![100]); } + + // ----------------------------------------------------------------- + // /dev/input/event{0,1} tests — A2 surface (open + OFD wiring). + // Read/poll drain semantics land in A5; ioctl dispatch in A3. + // ----------------------------------------------------------------- + + #[test] + fn match_virtual_device_recognizes_evdev_nodes() { + assert_eq!( + match_virtual_device(b"/dev/input/event0"), + Some(VirtualDevice::InputEvent { device: 0 }) + ); + assert_eq!( + match_virtual_device(b"/dev/input/event1"), + Some(VirtualDevice::InputEvent { device: 1 }) + ); + // event2+ deliberately not synthesised. + assert_eq!(match_virtual_device(b"/dev/input/event2"), None); + assert_eq!(match_virtual_device(b"/dev/input/event10"), None); + } + + #[test] + fn open_event0_yields_input_state_with_device_zero() { + let mut proc = Process::new(101); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/input/event0", O_RDWR, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let st = ofd.input().expect("input_state should be installed"); + assert_eq!(st.device, 0); + assert!(!st.grabbed); + assert!(!st.dropped); + assert!(st.event_ring.is_empty()); + // dri_state must NOT be installed on an evdev fd (disjoint + // sidecars). + assert!(ofd.dri_state.is_none()); + } + + #[test] + fn open_event1_yields_input_state_with_device_one() { + let mut proc = Process::new(102); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/input/event1", O_RDWR, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let st = proc + .ofd_table + .get(entry.ofd_ref.0) + .and_then(|o| o.input()) + .expect("input_state should be installed"); + assert_eq!(st.device, 1); + } + + #[test] + fn open_event0_is_multi_process_no_busy() { + // Unlike /dev/fb0 + /dev/input/mice + /dev/dsp (single-owner), + // evdev nodes accept multiple opens — every process can + // attach its own ring. + let mut proc1 = Process::new(201); + let mut proc2 = Process::new(202); + let mut host = MockHostIO::new(); + assert!(sys_open(&mut proc1, &mut host, b"/dev/input/event0", O_RDONLY, 0).is_ok()); + assert!(sys_open(&mut proc2, &mut host, b"/dev/input/event0", O_RDONLY, 0).is_ok()); + } + + #[test] + fn open_nonexistent_event_path_returns_enoent() { + // /dev/input/event2 isn't a virtual device + isn't on the host + // FS in tests, so it lands in the file-not-found path. Either + // ENOENT or whatever MockHostIO returns for an unknown path; + // the contract for v1 is "not a virtual device". + let mut proc = Process::new(301); + let mut host = MockHostIO::new(); + let r = sys_open(&mut proc, &mut host, b"/dev/input/event2", O_RDONLY, 0); + assert!(r.is_err(), "/dev/input/event2 must NOT open as a virtual device"); + } + + #[test] + fn read_eventN_returns_zero_before_any_event() { + // A2 placeholder: the ring is empty; read returns 0 (no + // events). A5 replaces this with the actual ring drain + + // SYN_DROPPED resync semantics. + let mut proc = Process::new(401); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/input/event0", O_RDONLY, 0).unwrap(); + let mut buf = [0u8; 24]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 0); + } } From 72991c28693c97124119c47964f0fa8f66e9629f Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 17:18:49 +0200 Subject: [PATCH 04/27] kernel(input): EVIOCG* ioctl dispatch + populate_evbit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kernel/src/input/mod.rs | 172 +++++++++++++++ crates/kernel/src/lib.rs | 1 + crates/kernel/src/syscalls.rs | 380 +++++++++++++++++++++++++++++++++ 3 files changed, 553 insertions(+) create mode 100644 crates/kernel/src/input/mod.rs diff --git a/crates/kernel/src/input/mod.rs b/crates/kernel/src/input/mod.rs new file mode 100644 index 0000000000..4a9370d728 --- /dev/null +++ b/crates/kernel/src/input/mod.rs @@ -0,0 +1,172 @@ +//! evdev input subsystem — backs `/dev/input/event{0,1}`. +//! +//! v1 covers `EVIOCG*` ioctl helpers + the canvas-dim cache used to +//! size `EVIOCGABS(ABS_X/ABS_Y)`. Host event push + sys_read ring +//! drain land in A4/A5. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use wasm_posix_shared::input::*; + +/// Canvas pixel dimensions used by `EVIOCGABS(ABS_X/ABS_Y)` on the +/// pointer device. The host sets these once a KMS canvas attaches +/// (A4 wires `HostIO`'s canvas-dims push); until then the default +/// is 1280×720 so SDL2 probes don't see a degenerate 0-wide axis +/// and reject the device. +static CANVAS_W: AtomicU32 = AtomicU32::new(1280); +static CANVAS_H: AtomicU32 = AtomicU32::new(720); + +pub fn canvas_dims() -> (u32, u32) { + (CANVAS_W.load(Ordering::Relaxed), CANVAS_H.load(Ordering::Relaxed)) +} + +/// Update the canvas-dim cache. Both dimensions are clamped to at +/// least 1 so `maximum = w - 1` in the EVIOCGABS reply doesn't go +/// negative. +pub fn set_canvas_dims(width: u32, height: u32) { + CANVAS_W.store(width.max(1), Ordering::Relaxed); + CANVAS_H.store(height.max(1), Ordering::Relaxed); +} + +fn set_bit(buf: &mut [u8], bit: u16) { + let byte = (bit as usize) >> 3; + let shift = (bit as usize) & 7; + if byte < buf.len() { + buf[byte] |= 1 << shift; + } +} + +/// Populate `buf` (already zeroed) with the bitmap returned by +/// `EVIOCGBIT(ev_type, len)` for the given device (`0` = keyboard, +/// `1` = pointer). Out-of-range bits are silently dropped — Linux +/// truncates to whatever buffer length the caller passed. +pub fn populate_evbit(device: u8, ev_type: u16, buf: &mut [u8]) { + match (device, ev_type) { + // ev_type = 0 — "which EV_* types does this device produce?" + (_, 0) => { + set_bit(buf, EV_SYN); + set_bit(buf, EV_KEY); + if device == 1 { + set_bit(buf, EV_REL); + set_bit(buf, EV_ABS); + } + } + // Keyboard advertises every KEY_* in the kbd surface range + // (A1 picked 1..=KEY_MICMUTE precisely so this is a single + // loop instead of a 248-entry table). KEY_RESERVED (0) is + // deliberately excluded — Linux doesn't advertise it either. + (0, t) if t == EV_KEY => { + for k in 1..=KEY_MICMUTE { + set_bit(buf, k); + } + } + // Pointer advertises only the five mouse buttons. + (1, t) if t == EV_KEY => { + for &b in &[BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA] { + set_bit(buf, b); + } + } + (1, t) if t == EV_REL => { + set_bit(buf, REL_X); + set_bit(buf, REL_Y); + set_bit(buf, REL_WHEEL); + set_bit(buf, REL_HWHEEL); + } + (1, t) if t == EV_ABS => { + set_bit(buf, ABS_X); + set_bit(buf, ABS_Y); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canvas_dims_round_trip_and_clamp_to_one() { + set_canvas_dims(640, 480); + assert_eq!(canvas_dims(), (640, 480)); + set_canvas_dims(0, 0); + assert_eq!(canvas_dims(), (1, 1)); + // Restore the default so any test running in parallel that + // expects 1280×720 sees the original value. + set_canvas_dims(1280, 720); + } + + #[test] + fn evbit_type_query_kbd_advertises_syn_and_key_only() { + let mut buf = [0u8; 4]; + populate_evbit(0, 0, &mut buf); + assert_eq!(buf[0], (1 << EV_SYN) | (1 << EV_KEY)); + assert_eq!(&buf[1..], &[0, 0, 0]); + } + + #[test] + fn evbit_type_query_pointer_adds_rel_and_abs() { + let mut buf = [0u8; 4]; + populate_evbit(1, 0, &mut buf); + assert_eq!( + buf[0], + (1 << EV_SYN) | (1 << EV_KEY) | (1 << EV_REL) | (1 << EV_ABS) + ); + } + + #[test] + fn evbit_kbd_advertises_key_a_and_key_z_not_reserved() { + let mut buf = [0u8; 32]; + populate_evbit(0, EV_KEY, &mut buf); + let a_byte = (KEY_A >> 3) as usize; + let z_byte = (KEY_Z >> 3) as usize; + assert_ne!(buf[a_byte] & (1 << (KEY_A & 7)), 0); + assert_ne!(buf[z_byte] & (1 << (KEY_Z & 7)), 0); + assert_eq!(buf[0] & 1, 0, "KEY_RESERVED must not be advertised"); + } + + #[test] + fn evbit_pointer_advertises_btn_left_not_key_a() { + // BTN_LEFT = 0x110 = bit 272 → byte 34. KEY_A = 30 → byte 3. + let mut buf = [0u8; 40]; + populate_evbit(1, EV_KEY, &mut buf); + let left_byte = (BTN_LEFT >> 3) as usize; + assert_ne!(buf[left_byte] & (1 << (BTN_LEFT & 7)), 0); + let a_byte = (KEY_A >> 3) as usize; + assert_eq!(buf[a_byte] & (1 << (KEY_A & 7)), 0); + } + + #[test] + fn evbit_pointer_rel_query_advertises_wheels() { + let mut buf = [0u8; 4]; + populate_evbit(1, EV_REL, &mut buf); + assert_ne!(buf[0] & (1 << REL_X), 0); + assert_ne!(buf[0] & (1 << REL_Y), 0); + assert_ne!(buf[0] & (1 << REL_HWHEEL), 0); + assert_ne!(buf[1] & (1 << (REL_WHEEL - 8)), 0); + } + + #[test] + fn evbit_pointer_abs_query_advertises_x_and_y() { + let mut buf = [0u8; 4]; + populate_evbit(1, EV_ABS, &mut buf); + assert_eq!(buf[0], (1 << ABS_X) | (1 << ABS_Y)); + } + + #[test] + fn evbit_kbd_abs_query_is_empty() { + // Keyboard has no absolute axes — populate_evbit leaves the + // caller-zeroed buffer alone. + let mut buf = [0u8; 4]; + populate_evbit(0, EV_ABS, &mut buf); + assert_eq!(buf, [0; 4]); + } + + #[test] + fn evbit_truncates_silently_when_buf_too_small() { + let mut buf = [0u8; 1]; + populate_evbit(0, EV_KEY, &mut buf); + // KEY_ESC (1) fits in bit 1; KEY_A (30) fell off the end — + // no panic. + assert_ne!(buf[0] & (1 << KEY_ESC), 0); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f245c19bad..df4ceafc08 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -17,6 +17,7 @@ pub(crate) mod exec_target; pub mod fd; pub mod fifo; pub mod fork; +pub mod input; pub mod ipc; pub(crate) mod ipc_wire; pub mod lock; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 82087371b6..c8a3865f36 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -1008,6 +1008,26 @@ fn commit_exec_state_impl( Ok(()) } +fn input_state( + proc: &Process, + ofd_idx: usize, +) -> Result<&crate::ofd::InputFdState, Errno> { + proc.ofd_table + .get(ofd_idx) + .and_then(|o| o.input()) + .ok_or(Errno::EBADF) +} + +fn input_state_mut( + proc: &mut Process, + ofd_idx: usize, +) -> Result<&mut crate::ofd::InputFdState, Errno> { + proc.ofd_table + .get_mut(ofd_idx) + .and_then(|o| o.input_mut()) + .ok_or(Errno::EBADF) +} + /// Release a per-fd handle (DESTROY_DUMB / GEM_CLOSE): drops the /// handle from the fd's namespace, decrefs the bo, and if the /// refcount hits zero asks the host to free the backing. @@ -1871,6 +1891,145 @@ fn handle_dri_card_ioctl( } } +/// `EVIOCG*` ioctl surface for `/dev/input/event{0,1}`. +/// +/// Unknown requests return `ENOTTY` rather than `EINVAL` so SDL2's +/// evdev probe (which greps the errno) keeps walking instead of +/// fataling on the first unsupported call. EVIOCGRAB records the +/// per-OFD grab flag but does not enforce cross-fd exclusivity in +/// v1 — that lands with plan 9's compositor. +fn handle_input_ioctl( + proc: &mut Process, + ofd_idx: usize, + request: u32, + buf: &mut [u8], +) -> Result<(), Errno> { + use wasm_posix_shared::input::*; + + // Decode (dir, magic, nr, size) per asm-generic/ioctl.h. The + // size sub-field is informational for variable-length ioctls + // (EVIOCGNAME, EVIOCGBIT) — Linux matches on (dir, magic, nr) + // only and uses _IOC_SIZE(request) to learn the caller's buffer + // length, which we mirror here. + let dir = (request >> 30) & 0x3; + let magic = (request >> 8) & 0xff; + let nr = request & 0xff; + let size = ((request >> 16) & 0x3fff) as usize; + + // Foreign magic — not an evdev ioctl. ENOTTY keeps probing loops + // moving (EINVAL would fatal SDL2's evdev detection). + if magic != b'E' as u32 { + return Err(Errno::ENOTTY); + } + + match nr { + // EVIOCGVERSION — read u32 + 0x01 if dir == 2 => { + if buf.len() < 4 { + return Err(Errno::EINVAL); + } + let version: u32 = 0x0001_0001; + buf[0..4].copy_from_slice(&version.to_le_bytes()); + Ok(()) + } + // EVIOCGID — read WpkInputId + 0x02 if dir == 2 => { + if buf.len() < core::mem::size_of::() { + return Err(Errno::EINVAL); + } + let device = input_state(proc, ofd_idx)?.device; + let id = WpkInputId { + bustype: BUS_VIRTUAL, + vendor: 0x1209, + product: if device == 0 { 0x0001 } else { 0x0002 }, + version: 0x0001, + }; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkInputId, id); + } + Ok(()) + } + // EVIOCGNAME(len) — variable size; nr fixed at EVIOCGNAME_NR. + n if n == EVIOCGNAME_NR && dir == 2 => { + let device = input_state(proc, ofd_idx)?.device; + let name: &[u8] = if device == 0 { + b"wpk virtual keyboard\0" + } else { + b"wpk virtual pointer\0" + }; + let copy_len = name.len().min(size).min(buf.len()); + buf[..copy_len].copy_from_slice(&name[..copy_len]); + Ok(()) + } + // EVIOCGBIT(ev_type, len) — nr = EVIOCGBIT_NR_BASE + ev_type; + // 32 EV_* slots reserved. + n if (EVIOCGBIT_NR_BASE..EVIOCGBIT_NR_BASE + 32).contains(&n) && dir == 2 => { + let ev_type = (n - EVIOCGBIT_NR_BASE) as u16; + let device = input_state(proc, ofd_idx)?.device; + let len = size.min(buf.len()); + let slice = &mut buf[..len]; + for b in slice.iter_mut() { + *b = 0; + } + crate::input::populate_evbit(device, ev_type, slice); + Ok(()) + } + // EVIOCGABS(axis) — pointer device only; nr = EVIOCGABS_NR_BASE + // + axis; 64 ABS_* slots reserved. + n if (EVIOCGABS_NR_BASE..EVIOCGABS_NR_BASE + 64).contains(&n) && dir == 2 => { + if buf.len() < core::mem::size_of::() { + return Err(Errno::EINVAL); + } + let axis = (n - EVIOCGABS_NR_BASE) as u16; + let device = input_state(proc, ofd_idx)?.device; + // Keyboard has no absolute axes — ENOTTY (not EINVAL) so + // SDL2 keeps probing. + if device != 1 { + return Err(Errno::ENOTTY); + } + let (w, h) = crate::input::canvas_dims(); + let abs = match axis { + ABS_X => WpkInputAbsinfo { + value: 0, + minimum: 0, + maximum: (w as i32) - 1, + fuzz: 0, + flat: 0, + resolution: 1, + }, + ABS_Y => WpkInputAbsinfo { + value: 0, + minimum: 0, + maximum: (h as i32) - 1, + fuzz: 0, + flat: 0, + resolution: 1, + }, + _ => return Err(Errno::ENOTTY), + }; + unsafe { + core::ptr::write_unaligned( + buf.as_mut_ptr() as *mut WpkInputAbsinfo, + abs, + ); + } + Ok(()) + } + // EVIOCGRAB — write i32 (value != 0 grabs, 0 releases). Per-fd + // idempotent (Linux semantics in drivers/input/evdev.c); cross- + // fd EBUSY enforcement is the plan 9 follow-up. + 0x90 if dir == 1 => { + if buf.len() < 4 { + return Err(Errno::EINVAL); + } + let value = i32::from_le_bytes(buf[..4].try_into().unwrap()); + input_state_mut(proc, ofd_idx)?.grabbed = value != 0; + Ok(()) + } + _ => Err(Errno::ENOTTY), + } +} + /// Run DRI-specific cleanup for a freshly-freed OFD: release the /// per-fd GEM handle map (decref every bo, free host backing on the /// last drop) and release a prime-bo capability cookie if any. @@ -14620,6 +14779,18 @@ pub fn sys_ioctl( } } + // --- /dev/input/event{0,1} ioctls — evdev EVIOCG* surface --- + { + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + if ofd.file_type == FileType::CharDevice { + if let Some(VirtualDevice::InputEvent { .. }) = + VirtualDevice::from_host_handle(ofd.host_handle) + { + return handle_input_ioctl(proc, ofd_idx, request, buf); + } + } + } + // --- Linux VT keyboard ioctls (KDGKBTYPE / KDGKBMODE / KDSKBMODE) --- // // fbDOOM (and other Linux-VT-targeted software) calls these on a @@ -45152,4 +45323,213 @@ mod tests { let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); assert_eq!(n, 0); } + + // ----------------------------------------------------------------- + // EVIOCG* ioctl dispatch (A3). + // ----------------------------------------------------------------- + + /// `_IOC(dir, magic, nr, size)` — mirrors include/uapi/asm-generic/ioctl.h + /// (dir 2 = read / dir 1 = write — Linux's `_IOC_READ` / `_IOC_WRITE`). + const fn evioc(dir: u32, nr: u32, size: u32) -> u32 { + (dir << 30) | (size << 16) | ((b'E' as u32) << 8) | nr + } + + fn open_evdev(pid: u32, path: &[u8]) -> (Process, MockHostIO, i32) { + let mut proc = Process::new(pid); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, path, O_RDWR, 0).unwrap(); + (proc, host, fd) + } + + #[test] + fn evioc_gversion_returns_010001() { + use wasm_posix_shared::input::EVIOCGVERSION; + let (mut proc, mut host, fd) = open_evdev(601, b"/dev/input/event0"); + let mut buf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host, fd, EVIOCGVERSION, &mut buf).unwrap(); + assert_eq!(u32::from_le_bytes(buf), 0x0001_0001); + } + + #[test] + fn evioc_gid_keyboard_vs_pointer_differs_by_product() { + use wasm_posix_shared::input::{EVIOCGID, WpkInputId, BUS_VIRTUAL}; + let (mut proc, mut host, kfd) = open_evdev(602, b"/dev/input/event0"); + let pfd = sys_open(&mut proc, &mut host, b"/dev/input/event1", O_RDWR, 0).unwrap(); + let mut kbuf = [0u8; core::mem::size_of::()]; + sys_ioctl(&mut proc, &mut host, kfd, EVIOCGID, &mut kbuf).unwrap(); + let kid: WpkInputId = unsafe { core::ptr::read_unaligned(kbuf.as_ptr() as *const _) }; + let mut pbuf = [0u8; core::mem::size_of::()]; + sys_ioctl(&mut proc, &mut host, pfd, EVIOCGID, &mut pbuf).unwrap(); + let pid_: WpkInputId = unsafe { core::ptr::read_unaligned(pbuf.as_ptr() as *const _) }; + assert_eq!(kid.bustype, BUS_VIRTUAL); + assert_eq!(pid_.bustype, BUS_VIRTUAL); + assert_eq!(kid.vendor, pid_.vendor, "vendor matches across devices"); + assert_ne!(kid.product, pid_.product, "product distinguishes kbd vs ptr"); + assert_eq!(kid.product, 0x0001); + assert_eq!(pid_.product, 0x0002); + } + + #[test] + fn evioc_gname_event0_returns_keyboard_string() { + use wasm_posix_shared::input::EVIOCGNAME_NR; + let (mut proc, mut host, fd) = open_evdev(603, b"/dev/input/event0"); + let mut buf = [0u8; 64]; + let req = evioc(2, EVIOCGNAME_NR, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + let nul = buf.iter().position(|&b| b == 0).unwrap(); + assert_eq!(&buf[..nul], b"wpk virtual keyboard"); + } + + #[test] + fn evioc_gname_event1_returns_pointer_string() { + use wasm_posix_shared::input::EVIOCGNAME_NR; + let (mut proc, mut host, fd) = open_evdev(604, b"/dev/input/event1"); + let mut buf = [0u8; 64]; + let req = evioc(2, EVIOCGNAME_NR, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + let nul = buf.iter().position(|&b| b == 0).unwrap(); + assert_eq!(&buf[..nul], b"wpk virtual pointer"); + } + + #[test] + fn evioc_gname_truncates_to_caller_buffer() { + use wasm_posix_shared::input::EVIOCGNAME_NR; + let (mut proc, mut host, fd) = open_evdev(605, b"/dev/input/event0"); + let mut buf = [0xffu8; 5]; + // Caller asks for 5 bytes; "wpk virtual keyboard" is 20 chars, + // so we should fill all 5 with the first 5 bytes (no terminator + // — caller is expected to handle the cut-off case). + let req = evioc(2, EVIOCGNAME_NR, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + assert_eq!(&buf, b"wpk v"); + } + + #[test] + fn evioc_gbit_keyboard_evtype_query_advertises_syn_and_key_only() { + use wasm_posix_shared::input::{EVIOCGBIT_NR_BASE, EV_KEY, EV_REL, EV_SYN}; + let (mut proc, mut host, fd) = open_evdev(606, b"/dev/input/event0"); + let mut buf = [0u8; 4]; + // EVIOCGBIT(ev_type = 0, len = 4) — nr = base + 0. + let req = evioc(2, EVIOCGBIT_NR_BASE, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + assert_ne!(buf[0] & (1 << EV_SYN), 0); + assert_ne!(buf[0] & (1 << EV_KEY), 0); + assert_eq!(buf[0] & (1 << EV_REL), 0, "keyboard must not advertise EV_REL"); + } + + #[test] + fn evioc_gbit_pointer_evtype_query_adds_rel_and_abs() { + use wasm_posix_shared::input::{EVIOCGBIT_NR_BASE, EV_ABS, EV_REL}; + let (mut proc, mut host, fd) = open_evdev(607, b"/dev/input/event1"); + let mut buf = [0u8; 4]; + let req = evioc(2, EVIOCGBIT_NR_BASE, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + assert_ne!(buf[0] & (1 << EV_REL), 0); + assert_ne!(buf[0] & (1 << EV_ABS), 0); + } + + #[test] + fn evioc_gbit_keyboard_ev_key_lists_key_a() { + use wasm_posix_shared::input::{EVIOCGBIT_NR_BASE, EV_KEY, KEY_A}; + let (mut proc, mut host, fd) = open_evdev(608, b"/dev/input/event0"); + let mut buf = [0u8; 32]; + // EVIOCGBIT(EV_KEY, 32) — nr = base + EV_KEY. + let req = evioc(2, EVIOCGBIT_NR_BASE + EV_KEY as u32, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); + let byte = (KEY_A >> 3) as usize; + assert_ne!(buf[byte] & (1 << (KEY_A & 7)), 0); + } + + #[test] + fn evioc_gabs_keyboard_returns_enotty() { + use wasm_posix_shared::input::{EVIOCGABS_NR_BASE, ABS_X, WpkInputAbsinfo}; + let (mut proc, mut host, fd) = open_evdev(609, b"/dev/input/event0"); + let mut buf = [0u8; core::mem::size_of::()]; + let req = evioc(2, EVIOCGABS_NR_BASE + ABS_X as u32, buf.len() as u32); + let err = sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap_err(); + // ENOTTY (not EINVAL) — SDL2 greps the errno; EINVAL fatals it. + assert_eq!(err, Errno::ENOTTY); + } + + #[test] + fn evioc_gabs_pointer_x_returns_canvas_width_minus_one() { + use wasm_posix_shared::input::{EVIOCGABS_NR_BASE, ABS_X, ABS_Y, WpkInputAbsinfo}; + crate::input::set_canvas_dims(800, 600); + let (mut proc, mut host, fd) = open_evdev(610, b"/dev/input/event1"); + let mut buf = [0u8; core::mem::size_of::()]; + let req_x = evioc(2, EVIOCGABS_NR_BASE + ABS_X as u32, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req_x, &mut buf).unwrap(); + let abs: WpkInputAbsinfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(abs.maximum, 799); + assert_eq!(abs.resolution, 1); + assert_eq!(abs.minimum, 0); + let req_y = evioc(2, EVIOCGABS_NR_BASE + ABS_Y as u32, buf.len() as u32); + sys_ioctl(&mut proc, &mut host, fd, req_y, &mut buf).unwrap(); + let aby: WpkInputAbsinfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(aby.maximum, 599); + // Restore the default — other tests in this module may run in + // parallel and expect the boot value. + crate::input::set_canvas_dims(1280, 720); + } + + #[test] + fn evioc_grab_sets_flag_then_release_clears_it() { + use wasm_posix_shared::input::EVIOCGRAB; + let (mut proc, mut host, fd) = open_evdev(611, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + let mut on = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut on).unwrap(); + assert!(proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().grabbed); + let mut off = 0i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut off).unwrap(); + assert!(!proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().grabbed); + } + + #[test] + fn evioc_grab_twice_from_same_fd_is_idempotent() { + use wasm_posix_shared::input::EVIOCGRAB; + let (mut proc, mut host, fd) = open_evdev(612, b"/dev/input/event0"); + let mut on = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut on).unwrap(); + // Re-grab from the same fd must succeed (Linux semantics in + // drivers/input/evdev.c). The cross-fd EBUSY case lands with + // plan 9. + let mut on2 = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut on2).unwrap(); + } + + #[test] + fn evioc_grab_release_without_prior_grab_is_a_noop() { + use wasm_posix_shared::input::EVIOCGRAB; + let (mut proc, mut host, fd) = open_evdev(613, b"/dev/input/event0"); + let mut off = 0i32.to_le_bytes(); + // Never grabbed; release returns 0, not an error. + sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut off).unwrap(); + } + + #[test] + fn evioc_unknown_request_returns_enotty_not_einval() { + // SDL2's evdev probe greps the errno from EVIOCG* calls; EINVAL + // fatals it. Every unhandled request on an evdev fd must come + // back as ENOTTY. + let (mut proc, mut host, fd) = open_evdev(614, b"/dev/input/event0"); + // 'E' magic, dir = 2 (read), nr = 0xfe (never assigned), size = 0. + let bogus = evioc(2, 0xfe, 0); + let mut buf = [0u8; 4]; + let err = sys_ioctl(&mut proc, &mut host, fd, bogus, &mut buf).unwrap_err(); + assert_eq!(err, Errno::ENOTTY); + } + + #[test] + fn evioc_foreign_magic_on_evdev_fd_returns_enotty() { + // Non-'E' magic — caller probed something foreign on the fd. + // ENOTTY (not EINVAL) so probing loops keep moving. + let (mut proc, mut host, fd) = open_evdev(615, b"/dev/input/event0"); + // dir = 2, magic = 'X', nr = 0x01, size = 4 — looks like a read + // ioctl for some other subsystem. + let foreign = (2u32 << 30) | (4u32 << 16) | ((b'X' as u32) << 8) | 0x01; + let mut buf = [0u8; 4]; + let err = sys_ioctl(&mut proc, &mut host, fd, foreign, &mut buf).unwrap_err(); + assert_eq!(err, Errno::ENOTTY); + } } From 8b2d4fa62991926dc11415e169452681f48d276b Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 17:29:35 +0200 Subject: [PATCH 05/27] kernel(input): kernel_input_event export + fan-out + ring overflow handling Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kernel/src/input/dispatch.rs | 255 ++++++++++++++++++++++++++++ crates/kernel/src/input/mod.rs | 9 +- crates/kernel/src/input/wait.rs | 26 +++ crates/kernel/src/wasm_api.rs | 56 ++++++ 4 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 crates/kernel/src/input/dispatch.rs create mode 100644 crates/kernel/src/input/wait.rs diff --git a/crates/kernel/src/input/dispatch.rs b/crates/kernel/src/input/dispatch.rs new file mode 100644 index 0000000000..03d8d9984a --- /dev/null +++ b/crates/kernel/src/input/dispatch.rs @@ -0,0 +1,255 @@ +//! Event producer for `/dev/input/event{0,1}`. +//! +//! The host calls `kernel_input_event` once per translated DOM key / +//! pointer event; that export feeds [`push_event`] here, which fans +//! the record out to every open OFD bound to the matching device. +//! +//! Overflow handling mirrors Linux `drivers/input/evdev.c:: +//! evdev_pass_values`: when an OFD's ring is full we set `dropped = +//! true` and discard the **incoming** record. The next `read()` on +//! that OFD (A5) synthesises a `SYN_DROPPED` marker at the head of +//! its output + clears the flag, so userspace can resynchronise via +//! `EVIOCG*`. Crucially, this bound holds for free even under +//! pathological producers — pushes-while-dropped are no-ops, so the +//! ring never grows past [`INPUT_RING_MAX_BYTES`]. + +use alloc::collections::VecDeque; + +use wasm_posix_shared::input::WpkInputEvent; + +use crate::ofd::INPUT_RING_MAX_BYTES; + +const RECORD_SIZE: usize = core::mem::size_of::(); + +/// Push one `WpkInputEvent` onto every open OFD bound to `device` +/// (0 = `/dev/input/event0` / keyboard, 1 = `event1` / pointer). +/// Other device numbers are dropped. +/// +/// `tv_sec` / `tv_usec` are the CLOCK_MONOTONIC timestamp the kernel +/// stamps the record with — the export wrapper supplies them so this +/// function stays testable without a host. +/// +/// Returns the number of OFDs that accepted the record (i.e. their +/// ring had space and `device` matched). Drops count as "not accepted". +pub fn push_event( + device: u8, + ev_type: u16, + code: u16, + value: i32, + tv_sec: i64, + tv_usec: i32, +) -> usize { + if device > 1 { + return 0; + } + let ev = WpkInputEvent { + tv_sec, + tv_usec, + _pad: 0, + ev_type, + code, + value, + }; + let mut delivered = 0; + let mut woken_ofds: alloc::vec::Vec = alloc::vec::Vec::new(); + crate::process_table::with_processes(|procs| { + for proc in procs { + for (idx, ofd) in proc.ofd_table.iter_mut() { + let Some(input) = ofd.input_mut() else { continue }; + if input.device != device { + continue; + } + // Ring full → set dropped, discard the incoming record. + // The bound on `event_ring.len()` holds because we + // never push when dropped flips on, and read() only + // clears it after emitting the SYN_DROPPED marker. + if input.event_ring.len() + RECORD_SIZE > INPUT_RING_MAX_BYTES { + input.dropped = true; + continue; + } + let was_empty = input.event_ring.is_empty(); + push_record(&mut input.event_ring, &ev); + let records = (input.event_ring.len() / RECORD_SIZE) as u32; + if records > input.ring_high_water { + input.ring_high_water = records; + } + delivered += 1; + if was_empty { + woken_ofds.push(idx); + } + } + } + }); + for ofd_idx in woken_ofds { + crate::input::wait::wake_event_reader(ofd_idx); + } + delivered +} + +fn push_record(ring: &mut VecDeque, ev: &WpkInputEvent) { + let bytes: [u8; RECORD_SIZE] = unsafe { + core::mem::transmute::(*ev) + }; + for &b in bytes.iter() { + ring.push_back(b); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ofd::{FileType, InputFdState, INPUT_RING_MAX_RECORDS}; + use crate::process::Process; + use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; + use alloc::boxed::Box; + use wasm_posix_shared::flags::O_RDWR; + use wasm_posix_shared::input::{EV_KEY, EV_REL, EV_SYN, KEY_A, REL_X, SYN_REPORT}; + + /// Several tests mutate the global PROCESS_TABLE; each uses a + /// distinct pid and only asserts on its own OFDs, so concurrent + /// runs are independent. Returns a 'static &mut to the inserted + /// process — safe because the ProcessTable backs each entry on + /// the heap and tests don't drop their pids. + fn install_process(pid: u32) -> &'static mut Process { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let _ = table.create_process(pid); + let proc = table.processes.get_mut(&pid).unwrap(); + unsafe { &mut *(proc as *mut Process) } + } + + /// Install a fresh OFD on `proc` with `input_state` populated for + /// `device`. Mirrors the shape `install_input_state_on_open` + /// produces from `sys_open`, without dragging MockHostIO across + /// module boundaries. + fn install_input_ofd(proc: &mut Process, device: u8) -> usize { + let host_handle = if device == 0 { -10 } else { -11 }; + let path: alloc::vec::Vec = if device == 0 { + b"/dev/input/event0".to_vec() + } else { + b"/dev/input/event1".to_vec() + }; + let ofd_idx = proc + .ofd_table + .create(FileType::CharDevice, O_RDWR, host_handle, path); + let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); + ofd.input_state = Some(Box::new(InputFdState { + device, + ..Default::default() + })); + ofd_idx + } + + fn ring_records(proc: &Process, ofd_idx: usize) -> usize { + proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().event_ring.len() + / RECORD_SIZE + } + + #[test] + fn push_event_with_unknown_device_is_a_noop() { + // device > 1 → not a valid evdev node; push returns 0. + let _ = install_process(7001); + let delivered = push_event(2, EV_KEY, KEY_A, 1, 0, 0); + assert_eq!(delivered, 0); + } + + #[test] + fn push_event_writes_24_byte_record_to_matching_ofd() { + let proc = install_process(7002); + let ofd_idx = install_input_ofd(proc, 0); + assert_eq!(ring_records(proc, ofd_idx), 0); + + push_event(0, EV_KEY, KEY_A, 1, 42, 1000); + + assert_eq!(ring_records(proc, ofd_idx), 1); + let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + // First 8 bytes = tv_sec (i64 LE) = 42. + let tv_sec_bytes: [u8; 8] = input + .event_ring + .iter() + .take(8) + .copied() + .collect::>() + .try_into() + .unwrap(); + assert_eq!(i64::from_le_bytes(tv_sec_bytes), 42); + } + + #[test] + fn push_event_skips_other_device() { + let proc = install_process(7003); + let kbd = install_input_ofd(proc, 0); + let ptr = install_input_ofd(proc, 1); + + push_event(1, EV_REL, REL_X, 5, 0, 0); + + assert_eq!(ring_records(proc, kbd), 0); + assert_eq!(ring_records(proc, ptr), 1); + } + + #[test] + fn ring_overflow_sets_dropped_and_discards_new_records() { + let proc = install_process(7004); + let ofd_idx = install_input_ofd(proc, 0); + for i in 0..INPUT_RING_MAX_RECORDS { + push_event(0, EV_KEY, KEY_A, i as i32, 0, 0); + } + assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); + assert!(!proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().dropped); + assert_eq!( + proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().ring_high_water as usize, + INPUT_RING_MAX_RECORDS + ); + + // One more push: ring stays at max, `dropped` latches on, the + // incoming record is the one discarded (Linux semantics). + push_event(0, EV_KEY, KEY_A, 0xdead, 0, 0); + assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); + assert!( + proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().dropped, + "dropped flag must latch on overflow" + ); + + // Pushes-while-dropped stay no-ops; the ring is bounded for free. + // (A5 clears `dropped` after emitting SYN_DROPPED at the head + // of the next read.) + push_event(0, EV_KEY, KEY_A, 0xbeef, 0, 0); + assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); + } + + #[test] + fn push_event_tracks_high_water() { + let proc = install_process(7005); + let ofd_idx = install_input_ofd(proc, 0); + for _ in 0..50 { + push_event(0, EV_KEY, KEY_A, 1, 0, 0); + } + assert_eq!( + proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().ring_high_water, + 50 + ); + } + + #[test] + fn push_event_fans_out_to_every_open_ofd_for_the_device() { + // Multi-open: every OFD bound to the same evdev node gets the + // record (mirrors A2's `open_event0_is_multi_process_no_busy` + // — every reader sees every event). + let proc = install_process(7006); + let a = install_input_ofd(proc, 0); + let b = install_input_ofd(proc, 0); + push_event(0, EV_KEY, KEY_A, 1, 0, 0); + assert_eq!(ring_records(proc, a), 1); + assert_eq!(ring_records(proc, b), 1); + } + + #[test] + fn push_event_syn_report_lands_in_ring_verbatim() { + // SYN_REPORT is just a record from the producer's POV — A5's + // read path treats it as the value-boundary marker. + let proc = install_process(7007); + let ofd_idx = install_input_ofd(proc, 0); + push_event(0, EV_KEY, KEY_A, 1, 0, 0); + push_event(0, EV_SYN, SYN_REPORT, 0, 0, 0); + assert_eq!(ring_records(proc, ofd_idx), 2); + } +} diff --git a/crates/kernel/src/input/mod.rs b/crates/kernel/src/input/mod.rs index 4a9370d728..408843d92d 100644 --- a/crates/kernel/src/input/mod.rs +++ b/crates/kernel/src/input/mod.rs @@ -1,8 +1,11 @@ //! evdev input subsystem — backs `/dev/input/event{0,1}`. //! -//! v1 covers `EVIOCG*` ioctl helpers + the canvas-dim cache used to -//! size `EVIOCGABS(ABS_X/ABS_Y)`. Host event push + sys_read ring -//! drain land in A4/A5. +//! Covers the `EVIOCG*` ioctl helpers, the canvas-dim cache used to +//! size `EVIOCGABS(ABS_X/ABS_Y)`, and (in [`dispatch`]) the host- +//! callable event fan-out. sys_read ring drain lands in A5. + +pub mod dispatch; +pub mod wait; use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/kernel/src/input/wait.rs b/crates/kernel/src/input/wait.rs new file mode 100644 index 0000000000..9c06a60e08 --- /dev/null +++ b/crates/kernel/src/input/wait.rs @@ -0,0 +1,26 @@ +//! Per-OFD wake hook for input-event readers. +//! +//! `push_event` calls [`wake_event_reader`] each time a previously- +//! empty ring transitions to non-empty. The host-side wake plumbing +//! (a `pendingInputReaders` registry keyed by OFD index, analogous to +//! `pendingPipeReaders` in `host/src/kernel-worker.ts`) lands in +//! Phase B together with the browser InputSource — until then this is +//! a no-op marker so the producer side can be shipped + tested in +//! isolation. Polls without targeted wake still complete via the +//! host's poll-retry timeout, the same way DRI card0's vblank reader +//! does today. + +/// Notify the host that the per-OFD ring at `ofd_idx` is newly +/// non-empty so any pending `poll(POLLIN)` reader can be woken. +/// +/// **v1 is a no-op.** Routing input wake events through +/// `crate::wakeup::push` today would collide with the pipe-index +/// namespace: the host's `drainAndProcessWakeupEvents` looks up +/// `wakeIdx` in `pendingPipeReaders`, and an OFD index that happens +/// to match a live pipe index would wake the wrong waiter. Phase B +/// will either allocate a separate wake-idx space (mirroring +/// `wakeup::alloc_accept_wake_idx`) or introduce a new wake-type bit +/// so the host can dispatch unambiguously. +pub fn wake_event_reader(_ofd_idx: usize) { + // Intentionally empty; see module docs. +} diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 1135114a8f..11bfaf5cd3 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13940,6 +13940,62 @@ pub extern "C" fn kernel_vblank() -> u32 { crate::dri::vblank_tick() } +/// Push one translated DOM input event onto every open OFD bound to +/// the matching `/dev/input/event{0,1}` node. +/// +/// The host calls this once per DOM keyboard / pointer event after +/// translating the browser-side code to evdev's KEY_* / BTN_* / +/// REL_* / ABS_*. The kernel timestamps each record with +/// CLOCK_MONOTONIC (same source as `kernel_vblank`) so user-side +/// libinput / SDL2 see a single monotonic timeline across vblank + +/// input streams. +/// +/// `device`: 0 = `event0` (kbd), 1 = `event1` (ptr); other values +/// are dropped. +/// `ev_type`: EV_SYN / EV_KEY / EV_REL / EV_ABS. +/// `code`: KEY_* / BTN_* / REL_* / ABS_* / SYN_*. +/// `value`: press(1) / release(0) / repeat(2) for KEY; delta for REL; +/// absolute position for ABS; 0 for SYN_REPORT. +/// +/// Convention: the host emits the type-specific record first +/// (EV_KEY, EV_REL, …) then a matching `EV_SYN(SYN_REPORT, 0)` to +/// close the logical event. SDL2 + libinput coalesce on SYN_REPORT. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_input_event( + device: u32, + ev_type: u32, + code: u32, + value: i32, +) { + let mut host = WasmHostIO; + let (tv_sec, tv_usec) = match host.host_clock_gettime( + wasm_posix_shared::clock::CLOCK_MONOTONIC, + ) { + Ok((sec, nsec)) => (sec, (nsec / 1000) as i32), + Err(_) => (0i64, 0i32), + }; + crate::input::dispatch::push_event( + device as u8, + ev_type as u16, + code as u16, + value, + tv_sec, + tv_usec, + ); +} + +/// Cache the canvas pixel dimensions used by `EVIOCGABS(ABS_X/ABS_Y)` +/// on `/dev/input/event1`. The host calls this once at boot, before +/// it starts the DOM `InputSource`, so the first SDL2 / libinput +/// probe sees the real axis range instead of the 1280×720 fallback. +/// +/// Additive export; ABI-safe. See the boot-ordering contract in plan +/// 5 §A4 step 3. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { + crate::input::set_canvas_dims(width, height); +} + /// Number of successful page-flip commits on the given crtc. /// /// Useful for the host-side stats UI ("how many frames has the From 4a0a571f1601333b8deed9764acab7232a09ed57 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 17:46:50 +0200 Subject: [PATCH 06/27] kernel(input): sys_read drains ring + sys_poll POLLIN gating + SYN_DROPPED resync Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kernel/src/syscalls.rs | 359 +++++++++++++++++++++++++++++++++- 1 file changed, 350 insertions(+), 9 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index c8a3865f36..bf3ec5dbec 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -4726,12 +4726,74 @@ pub fn sys_read( VirtualDevice::Null | VirtualDevice::Fb0 | VirtualDevice::DriRenderD128 => 0, // Real DSP descriptors use PcmPlayback and O_WRONLY. VirtualDevice::Dsp => return Err(Errno::EBADF), - // A2 placeholder — A5 replaces with the ring - // drain. Returning 0 here is "no events - // pending", which is what reads against a - // freshly-opened evdev fd see before any - // host-pushed events arrive. - VirtualDevice::InputEvent { .. } => 0, + VirtualDevice::InputEvent { .. } => { + // Drain the per-OFD evdev ring into the + // caller buffer. Linux evdev semantics: + // the buffer is floored to a whole + // 24-byte `struct input_event` boundary + // and we never return a partial record. + // A sub-record buffer is a protocol bug + // and returns EINVAL. + use wasm_posix_shared::clock::CLOCK_MONOTONIC; + use wasm_posix_shared::input::{ + EV_SYN, SYN_DROPPED, WpkInputEvent, + }; + let usable = (buf.len() / 24) * 24; + if usable == 0 { + return Err(Errno::EINVAL); + } + let input = input_state_mut(proc, ofd_idx)?; + // Nothing queued and no overflow latch + // outstanding: match DriCard0's + // non-blocking behaviour. The kernel + // doesn't actually park the reader here; + // host JS retries poll on a timer until + // wake_event_reader gets real routing in + // Phase B. + if input.event_ring.is_empty() && !input.dropped { + if status_flags & O_NONBLOCK != 0 { + return Err(Errno::EAGAIN); + } + return Ok(0); + } + let mut written = 0; + // Producer overflowed: synthesise a + // SYN_DROPPED marker at the head of this + // read so userspace can resync state via + // EVIOCG* before resuming the event + // stream. CLOCK_MONOTONIC stamp matches + // real records; fallback (0,0) is fine + // because readers select on type/code. + if input.dropped { + let (sec, nsec) = host + .host_clock_gettime(CLOCK_MONOTONIC) + .unwrap_or((0, 0)); + let synth = WpkInputEvent { + tv_sec: sec, + tv_usec: (nsec / 1_000) as i32, + _pad: 0, + ev_type: EV_SYN, + code: SYN_DROPPED, + value: 0, + }; + let bytes: [u8; 24] = unsafe { + core::mem::transmute(synth) + }; + buf[..24].copy_from_slice(&bytes); + written = 24; + input.dropped = false; + } + while written + 24 <= usable + && !input.event_ring.is_empty() + { + for i in 0..24 { + buf[written + i] = + input.event_ring.pop_front().unwrap(); + } + written += 24; + } + written + } VirtualDevice::DriCard0 => { // Drain queued DRM events (DRM_EVENT_FLIP_COMPLETE) // into the caller buffer, one byte at a time so a @@ -13723,6 +13785,53 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) revents |= POLLIN; } // Mice doesn't accept writes — never report POLLOUT. + } else if ofd.file_type == FileType::CharDevice + && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) + { + // /dev/dsp is write-only. POLLOUT is always ready — + // the ring drops oldest frames on overflow rather + // than blocking — and POLLIN never fires. + if pollfd.events & POLLOUT != 0 { + revents |= POLLOUT; + } + } else if ofd.file_type == FileType::CharDevice + && VirtualDevice::from_host_handle(ofd.host_handle) + == Some(VirtualDevice::DriCard0) + { + // /dev/dri/card0 gates POLLIN on the per-fd + // `event_ring` actually holding a DRM event. + // sys_read returns Ok(0) on an empty ring rather + // than blocking, so reporting always-ready POLLIN + // would race the vblank pump: poll → read → 0 → + // drmHandleEvent reports a short read and fails. + if pollfd.events & POLLIN != 0 { + if let Some(kms) = ofd.kms() { + if !kms.event_ring.is_empty() { + revents |= POLLIN; + } + } + } + // card0 doesn't accept writes — never report POLLOUT. + } else if ofd.file_type == FileType::CharDevice + && matches!( + VirtualDevice::from_host_handle(ofd.host_handle), + Some(VirtualDevice::InputEvent { .. }) + ) + { + // /dev/input/event{0,1} gates POLLIN on the per-OFD + // ring holding a record OR the SYN_DROPPED latch + // being set. Either condition means the next read + // returns >0 bytes — sys_read returns Ok(0) on an + // empty/no-latch ring, so reporting always-ready + // POLLIN would spin libinput. + if pollfd.events & POLLIN != 0 { + if let Some(input) = ofd.input() { + if !input.event_ring.is_empty() || input.dropped { + revents |= POLLIN; + } + } + } + // evdev is read-only — never report POLLOUT. } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -45313,9 +45422,11 @@ mod tests { #[test] fn read_eventN_returns_zero_before_any_event() { - // A2 placeholder: the ring is empty; read returns 0 (no - // events). A5 replaces this with the actual ring drain + - // SYN_DROPPED resync semantics. + // Empty ring + no dropped latch + caller didn't request + // O_NONBLOCK → Ok(0). Matches DriCard0's read semantics + // (the kernel doesn't park readers; host JS retries poll + // on a timer until wake_event_reader gets real routing in + // Phase B). let mut proc = Process::new(401); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/input/event0", O_RDONLY, 0).unwrap(); @@ -45532,4 +45643,234 @@ mod tests { let err = sys_ioctl(&mut proc, &mut host, fd, foreign, &mut buf).unwrap_err(); assert_eq!(err, Errno::ENOTTY); } + + // ----------------------------------------------------------------- + // sys_read drain + sys_poll(POLLIN) + SYN_DROPPED resync (A5). + // ----------------------------------------------------------------- + + /// Inject one `WpkInputEvent` into an OFD's ring without going + /// through `dispatch::push_event` (avoids registering the test + /// process in GLOBAL_PROCESS_TABLE). + fn push_event_into_ofd( + proc: &mut Process, + ofd_idx: usize, + ev_type: u16, + code: u16, + value: i32, + ) { + use wasm_posix_shared::input::WpkInputEvent; + let input = proc + .ofd_table + .get_mut(ofd_idx) + .unwrap() + .input_mut() + .unwrap(); + let ev = WpkInputEvent { + tv_sec: 0, + tv_usec: 0, + _pad: 0, + ev_type, + code, + value, + }; + let bytes: [u8; 24] = unsafe { core::mem::transmute(ev) }; + for b in bytes { + input.event_ring.push_back(b); + } + } + + fn extract_record_at( + buf: &[u8], + off: usize, + ) -> wasm_posix_shared::input::WpkInputEvent { + unsafe { + core::ptr::read_unaligned( + buf.as_ptr().add(off) as *const wasm_posix_shared::input::WpkInputEvent, + ) + } + } + + #[test] + fn read_returns_einval_for_buffer_shorter_than_one_record() { + // Linux evdev semantics: reads must be sized to at least one + // `struct input_event`. A 12-byte buffer would force a + // partial record return, which the protocol forbids. + let (mut proc, mut host, fd) = open_evdev(701, b"/dev/input/event0"); + let mut buf = [0u8; 12]; + let err = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap_err(); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn read_drains_whole_records_from_ring() { + use wasm_posix_shared::input::{EV_KEY, EV_SYN, KEY_A, SYN_REPORT}; + let (mut proc, mut host, fd) = open_evdev(702, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 1); + push_event_into_ofd(&mut proc, ofd_idx, EV_SYN, SYN_REPORT, 0); + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 0); + let mut buf = [0u8; 72]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 72); + let r0 = extract_record_at(&buf, 0); + let r1 = extract_record_at(&buf, 24); + let r2 = extract_record_at(&buf, 48); + assert_eq!((r0.ev_type, r0.code, r0.value), (EV_KEY, KEY_A, 1)); + assert_eq!((r1.ev_type, r1.code, r1.value), (EV_SYN, SYN_REPORT, 0)); + assert_eq!((r2.ev_type, r2.code, r2.value), (EV_KEY, KEY_A, 0)); + let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert!(input.event_ring.is_empty()); + } + + #[test] + fn read_truncates_to_whole_record_boundary_and_leaves_remainder() { + use wasm_posix_shared::input::{EV_KEY, KEY_A}; + let (mut proc, mut host, fd) = open_evdev(703, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + for v in 0..3 { + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, v); + } + // 50 bytes: floors to 48 (= 2 whole records); one stays in + // the ring. + let mut buf = [0u8; 50]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 48); + let r0 = extract_record_at(&buf, 0); + let r1 = extract_record_at(&buf, 24); + assert_eq!(r0.value, 0); + assert_eq!(r1.value, 1); + let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert_eq!(input.event_ring.len(), 24); + // The remaining record is value=2; drain via a second read. + let mut buf2 = [0u8; 24]; + let n2 = sys_read(&mut proc, &mut host, fd, &mut buf2).unwrap(); + assert_eq!(n2, 24); + assert_eq!(extract_record_at(&buf2, 0).value, 2); + } + + #[test] + fn read_with_dropped_flag_emits_syn_dropped_and_clears_flag() { + use wasm_posix_shared::input::{EV_SYN, SYN_DROPPED}; + let (mut proc, mut host, fd) = open_evdev(704, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table + .get_mut(ofd_idx) + .unwrap() + .input_mut() + .unwrap() + .dropped = true; + let mut buf = [0u8; 24]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 24); + let synth = extract_record_at(&buf, 0); + assert_eq!(synth.ev_type, EV_SYN); + assert_eq!(synth.code, SYN_DROPPED); + assert_eq!(synth.value, 0); + let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert!(!input.dropped, "dropped flag must clear after SYN_DROPPED emit"); + } + + #[test] + fn read_after_overflow_emits_syn_dropped_then_real_records() { + use wasm_posix_shared::input::{EV_KEY, EV_SYN, KEY_A, SYN_DROPPED}; + let (mut proc, mut host, fd) = open_evdev(705, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 100); + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 101); + // Latch the overflow flag as if the producer hit a full ring + // after pushing those two records — dispatch::push_event sets + // this exact field on the OFD. + proc.ofd_table + .get_mut(ofd_idx) + .unwrap() + .input_mut() + .unwrap() + .dropped = true; + let mut buf = [0u8; 72]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 72); + let synth = extract_record_at(&buf, 0); + assert_eq!((synth.ev_type, synth.code), (EV_SYN, SYN_DROPPED)); + let r1 = extract_record_at(&buf, 24); + let r2 = extract_record_at(&buf, 48); + assert_eq!((r1.ev_type, r1.code, r1.value), (EV_KEY, KEY_A, 100)); + assert_eq!((r2.ev_type, r2.code, r2.value), (EV_KEY, KEY_A, 101)); + let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert!(!input.dropped); + assert!(input.event_ring.is_empty()); + } + + #[test] + fn read_empty_ring_with_nonblock_returns_eagain() { + // Matches DriCard0: O_NONBLOCK + no data ready → EAGAIN. + // Blocking-mode reads still return Ok(0) (covered above). + let mut proc = Process::new(706); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/dev/input/event0", + O_RDONLY | O_NONBLOCK, + 0, + ) + .unwrap(); + let mut buf = [0u8; 24]; + let err = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap_err(); + assert_eq!(err, Errno::EAGAIN); + } + + #[test] + fn poll_pollin_idle_then_ready_after_event_pushed() { + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::input::{EV_KEY, KEY_A}; + use wasm_posix_shared::poll::POLLIN; + let (mut proc, mut host, fd) = open_evdev(707, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + let mut pollfd = WasmPollFd { fd, events: POLLIN, revents: 0 }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 0, "empty ring + no dropped latch → POLLIN idle"); + assert_eq!(pollfd.revents, 0); + push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 1); + let mut pollfd = WasmPollFd { fd, events: POLLIN, revents: 0 }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLIN, 0); + } + + #[test] + fn poll_pollin_ready_when_only_dropped_flag_is_set() { + // The SYN_DROPPED marker alone is a readable record — the + // ring is empty but read() will still return 24 bytes. + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::POLLIN; + let (mut proc, mut host, fd) = open_evdev(708, b"/dev/input/event0"); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table + .get_mut(ofd_idx) + .unwrap() + .input_mut() + .unwrap() + .dropped = true; + let mut pollfd = WasmPollFd { fd, events: POLLIN, revents: 0 }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLIN, 0); + } + + #[test] + fn poll_never_reports_pollout_for_evdev_fd() { + // Input devices are read-only — POLLOUT must never fire, + // even with an empty ring and POLLOUT requested. + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::{POLLIN, POLLOUT}; + let (mut proc, mut host, fd) = open_evdev(709, b"/dev/input/event0"); + let mut pollfd = WasmPollFd { + fd, + events: POLLIN | POLLOUT, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 0); + assert_eq!(pollfd.revents & POLLOUT, 0); + } } From 0028b6d5e2c7cb515dd74b18d4c6ed9e3a164893 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 18:16:19 +0200 Subject: [PATCH 07/27] kernel(input): release input_state on close + serialise across fork/exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kernel/src/fork.rs | 84 +++++++++++++++++++++--- crates/kernel/src/syscalls.rs | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 8 deletions(-) diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 6ffe261712..fd2997b4d2 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -639,6 +639,9 @@ const DRI_TAG_RENDER_NODE: u8 = 1; const DRI_TAG_CARD: u8 = 2; const DRI_TAG_PRIME_BO: u8 = 3; +const INPUT_TAG_NONE: u8 = 0; +const INPUT_TAG_SOME: u8 = 1; + fn write_dri_fd_state(w: &mut Writer<'_>, dri: &crate::ofd::DriFdState) -> Result<(), Errno> { w.write_u32(dri.handles.len() as u32)?; for (handle, bo_id) in &dri.handles { @@ -706,6 +709,30 @@ fn write_dri_state( } } +/// Serialise the evdev sidecar across a fork/exec. The ring is copied +/// byte-for-byte; the child inherits the parent's grab + dropped flag. +/// `EVIOCGRAB` is per-OFD in Linux, so an OFD shared by fork preserves +/// its grab — this matches our refcounted-OFD model where parent + +/// child each end up with their own copy of the InputFdState. +fn write_input_state( + w: &mut Writer<'_>, + state: Option<&crate::ofd::InputFdState>, +) -> Result<(), Errno> { + let Some(input) = state else { + return w.write_u8(INPUT_TAG_NONE); + }; + w.write_u8(INPUT_TAG_SOME)?; + w.write_u8(input.device)?; + w.write_u8(input.grabbed as u8)?; + w.write_u8(input.dropped as u8)?; + w.write_u32(input.ring_high_water)?; + w.write_u32(input.event_ring.len() as u32)?; + for &b in input.event_ring.iter() { + w.write_u8(b)?; + } + Ok(()) +} + /// Read a `DriFdState` from the wire and incref every referenced bo /// in the global registry so the new OFD has its own refcount. The /// caller may still drop the entire OFD if the surrounding deserialize @@ -788,6 +815,45 @@ fn read_kms_fd_state(r: &mut Reader<'_>) -> Result, +) -> Result>, Errno> { + use alloc::collections::VecDeque; + let tag = r.read_u8()?; + match tag { + INPUT_TAG_NONE => Ok(None), + INPUT_TAG_SOME => { + let device = r.read_u8()?; + let grabbed = r.read_u8()? != 0; + let dropped = r.read_u8()? != 0; + let ring_high_water = r.read_u32()?; + let ring_len = r.read_u32()? as usize; + // The ring is always whole 24-byte records and bounded + // at INPUT_RING_MAX_BYTES; reject anything else as a + // corrupted/forged fork stream. + if ring_len > crate::ofd::INPUT_RING_MAX_BYTES + || ring_len % core::mem::size_of::< + wasm_posix_shared::input::WpkInputEvent, + >() != 0 + { + return Err(Errno::EINVAL); + } + let mut event_ring = VecDeque::with_capacity(ring_len); + for _ in 0..ring_len { + event_ring.push_back(r.read_u8()?); + } + Ok(Some(alloc::boxed::Box::new(crate::ofd::InputFdState { + device, + event_ring, + grabbed, + dropped, + ring_high_water, + }))) + } + _ => Err(Errno::EINVAL), + } +} + fn read_dri_state( r: &mut Reader<'_>, ) -> Result>, Errno> { @@ -928,6 +994,8 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result<(), Er ofd_entries.push(None); } let dri_state = read_dri_state(&mut r)?; + let input_state = read_input_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -1277,11 +1346,7 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er dir_position_generation: 0, dir_pending_entry: None, dri_state, - // TODO(plan-5 A4/A5): once the host produces events and - // sys_read drains the ring, serialise input_state through - // fork the same way dri_state is. A2 leaves it None - // because no consumer exists yet. - input_state: None, + input_state, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -1763,6 +1828,10 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result { ofd_entries.push(None); } let dri_state = read_dri_state(&mut r)?; + let input_state = read_input_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -1957,9 +2027,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { dir_position_generation: 0, dir_pending_entry: None, dri_state, - // TODO(plan-5 A4/A5): see same TODO in the other - // OpenFileDesc reconstruction site above. - input_state: None, + input_state, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index bf3ec5dbec..fdd3b00ac1 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2087,6 +2087,26 @@ pub(crate) fn dri_release_ofd_state( } } +/// Run input-specific cleanup for a freshly-freed OFD: drop the per-fd +/// event ring + grab flag. +/// +/// Called from `sys_close` after `dec_ref` has freed the OFD slot. v1 +/// has no host-side per-OFD state to release — the host's `InputSource` +/// is a single producer and the kernel owns every per-OFD ring — so +/// the body is just an explicit drop of the boxed state. The signature +/// mirrors `dri_release_ofd_state` to leave room for plan 9 +/// (wpkcompositor), which will wire a `host.input_grab_released(pid, +/// device)` notification here so a focus-routed compositor can re- +/// grant ownership when a grab-holding OFD closes. +pub(crate) fn input_release_ofd_state( + _pid: i32, + _host: &mut dyn HostIO, + _ofd_idx: usize, + state: Option>, +) { + let _ = state; +} + /// Build a synthetic WasmStat for a virtual device. fn virtual_device_stat(dev: VirtualDevice, uid: u32, gid: u32) -> WasmStat { use wasm_posix_shared::mode::S_IFCHR; @@ -3842,6 +3862,21 @@ fn release_ofd_reference_impl( } }; + // Same dance for the evdev sidecar: take the boxed `InputFdState` + // off the OFD on last-ref so the close-time helper can drop the + // per-OFD ring + grab flag (and, eventually, notify the + // compositor that the grab has been released). + let input_state_for_release = { + let ofd = proc.ofd_table.get(idx).ok_or(Errno::EBADF)?; + if ofd.ref_count == 1 { + proc.ofd_table + .get_mut(idx) + .and_then(|ofd| ofd.input_state.take()) + } else { + None + } + }; + let freed = proc.ofd_table.dec_ref(idx); if freed { @@ -3849,6 +3884,10 @@ fn release_ofd_reference_impl( // KMS master, and prime-bo cookies so close-time bo destroy is // observed before any FileType-specific release path runs. dri_release_ofd_state(proc.pid as i32, host, idx, dri_state_for_release); + // evdev per-fd cleanup: drop the per-OFD event ring + grab + // flag. Order doesn't matter wrt DRI (disjoint state); placed + // here for parity with the dri_state release call. + input_release_ofd_state(proc.pid as i32, host, idx, input_state_for_release); match file_type { FileType::Pipe => { if host_handle >= 0 { @@ -45618,6 +45657,87 @@ mod tests { sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut off).unwrap(); } + #[test] + fn close_releases_grab_so_next_open_can_grab() { + use wasm_posix_shared::input::EVIOCGRAB; + // Open event0, grab it, then close. The OFD slot must drop — + // and a fresh open on the same node must come up clean + // (no leftover grab flag, no stale ring) and be re-grabbable. + // v1 doesn't enforce cross-OFD EBUSY anyway, so this mostly + // exercises that close-time input cleanup runs without panicking + // and the OFD slot is actually freed. + let (mut proc, mut host, fd_a) = open_evdev(616, b"/dev/input/event0"); + let idx_a = proc.fd_table.get(fd_a).unwrap().ofd_ref.0; + let mut on = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd_a, EVIOCGRAB, &mut on).unwrap(); + assert!(proc.ofd_table.get(idx_a).unwrap().input().unwrap().grabbed); + + sys_close(&mut proc, &mut host, fd_a).unwrap(); + // dec_ref freed the OFD slot — entries[idx_a] is now None. + assert!(proc.ofd_table.get(idx_a).is_none()); + + let fd_b = sys_open( + &mut proc, + &mut host, + b"/dev/input/event0", + O_RDWR, + 0, + ) + .unwrap(); + let idx_b = proc.fd_table.get(fd_b).unwrap().ofd_ref.0; + // Fresh OFD: ring empty, dropped clear, grab clear. + let input = proc.ofd_table.get(idx_b).unwrap().input().unwrap(); + assert!(input.event_ring.is_empty()); + assert!(!input.dropped); + assert!(!input.grabbed); + let mut on2 = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd_b, EVIOCGRAB, &mut on2).unwrap(); + assert!(proc.ofd_table.get(idx_b).unwrap().input().unwrap().grabbed); + } + + #[test] + fn fork_then_close_in_child_keeps_grab_on_parent() { + use wasm_posix_shared::input::{ + EVIOCGRAB, EV_KEY, EV_SYN, KEY_A, SYN_REPORT, + }; + let (mut parent, mut host, parent_fd) = + open_evdev(617, b"/dev/input/event0"); + let ofd_idx = parent.fd_table.get(parent_fd).unwrap().ofd_ref.0; + // Parent grabs + queues two events to verify serialisation + // round-trips both the grab flag and the ring contents. + let mut on = 1i32.to_le_bytes(); + sys_ioctl(&mut parent, &mut host, parent_fd, EVIOCGRAB, &mut on) + .unwrap(); + push_event_into_ofd(&mut parent, ofd_idx, EV_KEY, KEY_A, 1); + push_event_into_ofd(&mut parent, ofd_idx, EV_SYN, SYN_REPORT, 0); + + // "Fork": serialise the parent and reconstruct as the child. + // After this, parent and child each own an independent copy of + // the OFD (and its InputFdState) — mirrors the dri_state fork + // tests in fork.rs. + let mut buf = alloc::vec![0u8; 64 * 1024]; + let written = + crate::fork::serialize_fork_state(&parent, &mut buf).unwrap(); + let mut child = + crate::fork::deserialize_fork_state(&buf[..written], 717).unwrap(); + + // Child carries the grab + the ring contents. + let child_input = child.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert_eq!(child_input.device, 0); + assert!(child_input.grabbed); + assert_eq!(child_input.event_ring.len(), 48); // 2 × 24 + + // Closing the child's copy of the fd drops the child's OFD slot. + sys_close(&mut child, &mut host, parent_fd).unwrap(); + assert!(child.ofd_table.get(ofd_idx).is_none()); + + // Parent is untouched — separate Process structs after fork. + let parent_input = + parent.ofd_table.get(ofd_idx).unwrap().input().unwrap(); + assert!(parent_input.grabbed); + assert_eq!(parent_input.event_ring.len(), 48); + } + #[test] fn evioc_unknown_request_returns_enotty_not_einval() { // SDL2's evdev probe greps the errno from EVIOCG* calls; EINVAL From 128ba461ef6cc3185b6acbcb981c04310fe2d27a Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 18:19:34 +0200 Subject: [PATCH 08/27] abi: regenerate snapshot for additive evdev kernel exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 00d123bf9 — not introduced by this branch and not addressed here. Co-Authored-By: Claude Opus 4.7 (1M context) --- abi/snapshot.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/abi/snapshot.json b/abi/snapshot.json index 5f15ade07d..8db95d052c 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -2405,6 +2405,11 @@ "name": "kernel_inject_mouse_event", "signature": "(i32,i32,i32) -> ()" }, + { + "kind": "func", + "name": "kernel_input_event", + "signature": "(i32,i32,i32,i32) -> ()" + }, { "kind": "func", "name": "kernel_ioctl", @@ -2970,6 +2975,11 @@ "name": "kernel_set_fork_fd_action", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_set_input_canvas_dims", + "signature": "(i32,i32) -> ()" + }, { "kind": "func", "name": "kernel_set_max_addr", From 83fbccfec2422db8644644174980aec2c2dd7696 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 19:09:51 +0200 Subject: [PATCH 09/27] =?UTF-8?q?cleanup(input):=20drop=20ceremony=20?= =?UTF-8?q?=E2=80=94=20ring=5Fhigh=5Fwater,=20wake=20stub,=20close=20helpe?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kernel/src/fork.rs | 12 ++-- crates/kernel/src/input/dispatch.rs | 86 +++++------------------------ crates/kernel/src/input/mod.rs | 7 +-- crates/kernel/src/input/wait.rs | 26 --------- crates/kernel/src/ofd.rs | 5 -- crates/kernel/src/syscalls.rs | 63 +++------------------ crates/kernel/src/wasm_api.rs | 30 +++------- 7 files changed, 39 insertions(+), 190 deletions(-) delete mode 100644 crates/kernel/src/input/wait.rs diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index fd2997b4d2..8e423adde1 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -711,9 +711,8 @@ fn write_dri_state( /// Serialise the evdev sidecar across a fork/exec. The ring is copied /// byte-for-byte; the child inherits the parent's grab + dropped flag. -/// `EVIOCGRAB` is per-OFD in Linux, so an OFD shared by fork preserves -/// its grab — this matches our refcounted-OFD model where parent + -/// child each end up with their own copy of the InputFdState. +/// `EVIOCGRAB` is per-OFD in Linux, so each side of the fork ends up +/// with its own copy of the InputFdState. fn write_input_state( w: &mut Writer<'_>, state: Option<&crate::ofd::InputFdState>, @@ -725,7 +724,6 @@ fn write_input_state( w.write_u8(input.device)?; w.write_u8(input.grabbed as u8)?; w.write_u8(input.dropped as u8)?; - w.write_u32(input.ring_high_water)?; w.write_u32(input.event_ring.len() as u32)?; for &b in input.event_ring.iter() { w.write_u8(b)?; @@ -826,10 +824,9 @@ fn read_input_state( let device = r.read_u8()?; let grabbed = r.read_u8()? != 0; let dropped = r.read_u8()? != 0; - let ring_high_water = r.read_u32()?; let ring_len = r.read_u32()? as usize; - // The ring is always whole 24-byte records and bounded - // at INPUT_RING_MAX_BYTES; reject anything else as a + // The ring is always whole 24-byte records and bounded at + // INPUT_RING_MAX_BYTES — reject anything else as a // corrupted/forged fork stream. if ring_len > crate::ofd::INPUT_RING_MAX_BYTES || ring_len % core::mem::size_of::< @@ -847,7 +844,6 @@ fn read_input_state( event_ring, grabbed, dropped, - ring_high_water, }))) } _ => Err(Errno::EINVAL), diff --git a/crates/kernel/src/input/dispatch.rs b/crates/kernel/src/input/dispatch.rs index 03d8d9984a..78634174ad 100644 --- a/crates/kernel/src/input/dispatch.rs +++ b/crates/kernel/src/input/dispatch.rs @@ -1,17 +1,15 @@ //! Event producer for `/dev/input/event{0,1}`. //! -//! The host calls `kernel_input_event` once per translated DOM key / -//! pointer event; that export feeds [`push_event`] here, which fans -//! the record out to every open OFD bound to the matching device. +//! `kernel_input_event` feeds [`push_event`] here, which fans the +//! record out to every open OFD bound to the matching device. //! //! Overflow handling mirrors Linux `drivers/input/evdev.c:: //! evdev_pass_values`: when an OFD's ring is full we set `dropped = -//! true` and discard the **incoming** record. The next `read()` on -//! that OFD (A5) synthesises a `SYN_DROPPED` marker at the head of -//! its output + clears the flag, so userspace can resynchronise via -//! `EVIOCG*`. Crucially, this bound holds for free even under -//! pathological producers — pushes-while-dropped are no-ops, so the -//! ring never grows past [`INPUT_RING_MAX_BYTES`]. +//! true` and discard the **incoming** record. The next `read()` +//! synthesises a `SYN_DROPPED` marker at the head of its output and +//! clears the flag, so userspace can resynchronise via `EVIOCG*`. +//! Pushes-while-dropped are no-ops, so the ring never grows past +//! [`INPUT_RING_MAX_BYTES`]. use alloc::collections::VecDeque; @@ -22,15 +20,11 @@ use crate::ofd::INPUT_RING_MAX_BYTES; const RECORD_SIZE: usize = core::mem::size_of::(); /// Push one `WpkInputEvent` onto every open OFD bound to `device` -/// (0 = `/dev/input/event0` / keyboard, 1 = `event1` / pointer). -/// Other device numbers are dropped. +/// (0 = keyboard, 1 = pointer). Other device numbers are dropped. /// -/// `tv_sec` / `tv_usec` are the CLOCK_MONOTONIC timestamp the kernel -/// stamps the record with — the export wrapper supplies them so this +/// Returns the count of OFDs that accepted the record (drops do not +/// count). `tv_sec` / `tv_usec` are supplied by the caller so this /// function stays testable without a host. -/// -/// Returns the number of OFDs that accepted the record (i.e. their -/// ring had space and `device` matched). Drops count as "not accepted". pub fn push_event( device: u8, ev_type: u16, @@ -51,38 +45,22 @@ pub fn push_event( value, }; let mut delivered = 0; - let mut woken_ofds: alloc::vec::Vec = alloc::vec::Vec::new(); crate::process_table::with_processes(|procs| { for proc in procs { - for (idx, ofd) in proc.ofd_table.iter_mut() { + for (_idx, ofd) in proc.ofd_table.iter_mut() { let Some(input) = ofd.input_mut() else { continue }; if input.device != device { continue; } - // Ring full → set dropped, discard the incoming record. - // The bound on `event_ring.len()` holds because we - // never push when dropped flips on, and read() only - // clears it after emitting the SYN_DROPPED marker. if input.event_ring.len() + RECORD_SIZE > INPUT_RING_MAX_BYTES { input.dropped = true; continue; } - let was_empty = input.event_ring.is_empty(); push_record(&mut input.event_ring, &ev); - let records = (input.event_ring.len() / RECORD_SIZE) as u32; - if records > input.ring_high_water { - input.ring_high_water = records; - } delivered += 1; - if was_empty { - woken_ofds.push(idx); - } } } }); - for ofd_idx in woken_ofds { - crate::input::wait::wake_event_reader(ofd_idx); - } delivered } @@ -105,11 +83,8 @@ mod tests { use wasm_posix_shared::flags::O_RDWR; use wasm_posix_shared::input::{EV_KEY, EV_REL, EV_SYN, KEY_A, REL_X, SYN_REPORT}; - /// Several tests mutate the global PROCESS_TABLE; each uses a - /// distinct pid and only asserts on its own OFDs, so concurrent - /// runs are independent. Returns a 'static &mut to the inserted - /// process — safe because the ProcessTable backs each entry on - /// the heap and tests don't drop their pids. + // Tests mutate the global PROCESS_TABLE and only assert on their + // own pids, so concurrent runs are independent. fn install_process(pid: u32) -> &'static mut Process { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let _ = table.create_process(pid); @@ -117,10 +92,6 @@ mod tests { unsafe { &mut *(proc as *mut Process) } } - /// Install a fresh OFD on `proc` with `input_state` populated for - /// `device`. Mirrors the shape `install_input_state_on_open` - /// produces from `sys_open`, without dragging MockHostIO across - /// module boundaries. fn install_input_ofd(proc: &mut Process, device: u8) -> usize { let host_handle = if device == 0 { -10 } else { -11 }; let path: alloc::vec::Vec = if device == 0 { @@ -146,7 +117,6 @@ mod tests { #[test] fn push_event_with_unknown_device_is_a_noop() { - // device > 1 → not a valid evdev node; push returns 0. let _ = install_process(7001); let delivered = push_event(2, EV_KEY, KEY_A, 1, 0, 0); assert_eq!(delivered, 0); @@ -162,7 +132,6 @@ mod tests { assert_eq!(ring_records(proc, ofd_idx), 1); let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); - // First 8 bytes = tv_sec (i64 LE) = 42. let tv_sec_bytes: [u8; 8] = input .event_ring .iter() @@ -195,13 +164,9 @@ mod tests { } assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); assert!(!proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().dropped); - assert_eq!( - proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().ring_high_water as usize, - INPUT_RING_MAX_RECORDS - ); - // One more push: ring stays at max, `dropped` latches on, the - // incoming record is the one discarded (Linux semantics). + // Linux semantics: ring stays at max, `dropped` latches on, + // the incoming record is the one discarded. push_event(0, EV_KEY, KEY_A, 0xdead, 0, 0); assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); assert!( @@ -209,31 +174,12 @@ mod tests { "dropped flag must latch on overflow" ); - // Pushes-while-dropped stay no-ops; the ring is bounded for free. - // (A5 clears `dropped` after emitting SYN_DROPPED at the head - // of the next read.) push_event(0, EV_KEY, KEY_A, 0xbeef, 0, 0); assert_eq!(ring_records(proc, ofd_idx), INPUT_RING_MAX_RECORDS); } - #[test] - fn push_event_tracks_high_water() { - let proc = install_process(7005); - let ofd_idx = install_input_ofd(proc, 0); - for _ in 0..50 { - push_event(0, EV_KEY, KEY_A, 1, 0, 0); - } - assert_eq!( - proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().ring_high_water, - 50 - ); - } - #[test] fn push_event_fans_out_to_every_open_ofd_for_the_device() { - // Multi-open: every OFD bound to the same evdev node gets the - // record (mirrors A2's `open_event0_is_multi_process_no_busy` - // — every reader sees every event). let proc = install_process(7006); let a = install_input_ofd(proc, 0); let b = install_input_ofd(proc, 0); @@ -244,8 +190,6 @@ mod tests { #[test] fn push_event_syn_report_lands_in_ring_verbatim() { - // SYN_REPORT is just a record from the producer's POV — A5's - // read path treats it as the value-boundary marker. let proc = install_process(7007); let ofd_idx = install_input_ofd(proc, 0); push_event(0, EV_KEY, KEY_A, 1, 0, 0); diff --git a/crates/kernel/src/input/mod.rs b/crates/kernel/src/input/mod.rs index 408843d92d..5fbca9be1a 100644 --- a/crates/kernel/src/input/mod.rs +++ b/crates/kernel/src/input/mod.rs @@ -1,11 +1,10 @@ //! evdev input subsystem — backs `/dev/input/event{0,1}`. //! -//! Covers the `EVIOCG*` ioctl helpers, the canvas-dim cache used to -//! size `EVIOCGABS(ABS_X/ABS_Y)`, and (in [`dispatch`]) the host- -//! callable event fan-out. sys_read ring drain lands in A5. +//! Covers the canvas-dim cache used to size `EVIOCGABS(ABS_X/ABS_Y)`, +//! the `EVIOCGBIT(*)` bitmap helper, and (in [`dispatch`]) the host- +//! callable event fan-out. pub mod dispatch; -pub mod wait; use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/crates/kernel/src/input/wait.rs b/crates/kernel/src/input/wait.rs deleted file mode 100644 index 9c06a60e08..0000000000 --- a/crates/kernel/src/input/wait.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Per-OFD wake hook for input-event readers. -//! -//! `push_event` calls [`wake_event_reader`] each time a previously- -//! empty ring transitions to non-empty. The host-side wake plumbing -//! (a `pendingInputReaders` registry keyed by OFD index, analogous to -//! `pendingPipeReaders` in `host/src/kernel-worker.ts`) lands in -//! Phase B together with the browser InputSource — until then this is -//! a no-op marker so the producer side can be shipped + tested in -//! isolation. Polls without targeted wake still complete via the -//! host's poll-retry timeout, the same way DRI card0's vblank reader -//! does today. - -/// Notify the host that the per-OFD ring at `ofd_idx` is newly -/// non-empty so any pending `poll(POLLIN)` reader can be woken. -/// -/// **v1 is a no-op.** Routing input wake events through -/// `crate::wakeup::push` today would collide with the pipe-index -/// namespace: the host's `drainAndProcessWakeupEvents` looks up -/// `wakeIdx` in `pendingPipeReaders`, and an OFD index that happens -/// to match a live pipe index would wake the wrong waiter. Phase B -/// will either allocate a separate wake-idx space (mirroring -/// `wakeup::alloc_accept_wake_idx`) or introduce a new wake-type bit -/// so the host can dispatch unambiguously. -pub fn wake_event_reader(_ofd_idx: usize) { - // Intentionally empty; see module docs. -} diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 1ec9ddc3a2..244e6e5c69 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -314,10 +314,6 @@ pub struct InputFdState { /// next `read()` *after* a `SYN_DROPPED` synthetic record is /// delivered at the head of that read's output. pub dropped: bool, - - /// Peak record count seen on this ring — debug-only, not exposed - /// to userspace. - pub ring_high_water: u32, } #[derive(Clone)] @@ -1248,7 +1244,6 @@ mod tests { assert_eq!(st.device, 0); assert!(!st.grabbed); assert!(!st.dropped); - assert_eq!(st.ring_high_water, 0); assert!(st.event_ring.is_empty()); // input_mut lets us mutate the ring. diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index fdd3b00ac1..923a04066a 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -178,11 +178,7 @@ impl VirtualDevice { VirtualDevice::Dsp => -7, VirtualDevice::DriRenderD128 => -8, VirtualDevice::DriCard0 => -9, - VirtualDevice::InputEvent { device: 0 } => -10, - VirtualDevice::InputEvent { device: 1 } => -11, - // Any other device byte is unreachable — match_virtual_device - // only constructs InputEvent{0} or InputEvent{1}. - VirtualDevice::InputEvent { .. } => -10, + VirtualDevice::InputEvent { device } => -10 - device as i64, } } @@ -2087,26 +2083,6 @@ pub(crate) fn dri_release_ofd_state( } } -/// Run input-specific cleanup for a freshly-freed OFD: drop the per-fd -/// event ring + grab flag. -/// -/// Called from `sys_close` after `dec_ref` has freed the OFD slot. v1 -/// has no host-side per-OFD state to release — the host's `InputSource` -/// is a single producer and the kernel owns every per-OFD ring — so -/// the body is just an explicit drop of the boxed state. The signature -/// mirrors `dri_release_ofd_state` to leave room for plan 9 -/// (wpkcompositor), which will wire a `host.input_grab_released(pid, -/// device)` notification here so a focus-routed compositor can re- -/// grant ownership when a grab-holding OFD closes. -pub(crate) fn input_release_ofd_state( - _pid: i32, - _host: &mut dyn HostIO, - _ofd_idx: usize, - state: Option>, -) { - let _ = state; -} - /// Build a synthetic WasmStat for a virtual device. fn virtual_device_stat(dev: VirtualDevice, uid: u32, gid: u32) -> WasmStat { use wasm_posix_shared::mode::S_IFCHR; @@ -3862,20 +3838,6 @@ fn release_ofd_reference_impl( } }; - // Same dance for the evdev sidecar: take the boxed `InputFdState` - // off the OFD on last-ref so the close-time helper can drop the - // per-OFD ring + grab flag (and, eventually, notify the - // compositor that the grab has been released). - let input_state_for_release = { - let ofd = proc.ofd_table.get(idx).ok_or(Errno::EBADF)?; - if ofd.ref_count == 1 { - proc.ofd_table - .get_mut(idx) - .and_then(|ofd| ofd.input_state.take()) - } else { - None - } - }; let freed = proc.ofd_table.dec_ref(idx); @@ -3884,10 +3846,6 @@ fn release_ofd_reference_impl( // KMS master, and prime-bo cookies so close-time bo destroy is // observed before any FileType-specific release path runs. dri_release_ofd_state(proc.pid as i32, host, idx, dri_state_for_release); - // evdev per-fd cleanup: drop the per-OFD event ring + grab - // flag. Order doesn't matter wrt DRI (disjoint state); placed - // here for parity with the dri_state release call. - input_release_ofd_state(proc.pid as i32, host, idx, input_state_for_release); match file_type { FileType::Pipe => { if host_handle >= 0 { @@ -4782,13 +4740,10 @@ pub fn sys_read( return Err(Errno::EINVAL); } let input = input_state_mut(proc, ofd_idx)?; - // Nothing queued and no overflow latch - // outstanding: match DriCard0's - // non-blocking behaviour. The kernel - // doesn't actually park the reader here; - // host JS retries poll on a timer until - // wake_event_reader gets real routing in - // Phase B. + // Match DriCard0: the kernel never parks + // the reader; the host polls on a retry + // timer until Phase B wires targeted + // wake-ups. if input.event_ring.is_empty() && !input.dropped { if status_flags & O_NONBLOCK != 0 { return Err(Errno::EAGAIN); @@ -45461,11 +45416,9 @@ mod tests { #[test] fn read_eventN_returns_zero_before_any_event() { - // Empty ring + no dropped latch + caller didn't request - // O_NONBLOCK → Ok(0). Matches DriCard0's read semantics - // (the kernel doesn't park readers; host JS retries poll - // on a timer until wake_event_reader gets real routing in - // Phase B). + // Blocking read on an empty ring with no dropped latch + // returns Ok(0) — mirrors DriCard0 (kernel never parks the + // reader; host retries on poll timeout). let mut proc = Process::new(401); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/input/event0", O_RDONLY, 0).unwrap(); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 11bfaf5cd3..a919aed266 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13940,26 +13940,17 @@ pub extern "C" fn kernel_vblank() -> u32 { crate::dri::vblank_tick() } -/// Push one translated DOM input event onto every open OFD bound to -/// the matching `/dev/input/event{0,1}` node. +/// Fan one translated DOM input event out to every open OFD bound to +/// `/dev/input/event{0,1}`. Records are timestamped with +/// CLOCK_MONOTONIC so libinput / SDL2 see a single monotonic timeline +/// across vblank + input streams. /// -/// The host calls this once per DOM keyboard / pointer event after -/// translating the browser-side code to evdev's KEY_* / BTN_* / -/// REL_* / ABS_*. The kernel timestamps each record with -/// CLOCK_MONOTONIC (same source as `kernel_vblank`) so user-side -/// libinput / SDL2 see a single monotonic timeline across vblank + -/// input streams. -/// -/// `device`: 0 = `event0` (kbd), 1 = `event1` (ptr); other values -/// are dropped. +/// `device`: 0 = kbd (event0), 1 = ptr (event1); other values are +/// dropped. /// `ev_type`: EV_SYN / EV_KEY / EV_REL / EV_ABS. /// `code`: KEY_* / BTN_* / REL_* / ABS_* / SYN_*. /// `value`: press(1) / release(0) / repeat(2) for KEY; delta for REL; /// absolute position for ABS; 0 for SYN_REPORT. -/// -/// Convention: the host emits the type-specific record first -/// (EV_KEY, EV_REL, …) then a matching `EV_SYN(SYN_REPORT, 0)` to -/// close the logical event. SDL2 + libinput coalesce on SYN_REPORT. #[unsafe(no_mangle)] pub extern "C" fn kernel_input_event( device: u32, @@ -13985,12 +13976,9 @@ pub extern "C" fn kernel_input_event( } /// Cache the canvas pixel dimensions used by `EVIOCGABS(ABS_X/ABS_Y)` -/// on `/dev/input/event1`. The host calls this once at boot, before -/// it starts the DOM `InputSource`, so the first SDL2 / libinput -/// probe sees the real axis range instead of the 1280×720 fallback. -/// -/// Additive export; ABI-safe. See the boot-ordering contract in plan -/// 5 §A4 step 3. +/// on `/dev/input/event1`. The host calls this once at boot so the +/// first SDL2 / libinput probe sees the real axis range instead of +/// the 1280×720 fallback. #[unsafe(no_mangle)] pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { crate::input::set_canvas_dims(width, height); From d5c381d35044894dfc67b03b4bcf278822872a91 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 19:22:43 +0200 Subject: [PATCH 10/27] host(input): InputSource interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/src/input/input-source.ts | 33 +++++++++++++++++++++++++++++++ host/test/input-source.test.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 host/src/input/input-source.ts create mode 100644 host/test/input-source.test.ts diff --git a/host/src/input/input-source.ts b/host/src/input/input-source.ts new file mode 100644 index 0000000000..971c7cff79 --- /dev/null +++ b/host/src/input/input-source.ts @@ -0,0 +1,33 @@ +/** + * `InputSource` — host-side abstraction over an evdev-shaped event + * producer. One implementation per host: `BrowserInputSource` captures + * DOM events (keyboard + pointer + wheel) and translates them to Linux + * evdev codes; `NodeInputSource` is a null-source for headless test + * runs. The host wires `dispatch` to `kernel.exports.kernel_input_event` + * at boot, after `kernel.exports.kernel_set_input_canvas_dims`. + */ + +/** Records a single evdev-shaped event ready for kernel dispatch. + * + * `device` selects the virtual device: `0` is the keyboard + * (`/dev/input/event0`), `1` is the pointer (`/dev/input/event1`). + * `ev_type`, `code`, `value` mirror the Linux `struct input_event` + * tail — see `linux/input-event-codes.h` for the constant space. + */ +export interface InputEvent { + device: 0 | 1; + ev_type: number; + code: number; + value: number; +} + +export interface InputSource { + /** Begin capturing input. `dispatch` is called once per evdev record. + * Convention: the source emits the type-specific record (EV_KEY, + * EV_REL, EV_ABS, …) and then an `EV_SYN(SYN_REPORT, 0)` to close + * the logical frame — same shape Linux evdev produces. */ + start(dispatch: (ev: InputEvent) => void): void; + + /** Stop capturing; remove DOM listeners or clear timers. */ + stop(): void; +} diff --git a/host/test/input-source.test.ts b/host/test/input-source.test.ts new file mode 100644 index 0000000000..fd6d1ac239 --- /dev/null +++ b/host/test/input-source.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { + InputEvent, + InputSource, +} from "../src/input/input-source.js"; + +describe("InputSource interface", () => { + it("admits a minimal stub source that round-trips events through dispatch", () => { + const recorded: InputEvent[] = []; + + class StubSource implements InputSource { + private dispatch: ((ev: InputEvent) => void) | null = null; + start(dispatch: (ev: InputEvent) => void): void { + this.dispatch = dispatch; + } + stop(): void { + this.dispatch = null; + } + emit(ev: InputEvent): void { + this.dispatch?.(ev); + } + } + + const src = new StubSource(); + src.start((ev) => recorded.push(ev)); + src.emit({ device: 0, ev_type: 0x01, code: 30, value: 1 }); + src.emit({ device: 0, ev_type: 0x00, code: 0, value: 0 }); + src.stop(); + src.emit({ device: 1, ev_type: 0x02, code: 0, value: 5 }); + + expect(recorded).toEqual([ + { device: 0, ev_type: 0x01, code: 30, value: 1 }, + { device: 0, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); +}); From fa1d55f2321c0d7692d0907970209591113f212e Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 19:32:23 +0200 Subject: [PATCH 11/27] =?UTF-8?q?host(input):=20BrowserInputSource=20?= =?UTF-8?q?=E2=80=94=20DOM=20capture=20+=20KEY=5F*/BTN=5F*/REL=5F*/ABS=5F*?= =?UTF-8?q?=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/src/input/browser-input-source.ts | 163 ++++++++++++++++ host/src/input/key-code-table.ts | 149 +++++++++++++++ host/test/browser-input-source.test.ts | 253 +++++++++++++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 host/src/input/browser-input-source.ts create mode 100644 host/src/input/key-code-table.ts create mode 100644 host/test/browser-input-source.test.ts diff --git a/host/src/input/browser-input-source.ts b/host/src/input/browser-input-source.ts new file mode 100644 index 0000000000..3bb2ca1127 --- /dev/null +++ b/host/src/input/browser-input-source.ts @@ -0,0 +1,163 @@ +/** + * `BrowserInputSource` — captures DOM keyboard/pointer/wheel events, + * translates them to Linux evdev records (`KEY_*`, `BTN_*`, `REL_*`, + * `ABS_*`) and closes each logical input with a `SYN_REPORT`. Wired + * into `kernel.exports.kernel_input_event` by the browser host's worker + * entry at boot (B4). + * + * Coordinate convention: + * - Pointer-lock active → REL_X / REL_Y deltas (from movementX/Y). + * - Pointer-lock inactive → ABS_X / ABS_Y absolute (from offsetX/Y). + * On a lock-state transition we emit a bare SYN_REPORT so libinput / + * SDL2 see a re-sync point and don't carry forward a stale axis + * value. + */ +import type { InputSource, InputEvent } from "./input-source.js"; +import { codeToKey } from "./key-code-table.js"; + +const EV_SYN = 0x00, + EV_KEY = 0x01, + EV_REL = 0x02, + EV_ABS = 0x03; +const SYN_REPORT = 0x00; +const REL_X = 0x00, + REL_Y = 0x01, + REL_WHEEL = 0x08, + REL_HWHEEL = 0x06; +const ABS_X = 0x00, + ABS_Y = 0x01; +const BTN_LEFT = 0x110, + BTN_RIGHT = 0x111, + BTN_MIDDLE = 0x112; + +export class BrowserInputSource implements InputSource { + private dispatch: ((ev: InputEvent) => void) | null = null; + private bindings: Array<[EventTarget, string, EventListener]> = []; + + constructor( + private target: EventTarget = window, + // Stashed for B4 — `kernel_set_input_canvas_dims` reads w/h off it. + // Unused inside this module; intentionally kept on `this`. + private canvas?: HTMLCanvasElement | OffscreenCanvas, + ) { + void this.canvas; + } + + start(dispatch: (ev: InputEvent) => void): void { + this.dispatch = dispatch; + this.bind("keydown", this.onKeyDown); + this.bind("keyup", this.onKeyUp); + this.bind("pointermove", this.onPointerMove); + this.bind("pointerdown", this.onPointerDown); + this.bind("pointerup", this.onPointerUp); + this.bind("wheel", this.onWheel); + // `pointerlockchange` only fires on document, never on window — so + // it can't go through this.bind which is parametric over `target`. + // Tracked in `bindings` for symmetric removal in stop(). + const lockHandler = this.onPointerLockChange.bind(this) as EventListener; + this.bindings.push([document, "pointerlockchange", lockHandler]); + document.addEventListener("pointerlockchange", lockHandler); + } + + stop(): void { + for (const [t, n, l] of this.bindings) t.removeEventListener(n, l); + this.bindings = []; + this.dispatch = null; + } + + private bind(name: string, handler: (e: any) => void) { + const wrapped = handler.bind(this); + this.target.addEventListener(name, wrapped as EventListener); + this.bindings.push([this.target, name, wrapped as EventListener]); + } + + private emit( + device: 0 | 1, + ev_type: number, + code: number, + value: number, + ): void { + this.dispatch!({ device, ev_type, code, value }); + } + + private frame(device: 0 | 1): void { + this.emit(device, EV_SYN, SYN_REPORT, 0); + } + + private onPointerLockChange(): void { + this.frame(1); + } + + private onKeyDown(e: KeyboardEvent): void { + const key = codeToKey(e.code); + if (key === null) return; + e.preventDefault(); + this.emit(0, EV_KEY, key, e.repeat ? 2 : 1); + this.frame(0); + } + + private onKeyUp(e: KeyboardEvent): void { + const key = codeToKey(e.code); + if (key === null) return; + e.preventDefault(); + this.emit(0, EV_KEY, key, 0); + this.frame(0); + } + + private onPointerMove(e: PointerEvent): void { + if (document.pointerLockElement) { + if (e.movementX !== 0) this.emit(1, EV_REL, REL_X, e.movementX); + if (e.movementY !== 0) this.emit(1, EV_REL, REL_Y, e.movementY); + } else { + this.emit(1, EV_ABS, ABS_X, Math.round(e.offsetX)); + this.emit(1, EV_ABS, ABS_Y, Math.round(e.offsetY)); + } + this.frame(1); + } + + private onPointerDown(e: PointerEvent): void { + const btn = pointerButton(e); + if (btn === null) return; + this.emit(1, EV_KEY, btn, 1); + this.frame(1); + } + + private onPointerUp(e: PointerEvent): void { + const btn = pointerButton(e); + if (btn === null) return; + this.emit(1, EV_KEY, btn, 0); + this.frame(1); + } + + private onWheel(e: WheelEvent): void { + e.preventDefault(); + // Browser deltaMode quanta: 0 = PIXEL (Safari ±1–10, Chromium + // ±100/±120 per notch), 1 = LINE (Firefox, ±3 per notch). Divide + // by the mode-specific scale, then clamp small-but-nonzero deltas + // to ±1 so a continuous-trackpad scroll still emits at least one + // tick (otherwise Math.trunc(0.3 / 120) = 0 and the entire scroll + // event disappears). + const scaleY = e.deltaMode === 1 ? 1 : 120; + const scaleX = e.deltaMode === 1 ? 1 : 120; + let ticks_y = Math.trunc(e.deltaY / -scaleY); + let ticks_x = Math.trunc(e.deltaX / scaleX); + if (ticks_y === 0 && e.deltaY !== 0) ticks_y = e.deltaY < 0 ? 1 : -1; + if (ticks_x === 0 && e.deltaX !== 0) ticks_x = e.deltaX > 0 ? 1 : -1; + if (ticks_y !== 0) this.emit(1, EV_REL, REL_WHEEL, ticks_y); + if (ticks_x !== 0) this.emit(1, EV_REL, REL_HWHEEL, ticks_x); + if (ticks_y !== 0 || ticks_x !== 0) this.frame(1); + } +} + +function pointerButton(e: PointerEvent): number | null { + switch (e.button) { + case 0: + return BTN_LEFT; + case 1: + return BTN_MIDDLE; + case 2: + return BTN_RIGHT; + default: + return null; + } +} diff --git a/host/src/input/key-code-table.ts b/host/src/input/key-code-table.ts new file mode 100644 index 0000000000..023af6a466 --- /dev/null +++ b/host/src/input/key-code-table.ts @@ -0,0 +1,149 @@ +/** + * `KeyboardEvent.code` → Linux `KEY_*` lookup. + * + * Matches the kernel-side `shared::input::KEY_*` constants (Linux UAPI + * verbatim — same numeric space SDL2's evdev backend would consume on + * real Linux). The W3C "UI Events KeyboardEvent code Values" spec + * defines the `KeyboardEvent.code` strings; we map each one to its + * Linux keycode where Linux has an equivalent. + * + * Returns `null` for codes we don't translate (locale-specific keys + * Linux has no UAPI for, browser-specific extensions, etc.). userspace + * stacks like libxkbcommon handle the locale layer. + */ + +const CODE_TO_KEY: Record = { + // Writing-system letters: KeyA → KEY_A = 30, etc. + KeyA: 30, KeyB: 48, KeyC: 46, KeyD: 32, KeyE: 18, KeyF: 33, + KeyG: 34, KeyH: 35, KeyI: 23, KeyJ: 36, KeyK: 37, KeyL: 38, + KeyM: 50, KeyN: 49, KeyO: 24, KeyP: 25, KeyQ: 16, KeyR: 19, + KeyS: 31, KeyT: 20, KeyU: 22, KeyV: 47, KeyW: 17, KeyX: 45, + KeyY: 21, KeyZ: 44, + + // Top-row digits: Digit1 → KEY_1 = 2, …, Digit0 → KEY_0 = 11. + Digit1: 2, Digit2: 3, Digit3: 4, Digit4: 5, Digit5: 6, + Digit6: 7, Digit7: 8, Digit8: 9, Digit9: 10, Digit0: 11, + + // Punctuation. + Minus: 12, + Equal: 13, + BracketLeft: 26, + BracketRight: 27, + Backslash: 43, + Semicolon: 39, + Quote: 40, + Backquote: 41, + Comma: 51, + Period: 52, + Slash: 53, + + // International (rare on US layouts; required for JIS/PT-BR/etc). + IntlBackslash: 86, // KEY_102ND + IntlRo: 89, // KEY_RO + IntlYen: 124, // KEY_YEN + + // Whitespace + editing. + Enter: 28, + Tab: 15, + Space: 57, + Backspace: 14, + Escape: 1, + + // Modifiers. + ShiftLeft: 42, + ShiftRight: 54, + ControlLeft: 29, + ControlRight: 97, + AltLeft: 56, + AltRight: 100, + MetaLeft: 125, + MetaRight: 126, + CapsLock: 58, + + // Function keys F1–F24. + F1: 59, F2: 60, F3: 61, F4: 62, F5: 63, F6: 64, + F7: 65, F8: 66, F9: 67, F10: 68, F11: 87, F12: 88, + F13: 183, F14: 184, F15: 185, F16: 186, F17: 187, F18: 188, + F19: 189, F20: 190, F21: 191, F22: 192, F23: 193, F24: 194, + + // Control pad. + Insert: 110, + Delete: 111, + Home: 102, + End: 107, + PageUp: 104, + PageDown: 109, + Help: 138, + + // Arrow pad. + ArrowUp: 103, + ArrowDown: 108, + ArrowLeft: 105, + ArrowRight: 106, + + // System keys. + PrintScreen: 99, // KEY_SYSRQ + ScrollLock: 70, + Pause: 119, + ContextMenu: 127, // KEY_COMPOSE — the "menu" key beside RightMeta + Power: 116, + Sleep: 142, + WakeUp: 143, + + // Numpad. + NumLock: 69, + Numpad0: 82, + Numpad1: 79, Numpad2: 80, Numpad3: 81, + Numpad4: 75, Numpad5: 76, Numpad6: 77, + Numpad7: 71, Numpad8: 72, Numpad9: 73, + NumpadAdd: 78, // KEY_KPPLUS + NumpadSubtract: 74, // KEY_KPMINUS + NumpadMultiply: 55, // KEY_KPASTERISK + NumpadDivide: 98, // KEY_KPSLASH + NumpadDecimal: 83, // KEY_KPDOT + NumpadEnter: 96, // KEY_KPENTER + NumpadEqual: 117, // KEY_KPEQUAL + NumpadComma: 121, // KEY_KPCOMMA + + // IME / CJK input. + Convert: 92, // KEY_HENKAN + NonConvert: 94, // KEY_MUHENKAN + KanaMode: 93, // KEY_KATAKANAHIRAGANA + Lang1: 122, // KEY_HANGEUL — Korean Hangul/English toggle + Lang2: 123, // KEY_HANJA — Korean Hanja conversion + Lang3: 90, // KEY_KATAKANA + Lang4: 91, // KEY_HIRAGANA + + // Audio / media. + AudioVolumeMute: 113, // KEY_MUTE + AudioVolumeDown: 114, // KEY_VOLUMEDOWN + AudioVolumeUp: 115, // KEY_VOLUMEUP + MediaPlayPause: 164, + MediaStop: 166, // KEY_STOPCD + MediaTrackNext: 163, // KEY_NEXTSONG + MediaTrackPrevious: 165, // KEY_PREVIOUSSONG + Eject: 161, // KEY_EJECTCD + + // Browser-style hotkeys (Linux UAPI subset). + BrowserRefresh: 173, + BrowserStop: 128, // KEY_STOP + LaunchApp2: 140, // KEY_CALC + + // Editing hotkeys (mostly Sun-keyboard heritage; libinput still emits). + Cut: 137, + Copy: 133, + Paste: 135, + Undo: 131, + Again: 129, + Find: 136, + Open: 134, + Props: 130, +}; + +/** Translate a `KeyboardEvent.code` string to its Linux `KEY_*` value. + * Returns `null` for codes we don't translate (locale-specific keys + * outside Linux UAPI, browser-specific extensions). */ +export function codeToKey(code: string): number | null { + const k = CODE_TO_KEY[code]; + return k === undefined ? null : k; +} diff --git a/host/test/browser-input-source.test.ts b/host/test/browser-input-source.test.ts new file mode 100644 index 0000000000..d0d682c3af --- /dev/null +++ b/host/test/browser-input-source.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserInputSource } from "../src/input/browser-input-source.js"; +import type { InputEvent } from "../src/input/input-source.js"; + +/** + * Minimal EventTarget stub. We don't pull in jsdom/happy-dom — these + * tests cover translation logic, not DOM semantics. `fire(name, ev)` + * synchronously invokes every listener bound for that event name. + */ +class FakeTarget implements EventTarget { + private listeners = new Map(); + addEventListener(name: string, l: EventListenerOrEventListenerObject | null) { + if (typeof l !== "function") return; + const arr = this.listeners.get(name) ?? []; + arr.push(l); + this.listeners.set(name, arr); + } + removeEventListener(name: string, l: EventListenerOrEventListenerObject | null) { + if (typeof l !== "function") return; + const arr = (this.listeners.get(name) ?? []).filter((x) => x !== l); + this.listeners.set(name, arr); + } + dispatchEvent(_e: Event): boolean { + return true; + } + fire(name: string, ev: object): void { + for (const l of this.listeners.get(name) ?? []) l(ev as Event); + } + count(name: string): number { + return (this.listeners.get(name) ?? []).length; + } +} + +describe("BrowserInputSource", () => { + let target: FakeTarget; + let doc: FakeTarget & { pointerLockElement: Element | null }; + let recorded: InputEvent[]; + let src: BrowserInputSource; + + beforeEach(() => { + target = new FakeTarget(); + doc = Object.assign(new FakeTarget(), { + pointerLockElement: null as Element | null, + }); + vi.stubGlobal("document", doc); + recorded = []; + src = new BrowserInputSource(target); + src.start((ev) => recorded.push(ev)); + }); + + afterEach(() => { + src.stop(); + vi.unstubAllGlobals(); + }); + + it("keydown emits EV_KEY(KEY_A, 1) then SYN_REPORT on the keyboard device", () => { + target.fire("keydown", { + code: "KeyA", + repeat: false, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 0, ev_type: 0x01, code: 30, value: 1 }, + { device: 0, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("repeat keydown emits value=2 (Linux autorepeat convention)", () => { + target.fire("keydown", { + code: "Space", + repeat: true, + preventDefault() {}, + }); + expect(recorded[0]).toEqual({ + device: 0, + ev_type: 0x01, + code: 57, + value: 2, + }); + }); + + it("unknown KeyboardEvent.code is ignored and preventDefault is not called", () => { + let prevented = false; + target.fire("keydown", { + code: "Hyper", + repeat: false, + preventDefault() { + prevented = true; + }, + }); + expect(recorded).toEqual([]); + expect(prevented).toBe(false); + }); + + it("keyup emits EV_KEY(code, 0) then SYN_REPORT", () => { + target.fire("keyup", { + code: "Escape", + repeat: false, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 0, ev_type: 0x01, code: 1, value: 0 }, + { device: 0, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("pointermove without pointer lock emits ABS_X/ABS_Y absolute coords", () => { + target.fire("pointermove", { + offsetX: 123.7, + offsetY: 45, + movementX: 0, + movementY: 0, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x03, code: 0x00, value: 124 }, + { device: 1, ev_type: 0x03, code: 0x01, value: 45 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("pointermove with pointer lock active emits REL_X/REL_Y deltas", () => { + doc.pointerLockElement = {} as Element; + target.fire("pointermove", { + offsetX: 0, + offsetY: 0, + movementX: -3, + movementY: 7, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x00, value: -3 }, + { device: 1, ev_type: 0x02, code: 0x01, value: 7 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("pointermove in lock with zero movement on one axis skips that axis", () => { + doc.pointerLockElement = {} as Element; + target.fire("pointermove", { + offsetX: 0, + offsetY: 0, + movementX: 5, + movementY: 0, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x00, value: 5 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("pointerdown emits BTN_LEFT/MIDDLE/RIGHT for each mouse button", () => { + target.fire("pointerdown", { button: 0 }); + target.fire("pointerdown", { button: 1 }); + target.fire("pointerdown", { button: 2 }); + const codes = recorded.filter((e) => e.ev_type === 0x01).map((e) => e.code); + expect(codes).toEqual([0x110, 0x112, 0x111]); + }); + + it("pointerup emits BTN_LEFT release", () => { + target.fire("pointerup", { button: 0 }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x01, code: 0x110, value: 0 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("pointerdown for unknown button (e.g. side button) drops the event", () => { + target.fire("pointerdown", { button: 3 }); + expect(recorded).toEqual([]); + }); + + it("wheel deltaMode=PIXEL with ±120 chunks normalises to ±1 tick", () => { + target.fire("wheel", { + deltaMode: 0, + deltaX: 0, + deltaY: 120, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x08, value: -1 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("wheel deltaMode=LINE with -3 lines normalises to +3 ticks", () => { + target.fire("wheel", { + deltaMode: 1, + deltaX: 0, + deltaY: -3, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x08, value: 3 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("wheel small-but-nonzero pixel delta clamps to ±1 tick (trackpad)", () => { + target.fire("wheel", { + deltaMode: 0, + deltaX: 0, + deltaY: 1, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x08, value: -1 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("wheel horizontal-only emits REL_HWHEEL and frames", () => { + target.fire("wheel", { + deltaMode: 0, + deltaX: 240, + deltaY: 0, + preventDefault() {}, + }); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x02, code: 0x06, value: 2 }, + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("wheel with zero delta emits no records", () => { + target.fire("wheel", { + deltaMode: 0, + deltaX: 0, + deltaY: 0, + preventDefault() {}, + }); + expect(recorded).toEqual([]); + }); + + it("pointerlockchange emits a bare SYN_REPORT on the pointer device", () => { + doc.fire("pointerlockchange", {}); + expect(recorded).toEqual([ + { device: 1, ev_type: 0x00, code: 0, value: 0 }, + ]); + }); + + it("stop() removes all listeners; subsequent fires emit nothing", () => { + src.stop(); + expect(target.count("keydown")).toBe(0); + expect(target.count("pointermove")).toBe(0); + expect(doc.count("pointerlockchange")).toBe(0); + target.fire("keydown", { + code: "KeyA", + repeat: false, + preventDefault() {}, + }); + doc.fire("pointerlockchange", {}); + expect(recorded).toEqual([]); + }); +}); From 52c074d5e68a6301d8a5f21f60e19ea9083b2ca6 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 19:33:45 +0200 Subject: [PATCH 12/27] =?UTF-8?q?host(input):=20NodeInputSource=20?= =?UTF-8?q?=E2=80=94=20null-source=20for=20headless=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/src/input/node-input-source.ts | 20 ++++++++++++++++++++ host/test/node-input-source.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 host/src/input/node-input-source.ts create mode 100644 host/test/node-input-source.test.ts diff --git a/host/src/input/node-input-source.ts b/host/src/input/node-input-source.ts new file mode 100644 index 0000000000..affefd5acd --- /dev/null +++ b/host/src/input/node-input-source.ts @@ -0,0 +1,20 @@ +/** + * `NodeInputSource` — null-source for the Node host. 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 host still registers an + * `InputSource` at boot so the Node-side init path is symmetric with + * the browser-side one (CLAUDE.md §"Two hosts" — dual-host parity is + * load-bearing). `start()` and `stop()` are deliberate no-ops; no + * records are ever emitted through the registered `dispatch` callback. + */ +import type { InputSource, InputEvent } from "./input-source.js"; + +export class NodeInputSource implements InputSource { + start(_dispatch: (ev: InputEvent) => void): void { + /* intentional no-op — tests call kernel_input_event directly */ + } + stop(): void { + /* intentional no-op */ + } +} diff --git a/host/test/node-input-source.test.ts b/host/test/node-input-source.test.ts new file mode 100644 index 0000000000..e56b0d5b66 --- /dev/null +++ b/host/test/node-input-source.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { NodeInputSource } from "../src/input/node-input-source.js"; +import type { InputEvent } from "../src/input/input-source.js"; + +describe("NodeInputSource", () => { + it("start() registers but emits no records; stop() is a no-op too", () => { + const recorded: InputEvent[] = []; + const src = new NodeInputSource(); + src.start((ev) => recorded.push(ev)); + src.stop(); + expect(recorded).toEqual([]); + }); + + it("can be started + stopped repeatedly without throwing", () => { + const src = new NodeInputSource(); + src.start(() => {}); + src.stop(); + src.start(() => {}); + src.stop(); + expect(true).toBe(true); + }); +}); From 62ec2437a5fc6e224c27895d8689be366042969c Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 19:55:11 +0200 Subject: [PATCH 13/27] host(input): wire kernel_input_event + dual-host boot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/src/browser-kernel-host.ts | 54 +++++++++++++ host/src/browser-kernel-protocol.ts | 31 ++++++++ host/src/browser-kernel-worker-entry.ts | 6 ++ host/src/kernel-worker.ts | 28 +++++++ host/src/kernel.ts | 37 +++++++++ host/src/node-kernel-host.ts | 52 ++++++++++++ host/src/node-kernel-protocol.ts | 30 ++++++- host/src/node-kernel-worker-entry.ts | 6 ++ host/test/input-attach-source.test.ts | 100 ++++++++++++++++++++++++ 9 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 host/test/input-attach-source.test.ts diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index f5618f7e9b..72b35e11a8 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -24,6 +24,7 @@ import { type BrowserCorsProxyConfig, validateBrowserCorsProxyConfig, } from "./networking/browser-cors-proxy"; +import type { InputSource } from "./input/input-source"; export type { HttpRequest, HttpResponse }; import workerEntryUrl from "./worker-entry-browser.ts?worker&url"; @@ -1059,6 +1060,59 @@ export class BrowserKernel { this.sendToKernel({ type: "mouse_inject", dx, dy, buttons }); } + /** + * Push one evdev record into the kernel's `/dev/input/event{0,1}` + * ring. `device` is 0 for the keyboard, 1 for the pointer; the + * other three fields mirror Linux `struct input_event`. Apps + * normally route through `attachInputSource` and never call this + * directly — exposed for tests + niche injection paths. + */ + injectInputEvent( + device: 0 | 1, + ev_type: number, + code: number, + value: number, + ): void { + this.sendToKernel({ + type: "input_event_inject", + device, + ev_type, + code, + value, + }); + } + + /** + * Tell the kernel the current host canvas dimensions so EVIOCGABS + * on `/dev/input/event1` reports `ABS_X.maximum = width - 1` and + * `ABS_Y.maximum = height - 1`. Call once at boot when the canvas + * is attached and again on any resize. + */ + setInputCanvasDims(width: number, height: number): void { + this.sendToKernel({ type: "set_input_canvas_dims", width, height }); + } + + /** + * Wire an `InputSource` into the kernel: sets canvas dims, then + * starts the source with a dispatch callback that funnels each + * emitted record through `injectInputEvent`. Mirrors + * `NodeKernelHost.attachInputSource` — dual-host parity per + * CLAUDE.md §"Two hosts". + * + * The browser caller is responsible for instantiating the source + * with the right DOM target + canvas: typically + * `new BrowserInputSource(window, canvas)`. + */ + attachInputSource( + source: InputSource, + dims: { width: number; height: number }, + ): void { + this.setInputCanvasDims(dims.width, dims.height); + source.start((ev) => + this.injectInputEvent(ev.device, ev.ev_type, ev.code, ev.value), + ); + } + /** * Hand an `OffscreenCanvas` to the kernel worker as the scanout * target for KMS CRTC `crtcId`. The worker's vblank pump blits the diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index d122b04aab..0ac99e8d46 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -310,6 +310,35 @@ export interface MouseInjectMessage { buttons: number; } +/** + * Main-thread → kernel-worker evdev injection. The main thread's + * `BrowserInputSource` translates DOM events to evdev records and + * forwards them here; the worker calls + * `CentralizedKernelWorker.injectInputEvent` which routes the record + * through the kernel's fan-out (`kernel_input_event` → `push_event`) + * to `/dev/input/event{0,1}` and wakes any blocked reader. + */ +export interface InputEventInjectMessage { + type: "input_event_inject"; + device: 0 | 1; + ev_type: number; + code: number; + value: number; +} + +/** + * Main-thread → kernel-worker canvas-dims update. Tells the kernel + * the current host canvas dimensions so EVIOCGABS on + * `/dev/input/event1` reports the right `ABS_X.maximum` / + * `ABS_Y.maximum`. Sent at boot once the canvas exists; resend on + * canvas resize. + */ +export interface SetInputCanvasDimsMessage { + type: "set_input_canvas_dims"; + width: number; + height: number; +} + /** * Main-thread → kernel-worker audio drain request. The main thread's * AudioContext scheduler ticks every ~50 ms, asks the kernel ring for @@ -461,6 +490,8 @@ export type MainToKernelMessage = | GetKernelMemoryPagesRequestMessage | GetSpawnScratchCapacityRequestMessage | MouseInjectMessage + | InputEventInjectMessage + | SetInputCanvasDimsMessage | AudioDrainMessage | EnumProcsRequestMessage | ReadProcMapsRequestMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 7298d4dc67..8bb2d8dfb0 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -4409,6 +4409,12 @@ sw.onmessage = (e: MessageEvent) => { case "fb_release_generation_ack": acknowledgeMainFramebufferRelease(msg.requestId); break; + case "input_event_inject": + kernelWorker.injectInputEvent(msg.device, msg.ev_type, msg.code, msg.value); + break; + case "set_input_canvas_dims": + kernelWorker.setInputCanvasDims(msg.width, msg.height); + break; default: { // Every typed MainToKernelMessage must have a case above. Browser // tooling also sends a few deliberately out-of-band control messages, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 51ecbdbd40..f7c29c811c 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30117,6 +30117,34 @@ export class CentralizedKernelWorker { ); } + /** + * Push one evdev record into `/dev/input/event{0,1}` and wake any + * process blocked on `sys_read` / `sys_poll` against the device. + * The per-OFD ring caps at 1024 records; overflow latches `dropped` + * and the next read returns `SYN_DROPPED` (kernel A4/A5). Wake + * routing reuses `scheduleWakeBlockedRetries` so the existing + * pending-readers tick services event ofds too — same shape as + * `injectMouseEvent` for `/dev/input/mice`. + */ + injectInputEvent( + device: 0 | 1, + ev_type: number, + code: number, + value: number, + ): void { + this.kernel.injectInputEvent(device, ev_type, code, value); + this.scheduleWakeBlockedRetries(); + } + + /** + * Tell the kernel the current host canvas dimensions so EVIOCGABS + * on `/dev/input/event1` reports the right `ABS_X.maximum` / + * `ABS_Y.maximum`. Idempotent; call again on canvas resize. + */ + setInputCanvasDims(width: number, height: number): void { + this.kernel.setInputCanvasDims(width, height); + } + /** * Drain up to `out.byteLength` bytes of PCM audio buffered in * `/dev/dsp` into `out`. Returns the number of bytes copied, always diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 860feb8429..da9b5d3365 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -1257,6 +1257,43 @@ export class WasmPosixKernel { ); } + /** + * Push one evdev record into the kernel's `/dev/input/event{0,1}` + * ring. `device` selects keyboard (0) or pointer (1); `ev_type`, + * `code`, `value` mirror the Linux `struct input_event` tail. The + * host runtime is expected to follow each type-specific record with + * an `EV_SYN(SYN_REPORT, 0)` so the kernel sees one logical frame + * per gesture — see `BrowserInputSource`'s dispatch contract. + * Silently dropped if the kernel module is not instantiated yet. + */ + injectInputEvent( + device: 0 | 1, + ev_type: number, + code: number, + value: number, + ): void { + const inject = this.instance?.exports?.kernel_input_event as + | ((device: number, ev_type: number, code: number, value: number) => void) + | undefined; + if (!inject) return; + inject(device, ev_type, code, value); + } + + /** + * Record the host canvas dimensions on the kernel so EVIOCGABS on + * `/dev/input/event1` reports `ABS_X.maximum = width - 1` and + * `ABS_Y.maximum = height - 1`. Must be called once the canvas + * exists and again on any resize; silently dropped if the kernel + * module is not instantiated yet. + */ + setInputCanvasDims(width: number, height: number): void { + const set = this.instance?.exports?.kernel_set_input_canvas_dims as + | ((width: number, height: number) => void) + | undefined; + if (!set) return; + set(width, height); + } + // --------------------------------------------------------------------------- // /dev/dsp — host-drained PCM audio // --------------------------------------------------------------------------- diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 74af94b6e5..652e008942 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -51,6 +51,7 @@ import { snapshotPublishedPrivilegedProgramBrowserMount, type PublishedPrivilegedProgramProduct, } from "./vfs/privileged-projection"; +import type { InputSource } from "./input/input-source"; export type { HttpRequest, HttpResponse }; @@ -688,6 +689,57 @@ export class NodeKernelHost { this.sendToWorker({ type: "kms_attach_stats", crtcId, stats }); } + /** + * Push one evdev record into the kernel's `/dev/input/event{0,1}` + * ring. Mirrors `BrowserKernel.injectInputEvent`. The Node host + * doesn't have a DOM source; tests drive evdev traffic directly + * via this entry point. + */ + injectInputEvent( + device: 0 | 1, + ev_type: number, + code: number, + value: number, + ): void { + this.sendToWorker({ + type: "input_event_inject", + device, + ev_type, + code, + value, + }); + } + + /** + * Tell the kernel the current host canvas dimensions so EVIOCGABS + * on `/dev/input/event1` reports the right `ABS_X.maximum` / + * `ABS_Y.maximum`. Mirrors `BrowserKernel.setInputCanvasDims`. + */ + setInputCanvasDims(width: number, height: number): void { + this.sendToWorker({ type: "set_input_canvas_dims", width, height }); + } + + /** + * Wire an `InputSource` into the kernel: sets canvas dims, then + * starts the source with a dispatch callback that funnels each + * emitted record through `injectInputEvent`. Mirrors + * `BrowserKernel.attachInputSource` — dual-host parity per + * CLAUDE.md §"Two hosts". + * + * On the Node host the source is typically a `NodeInputSource` + * (no-op) so the init path is symmetric with the browser; tests + * call `injectInputEvent` directly afterwards. + */ + attachInputSource( + source: InputSource, + dims: { width: number; height: number }, + ): void { + this.setInputCanvasDims(dims.width, dims.height); + source.start((ev) => + this.injectInputEvent(ev.device, ev.ev_type, ev.code, ev.value), + ); + } + /** * Send an HTTP request to a server running inside the kernel and return * the parsed response. Bypasses real TCP by using the kernel's injected diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 1a263fe2cb..15d7ceac15 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -339,6 +339,32 @@ export interface KmsAttachStatsMessage { stats: SharedArrayBuffer; } +/** + * Main-thread → kernel-worker evdev injection. Mirrors the Browser-side + * `InputEventInjectMessage`. Under Node there is no DOM, so production + * traffic on this channel comes from tests / headless drivers; the + * Node-side `NodeInputSource` is a null-source. Routes to + * `CentralizedKernelWorker.injectInputEvent`. + */ +export interface InputEventInjectMessage { + type: "input_event_inject"; + device: 0 | 1; + ev_type: number; + code: number; + value: number; +} + +/** + * Main-thread → kernel-worker canvas-dims update. Mirrors the + * Browser-side `SetInputCanvasDimsMessage`. Sets `ABS_X.maximum` / + * `ABS_Y.maximum` reported by EVIOCGABS on `/dev/input/event1`. + */ +export interface SetInputCanvasDimsMessage { + type: "set_input_canvas_dims"; + width: number; + height: number; +} + export type MainToKernelMessage = | InitMessage | SpawnMessage @@ -371,7 +397,9 @@ export type MainToKernelMessage = | DrainSyscallTraceMessage | HttpRequestMessage | KmsAttachCanvasMessage - | KmsAttachStatsMessage; + | KmsAttachStatsMessage + | InputEventInjectMessage + | SetInputCanvasDimsMessage; // ── Kernel Worker → Main Thread ── diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 3c39f7e331..ba65a0379c 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -3872,6 +3872,12 @@ port.on("message", (msg: MainToKernelMessage) => { case "kms_attach_stats": kernelWorker.attachKmsStats(msg.crtcId, msg.stats); break; + case "input_event_inject": + kernelWorker.injectInputEvent(msg.device, msg.ev_type, msg.code, msg.value); + break; + case "set_input_canvas_dims": + kernelWorker.setInputCanvasDims(msg.width, msg.height); + break; default: { const exhaustive: never = msg; void exhaustive; diff --git a/host/test/input-attach-source.test.ts b/host/test/input-attach-source.test.ts new file mode 100644 index 0000000000..533bd012d6 --- /dev/null +++ b/host/test/input-attach-source.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { NodeKernelHost } from "../src/node-kernel-host.js"; +import { NodeInputSource } from "../src/input/node-input-source.js"; +import type { MainToKernelMessage } from "../src/node-kernel-protocol.js"; + +/** + * B4 dual-host parity test. Covers the Node side of `attachInputSource` + * — the browser side is mirror-imaged in `BrowserKernel` and exercised + * end-to-end at Phase C via Playwright. The contract verified here: + * + * 1. `setInputCanvasDims` runs exactly once with the requested dims. + * 2. `source.start(dispatch)` runs exactly once. + * 3. The dispatch handed to `start` funnels each emitted record + * through `injectInputEvent` (→ `input_event_inject` worker msg). + * + * We bypass `init()` (which spawns a worker_thread + waits for ready + * over a worker channel) and stub `sendToWorker` directly. The + * constructor only stores options, so a bare `new NodeKernelHost()` + * is safe to construct. + */ +describe("NodeKernelHost.attachInputSource", () => { + it("sets canvas dims, starts the source, and routes dispatch to injectInputEvent", () => { + const host = new NodeKernelHost(); + const sent: MainToKernelMessage[] = []; + (host as unknown as { sendToWorker: (m: MainToKernelMessage) => void }) + .sendToWorker = (m) => sent.push(m); + + const source = new NodeInputSource(); + const startSpy = vi.spyOn(source, "start"); + + host.attachInputSource(source, { width: 1024, height: 768 }); + + // 1. canvas dims went out exactly once with the right values. + const dims = sent.filter((m) => m.type === "set_input_canvas_dims"); + expect(dims).toEqual([ + { type: "set_input_canvas_dims", width: 1024, height: 768 }, + ]); + + // 2. source.start was called exactly once with a function arg. + expect(startSpy).toHaveBeenCalledTimes(1); + const dispatch = startSpy.mock.calls[0]?.[0]; + expect(typeof dispatch).toBe("function"); + + // 3. The dispatch routes each record through input_event_inject. + dispatch!({ device: 0, ev_type: 0x01, code: 30, value: 1 }); + dispatch!({ device: 1, ev_type: 0x02, code: 0x00, value: -5 }); + + const injects = sent.filter((m) => m.type === "input_event_inject"); + expect(injects).toEqual([ + { + type: "input_event_inject", + device: 0, + ev_type: 0x01, + code: 30, + value: 1, + }, + { + type: "input_event_inject", + device: 1, + ev_type: 0x02, + code: 0x00, + value: -5, + }, + ]); + }); + + it("setInputCanvasDims posts the worker message standalone", () => { + const host = new NodeKernelHost(); + const sent: MainToKernelMessage[] = []; + (host as unknown as { sendToWorker: (m: MainToKernelMessage) => void }) + .sendToWorker = (m) => sent.push(m); + + host.setInputCanvasDims(640, 480); + host.setInputCanvasDims(800, 600); + + expect(sent).toEqual([ + { type: "set_input_canvas_dims", width: 640, height: 480 }, + { type: "set_input_canvas_dims", width: 800, height: 600 }, + ]); + }); + + it("injectInputEvent posts the worker message standalone", () => { + const host = new NodeKernelHost(); + const sent: MainToKernelMessage[] = []; + (host as unknown as { sendToWorker: (m: MainToKernelMessage) => void }) + .sendToWorker = (m) => sent.push(m); + + host.injectInputEvent(0, 0x01, 1, 0); + + expect(sent).toEqual([ + { + type: "input_event_inject", + device: 0, + ev_type: 0x01, + code: 1, + value: 0, + }, + ]); + }); +}); From 4bc6e9c10e6e43c16530741ea8999165efb27bb5 Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 11 Jun 2026 20:47:39 +0200 Subject: [PATCH 14/27] =?UTF-8?q?host(input):=20vitest=20=E2=80=94=20end-t?= =?UTF-8?q?o-end=20key=20+=20pointer=20+=20ring=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- host/test/input-evdev.test.ts | 185 ++++++++++++++++++++++++++++++++++ programs/input-evdev-smoke.c | 155 ++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 host/test/input-evdev.test.ts create mode 100644 programs/input-evdev-smoke.c diff --git a/host/test/input-evdev.test.ts b/host/test/input-evdev.test.ts new file mode 100644 index 0000000000..db59c17527 --- /dev/null +++ b/host/test/input-evdev.test.ts @@ -0,0 +1,185 @@ +/** + * B5 end-to-end gate for the evdev path. Spawns `input-evdev-smoke` + * inside the centralized kernel, then drives `kernel_input_event` + * from the host via `NodeKernelHost.injectInputEvent` — same shape a + * real `BrowserInputSource` will use at Phase C. + * + * The fixture gates each phase on a stdin byte so the host injects + * events AFTER the fixture has opened the matching device. `push_event` + * fans out at injection time, so an OFD must already exist. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const fixtureBinary = tryResolveBinary("programs/input-evdev-smoke.wasm"); + +const CANVAS_W = 1024; +const CANVAS_H = 768; + +const EV_SYN = 0x00; +const EV_KEY = 0x01; +const EV_REL = 0x02; +const SYN_REPORT = 0x00; +const SYN_DROPPED = 0x03; +const KEY_A = 30; +const REL_X = 0x00; +const RING_CAP = 1024; + +const KICK = new Uint8Array([0x0a]); + +async function waitFor( + stdoutRef: { value: string }, + needle: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (stdoutRef.value.includes(needle)) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error( + `Timed out waiting for ${JSON.stringify(needle)}.\n` + + `stdout so far:\n${stdoutRef.value}`, + ); +} + +describe("evdev — end-to-end key + pointer + ring overflow", () => { + it.skipIf(!fixtureBinary)( + "round-trips keyboard + pointer events and surfaces SYN_DROPPED on overflow", + async () => { + const fileBuf = readFileSync(fixtureBinary!); + const programBytes = fileBuf.buffer.slice( + fileBuf.byteOffset, + fileBuf.byteOffset + fileBuf.byteLength, + ); + + const stdout = { value: "" }; + const stderr = { value: "" }; + + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { + stdout.value += new TextDecoder().decode(data); + }, + onStderr: (_pid, data) => { + stderr.value += new TextDecoder().decode(data); + }, + }); + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + let pid = 0; + const exitPromise = host.spawn(programBytes, ["input-evdev-smoke"], { + onStarted: (p) => { + pid = p; + }, + }); + + // Phase 1 — keyboard. + await waitFor(stdout, "READY:kbd\n", 10_000); + host.injectInputEvent(0, EV_KEY, KEY_A, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.appendStdinData(pid, KICK); + + // Phase 2 — pointer. + await waitFor(stdout, "READY:ptr\n", 10_000); + host.injectInputEvent(1, EV_REL, REL_X, 5); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.appendStdinData(pid, KICK); + + // Phase 3 — overflow on event0. Push 1100 KEY_A toggles; the + // ring caps at 1024, latches dropped, and the next read + // prepends a synthesised SYN_DROPPED. + await waitFor(stdout, "READY:overflow\n", 10_000); + for (let i = 0; i < 1100; i++) { + host.injectInputEvent(0, EV_KEY, KEY_A, i & 1); + } + host.appendStdinData(pid, KICK); + + const exitCode = await Promise.race([ + exitPromise, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `fixture timed out\nstdout:\n${stdout.value}\nstderr:\n${stderr.value}`, + ), + ), + 30_000, + ), + ), + ]); + expect( + exitCode, + `stdout=${stdout.value}\nstderr=${stderr.value}`, + ).toBe(0); + + // EVIOCGNAME returns the kernel-supplied device names. + expect(stdout.value).toContain("kbd_name=wpk virtual keyboard"); + + // EVIOCGABS(ABS_X) on event1 reports canvas_w - 1. + expect(stdout.value).toMatch( + new RegExp(`ptr_abs_x_max=${CANVAS_W - 1}\\b`), + ); + + // Phase 1: KEY_A down + SYN_REPORT, in that order, with + // monotonic-non-decreasing CLOCK_MONOTONIC timestamps. + const kev0 = stdout.value.match( + /kbd_ev0 type=(\d+) code=(\d+) value=(-?\d+) tv_sec=(-?\d+) tv_usec=(-?\d+)/, + ); + const kev1 = stdout.value.match( + /kbd_ev1 type=(\d+) code=(\d+) value=(-?\d+) tv_sec=(-?\d+) tv_usec=(-?\d+)/, + ); + expect(kev0, `missing kbd_ev0 in:\n${stdout.value}`).not.toBeNull(); + expect(kev1, `missing kbd_ev1 in:\n${stdout.value}`).not.toBeNull(); + expect(parseInt(kev0![1], 10)).toBe(EV_KEY); + expect(parseInt(kev0![2], 10)).toBe(KEY_A); + expect(parseInt(kev0![3], 10)).toBe(1); + expect(parseInt(kev1![1], 10)).toBe(EV_SYN); + expect(parseInt(kev1![2], 10)).toBe(SYN_REPORT); + const ts0 = + BigInt(kev0![4]) * 1_000_000n + BigInt(parseInt(kev0![5], 10)); + const ts1 = + BigInt(kev1![4]) * 1_000_000n + BigInt(parseInt(kev1![5], 10)); + expect(ts1 >= ts0).toBe(true); + + // Phase 2: REL_X=+5 + SYN_REPORT. + const pev0 = stdout.value.match( + /ptr_ev0 type=(\d+) code=(\d+) value=(-?\d+)/, + ); + const pev1 = stdout.value.match( + /ptr_ev1 type=(\d+) code=(\d+) value=(-?\d+)/, + ); + expect(pev0, `missing ptr_ev0 in:\n${stdout.value}`).not.toBeNull(); + expect(pev1, `missing ptr_ev1 in:\n${stdout.value}`).not.toBeNull(); + expect(parseInt(pev0![1], 10)).toBe(EV_REL); + expect(parseInt(pev0![2], 10)).toBe(REL_X); + expect(parseInt(pev0![3], 10)).toBe(5); + expect(parseInt(pev1![1], 10)).toBe(EV_SYN); + expect(parseInt(pev1![2], 10)).toBe(SYN_REPORT); + + // Phase 3 overflow: SYN_DROPPED first, then the surviving 1024 + // ring records (the most recent of the 1100 pushed before the + // ring saturated). Total = 1 synth + 1024 = 1025. + const ov = stdout.value.match( + /ov_count=(\d+) ov_syn_dropped_at=(-?\d+) ov_real=(\d+) ov_last_type=(\d+) ov_last_code=(\d+)/, + ); + expect(ov, `missing ov_ line in:\n${stdout.value}`).not.toBeNull(); + expect(parseInt(ov![1], 10)).toBe(RING_CAP + 1); + expect(parseInt(ov![2], 10)).toBe(0); + expect(parseInt(ov![3], 10)).toBe(RING_CAP); + // Last surviving record is an EV_KEY/KEY_A (the toggles we + // pushed), not a stray SYN. + expect(parseInt(ov![4], 10)).toBe(EV_KEY); + expect(parseInt(ov![5], 10)).toBe(KEY_A); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/programs/input-evdev-smoke.c b/programs/input-evdev-smoke.c new file mode 100644 index 0000000000..f0f9fae23f --- /dev/null +++ b/programs/input-evdev-smoke.c @@ -0,0 +1,155 @@ +/* + * input-evdev-smoke — end-to-end fixture for host/test/input-evdev.test.ts. + * + * Three phases gated by stdin barriers so the test can inject events + * AFTER the program has opened the matching device (push_event fans + * out at injection time, so an OFD must exist). + * + * 1. open /dev/input/event0, EVIOCGNAME, then "READY:kbd\n"; on the + * next stdin byte, read 48 bytes (two records) and print both. + * 2. open /dev/input/event1, EVIOCGABS(ABS_X), then "READY:ptr\n"; + * on the next stdin byte, read 48 bytes and print both. + * 3. "READY:overflow\n"; on the next stdin byte, drain event0 to + * empty (kernel returns 0 on empty + blocking), printing each + * record's (type, code). First record must be SYN_DROPPED. + * + * Linux isn't in the wasm sysroot until Phase C, so + * the evdev structs/ioctl numbers are spelled inline — same pattern + * as programs/kms-pageflip-smoke.c. + */ +#include +#include +#include +#include +#include +#include + +#define EV_SYN 0x00 +#define EV_KEY 0x01 +#define EV_REL 0x02 +#define SYN_REPORT 0x00 +#define SYN_DROPPED 0x03 + +/* Linux ioctl encoding: (dir << 30) | (size << 16) | (magic << 8) | nr. + * EVIOCGNAME bakes the caller-supplied buffer size into the size field; + * the kernel A3 dispatch reads back (dir, magic, nr) and re-derives the + * buffer length from size. ABS_X / ABS_Y land at nr = 0x40 + axis, + * size = sizeof(struct input_absinfo) = 24. */ +#define EVIOC_DIR_READ (2u << 30) +#define EVIOC_MAGIC (0x45u << 8) /* 'E' */ +#define EVIOCGNAME(len) (EVIOC_DIR_READ | (((unsigned)(len) & 0x3fffu) << 16) | EVIOC_MAGIC | 0x06u) +#define EVIOCGABS(axis) (EVIOC_DIR_READ | ((24u) << 16) | EVIOC_MAGIC | (0x40u + ((unsigned)(axis) & 0x3fu))) + +struct wpk_event { + int64_t tv_sec; + int32_t tv_usec; + int32_t _pad; + uint16_t ev_type; + uint16_t code; + int32_t value; +}; + +struct wpk_absinfo { + int32_t value, minimum, maximum, fuzz, flat, resolution; +}; + +_Static_assert(sizeof(struct wpk_event) == 24, "WpkInputEvent must be 24 bytes"); +_Static_assert(sizeof(struct wpk_absinfo) == 24, "WpkInputAbsinfo must be 24 bytes"); + +static void wait_sync(void) { + /* Block until the host writes one byte via appendStdinData. */ + char c; + while (read(0, &c, 1) <= 0) { } +} + +static void print_event(const char *tag, int idx, const struct wpk_event *e) { + printf("%s_ev%d type=%u code=%u value=%d tv_sec=%lld tv_usec=%d\n", + tag, idx, (unsigned)e->ev_type, (unsigned)e->code, (int)e->value, + (long long)e->tv_sec, (int)e->tv_usec); +} + +int main(void) { + /* --- Phase 1: keyboard (event0) ----------------------------------- */ + int fd0 = open("/dev/input/event0", O_RDONLY); + if (fd0 < 0) { perror("open event0"); return 1; } + + char name0[64] = {0}; + if (ioctl(fd0, EVIOCGNAME(sizeof(name0)), name0) < 0) { + perror("EVIOCGNAME event0"); return 1; + } + printf("kbd_name=%s\n", name0); + printf("READY:kbd\n"); + fflush(stdout); + wait_sync(); + + char buf0[48]; + ssize_t n0 = read(fd0, buf0, sizeof(buf0)); + if (n0 != 48) { fprintf(stderr, "kbd read returned %zd\n", n0); return 1; } + struct wpk_event ke0, ke1; + memcpy(&ke0, buf0, sizeof(ke0)); + memcpy(&ke1, buf0 + 24, sizeof(ke1)); + print_event("kbd", 0, &ke0); + print_event("kbd", 1, &ke1); + fflush(stdout); + + /* --- Phase 2: pointer (event1) ------------------------------------ */ + int fd1 = open("/dev/input/event1", O_RDONLY); + if (fd1 < 0) { perror("open event1"); return 1; } + + struct wpk_absinfo abs_x; + if (ioctl(fd1, EVIOCGABS(0 /* ABS_X */), &abs_x) < 0) { + perror("EVIOCGABS ABS_X"); return 1; + } + printf("ptr_abs_x_max=%d\n", (int)abs_x.maximum); + printf("READY:ptr\n"); + fflush(stdout); + wait_sync(); + + char buf1[48]; + ssize_t n1 = read(fd1, buf1, sizeof(buf1)); + if (n1 != 48) { fprintf(stderr, "ptr read returned %zd\n", n1); return 1; } + struct wpk_event pe0, pe1; + memcpy(&pe0, buf1, sizeof(pe0)); + memcpy(&pe1, buf1 + 24, sizeof(pe1)); + print_event("ptr", 0, &pe0); + print_event("ptr", 1, &pe1); + fflush(stdout); + + /* --- Phase 3: ring overflow on event0 ----------------------------- */ + printf("READY:overflow\n"); + fflush(stdout); + wait_sync(); + + /* The kernel ring caps at 1024 records (24 KiB); on overflow the + * `dropped` flag latches, the incoming record is discarded, and + * the next read prepends a synthesised SYN_DROPPED to the drain. + * Read one record at a time so we can count exactly. Blocking + * read on an empty+clean ring returns 0 — that's our drain + * terminator. */ + int count = 0, syn_dropped_at = -1, non_syn_dropped = 0; + struct wpk_event last = {0}; + for (;;) { + char rec[24]; + ssize_t n = read(fd0, rec, sizeof(rec)); + if (n == 0) break; + if (n != 24) { fprintf(stderr, "drain short read %zd\n", n); return 1; } + struct wpk_event ev; + memcpy(&ev, rec, sizeof(ev)); + if (ev.ev_type == EV_SYN && ev.code == SYN_DROPPED) { + if (syn_dropped_at < 0) syn_dropped_at = count; + } else { + non_syn_dropped++; + } + last = ev; + count++; + if (count > 1500) { fprintf(stderr, "drain runaway\n"); return 1; } + } + printf("ov_count=%d ov_syn_dropped_at=%d ov_real=%d ov_last_type=%u ov_last_code=%u\n", + count, syn_dropped_at, non_syn_dropped, + (unsigned)last.ev_type, (unsigned)last.code); + fflush(stdout); + + close(fd0); + close(fd1); + return 0; +} From 1ed2069ca67002fe5fc7f2ec2a21b27d80e904ee Mon Sep 17 00:00:00 2001 From: mho22 Date: Fri, 12 Jun 2026 15:54:05 +0200 Subject: [PATCH 15/27] =?UTF-8?q?dri(input):=20Phase=20C=20=E2=80=94=20sys?= =?UTF-8?q?root=20headers=20+=20evdev=5Fdemo=20+=20kandelo=20preset=20+=20?= =?UTF-8?q?browser=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 b25ef5942 ported the DRI sources forward but missed the build wiring from commits a091a5b83 and 2f7feada4. 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) --- .../pages/kandelo/kernel-host/live-setup.ts | 67 ++++++ apps/browser-demos/pages/kandelo/presets.ts | 11 + apps/browser-demos/test/kandelo-evdev.spec.ts | 58 +++++ .../include/linux/input-event-codes.h | 223 ++++++++++++++++++ libc/musl-overlay/include/linux/input.h | 91 +++++++ programs/evdev_demo.c | 101 ++++++++ 6 files changed, 551 insertions(+) create mode 100644 apps/browser-demos/test/kandelo-evdev.spec.ts create mode 100644 libc/musl-overlay/include/linux/input-event-codes.h create mode 100644 libc/musl-overlay/include/linux/input.h create mode 100644 programs/evdev_demo.c diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 7678ac0232..7f91e28e3b 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -7,6 +7,7 @@ import { bindImageOwnedRuntimeUrls, type ImageOwnedRuntimeLazyAssets, } from "../../../lib/init/image-owned-runtime-urls"; +import { BrowserInputSource } from "../../../../../host/src/input/browser-input-source"; import { resolveShellLazyArchiveUrl } from "../../../lib/init/lazy-archives"; import { WORDPRESS_CONFIG_INIT_SCRIPT, @@ -195,6 +196,12 @@ const OPTIONAL_BINARY_URLS = { import: "default", }, ), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/evdev_demo.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/evdev_demo.wasm", { + query: "?url", import: "default", + }), } as Record Promise>; async function optionalBinaryUrl( @@ -393,6 +400,7 @@ const LIVE_DEMO_IDS = [ "wordpress-mariadb", "doom", "modeset", + "evdev", ] as const; type LiveDemoId = (typeof LIVE_DEMO_IDS)[number]; @@ -482,6 +490,9 @@ const LIVE_DEMO_SPECS: Record = { image: "shell", features: ["kms"], }, + evdev: { + image: "shell", + }, }; const DEFAULT_DEMO_FOR_VFS_IMAGE: Record = { @@ -540,6 +551,14 @@ interface LiveProfile { }; }; framebufferTest: boolean; + /** + /** + * Stage `evdev_demo` into `/usr/local/bin`, attach a `BrowserInputSource` + * to the window so keyboard/pointer events flow into the kernel's + * `/dev/input/event{0,1}`, and run the binary from bash so its event + * log streams to the user's Shell pane. The C1 sysroot vendoring proof. + */ + evdevDemo: boolean; } interface WebReadinessState { @@ -999,6 +1018,7 @@ function customVfsProfile( shell: "default", maxVfsByteLength: CUSTOM_VFS_PROFILE_MAX_BYTES, framebufferTest: fb === "test", + evdevDemo: false, }; } @@ -1018,6 +1038,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { fallbackPresentation: software.presentation, init: software.init, framebufferTest: false, + evdevDemo: false, }; } @@ -1065,6 +1086,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { }, }, framebufferTest: fb === "test", + evdevDemo: normalized === "evdev", }; } @@ -1850,6 +1872,51 @@ async function bootProfile( tick, assertCurrent, ); + } else if (profile.evdevDemo) { + // Stage evdev_demo into the VFS so bash can exec it, attach a + // BrowserInputSource to window so DOM keyboard/pointer events flow + // into `/dev/input/event{0,1}`, then run the binary through bash so + // its stdout streams to the user's Shell pane. autoCommand isn't + // used here because the staging has to happen before exec, and we + // want the InputSource attached before the program starts polling. + const kernelForEvdev = kernel; + void (async () => { + try { + const evdevDemoWasmUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/evdev_demo.wasm", + "../../../../../binaries/programs/wasm32/evdev_demo.wasm", + ], "evdev_demo.wasm"); + tick("staging evdev_demo binary..."); + const bytes = await fetch(evdevDemoWasmUrl) + .then(failOn("evdev_demo.wasm")) + .then((r) => r.arrayBuffer()); + ensureDirRecursive(kernelForEvdev.fs, "/usr/local/bin"); + writeVfsBinary( + kernelForEvdev.fs, + "/usr/local/bin/evdev_demo", + new Uint8Array(bytes), + 0o755, + ); + tick("attaching input source..."); + kernelForEvdev.attachInputSource(new BrowserInputSource(window), { + width: window.innerWidth, + height: window.innerHeight, + }); + tick("running evdev_demo..."); + // evdev_demo runs forever; runShellCommand resolves when the + // bash prompt reappears (it never will) or rejects after its + // internal 5-minute timeout. Both are expected — log neutrally. + await host.runShellCommand("/usr/local/bin/evdev_demo"); + tick("evdev_demo exited"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/timed out waiting for PTY prompt/.test(msg)) { + tick("evdev_demo running (long-tail; no further status updates)"); + } else { + tick(`evdev_demo failed: ${msg}`); + } + } + })(); } else if (presentation?.autoCommand) { tick("starting configured command from the default shell..."); void host.runShellCommand(presentation.autoCommand).catch((err) => { diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index 776c142df8..0a048b4ea4 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -128,4 +128,15 @@ export const PRESET_LIBRARY: Preset[] = [ bootCommand: ["/usr/local/bin/modeset"], estimatedUrlBytes: 612, }, + { + id: "evdev", + title: "Evdev input log", + summary: "Keystrokes + pointer motion captured from the DOM and replayed through /dev/input/event{0,1}.", + base: SHELL_BASE, + packages: ["bash@local", "coreutils@local"], + accent: "#7e57c2", + glyph: "E", + bootCommand: ["bash", "-l", "-i"], + estimatedUrlBytes: 612, + }, ]; diff --git a/apps/browser-demos/test/kandelo-evdev.spec.ts b/apps/browser-demos/test/kandelo-evdev.spec.ts new file mode 100644 index 0000000000..8d404bace8 --- /dev/null +++ b/apps/browser-demos/test/kandelo-evdev.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +async function terminalText(page: Page): Promise { + return page.locator(".xterm-rows").first().evaluate((node) => node.textContent ?? ""); +} + +test("Kandelo evdev demo forwards keystrokes + pointer through /dev/input/event{0,1}", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=evdev"); + + // The evdev_demo binary prints "ready:" once both /dev/input/event0 + // and /dev/input/event1 have been opened and EVIOCGNAME has succeeded + // on both. Waiting for that proves: the binary was staged into the + // VFS, bash exec'd it, and the kernel's A3 EVIOC* dispatch returned + // the correct device names. + await expect + .poll(() => terminalText(page), { timeout: 180_000 }) + .toContain("ready:"); + + const readyText = await terminalText(page); + expect(readyText).toContain("kbd: wpk virtual keyboard"); + expect(readyText).toContain("ptr: wpk virtual pointer"); + + // BrowserInputSource preventDefaults every key it translates, so when + // the terminal pane is focused the only way "key down: code=30" + // (KEY_A) can appear in the xterm output is if BrowserInputSource + // caught the keydown, dispatched into kernel_input_event, the kernel + // fanned out to /dev/input/event0, and evdev_demo's read returned it. + // The dual-host parity claim of B4 is what this proves end-to-end. + await page.keyboard.press("KeyA"); + await expect + .poll(() => terminalText(page), { timeout: 15_000 }) + .toMatch(/key down: code=30/); + + // Pointer move → ABS_X/ABS_Y (pointer-lock inactive) → evdev_demo + // prints "ptr abs code=0 value=N" (REL_X==ABS_X==0 in Linux UAPI). + // The exact value depends on which DOM element pointermove fires on + // and its offsetX/offsetY, so just assert the shape of the line. + await page.mouse.move(100, 200); + await page.mouse.move(150, 250); + await expect + .poll(() => terminalText(page), { timeout: 15_000 }) + .toMatch(/ptr (abs|rel) code=\d+ value=-?\d+/); +}); diff --git a/libc/musl-overlay/include/linux/input-event-codes.h b/libc/musl-overlay/include/linux/input-event-codes.h new file mode 100644 index 0000000000..31d318d4e5 --- /dev/null +++ b/libc/musl-overlay/include/linux/input-event-codes.h @@ -0,0 +1,223 @@ +/* + * Minimal for kandelo. + * + * Mirrors the constant set kandelo's kernel-side `shared::input` module + * defines in `crates/shared/src/lib.rs` — same numeric space SDL2's + * evdev backend (and any Linux userspace) would consume on real Linux. + * The KEY_* range covers what Chrome / Firefox / WebKit emit through + * `KeyboardEvent.code`; values >248 (KEY_BUTTONCONFIG, KEY_VENDOR + * range, etc.) are not browser-reachable and aren't vendored. + * + * Any change here is part of the kernel ABI — bump ABI_VERSION. + */ +#ifndef _LINUX_INPUT_EVENT_CODES_H +#define _LINUX_INPUT_EVENT_CODES_H 1 + +/* --- Event types (struct input_event.type) --------------------------- */ + +#define EV_SYN 0x00 +#define EV_KEY 0x01 +#define EV_REL 0x02 +#define EV_ABS 0x03 +#define EV_MSC 0x04 + +/* --- SYN codes (struct input_event.code when type == EV_SYN) --------- */ + +#define SYN_REPORT 0 +#define SYN_DROPPED 3 + +/* --- KEY_* codes (verbatim from upstream linux/input-event-codes.h) -- */ + +#define KEY_RESERVED 0 +#define KEY_ESC 1 +#define KEY_1 2 +#define KEY_2 3 +#define KEY_3 4 +#define KEY_4 5 +#define KEY_5 6 +#define KEY_6 7 +#define KEY_7 8 +#define KEY_8 9 +#define KEY_9 10 +#define KEY_0 11 +#define KEY_MINUS 12 +#define KEY_EQUAL 13 +#define KEY_BACKSPACE 14 +#define KEY_TAB 15 +#define KEY_Q 16 +#define KEY_W 17 +#define KEY_E 18 +#define KEY_R 19 +#define KEY_T 20 +#define KEY_Y 21 +#define KEY_U 22 +#define KEY_I 23 +#define KEY_O 24 +#define KEY_P 25 +#define KEY_LEFTBRACE 26 +#define KEY_RIGHTBRACE 27 +#define KEY_ENTER 28 +#define KEY_LEFTCTRL 29 +#define KEY_A 30 +#define KEY_S 31 +#define KEY_D 32 +#define KEY_F 33 +#define KEY_G 34 +#define KEY_H 35 +#define KEY_J 36 +#define KEY_K 37 +#define KEY_L 38 +#define KEY_SEMICOLON 39 +#define KEY_APOSTROPHE 40 +#define KEY_GRAVE 41 +#define KEY_LEFTSHIFT 42 +#define KEY_BACKSLASH 43 +#define KEY_Z 44 +#define KEY_X 45 +#define KEY_C 46 +#define KEY_V 47 +#define KEY_B 48 +#define KEY_N 49 +#define KEY_M 50 +#define KEY_COMMA 51 +#define KEY_DOT 52 +#define KEY_SLASH 53 +#define KEY_RIGHTSHIFT 54 +#define KEY_KPASTERISK 55 +#define KEY_LEFTALT 56 +#define KEY_SPACE 57 +#define KEY_CAPSLOCK 58 +#define KEY_F1 59 +#define KEY_F2 60 +#define KEY_F3 61 +#define KEY_F4 62 +#define KEY_F5 63 +#define KEY_F6 64 +#define KEY_F7 65 +#define KEY_F8 66 +#define KEY_F9 67 +#define KEY_F10 68 +#define KEY_NUMLOCK 69 +#define KEY_SCROLLLOCK 70 +#define KEY_KP7 71 +#define KEY_KP8 72 +#define KEY_KP9 73 +#define KEY_KPMINUS 74 +#define KEY_KP4 75 +#define KEY_KP5 76 +#define KEY_KP6 77 +#define KEY_KPPLUS 78 +#define KEY_KP1 79 +#define KEY_KP2 80 +#define KEY_KP3 81 +#define KEY_KP0 82 +#define KEY_KPDOT 83 +#define KEY_ZENKAKUHANKAKU 85 +#define KEY_102ND 86 +#define KEY_F11 87 +#define KEY_F12 88 +#define KEY_RO 89 +#define KEY_KATAKANA 90 +#define KEY_HIRAGANA 91 +#define KEY_HENKAN 92 +#define KEY_KATAKANAHIRAGANA 93 +#define KEY_MUHENKAN 94 +#define KEY_KPJPCOMMA 95 +#define KEY_KPENTER 96 +#define KEY_RIGHTCTRL 97 +#define KEY_KPSLASH 98 +#define KEY_SYSRQ 99 +#define KEY_RIGHTALT 100 +#define KEY_LINEFEED 101 +#define KEY_HOME 102 +#define KEY_UP 103 +#define KEY_PAGEUP 104 +#define KEY_LEFT 105 +#define KEY_RIGHT 106 +#define KEY_END 107 +#define KEY_DOWN 108 +#define KEY_PAGEDOWN 109 +#define KEY_INSERT 110 +#define KEY_DELETE 111 +#define KEY_MACRO 112 +#define KEY_MUTE 113 +#define KEY_VOLUMEDOWN 114 +#define KEY_VOLUMEUP 115 +#define KEY_POWER 116 +#define KEY_KPEQUAL 117 +#define KEY_KPPLUSMINUS 118 +#define KEY_PAUSE 119 +#define KEY_SCALE 120 +#define KEY_KPCOMMA 121 +#define KEY_HANGEUL 122 +#define KEY_HANJA 123 +#define KEY_YEN 124 +#define KEY_LEFTMETA 125 +#define KEY_RIGHTMETA 126 +#define KEY_COMPOSE 127 +#define KEY_STOP 128 +#define KEY_AGAIN 129 +#define KEY_PROPS 130 +#define KEY_UNDO 131 +#define KEY_FRONT 132 +#define KEY_COPY 133 +#define KEY_OPEN 134 +#define KEY_PASTE 135 +#define KEY_FIND 136 +#define KEY_CUT 137 +#define KEY_HELP 138 +#define KEY_MENU 139 +#define KEY_CALC 140 +#define KEY_SLEEP 142 +#define KEY_WAKEUP 143 +#define KEY_EJECTCD 161 +#define KEY_NEXTSONG 163 +#define KEY_PLAYPAUSE 164 +#define KEY_PREVIOUSSONG 165 +#define KEY_STOPCD 166 +#define KEY_REFRESH 173 +#define KEY_F13 183 +#define KEY_F14 184 +#define KEY_F15 185 +#define KEY_F16 186 +#define KEY_F17 187 +#define KEY_F18 188 +#define KEY_F19 189 +#define KEY_F20 190 +#define KEY_F21 191 +#define KEY_F22 192 +#define KEY_F23 193 +#define KEY_F24 194 +#define KEY_PLAYCD 200 +#define KEY_PAUSECD 201 +#define KEY_BRIGHTNESSDOWN 224 +#define KEY_BRIGHTNESSUP 225 +#define KEY_MICMUTE 248 + +/* --- BTN_* codes (button class; reuse the EV_KEY event type) --------- */ + +#define BTN_LEFT 0x110 +#define BTN_RIGHT 0x111 +#define BTN_MIDDLE 0x112 +#define BTN_SIDE 0x113 +#define BTN_EXTRA 0x114 + +/* --- REL_* codes (relative axes; EV_REL records carry these) --------- */ + +#define REL_X 0x00 +#define REL_Y 0x01 +#define REL_HWHEEL 0x06 +#define REL_WHEEL 0x08 + +/* --- ABS_* codes (absolute axes; EV_ABS records carry these) --------- */ + +#define ABS_X 0x00 +#define ABS_Y 0x01 + +/* --- BUS_* constants (subset) ---------------------------------------- */ + +/* `BUS_VIRTUAL` — closest match for a kernel-synthesised device (Linux + * uses this for `uinput`-backed devices). */ +#define BUS_VIRTUAL 0x06 + +#endif /* _LINUX_INPUT_EVENT_CODES_H */ diff --git a/libc/musl-overlay/include/linux/input.h b/libc/musl-overlay/include/linux/input.h new file mode 100644 index 0000000000..4a588a8420 --- /dev/null +++ b/libc/musl-overlay/include/linux/input.h @@ -0,0 +1,91 @@ +/* + * Minimal for kandelo. + * + * The real Linux header carries a lot of Linux-specific details + * (force-feedback, autorepeat, MT slots, etc.) we don't implement and + * don't expose to user-space programs. This subset matches the structs + * and ioctls that the kernel marshals via crates/shared/src/lib.rs::input. + * + * Programs using `` against our `/dev/input/event{0,1}` + * see the same `struct input_event` layout (24 bytes; trailing 8-byte + * `struct timeval` + `__u16 type` + `__u16 code` + `__s32 value`) and + * EVIOC* ioctl numbers as on real Linux. + * + * Any change here is part of the kernel ABI — bump ABI_VERSION. + */ +#ifndef _LINUX_INPUT_H +#define _LINUX_INPUT_H 1 + +#include +#include +#include +#include + +/* Linux UAPI naming. Defined inline rather than dragging in a separate + * stub. Guard each so a parent project that already + * defines them via its own doesn't see a redefinition. */ +#ifndef __u8 +typedef uint8_t __u8; +#endif +#ifndef __u16 +typedef uint16_t __u16; +#endif +#ifndef __u32 +typedef uint32_t __u32; +#endif +#ifndef __s8 +typedef int8_t __s8; +#endif +#ifndef __s16 +typedef int16_t __s16; +#endif +#ifndef __s32 +typedef int32_t __s32; +#endif + +/* `struct input_event` on wasm32-musl. Total 24 bytes: + * struct timeval (i64 tv_sec + i32 tv_usec + 4B trailing pad to + * re-align to 8) = 16 bytes, + * __u16 type + __u16 code + __s32 value = 8. + * Matches `shared::input::WpkInputEvent`. */ +struct input_event { + struct timeval time; + __u16 type; + __u16 code; + __s32 value; +}; + +/* Returned by EVIOCGID. Total 8 bytes. */ +struct input_id { + __u16 bustype; + __u16 vendor; + __u16 product; + __u16 version; +}; + +/* Returned by EVIOCGABS(axis). Total 24 bytes. The kernel reports + * `maximum = canvas_dim - 1`, `resolution = 1` unit per pixel; other + * fields are zero. */ +struct input_absinfo { + __s32 value; + __s32 minimum; + __s32 maximum; + __s32 fuzz; + __s32 flat; + __s32 resolution; +}; + +/* --- ioctl numbers ('E' magic, Linux UAPI verbatim) ------------------ + * + * The kernel A3 dispatch matches on (dir, magic, nr); the `size` field + * (bits 16..29) is informational on the userspace side — the kernel + * re-computes the buffer length from `size` at dispatch time. */ + +#define EVIOCGVERSION _IOR('E', 0x01, int) +#define EVIOCGID _IOR('E', 0x02, struct input_id) +#define EVIOCGNAME(len) _IOC(_IOC_READ, 'E', 0x06, len) +#define EVIOCGBIT(ev, len) _IOC(_IOC_READ, 'E', 0x20 + (ev), len) +#define EVIOCGABS(abs) _IOR('E', 0x40 + (abs), struct input_absinfo) +#define EVIOCGRAB _IOW('E', 0x90, int) + +#endif /* _LINUX_INPUT_H */ diff --git a/programs/evdev_demo.c b/programs/evdev_demo.c new file mode 100644 index 0000000000..59f0b1134d --- /dev/null +++ b/programs/evdev_demo.c @@ -0,0 +1,101 @@ +/* + * evdev_demo — interactive keystroke + pointer log for the Kandelo + * `/dev/input/event*` evdev backend. + * + * Companion to the kandelo browser demo `/?demo=evdev`. Opens + * `/dev/input/event0` (keyboard) and `/dev/input/event1` (pointer), + * prints each device's EVIOCGNAME, then polls both forever and logs + * every key press / release / pointer-axis event to stdout. The pane + * surfaces the log lines. + * + * Also serves as the C1 compile proof: this program is the only thing + * in tree that does `#include `. If the vendored headers + * ever drift (e.g. `struct input_event` grows to 32 bytes), the + * trailing _Static_assert here fires at build time before runtime. + * + * Re-use of input-evdev-smoke.c was considered. That fixture is + * stdin-barrier-gated (the three-phase test harness) which doesn't + * work for free-running interactive use, and it inlines structs the + * vendored header now provides. Keeping the two programs separate + * lets each be optimised for its purpose without coupling. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +_Static_assert(sizeof(struct input_event) == 24, + "struct input_event must be 24 bytes on wasm32 (musl 64-bit time_t)"); + +#define EV_BATCH 16 + +static void log_kbd(const struct input_event *e) { + if (e->type == EV_KEY) { + const char *state = e->value == 0 ? "up" + : e->value == 1 ? "down" + : "repeat"; + printf("key %s: code=%u\n", state, (unsigned) e->code); + fflush(stdout); + } +} + +static void log_ptr(const struct input_event *e) { + if (e->type == EV_REL) { + printf("ptr rel code=%u value=%d\n", + (unsigned) e->code, (int) e->value); + fflush(stdout); + } else if (e->type == EV_ABS) { + printf("ptr abs code=%u value=%d\n", + (unsigned) e->code, (int) e->value); + fflush(stdout); + } +} + +int main(void) { + int kbd = open("/dev/input/event0", O_RDONLY | O_CLOEXEC); + if (kbd < 0) { perror("open /dev/input/event0"); return 1; } + int ptr = open("/dev/input/event1", O_RDONLY | O_CLOEXEC); + if (ptr < 0) { perror("open /dev/input/event1"); return 1; } + + char name[64] = {0}; + if (ioctl(kbd, EVIOCGNAME(sizeof name), name) < 0) { + perror("EVIOCGNAME event0"); return 1; + } + printf("kbd: %s\n", name); + if (ioctl(ptr, EVIOCGNAME(sizeof name), name) < 0) { + perror("EVIOCGNAME event1"); return 1; + } + printf("ptr: %s\n", name); + printf("ready: type or move the mouse over the canvas\n"); + fflush(stdout); + + struct pollfd pfds[2] = { + { .fd = kbd, .events = POLLIN }, + { .fd = ptr, .events = POLLIN }, + }; + + for (;;) { + int n = poll(pfds, 2, -1 /* block until any fd is ready */); + if (n < 0) { + if (errno == EINTR) continue; + perror("poll"); return 1; + } + struct input_event evs[EV_BATCH]; + if (pfds[0].revents & POLLIN) { + ssize_t r = read(kbd, evs, sizeof evs); + for (ssize_t i = 0; i < r / (ssize_t) sizeof(evs[0]); i++) { + log_kbd(&evs[i]); + } + } + if (pfds[1].revents & POLLIN) { + ssize_t r = read(ptr, evs, sizeof evs); + for (ssize_t i = 0; i < r / (ssize_t) sizeof(evs[0]); i++) { + log_ptr(&evs[i]); + } + } + } +} From e75277c89ac12564dd094d143155f0332b95e4df Mon Sep 17 00:00:00 2001 From: mho22 Date: Fri, 12 Jun 2026 17:13:50 +0200 Subject: [PATCH 16/27] =?UTF-8?q?cleanup(input):=20devil's=20advocate=20?= =?UTF-8?q?=E2=80=94=20narration=20+=20verbose=20docs=20cut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pages/kandelo/kernel-host/live-setup.ts | 10 +- crates/kernel/src/devfs.rs | 1 - crates/kernel/src/input/dispatch.rs | 16 +- crates/kernel/src/input/mod.rs | 14 +- crates/kernel/src/ofd.rs | 4 +- crates/kernel/src/syscalls.rs | 154 ++++-------------- crates/kernel/src/wasm_api.rs | 19 +-- host/src/browser-kernel-host.ts | 4 - host/src/input/browser-input-source.ts | 9 +- libc/musl-overlay/include/linux/input.h | 15 +- programs/evdev_demo.c | 25 +-- programs/input-evdev-smoke.c | 33 +--- 12 files changed, 62 insertions(+), 242 deletions(-) diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 7f91e28e3b..a281ff3234 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -551,7 +551,6 @@ interface LiveProfile { }; }; framebufferTest: boolean; - /** /** * Stage `evdev_demo` into `/usr/local/bin`, attach a `BrowserInputSource` * to the window so keyboard/pointer events flow into the kernel's @@ -1873,12 +1872,9 @@ async function bootProfile( assertCurrent, ); } else if (profile.evdevDemo) { - // Stage evdev_demo into the VFS so bash can exec it, attach a - // BrowserInputSource to window so DOM keyboard/pointer events flow - // into `/dev/input/event{0,1}`, then run the binary through bash so - // its stdout streams to the user's Shell pane. autoCommand isn't - // used here because the staging has to happen before exec, and we - // want the InputSource attached before the program starts polling. + // autoCommand can't run this: the InputSource must be attached + // before the binary starts polling /dev/input/event{0,1}, and + // the binary itself has to be staged into the VFS first. const kernelForEvdev = kernel; void (async () => { try { diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index 91b1a55e4e..616efa07c2 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -536,7 +536,6 @@ mod tests { assert!(names.iter().any(|n| *n == b"event1"), "event1 missing: {:?}", names); // event2 deliberately NOT synthesised. assert!(!names.iter().any(|n| *n == b"event2")); - // Both must be char devices. for (name, dtype, _) in entries.iter() { if name.as_slice() == b"event0" || name.as_slice() == b"event1" { assert_eq!(*dtype, DT_CHR); diff --git a/crates/kernel/src/input/dispatch.rs b/crates/kernel/src/input/dispatch.rs index 78634174ad..74a9f1856d 100644 --- a/crates/kernel/src/input/dispatch.rs +++ b/crates/kernel/src/input/dispatch.rs @@ -1,15 +1,7 @@ -//! Event producer for `/dev/input/event{0,1}`. -//! -//! `kernel_input_event` feeds [`push_event`] here, which fans the -//! record out to every open OFD bound to the matching device. -//! -//! Overflow handling mirrors Linux `drivers/input/evdev.c:: -//! evdev_pass_values`: when an OFD's ring is full we set `dropped = -//! true` and discard the **incoming** record. The next `read()` -//! synthesises a `SYN_DROPPED` marker at the head of its output and -//! clears the flag, so userspace can resynchronise via `EVIOCG*`. -//! Pushes-while-dropped are no-ops, so the ring never grows past -//! [`INPUT_RING_MAX_BYTES`]. +//! Event producer for `/dev/input/event{0,1}`. Mirrors Linux +//! `drivers/input/evdev.c::evdev_pass_values`: a full ring discards +//! the incoming record and latches `dropped`; the next read prepends +//! a synthetic `SYN_DROPPED` so userspace can resync via `EVIOCG*`. use alloc::collections::VecDeque; diff --git a/crates/kernel/src/input/mod.rs b/crates/kernel/src/input/mod.rs index 5fbca9be1a..747c63ef79 100644 --- a/crates/kernel/src/input/mod.rs +++ b/crates/kernel/src/input/mod.rs @@ -44,7 +44,6 @@ fn set_bit(buf: &mut [u8], bit: u16) { /// truncates to whatever buffer length the caller passed. pub fn populate_evbit(device: u8, ev_type: u16, buf: &mut [u8]) { match (device, ev_type) { - // ev_type = 0 — "which EV_* types does this device produce?" (_, 0) => { set_bit(buf, EV_SYN); set_bit(buf, EV_KEY); @@ -53,16 +52,14 @@ pub fn populate_evbit(device: u8, ev_type: u16, buf: &mut [u8]) { set_bit(buf, EV_ABS); } } - // Keyboard advertises every KEY_* in the kbd surface range - // (A1 picked 1..=KEY_MICMUTE precisely so this is a single - // loop instead of a 248-entry table). KEY_RESERVED (0) is - // deliberately excluded — Linux doesn't advertise it either. + // A1 picked 1..=KEY_MICMUTE precisely so this is a single + // loop instead of a 248-entry table. KEY_RESERVED (0) is + // skipped so the bitmap matches Linux byte-for-byte. (0, t) if t == EV_KEY => { for k in 1..=KEY_MICMUTE { set_bit(buf, k); } } - // Pointer advertises only the five mouse buttons. (1, t) if t == EV_KEY => { for &b in &[BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA] { set_bit(buf, b); @@ -156,8 +153,6 @@ mod tests { #[test] fn evbit_kbd_abs_query_is_empty() { - // Keyboard has no absolute axes — populate_evbit leaves the - // caller-zeroed buffer alone. let mut buf = [0u8; 4]; populate_evbit(0, EV_ABS, &mut buf); assert_eq!(buf, [0; 4]); @@ -165,10 +160,9 @@ mod tests { #[test] fn evbit_truncates_silently_when_buf_too_small() { + // KEY_ESC fits in bit 1; KEY_A (30) falls off — no panic. let mut buf = [0u8; 1]; populate_evbit(0, EV_KEY, &mut buf); - // KEY_ESC (1) fits in bit 1; KEY_A (30) fell off the end — - // no panic. assert_ne!(buf[0] & (1 << KEY_ESC), 0); } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 244e6e5c69..4c76b39fa2 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -1246,7 +1246,6 @@ mod tests { assert!(!st.dropped); assert!(st.event_ring.is_empty()); - // input_mut lets us mutate the ring. let st = table.get_mut(idx).unwrap().input_mut().unwrap(); st.event_ring.push_back(0xab); assert_eq!(table.get(idx).unwrap().input().unwrap().event_ring.len(), 1); @@ -1254,8 +1253,7 @@ mod tests { #[test] fn input_ring_cap_bytes_is_24_kib() { - // Lock the ring cap so the per-fd memory budget cannot drift - // without a deliberate edit + review. + // Lock the per-fd memory budget so it cannot drift silently. assert_eq!(INPUT_RING_MAX_RECORDS, 1024); assert_eq!(INPUT_RING_MAX_BYTES, 24 * 1024); } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 923a04066a..033fa6bd9c 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -1887,13 +1887,9 @@ fn handle_dri_card_ioctl( } } -/// `EVIOCG*` ioctl surface for `/dev/input/event{0,1}`. -/// -/// Unknown requests return `ENOTTY` rather than `EINVAL` so SDL2's -/// evdev probe (which greps the errno) keeps walking instead of -/// fataling on the first unsupported call. EVIOCGRAB records the -/// per-OFD grab flag but does not enforce cross-fd exclusivity in -/// v1 — that lands with plan 9's compositor. +/// `EVIOCG*` ioctl surface for `/dev/input/event{0,1}`. Unknown +/// requests return `ENOTTY` (not `EINVAL`) so SDL2's evdev probe keeps +/// walking instead of fataling on the first unsupported call. fn handle_input_ioctl( proc: &mut Process, ofd_idx: usize, @@ -1902,24 +1898,16 @@ fn handle_input_ioctl( ) -> Result<(), Errno> { use wasm_posix_shared::input::*; - // Decode (dir, magic, nr, size) per asm-generic/ioctl.h. The - // size sub-field is informational for variable-length ioctls - // (EVIOCGNAME, EVIOCGBIT) — Linux matches on (dir, magic, nr) - // only and uses _IOC_SIZE(request) to learn the caller's buffer - // length, which we mirror here. let dir = (request >> 30) & 0x3; let magic = (request >> 8) & 0xff; let nr = request & 0xff; let size = ((request >> 16) & 0x3fff) as usize; - // Foreign magic — not an evdev ioctl. ENOTTY keeps probing loops - // moving (EINVAL would fatal SDL2's evdev detection). if magic != b'E' as u32 { return Err(Errno::ENOTTY); } match nr { - // EVIOCGVERSION — read u32 0x01 if dir == 2 => { if buf.len() < 4 { return Err(Errno::EINVAL); @@ -1928,7 +1916,6 @@ fn handle_input_ioctl( buf[0..4].copy_from_slice(&version.to_le_bytes()); Ok(()) } - // EVIOCGID — read WpkInputId 0x02 if dir == 2 => { if buf.len() < core::mem::size_of::() { return Err(Errno::EINVAL); @@ -1945,7 +1932,6 @@ fn handle_input_ioctl( } Ok(()) } - // EVIOCGNAME(len) — variable size; nr fixed at EVIOCGNAME_NR. n if n == EVIOCGNAME_NR && dir == 2 => { let device = input_state(proc, ofd_idx)?.device; let name: &[u8] = if device == 0 { @@ -1957,8 +1943,6 @@ fn handle_input_ioctl( buf[..copy_len].copy_from_slice(&name[..copy_len]); Ok(()) } - // EVIOCGBIT(ev_type, len) — nr = EVIOCGBIT_NR_BASE + ev_type; - // 32 EV_* slots reserved. n if (EVIOCGBIT_NR_BASE..EVIOCGBIT_NR_BASE + 32).contains(&n) && dir == 2 => { let ev_type = (n - EVIOCGBIT_NR_BASE) as u16; let device = input_state(proc, ofd_idx)?.device; @@ -1970,16 +1954,12 @@ fn handle_input_ioctl( crate::input::populate_evbit(device, ev_type, slice); Ok(()) } - // EVIOCGABS(axis) — pointer device only; nr = EVIOCGABS_NR_BASE - // + axis; 64 ABS_* slots reserved. n if (EVIOCGABS_NR_BASE..EVIOCGABS_NR_BASE + 64).contains(&n) && dir == 2 => { if buf.len() < core::mem::size_of::() { return Err(Errno::EINVAL); } let axis = (n - EVIOCGABS_NR_BASE) as u16; let device = input_state(proc, ofd_idx)?.device; - // Keyboard has no absolute axes — ENOTTY (not EINVAL) so - // SDL2 keeps probing. if device != 1 { return Err(Errno::ENOTTY); } @@ -2011,9 +1991,6 @@ fn handle_input_ioctl( } Ok(()) } - // EVIOCGRAB — write i32 (value != 0 grabs, 0 releases). Per-fd - // idempotent (Linux semantics in drivers/input/evdev.c); cross- - // fd EBUSY enforcement is the plan 9 follow-up. 0x90 if dir == 1 => { if buf.len() < 4 { return Err(Errno::EINVAL); @@ -4724,13 +4701,6 @@ pub fn sys_read( // Real DSP descriptors use PcmPlayback and O_WRONLY. VirtualDevice::Dsp => return Err(Errno::EBADF), VirtualDevice::InputEvent { .. } => { - // Drain the per-OFD evdev ring into the - // caller buffer. Linux evdev semantics: - // the buffer is floored to a whole - // 24-byte `struct input_event` boundary - // and we never return a partial record. - // A sub-record buffer is a protocol bug - // and returns EINVAL. use wasm_posix_shared::clock::CLOCK_MONOTONIC; use wasm_posix_shared::input::{ EV_SYN, SYN_DROPPED, WpkInputEvent, @@ -4740,10 +4710,9 @@ pub fn sys_read( return Err(Errno::EINVAL); } let input = input_state_mut(proc, ofd_idx)?; - // Match DriCard0: the kernel never parks - // the reader; the host polls on a retry - // timer until Phase B wires targeted - // wake-ups. + // Blocking read returns Ok(0) (not park) + // so the host can retry on a poll timer + // — matches DriCard0. if input.event_ring.is_empty() && !input.dropped { if status_flags & O_NONBLOCK != 0 { return Err(Errno::EAGAIN); @@ -4751,13 +4720,9 @@ pub fn sys_read( return Ok(0); } let mut written = 0; - // Producer overflowed: synthesise a - // SYN_DROPPED marker at the head of this - // read so userspace can resync state via - // EVIOCG* before resuming the event - // stream. CLOCK_MONOTONIC stamp matches - // real records; fallback (0,0) is fine - // because readers select on type/code. + // Producer overflowed: prepend SYN_DROPPED + // so userspace resyncs via EVIOCG* before + // consuming the next real record. if input.dropped { let (sec, nsec) = host .host_clock_gettime(CLOCK_MONOTONIC) @@ -13812,12 +13777,9 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) Some(VirtualDevice::InputEvent { .. }) ) { - // /dev/input/event{0,1} gates POLLIN on the per-OFD - // ring holding a record OR the SYN_DROPPED latch - // being set. Either condition means the next read - // returns >0 bytes — sys_read returns Ok(0) on an - // empty/no-latch ring, so reporting always-ready - // POLLIN would spin libinput. + // Gate POLLIN on the ring or the SYN_DROPPED latch: + // always-ready would spin libinput against an empty + // ring (sys_read returns Ok(0), not a record). if pollfd.events & POLLIN != 0 { if let Some(input) = ofd.input() { if !input.event_ring.is_empty() || input.dropped { @@ -13825,7 +13787,6 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } } } - // evdev is read-only — never report POLLOUT. } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -45371,8 +45332,7 @@ mod tests { assert!(!st.grabbed); assert!(!st.dropped); assert!(st.event_ring.is_empty()); - // dri_state must NOT be installed on an evdev fd (disjoint - // sidecars). + // input + dri sidecars are disjoint state machines. assert!(ofd.dri_state.is_none()); } @@ -45404,10 +45364,6 @@ mod tests { #[test] fn open_nonexistent_event_path_returns_enoent() { - // /dev/input/event2 isn't a virtual device + isn't on the host - // FS in tests, so it lands in the file-not-found path. Either - // ENOENT or whatever MockHostIO returns for an unknown path; - // the contract for v1 is "not a virtual device". let mut proc = Process::new(301); let mut host = MockHostIO::new(); let r = sys_open(&mut proc, &mut host, b"/dev/input/event2", O_RDONLY, 0); @@ -45416,9 +45372,6 @@ mod tests { #[test] fn read_eventN_returns_zero_before_any_event() { - // Blocking read on an empty ring with no dropped latch - // returns Ok(0) — mirrors DriCard0 (kernel never parks the - // reader; host retries on poll timeout). let mut proc = Process::new(401); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/input/event0", O_RDONLY, 0).unwrap(); @@ -45427,12 +45380,6 @@ mod tests { assert_eq!(n, 0); } - // ----------------------------------------------------------------- - // EVIOCG* ioctl dispatch (A3). - // ----------------------------------------------------------------- - - /// `_IOC(dir, magic, nr, size)` — mirrors include/uapi/asm-generic/ioctl.h - /// (dir 2 = read / dir 1 = write — Linux's `_IOC_READ` / `_IOC_WRITE`). const fn evioc(dir: u32, nr: u32, size: u32) -> u32 { (dir << 30) | (size << 16) | ((b'E' as u32) << 8) | nr } @@ -45498,10 +45445,9 @@ mod tests { fn evioc_gname_truncates_to_caller_buffer() { use wasm_posix_shared::input::EVIOCGNAME_NR; let (mut proc, mut host, fd) = open_evdev(605, b"/dev/input/event0"); + // "wpk virtual keyboard" is 20 chars; a 5-byte buffer fills with + // the prefix and no NUL terminator — caller handles the cut-off. let mut buf = [0xffu8; 5]; - // Caller asks for 5 bytes; "wpk virtual keyboard" is 20 chars, - // so we should fill all 5 with the first 5 bytes (no terminator - // — caller is expected to handle the cut-off case). let req = evioc(2, EVIOCGNAME_NR, buf.len() as u32); sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); assert_eq!(&buf, b"wpk v"); @@ -45512,7 +45458,6 @@ mod tests { use wasm_posix_shared::input::{EVIOCGBIT_NR_BASE, EV_KEY, EV_REL, EV_SYN}; let (mut proc, mut host, fd) = open_evdev(606, b"/dev/input/event0"); let mut buf = [0u8; 4]; - // EVIOCGBIT(ev_type = 0, len = 4) — nr = base + 0. let req = evioc(2, EVIOCGBIT_NR_BASE, buf.len() as u32); sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); assert_ne!(buf[0] & (1 << EV_SYN), 0); @@ -45536,7 +45481,6 @@ mod tests { use wasm_posix_shared::input::{EVIOCGBIT_NR_BASE, EV_KEY, KEY_A}; let (mut proc, mut host, fd) = open_evdev(608, b"/dev/input/event0"); let mut buf = [0u8; 32]; - // EVIOCGBIT(EV_KEY, 32) — nr = base + EV_KEY. let req = evioc(2, EVIOCGBIT_NR_BASE + EV_KEY as u32, buf.len() as u32); sys_ioctl(&mut proc, &mut host, fd, req, &mut buf).unwrap(); let byte = (KEY_A >> 3) as usize; @@ -45570,8 +45514,8 @@ mod tests { sys_ioctl(&mut proc, &mut host, fd, req_y, &mut buf).unwrap(); let aby: WpkInputAbsinfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; assert_eq!(aby.maximum, 599); - // Restore the default — other tests in this module may run in - // parallel and expect the boot value. + // Restore the default so other tests running in parallel see + // the boot value. crate::input::set_canvas_dims(1280, 720); } @@ -45594,9 +45538,6 @@ mod tests { let (mut proc, mut host, fd) = open_evdev(612, b"/dev/input/event0"); let mut on = 1i32.to_le_bytes(); sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut on).unwrap(); - // Re-grab from the same fd must succeed (Linux semantics in - // drivers/input/evdev.c). The cross-fd EBUSY case lands with - // plan 9. let mut on2 = 1i32.to_le_bytes(); sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut on2).unwrap(); } @@ -45606,19 +45547,12 @@ mod tests { use wasm_posix_shared::input::EVIOCGRAB; let (mut proc, mut host, fd) = open_evdev(613, b"/dev/input/event0"); let mut off = 0i32.to_le_bytes(); - // Never grabbed; release returns 0, not an error. sys_ioctl(&mut proc, &mut host, fd, EVIOCGRAB, &mut off).unwrap(); } #[test] fn close_releases_grab_so_next_open_can_grab() { use wasm_posix_shared::input::EVIOCGRAB; - // Open event0, grab it, then close. The OFD slot must drop — - // and a fresh open on the same node must come up clean - // (no leftover grab flag, no stale ring) and be re-grabbable. - // v1 doesn't enforce cross-OFD EBUSY anyway, so this mostly - // exercises that close-time input cleanup runs without panicking - // and the OFD slot is actually freed. let (mut proc, mut host, fd_a) = open_evdev(616, b"/dev/input/event0"); let idx_a = proc.fd_table.get(fd_a).unwrap().ofd_ref.0; let mut on = 1i32.to_le_bytes(); @@ -45626,7 +45560,6 @@ mod tests { assert!(proc.ofd_table.get(idx_a).unwrap().input().unwrap().grabbed); sys_close(&mut proc, &mut host, fd_a).unwrap(); - // dec_ref freed the OFD slot — entries[idx_a] is now None. assert!(proc.ofd_table.get(idx_a).is_none()); let fd_b = sys_open( @@ -45638,7 +45571,6 @@ mod tests { ) .unwrap(); let idx_b = proc.fd_table.get(fd_b).unwrap().ofd_ref.0; - // Fresh OFD: ring empty, dropped clear, grab clear. let input = proc.ofd_table.get(idx_b).unwrap().input().unwrap(); assert!(input.event_ring.is_empty()); assert!(!input.dropped); @@ -45656,35 +45588,26 @@ mod tests { let (mut parent, mut host, parent_fd) = open_evdev(617, b"/dev/input/event0"); let ofd_idx = parent.fd_table.get(parent_fd).unwrap().ofd_ref.0; - // Parent grabs + queues two events to verify serialisation - // round-trips both the grab flag and the ring contents. let mut on = 1i32.to_le_bytes(); sys_ioctl(&mut parent, &mut host, parent_fd, EVIOCGRAB, &mut on) .unwrap(); push_event_into_ofd(&mut parent, ofd_idx, EV_KEY, KEY_A, 1); push_event_into_ofd(&mut parent, ofd_idx, EV_SYN, SYN_REPORT, 0); - // "Fork": serialise the parent and reconstruct as the child. - // After this, parent and child each own an independent copy of - // the OFD (and its InputFdState) — mirrors the dri_state fork - // tests in fork.rs. let mut buf = alloc::vec![0u8; 64 * 1024]; let written = crate::fork::serialize_fork_state(&parent, &mut buf).unwrap(); let mut child = crate::fork::deserialize_fork_state(&buf[..written], 717).unwrap(); - // Child carries the grab + the ring contents. let child_input = child.ofd_table.get(ofd_idx).unwrap().input().unwrap(); assert_eq!(child_input.device, 0); assert!(child_input.grabbed); - assert_eq!(child_input.event_ring.len(), 48); // 2 × 24 + assert_eq!(child_input.event_ring.len(), 48); - // Closing the child's copy of the fd drops the child's OFD slot. sys_close(&mut child, &mut host, parent_fd).unwrap(); assert!(child.ofd_table.get(ofd_idx).is_none()); - // Parent is untouched — separate Process structs after fork. let parent_input = parent.ofd_table.get(ofd_idx).unwrap().input().unwrap(); assert!(parent_input.grabbed); @@ -45693,11 +45616,8 @@ mod tests { #[test] fn evioc_unknown_request_returns_enotty_not_einval() { - // SDL2's evdev probe greps the errno from EVIOCG* calls; EINVAL - // fatals it. Every unhandled request on an evdev fd must come - // back as ENOTTY. + // SDL2's evdev probe greps the errno; EINVAL fatals it. let (mut proc, mut host, fd) = open_evdev(614, b"/dev/input/event0"); - // 'E' magic, dir = 2 (read), nr = 0xfe (never assigned), size = 0. let bogus = evioc(2, 0xfe, 0); let mut buf = [0u8; 4]; let err = sys_ioctl(&mut proc, &mut host, fd, bogus, &mut buf).unwrap_err(); @@ -45706,24 +45626,18 @@ mod tests { #[test] fn evioc_foreign_magic_on_evdev_fd_returns_enotty() { - // Non-'E' magic — caller probed something foreign on the fd. - // ENOTTY (not EINVAL) so probing loops keep moving. + // ENOTTY (not EINVAL) so probing loops keep moving past a + // foreign-subsystem ioctl issued on an evdev fd. let (mut proc, mut host, fd) = open_evdev(615, b"/dev/input/event0"); - // dir = 2, magic = 'X', nr = 0x01, size = 4 — looks like a read - // ioctl for some other subsystem. let foreign = (2u32 << 30) | (4u32 << 16) | ((b'X' as u32) << 8) | 0x01; let mut buf = [0u8; 4]; let err = sys_ioctl(&mut proc, &mut host, fd, foreign, &mut buf).unwrap_err(); assert_eq!(err, Errno::ENOTTY); } - // ----------------------------------------------------------------- - // sys_read drain + sys_poll(POLLIN) + SYN_DROPPED resync (A5). - // ----------------------------------------------------------------- - /// Inject one `WpkInputEvent` into an OFD's ring without going - /// through `dispatch::push_event` (avoids registering the test - /// process in GLOBAL_PROCESS_TABLE). + /// through `dispatch::push_event` — avoids registering the test + /// process in GLOBAL_PROCESS_TABLE. fn push_event_into_ofd( proc: &mut Process, ofd_idx: usize, @@ -45765,9 +45679,8 @@ mod tests { #[test] fn read_returns_einval_for_buffer_shorter_than_one_record() { - // Linux evdev semantics: reads must be sized to at least one - // `struct input_event`. A 12-byte buffer would force a - // partial record return, which the protocol forbids. + // Linux evdev rejects sub-record reads — partial returns would + // break the input_event boundary contract. let (mut proc, mut host, fd) = open_evdev(701, b"/dev/input/event0"); let mut buf = [0u8; 12]; let err = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap_err(); @@ -45803,8 +45716,7 @@ mod tests { for v in 0..3 { push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, v); } - // 50 bytes: floors to 48 (= 2 whole records); one stays in - // the ring. + // 50 floors to 48 (= 2 records); one stays in the ring. let mut buf = [0u8; 50]; let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); assert_eq!(n, 48); @@ -45814,7 +45726,6 @@ mod tests { assert_eq!(r1.value, 1); let input = proc.ofd_table.get(ofd_idx).unwrap().input().unwrap(); assert_eq!(input.event_ring.len(), 24); - // The remaining record is value=2; drain via a second read. let mut buf2 = [0u8; 24]; let n2 = sys_read(&mut proc, &mut host, fd, &mut buf2).unwrap(); assert_eq!(n2, 24); @@ -45850,9 +45761,8 @@ mod tests { let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 100); push_event_into_ofd(&mut proc, ofd_idx, EV_KEY, KEY_A, 101); - // Latch the overflow flag as if the producer hit a full ring - // after pushing those two records — dispatch::push_event sets - // this exact field on the OFD. + // dispatch::push_event would latch `dropped` on a full ring — + // simulate that here without running the producer. proc.ofd_table .get_mut(ofd_idx) .unwrap() @@ -45875,8 +45785,6 @@ mod tests { #[test] fn read_empty_ring_with_nonblock_returns_eagain() { - // Matches DriCard0: O_NONBLOCK + no data ready → EAGAIN. - // Blocking-mode reads still return Ok(0) (covered above). let mut proc = Process::new(706); let mut host = MockHostIO::new(); let fd = sys_open( @@ -45912,8 +45820,8 @@ mod tests { #[test] fn poll_pollin_ready_when_only_dropped_flag_is_set() { - // The SYN_DROPPED marker alone is a readable record — the - // ring is empty but read() will still return 24 bytes. + // The SYN_DROPPED marker alone is a readable 24-byte record; + // poll must fire even with an empty ring. use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::POLLIN; let (mut proc, mut host, fd) = open_evdev(708, b"/dev/input/event0"); @@ -45932,8 +45840,6 @@ mod tests { #[test] fn poll_never_reports_pollout_for_evdev_fd() { - // Input devices are read-only — POLLOUT must never fire, - // even with an empty ring and POLLOUT requested. use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::{POLLIN, POLLOUT}; let (mut proc, mut host, fd) = open_evdev(709, b"/dev/input/event0"); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index a919aed266..b3df35d589 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13941,16 +13941,8 @@ pub extern "C" fn kernel_vblank() -> u32 { } /// Fan one translated DOM input event out to every open OFD bound to -/// `/dev/input/event{0,1}`. Records are timestamped with -/// CLOCK_MONOTONIC so libinput / SDL2 see a single monotonic timeline -/// across vblank + input streams. -/// -/// `device`: 0 = kbd (event0), 1 = ptr (event1); other values are -/// dropped. -/// `ev_type`: EV_SYN / EV_KEY / EV_REL / EV_ABS. -/// `code`: KEY_* / BTN_* / REL_* / ABS_* / SYN_*. -/// `value`: press(1) / release(0) / repeat(2) for KEY; delta for REL; -/// absolute position for ABS; 0 for SYN_REPORT. +/// `/dev/input/event{0,1}`. Stamped with CLOCK_MONOTONIC so libinput / +/// SDL2 see a single monotonic timeline across vblank + input streams. #[unsafe(no_mangle)] pub extern "C" fn kernel_input_event( device: u32, @@ -13975,10 +13967,9 @@ pub extern "C" fn kernel_input_event( ); } -/// Cache the canvas pixel dimensions used by `EVIOCGABS(ABS_X/ABS_Y)` -/// on `/dev/input/event1`. The host calls this once at boot so the -/// first SDL2 / libinput probe sees the real axis range instead of -/// the 1280×720 fallback. +/// Cache the canvas pixel dimensions advertised by +/// `EVIOCGABS(ABS_X/ABS_Y)` on `/dev/input/event1`. Without this the +/// first SDL2 / libinput probe sees the 1280×720 fallback. #[unsafe(no_mangle)] pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { crate::input::set_canvas_dims(width, height); diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 72b35e11a8..7e86794e2e 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -1098,10 +1098,6 @@ export class BrowserKernel { * emitted record through `injectInputEvent`. Mirrors * `NodeKernelHost.attachInputSource` — dual-host parity per * CLAUDE.md §"Two hosts". - * - * The browser caller is responsible for instantiating the source - * with the right DOM target + canvas: typically - * `new BrowserInputSource(window, canvas)`. */ attachInputSource( source: InputSource, diff --git a/host/src/input/browser-input-source.ts b/host/src/input/browser-input-source.ts index 3bb2ca1127..fd7e9e7947 100644 --- a/host/src/input/browser-input-source.ts +++ b/host/src/input/browser-input-source.ts @@ -34,14 +34,7 @@ export class BrowserInputSource implements InputSource { private dispatch: ((ev: InputEvent) => void) | null = null; private bindings: Array<[EventTarget, string, EventListener]> = []; - constructor( - private target: EventTarget = window, - // Stashed for B4 — `kernel_set_input_canvas_dims` reads w/h off it. - // Unused inside this module; intentionally kept on `this`. - private canvas?: HTMLCanvasElement | OffscreenCanvas, - ) { - void this.canvas; - } + constructor(private target: EventTarget = window) {} start(dispatch: (ev: InputEvent) => void): void { this.dispatch = dispatch; diff --git a/libc/musl-overlay/include/linux/input.h b/libc/musl-overlay/include/linux/input.h index 4a588a8420..bd33437ca8 100644 --- a/libc/musl-overlay/include/linux/input.h +++ b/libc/musl-overlay/include/linux/input.h @@ -1,15 +1,8 @@ /* - * Minimal for kandelo. - * - * The real Linux header carries a lot of Linux-specific details - * (force-feedback, autorepeat, MT slots, etc.) we don't implement and - * don't expose to user-space programs. This subset matches the structs - * and ioctls that the kernel marshals via crates/shared/src/lib.rs::input. - * - * Programs using `` against our `/dev/input/event{0,1}` - * see the same `struct input_event` layout (24 bytes; trailing 8-byte - * `struct timeval` + `__u16 type` + `__u16 code` + `__s32 value`) and - * EVIOC* ioctl numbers as on real Linux. + * Subset of matching what crates/shared/src/lib.rs::input + * marshals. Force-feedback, autorepeat, MT slots, and the rest of the + * Linux UAPI surface are intentionally omitted — kandelo doesn't + * implement them. * * Any change here is part of the kernel ABI — bump ABI_VERSION. */ diff --git a/programs/evdev_demo.c b/programs/evdev_demo.c index 59f0b1134d..95dc368429 100644 --- a/programs/evdev_demo.c +++ b/programs/evdev_demo.c @@ -1,23 +1,10 @@ /* - * evdev_demo — interactive keystroke + pointer log for the Kandelo - * `/dev/input/event*` evdev backend. + * evdev_demo — C1 compile proof and runtime backing for `/?demo=evdev`. * - * Companion to the kandelo browser demo `/?demo=evdev`. Opens - * `/dev/input/event0` (keyboard) and `/dev/input/event1` (pointer), - * prints each device's EVIOCGNAME, then polls both forever and logs - * every key press / release / pointer-axis event to stdout. The pane - * surfaces the log lines. - * - * Also serves as the C1 compile proof: this program is the only thing - * in tree that does `#include `. If the vendored headers - * ever drift (e.g. `struct input_event` grows to 32 bytes), the - * trailing _Static_assert here fires at build time before runtime. - * - * Re-use of input-evdev-smoke.c was considered. That fixture is - * stdin-barrier-gated (the three-phase test harness) which doesn't - * work for free-running interactive use, and it inlines structs the - * vendored header now provides. Keeping the two programs separate - * lets each be optimised for its purpose without coupling. + * Only in-tree consumer of ``: the _Static_assert below + * fires at build time if the vendored header drifts away from the + * kernel-side WpkInputEvent layout. input-evdev-smoke.c stays inline + * on purpose so the B5 fixture's ABI check is independent of C1. */ #include #include @@ -79,7 +66,7 @@ int main(void) { }; for (;;) { - int n = poll(pfds, 2, -1 /* block until any fd is ready */); + int n = poll(pfds, 2, -1); if (n < 0) { if (errno == EINTR) continue; perror("poll"); return 1; diff --git a/programs/input-evdev-smoke.c b/programs/input-evdev-smoke.c index f0f9fae23f..5555619636 100644 --- a/programs/input-evdev-smoke.c +++ b/programs/input-evdev-smoke.c @@ -1,21 +1,7 @@ /* - * input-evdev-smoke — end-to-end fixture for host/test/input-evdev.test.ts. - * - * Three phases gated by stdin barriers so the test can inject events - * AFTER the program has opened the matching device (push_event fans - * out at injection time, so an OFD must exist). - * - * 1. open /dev/input/event0, EVIOCGNAME, then "READY:kbd\n"; on the - * next stdin byte, read 48 bytes (two records) and print both. - * 2. open /dev/input/event1, EVIOCGABS(ABS_X), then "READY:ptr\n"; - * on the next stdin byte, read 48 bytes and print both. - * 3. "READY:overflow\n"; on the next stdin byte, drain event0 to - * empty (kernel returns 0 on empty + blocking), printing each - * record's (type, code). First record must be SYN_DROPPED. - * - * Linux isn't in the wasm sysroot until Phase C, so - * the evdev structs/ioctl numbers are spelled inline — same pattern - * as programs/kms-pageflip-smoke.c. + * Three-phase fixture driven by host/test/input-evdev.test.ts. Structs + * and ioctl numbers are inlined (not ) so the B5 ABI + * check stays independent of the C1 sysroot header land. */ #include #include @@ -30,11 +16,6 @@ #define SYN_REPORT 0x00 #define SYN_DROPPED 0x03 -/* Linux ioctl encoding: (dir << 30) | (size << 16) | (magic << 8) | nr. - * EVIOCGNAME bakes the caller-supplied buffer size into the size field; - * the kernel A3 dispatch reads back (dir, magic, nr) and re-derives the - * buffer length from size. ABS_X / ABS_Y land at nr = 0x40 + axis, - * size = sizeof(struct input_absinfo) = 24. */ #define EVIOC_DIR_READ (2u << 30) #define EVIOC_MAGIC (0x45u << 8) /* 'E' */ #define EVIOCGNAME(len) (EVIOC_DIR_READ | (((unsigned)(len) & 0x3fffu) << 16) | EVIOC_MAGIC | 0x06u) @@ -57,7 +38,6 @@ _Static_assert(sizeof(struct wpk_event) == 24, "WpkInputEvent must be 24 bytes") _Static_assert(sizeof(struct wpk_absinfo) == 24, "WpkInputAbsinfo must be 24 bytes"); static void wait_sync(void) { - /* Block until the host writes one byte via appendStdinData. */ char c; while (read(0, &c, 1) <= 0) { } } @@ -120,12 +100,7 @@ int main(void) { fflush(stdout); wait_sync(); - /* The kernel ring caps at 1024 records (24 KiB); on overflow the - * `dropped` flag latches, the incoming record is discarded, and - * the next read prepends a synthesised SYN_DROPPED to the drain. - * Read one record at a time so we can count exactly. Blocking - * read on an empty+clean ring returns 0 — that's our drain - * terminator. */ + /* Blocking read on an empty+clean ring returns 0 — drain terminator. */ int count = 0, syn_dropped_at = -1, non_syn_dropped = 0; struct wpk_event last = {0}; for (;;) { From 910c90acb59269dbbdee96ffbbf0bea2ebcb7e3e Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 15 Jun 2026 15:00:39 +0200 Subject: [PATCH 17/27] Add ALSA audio backend and espeak-ng browser demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- abi/snapshot.json | 15 + .../pages/kandelo/kernel-host/live-setup.ts | 80 + apps/browser-demos/pages/kandelo/presets.ts | 11 + .../browser-demos/test/kandelo-espeak.spec.ts | 52 + crates/kernel/src/audio/mmap.rs | 335 ++++ crates/kernel/src/audio/mod.rs | 21 + crates/kernel/src/{audio.rs => audio/oss.rs} | 0 crates/kernel/src/audio/pcm_ioctl.rs | 1507 +++++++++++++++++ crates/kernel/src/audio/sab.rs | 163 ++ crates/kernel/src/audio/tick.rs | 268 +++ crates/kernel/src/audio/wait.rs | 86 + crates/kernel/src/devfs.rs | 43 + crates/kernel/src/fork.rs | 454 +++++ crates/kernel/src/ofd.rs | 194 +++ crates/kernel/src/process.rs | 23 + crates/kernel/src/syscalls.rs | 472 +++++- crates/kernel/src/wasm_api.rs | 60 + crates/shared/src/lib.rs | 326 ++++ host/src/audio/audio-driver.ts | 48 + host/src/audio/browser-audio-driver.ts | 183 ++ host/src/audio/instrumented-audio-driver.ts | 56 + host/src/audio/node-audio-driver.ts | 53 + host/src/audio/wpk-audio-worklet.js | 78 + host/src/browser-kernel-host.ts | 115 ++ host/src/browser-kernel-protocol.ts | 49 + host/src/browser-kernel-worker-entry.ts | 15 + host/src/kernel-worker.ts | 44 + host/src/kernel.ts | 76 + host/src/node-kernel-host.ts | 104 ++ host/src/node-kernel-protocol.ts | 45 +- host/src/node-kernel-worker-entry.ts | 15 + host/test/audio-driver.test.ts | 161 ++ host/test/browser-audio-driver-drain.test.ts | 180 ++ host/test/instrumented-audio-driver.test.ts | 121 ++ images/vfs/scripts/build-shell-vfs-image.sh | 5 + images/vfs/scripts/build-shell-vfs-image.ts | 35 +- libc/musl-overlay/include/sound/asound.h | 308 ++++ .../registry/espeak-ng/build-espeak-ng.sh | 229 +++ packages/registry/espeak-ng/build.toml | 13 + packages/registry/espeak-ng/package.toml | 32 + .../espeak-ng/wasm32-posix-toolchain.cmake | 134 ++ .../package-system/shell-vfs-install.test.ts | 18 + 42 files changed, 6223 insertions(+), 4 deletions(-) create mode 100644 apps/browser-demos/test/kandelo-espeak.spec.ts create mode 100644 crates/kernel/src/audio/mmap.rs create mode 100644 crates/kernel/src/audio/mod.rs rename crates/kernel/src/{audio.rs => audio/oss.rs} (100%) create mode 100644 crates/kernel/src/audio/pcm_ioctl.rs create mode 100644 crates/kernel/src/audio/sab.rs create mode 100644 crates/kernel/src/audio/tick.rs create mode 100644 crates/kernel/src/audio/wait.rs create mode 100644 host/src/audio/audio-driver.ts create mode 100644 host/src/audio/browser-audio-driver.ts create mode 100644 host/src/audio/instrumented-audio-driver.ts create mode 100644 host/src/audio/node-audio-driver.ts create mode 100644 host/src/audio/wpk-audio-worklet.js create mode 100644 host/test/audio-driver.test.ts create mode 100644 host/test/browser-audio-driver-drain.test.ts create mode 100644 host/test/instrumented-audio-driver.test.ts create mode 100644 libc/musl-overlay/include/sound/asound.h create mode 100755 packages/registry/espeak-ng/build-espeak-ng.sh create mode 100644 packages/registry/espeak-ng/build.toml create mode 100644 packages/registry/espeak-ng/package.toml create mode 100644 packages/registry/espeak-ng/wasm32-posix-toolchain.cmake create mode 100644 tests/package-system/shell-vfs-install.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 8db95d052c..177c9a3cb4 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1800,11 +1800,26 @@ "name": "kernel_audio_channels", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_audio_get_appl_ptr", + "signature": "(i32) -> (i64)" + }, + { + "kind": "func", + "name": "kernel_audio_init_sab", + "signature": "(i32,i64,i32) -> ()" + }, { "kind": "func", "name": "kernel_audio_pending", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_audio_period_tick", + "signature": "(i32,i32) -> ()" + }, { "kind": "func", "name": "kernel_audio_sample_rate", diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index a281ff3234..6ce3ed70cc 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -8,6 +8,18 @@ import { type ImageOwnedRuntimeLazyAssets, } from "../../../lib/init/image-owned-runtime-urls"; import { BrowserInputSource } from "../../../../../host/src/input/browser-input-source"; +import { BrowserAudioDriver } from "../../../../../host/src/audio/browser-audio-driver"; +import { + instrumentAudioDriver, + type InstrumentedAudioDriver, +} from "../../../../../host/src/audio/instrumented-audio-driver"; +import wpkAudioWorkletUrl from "../../../../../host/src/audio/wpk-audio-worklet.js?url"; +import { + ensureServiceWorkerReady, + initServiceWorkerBridge, +} from "../../../lib/init/service-worker-bridge"; +import { HttpBridgeHost } from "../../../lib/http-bridge"; +import { rewriteShellLazyFileUrls } from "../../../lib/init/shell-lazy-files"; import { resolveShellLazyArchiveUrl } from "../../../lib/init/lazy-archives"; import { WORDPRESS_CONFIG_INIT_SCRIPT, @@ -401,6 +413,7 @@ const LIVE_DEMO_IDS = [ "doom", "modeset", "evdev", + "espeak", ] as const; type LiveDemoId = (typeof LIVE_DEMO_IDS)[number]; @@ -493,6 +506,9 @@ const LIVE_DEMO_SPECS: Record = { evdev: { image: "shell", }, + espeak: { + image: "shell", + }, }; const DEFAULT_DEMO_FOR_VFS_IMAGE: Record = { @@ -558,6 +574,16 @@ interface LiveProfile { * log streams to the user's Shell pane. The C1 sysroot vendoring proof. */ evdevDemo: boolean; + /** + * Attach a `BrowserAudioDriver` and spawn `espeak-ng "..."` from + * the booted shell. espeak-ng links against our patched pcaudiolib + * whose `create_audio_device_object` is wired to the kandelo + * backend (open /dev/snd/pcmC0D0p + WRITEI loop), so a single + * binary invocation produces audible synthesised speech without + * any host-side pipeline. The binary + data dir are baked into + * the shell VFS image via `populateEspeakRuntime`. + */ + espeakDemo: boolean; } interface WebReadinessState { @@ -1018,6 +1044,7 @@ function customVfsProfile( maxVfsByteLength: CUSTOM_VFS_PROFILE_MAX_BYTES, framebufferTest: fb === "test", evdevDemo: false, + espeakDemo: false, }; } @@ -1038,6 +1065,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { init: software.init, framebufferTest: false, evdevDemo: false, + espeakDemo: false, }; } @@ -1086,6 +1114,7 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { }, framebufferTest: fb === "test", evdevDemo: normalized === "evdev", + espeakDemo: normalized === "espeak", }; } @@ -1871,6 +1900,40 @@ async function bootProfile( tick, assertCurrent, ); + } else if (profile.espeakDemo) { + // espeak-ng + its data dir are baked into the shell VFS image + // (see populateEspeakRuntime in build-shell-vfs-image.ts), so + // no runtime binary staging is needed. The audio driver MUST be + // attached before the binary opens /dev/snd/pcmC0D0p — the + // WRITEI path returns EBADFD until the SAB ring is registered. + // espeak-ng emits at 22050 Hz mono (its internal synth rate); + // the worklet resamples to the AudioContext rate. + const kernelForEspeak = kernel; + void (async () => { + try { + tick("attaching audio driver..."); + const audioDriver = createInstrumentedAudioDriver(); + await kernelForEspeak.attachAudioDriver(audioDriver, { + pcmId: 0, + sampleRate: 22_050, + channels: 1, + periodFrames: 1024, + ringBytes: 64 * 1024, + }); + tick("running espeak-ng..."); + try { + await host.runShellCommand( + `/usr/bin/espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`, + ); + tick("espeak-ng exited"); + } finally { + audioDriver.stop(0); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + tick(`espeak-ng failed: ${msg}`); + } + })(); } else if (profile.evdevDemo) { // autoCommand can't run this: the InputSource must be attached // before the binary starts polling /dev/input/event{0,1}, and @@ -1941,6 +2004,23 @@ async function bootProfile( } } +/** + * Wraps `BrowserAudioDriver` so the per-period tick callback also + * bumps `window.__alsaFramesConsumed`. Playwright reads that counter + * to confirm the AudioWorklet is alive and the kernel is being + * ticked. The forwarding contract is exercised by + * `host/test/instrumented-audio-driver.test.ts`. + */ +function createInstrumentedAudioDriver(): InstrumentedAudioDriver { + return instrumentAudioDriver( + new BrowserAudioDriver(wpkAudioWorkletUrl), + (_frames, total) => { + (window as unknown as { __alsaFramesConsumed?: number }) + .__alsaFramesConsumed = total; + }, + ); +} + function genericPresentationForProfile(profile: LiveProfile): DemoPresentation { if (profile.init?.web) return genericDemoPresentation("web"); if (profile.descriptor.runtime.features.includes("kms")) { diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index 0a048b4ea4..b505df953c 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -139,4 +139,15 @@ export const PRESET_LIBRARY: Preset[] = [ bootCommand: ["bash", "-l", "-i"], estimatedUrlBytes: 612, }, + { + id: "espeak", + title: "ALSA - Espeak-NG", + summary: "The kernel speaks: espeak-ng synthesises text directly through libpcaudio's kandelo backend.", + base: SHELL_BASE, + packages: ["bash@local", "coreutils@local"], + accent: "#f48fb1", + glyph: "T", + bootCommand: ["bash", "-l", "-i"], + estimatedUrlBytes: 612, + }, ]; diff --git a/apps/browser-demos/test/kandelo-espeak.spec.ts b/apps/browser-demos/test/kandelo-espeak.spec.ts new file mode 100644 index 0000000000..bb63badac3 --- /dev/null +++ b/apps/browser-demos/test/kandelo-espeak.spec.ts @@ -0,0 +1,52 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +async function terminalText(page: Page): Promise { + return page.locator(".xterm-rows").first().evaluate((node) => node.textContent ?? ""); +} + +async function framesConsumed(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as { __alsaFramesConsumed?: number }; + return w.__alsaFramesConsumed ?? 0; + }); +} + +test("Kandelo espeak-ng demo speaks through pcaudiolib + /dev/snd/pcmC0D0p", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=espeak"); + + // The boot-path branch in live-setup.ts attaches the BrowserAudioDriver + // and then runs `espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`. + // espeak-ng prints a few status lines on stderr; the more reliable + // signal that the synth path worked end-to-end is the bash prompt + // reappearing after the binary exits. We watch for the trailing + // shell prompt instead of a specific espeak output line so the test + // doesn't break on cosmetic CLI changes upstream. + await expect + .poll(() => terminalText(page), { timeout: 180_000 }) + .toMatch(/[#$]\s*$/); + + // Frames-consumed counter: the instrumented audio driver bumps + // `window.__alsaFramesConsumed` from the per-period tick + // callback. The phrase is ~3 s of audio at 22050 Hz mono = + // ~66150 frames. Demand at least 22050 (~1 s) so the test passes + // even with aggressive worklet startup delay or early-exit + // synthesis variants. A non-zero count proves the worklet → main + // → kernel pipeline (browser-host parity). + const consumed = await framesConsumed(page); + expect(consumed).toBeGreaterThanOrEqual(22_050); +}); diff --git a/crates/kernel/src/audio/mmap.rs b/crates/kernel/src/audio/mmap.rs new file mode 100644 index 0000000000..4c3420cb6a --- /dev/null +++ b/crates/kernel/src/audio/mmap.rs @@ -0,0 +1,335 @@ +//! `mmap()` dispatcher for `/dev/snd/pcmC0D

p` open file descriptions. +//! +//! alsa-lib calls `mmap(pcm_fd, ..., offset)` three times right after +//! `HW_PARAMS`, one per page: +//! +//! - [`SNDRV_PCM_MMAP_OFFSET_STATUS`] — `snd_pcm_mmap_status`: +//! kernel-writes / userspace-reads. Lazily allocated as +//! [`AlsaFdState::mmap_status`]. +//! - [`SNDRV_PCM_MMAP_OFFSET_CONTROL`] — `snd_pcm_mmap_control`: +//! userspace-writes / kernel-reads. Lazily allocated as +//! [`AlsaFdState::mmap_control`]. +//! - [`SNDRV_PCM_MMAP_OFFSET_DATA`] — the SAB-backed PCM ring registered +//! via `kernel_audio_init_sab`. Returns [`Errno::ENODEV`] before the +//! host has issued that call. +//! +//! In v1 the user-space allocation is a plain anonymous wasm-page +//! reservation: alsa-lib gets back a base pointer it can pass to its +//! mmap-based reads, and the kernel-side `Box`es / SAB hold the actual +//! state. Mirroring the kernel-side state into the user pages is host +//! work (Phase B) — A5 just sets up the lazy allocation and dispatch. + +use alloc::boxed::Box; + +use wasm_posix_shared::Errno; +use wasm_posix_shared::audio::{ + SNDRV_PCM_MMAP_OFFSET_CONTROL, SNDRV_PCM_MMAP_OFFSET_DATA, SNDRV_PCM_MMAP_OFFSET_STATUS, + WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, +}; +use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_FAILED}; + +use crate::process::Process; + +/// Entry point invoked by [`crate::syscalls::sys_mmap`] when the target +/// fd has an attached [`crate::ofd::AlsaFdState`] sidecar (i.e. it was +/// opened against `/dev/snd/pcmC0D

p`). +pub fn handle_alsa_pcm_mmap( + proc: &mut Process, + ofd_idx: usize, + addr: usize, + len: usize, + prot: u32, + flags: u32, + offset: i64, +) -> Result { + if offset < 0 { + return Err(Errno::EINVAL); + } + match offset as u64 { + SNDRV_PCM_MMAP_OFFSET_STATUS => map_status_page(proc, ofd_idx, addr, len, prot, flags), + SNDRV_PCM_MMAP_OFFSET_CONTROL => map_control_page(proc, ofd_idx, addr, len, prot, flags), + SNDRV_PCM_MMAP_OFFSET_DATA => map_data_page(proc, ofd_idx, addr, len, prot, flags), + _ => Err(Errno::EINVAL), + } +} + +fn allocate_user_pages( + proc: &mut Process, + addr: usize, + len: usize, + prot: u32, + flags: u32, +) -> Result { + let alloc_flags = flags | MAP_ANONYMOUS; + let result = proc.memory.mmap_anonymous(addr, len, prot, alloc_flags); + if result == MAP_FAILED { + return Err(Errno::ENOMEM); + } + Ok(result) +} + +fn map_status_page( + proc: &mut Process, + ofd_idx: usize, + addr: usize, + len: usize, + prot: u32, + flags: u32, +) -> Result { + let user_addr = allocate_user_pages(proc, addr, len, prot, flags)?; + let audio = proc + .ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .audio_mut() + .ok_or(Errno::EBADFD)?; + if audio.mmap_status.is_none() { + audio.mmap_status = Some(Box::new(WpkAlsaPcmMmapStatus::default())); + } + Ok(user_addr) +} + +fn map_control_page( + proc: &mut Process, + ofd_idx: usize, + addr: usize, + len: usize, + prot: u32, + flags: u32, +) -> Result { + let user_addr = allocate_user_pages(proc, addr, len, prot, flags)?; + let audio = proc + .ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .audio_mut() + .ok_or(Errno::EBADFD)?; + if audio.mmap_control.is_none() { + audio.mmap_control = Some(Box::new(WpkAlsaPcmMmapControl::default())); + } + Ok(user_addr) +} + +fn map_data_page( + proc: &mut Process, + ofd_idx: usize, + addr: usize, + len: usize, + prot: u32, + flags: u32, +) -> Result { + // ENODEV before the SAB is registered. Read pcm_id off the OFD via + // an immutable borrow so the later mmap_anonymous can re-borrow + // proc mutably without aliasing. + let pcm_id = proc + .ofd_table + .get(ofd_idx) + .ok_or(Errno::EBADF)? + .audio() + .ok_or(Errno::EBADFD)? + .pcm_id; + if crate::audio::sab::lookup(pcm_id).is_none() { + return Err(Errno::ENODEV); + } + allocate_user_pages(proc, addr, len, prot, flags) +} + +// -------------------------------------------------------------------- +// Tests. +// -------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::ofd::{AlsaFdState, FileType, PcmDir}; + use crate::process::Process; + use crate::syscalls::VirtualDevice; + + fn install_pcm(proc: &mut Process) -> usize { + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + } + .host_handle(); + let idx = proc.ofd_table.create( + FileType::CharDevice, + 0, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + let ofd = proc.ofd_table.get_mut(idx).expect("created ofd"); + // Unlike the pcm_ioctl tests, leave mmap_status / mmap_control + // unset so A5 can prove it allocates them on first mmap. + ofd.audio = Some(Box::new(AlsaFdState { + pcm_id: 0, + ..AlsaFdState::default() + })); + idx + } + + fn fresh_sab() -> std::sync::MutexGuard<'static, ()> { + let g = crate::audio::sab::TEST_SAB_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::audio::sab::reset_table(); + g + } + + #[test] + fn mmap_status_page_allocates_box_and_returns_user_addr() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let user_addr = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, // PROT_READ | PROT_WRITE + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_STATUS as i64, + ) + .expect("mmap STATUS"); + assert!(user_addr >= 0x04000000, "addr {:#x} below MMAP_BASE", user_addr); + let ofd = proc.ofd_table.get(idx).expect("ofd"); + let audio = ofd.audio().expect("audio sidecar"); + assert!(audio.mmap_status.is_some(), "STATUS box must be allocated"); + assert!(audio.mmap_control.is_none(), "CONTROL untouched by STATUS mmap"); + } + + #[test] + fn mmap_control_page_allocates_box_and_returns_user_addr() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let user_addr = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_CONTROL as i64, + ) + .expect("mmap CONTROL"); + assert!(user_addr >= 0x04000000); + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + assert!(audio.mmap_control.is_some(), "CONTROL box must be allocated"); + assert!(audio.mmap_status.is_none(), "STATUS untouched by CONTROL mmap"); + } + + #[test] + fn mmap_data_page_returns_user_addr_when_sab_registered() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + // Register a (fake) SAB so DATA mmap can succeed. + crate::audio::sab::register( + 0, + crate::audio::sab::SabSlice { + base: 0xdead_beef, + len: 8192, + }, + ) + .expect("sab register"); + let user_addr = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_DATA as i64, + ) + .expect("mmap DATA"); + assert!(user_addr >= 0x04000000); + } + + #[test] + fn mmap_data_page_before_init_sab_returns_enodev() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let err = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_DATA as i64, + ) + .expect_err("DATA without SAB must ENODEV"); + assert_eq!(err, Errno::ENODEV); + } + + #[test] + fn mmap_unknown_offset_returns_einval() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let err = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + 0x4000_0000, // not STATUS / CONTROL / DATA + ) + .expect_err("unknown offset"); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn mmap_negative_offset_returns_einval() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let err = handle_alsa_pcm_mmap(&mut proc, idx, 0, 0x10000, 3, 0, -1) + .expect_err("negative offset"); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn mmap_status_is_idempotent_does_not_realloc_box() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let idx = install_pcm(&mut proc); + let _ = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_STATUS as i64, + ) + .expect("first STATUS mmap"); + // Stash the Box pointer; the second mmap must NOT replace it. + let ptr_before = { + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + audio.mmap_status.as_ref().unwrap().as_ref() as *const _ as usize + }; + let _ = handle_alsa_pcm_mmap( + &mut proc, + idx, + 0, + 0x10000, + 3, + wasm_posix_shared::mmap::MAP_SHARED, + SNDRV_PCM_MMAP_OFFSET_STATUS as i64, + ) + .expect("second STATUS mmap"); + let ptr_after = { + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + audio.mmap_status.as_ref().unwrap().as_ref() as *const _ as usize + }; + assert_eq!( + ptr_before, ptr_after, + "second mmap must NOT reallocate the Box — alsa-lib expects a stable pointer", + ); + } +} diff --git a/crates/kernel/src/audio/mod.rs b/crates/kernel/src/audio/mod.rs new file mode 100644 index 0000000000..ca80146d1d --- /dev/null +++ b/crates/kernel/src/audio/mod.rs @@ -0,0 +1,21 @@ +//! Audio subsystems. +//! +//! - [`oss`] implements the legacy `/dev/dsp` single-owner PCM sink +//! (OSS-style `ioctl`s + raw S16-LE writes; drained by the host via +//! `kernel_drain_audio`). Existing call sites reach OSS symbols +//! directly through `crate::audio::*` via the re-export below. +//! - ALSA modules (`pcm_ioctl`, `sab`, `mmap`, `tick`, `wait`) serve +//! `/dev/snd/pcmC0Dp`. `/dev/snd/controlC0` opens succeed via the +//! devfs node (so libasound's first probe doesn't crash), but no +//! ioctl dispatch lives here — espeak-ng/pcaudiolib never touches +//! the control surface, so a dedicated path would be code without +//! a caller. + +pub mod mmap; +pub mod oss; +pub mod pcm_ioctl; +pub mod sab; +pub mod tick; +pub mod wait; + +pub(crate) use oss::*; diff --git a/crates/kernel/src/audio.rs b/crates/kernel/src/audio/oss.rs similarity index 100% rename from crates/kernel/src/audio.rs rename to crates/kernel/src/audio/oss.rs diff --git a/crates/kernel/src/audio/pcm_ioctl.rs b/crates/kernel/src/audio/pcm_ioctl.rs new file mode 100644 index 0000000000..b418c459fd --- /dev/null +++ b/crates/kernel/src/audio/pcm_ioctl.rs @@ -0,0 +1,1507 @@ +//! ALSA `/dev/snd/pcmC0D0p` ioctl dispatch. +//! +//! Implements the SNDRV_PCM_IOCTL_* surface that alsa-lib exercises +//! during open / configure / playback startup: +//! +//! ```text +//! PVERSION return the ALSA protocol version (alsa-lib bails if +//! this exceeds the runtime version) +//! INFO describe the device (card / device / stream / name) +//! HW_REFINE narrow a wildcard hw_params request to a single +//! concrete combination (S16_LE, 1..2 ch, 8000..48000 Hz, +//! period 64..4096 frames, buffer 256..16384 frames) +//! HW_PARAMS commit a refined hw_params (OPEN/SETUP → SETUP) +//! HW_FREE drop the committed hw/sw params (→ OPEN) +//! SW_PARAMS cache the avail_min / thresholds / boundary +//! PREPARE reset hw_ptr/appl_ptr (→ PREPARED) +//! START begin streaming (PREPARED → RUNNING) +//! DROP halt + return to SETUP +//! PAUSE toggle RUNNING ↔ PAUSED based on argument +//! STATUS snapshot state + pointers + monotonic timestamp +//! ``` +//! +//! State machine: +//! +//! ```text +//! OPEN ──(HW_PARAMS)──▶ SETUP ──(PREPARE)──▶ PREPARED ──(START)──▶ RUNNING +//! ▲ │ │ ▲ +//! │ │ │ │ +//! └──(HW_FREE)─────────── ┘ (PAUSE 1/0) ──▶ PAUSED +//! ▲ │ +//! └──────────(DROP)──────────────────────────┘ +//! ``` +//! +//! WRITEI_FRAMES, mmap, ctl ioctls, and the `kernel_audio_period_tick` +//! producer all land in subsequent tasks (A4 / A5 / A6). + +use alloc::boxed::Box; +use wasm_posix_shared::audio::*; +use wasm_posix_shared::Errno; + +use crate::ofd::{AlsaFdState, HwParamsCache, SwParamsCache}; +use crate::process::{HostIO, Process}; + +/// ALSA protocol version reported by `SNDRV_PCM_IOCTL_PVERSION`. +/// +/// alsa-lib bails when the kernel's protocol version is *higher* than +/// the runtime it was linked against; 13.0.0 is the floor that current +/// alsa-lib (1.2.x) negotiates with. +const SNDRV_PROTOCOL_VERSION: u32 = 0x000d_0000; + +// SND_PCM_INFO_* flags relevant to v1's playback surface. +const SNDRV_PCM_INFO_MMAP: u32 = 0x0000_0001; +const SNDRV_PCM_INFO_MMAP_VALID: u32 = 0x0000_0002; +const SNDRV_PCM_INFO_INTERLEAVED: u32 = 0x0000_0100; +const SNDRV_PCM_INFO_BLOCK_TRANSFER: u32 = 0x0000_0010; +const SNDRV_PCM_INFO_PAUSE: u32 = 0x0000_0080; +const SNDRV_PCM_CLASS_GENERIC: u32 = 0; + +// snd_pcm_hw_params mask indices — see Linux UAPI `enum snd_pcm_hw_param`. +const PARAM_ACCESS: usize = 0; +const PARAM_FORMAT: usize = 1; +const PARAM_SUBFORMAT: usize = 2; + +// snd_pcm_hw_params interval indices. +const PARAM_SAMPLE_BITS: usize = 0; +const PARAM_FRAME_BITS: usize = 1; +const PARAM_CHANNELS: usize = 2; +const PARAM_RATE: usize = 3; +const PARAM_PERIOD_SIZE: usize = 5; +const PARAM_PERIODS: usize = 7; +const PARAM_BUFFER_SIZE: usize = 9; + +const SNDRV_PCM_SUBFORMAT_STD: u32 = 0; + +// v1 capability bounds. +const MIN_CHANNELS: u32 = 1; +const MAX_CHANNELS: u32 = 2; +const MIN_RATE: u32 = 8000; +const MAX_RATE: u32 = 48000; +const MIN_PERIOD_SIZE: u32 = 64; +const MAX_PERIOD_SIZE: u32 = 4096; +const MIN_BUFFER_SIZE: u32 = 256; +const MAX_BUFFER_SIZE: u32 = 16384; +const SAMPLE_BITS_S16_LE: u32 = 16; + +// -------------------------------------------------------------------- +// Byte-buffer helpers. +// -------------------------------------------------------------------- + +fn read_struct(buf: &[u8]) -> Result { + if buf.len() < core::mem::size_of::() { + return Err(Errno::EINVAL); + } + Ok(unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const T) }) +} + +fn write_struct(buf: &mut [u8], value: &T) -> Result<(), Errno> { + if buf.len() < core::mem::size_of::() { + return Err(Errno::EINVAL); + } + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut T, *value); + } + Ok(()) +} + +fn read_u32(buf: &[u8]) -> Result { + if buf.len() < 4 { + return Err(Errno::EINVAL); + } + Ok(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) +} + +fn write_u32(buf: &mut [u8], value: u32) -> Result<(), Errno> { + if buf.len() < 4 { + return Err(Errno::EINVAL); + } + buf[..4].copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +// -------------------------------------------------------------------- +// snd_mask helpers (each snd_mask is u32[8] inside hw_params.masks[64]). +// -------------------------------------------------------------------- + +const MASK_WORDS: usize = 8; + +fn mask_at(masks: &[u32; 64], idx: usize) -> &[u32] { + &masks[idx * MASK_WORDS..idx * MASK_WORDS + MASK_WORDS] +} + +fn mask_at_mut(masks: &mut [u32; 64], idx: usize) -> &mut [u32] { + &mut masks[idx * MASK_WORDS..idx * MASK_WORDS + MASK_WORDS] +} + +fn mask_is_empty(m: &[u32]) -> bool { + m.iter().all(|&w| w == 0) +} + +/// Treat a fully-zero mask as a wildcard ("user did not constrain this +/// dimension") and stamp the capability set in. After the intersection +/// downstream, that becomes the v1-allowed set. +fn fill_if_empty(m: &mut [u32], capability: &[u32; MASK_WORDS]) { + if mask_is_empty(m) { + m.copy_from_slice(capability); + } +} + +fn intersect_with(m: &mut [u32], capability: &[u32; MASK_WORDS]) { + for (w, c) in m.iter_mut().zip(capability.iter()) { + *w &= *c; + } +} + +fn capability_one(bit: u32) -> [u32; MASK_WORDS] { + let mut out = [0u32; MASK_WORDS]; + let word = (bit / 32) as usize; + out[word] |= 1u32 << (bit % 32); + out +} + +fn capability_two(a: u32, b: u32) -> [u32; MASK_WORDS] { + let mut out = [0u32; MASK_WORDS]; + out[(a / 32) as usize] |= 1u32 << (a % 32); + out[(b / 32) as usize] |= 1u32 << (b % 32); + out +} + +fn mask_first_set(m: &[u32]) -> Option { + for (i, &w) in m.iter().enumerate() { + if w != 0 { + return Some(i as u32 * 32 + w.trailing_zeros()); + } + } + None +} + +// -------------------------------------------------------------------- +// snd_interval helpers. +// -------------------------------------------------------------------- + +/// Clamp `interval` to `[min, max]` and narrow it to a single value +/// (set both endpoints to the chosen value — the alsa-lib refine +/// contract is "return one concrete combination"). +/// +/// Wildcard interpretation: a default-initialised `WpkSndInterval` +/// (`min == 0 && max == 0`) is treated as "user hasn't constrained +/// this", so we expand it to the capability range before clamping. +fn refine_interval( + interval: &mut WpkSndInterval, + min: u32, + max: u32, +) -> Result { + if interval.min == 0 && interval.max == 0 { + interval.min = min; + interval.max = max; + } + if interval.max == 0 || interval.max > max { + interval.max = max; + } + if interval.min < min { + interval.min = min; + } + if interval.min > interval.max { + return Err(Errno::EINVAL); + } + // Narrow to the lower bound. Deterministic + matches alsa-lib's + // common "prefer smaller buffer" preference for low-latency apps. + let chosen = interval.min; + interval.max = chosen; + interval.flags = 0; + Ok(chosen) +} + +fn read_interval_single(interval: &WpkSndInterval) -> Result { + if interval.min == 0 { + return Err(Errno::EINVAL); + } + if interval.max != 0 && interval.max != interval.min { + return Err(Errno::EINVAL); + } + Ok(interval.min) +} + +// -------------------------------------------------------------------- +// hw_params refine + extract. +// -------------------------------------------------------------------- + +/// Refine a wildcard / partially-constrained `hw_params` request against +/// v1 capabilities. On success the struct is mutated in place to hold +/// the single concrete combination the kernel commits to. EINVAL if no +/// combination fits (e.g. user asked for S32_LE, which we don't ship). +fn refine_hw_params(req: &mut WpkAlsaPcmHwParams) -> Result<(), Errno> { + // --- masks ------------------------------------------------------- + let access_cap = capability_two( + SNDRV_PCM_ACCESS_MMAP_INTERLEAVED, + SNDRV_PCM_ACCESS_RW_INTERLEAVED, + ); + let format_cap = capability_one(SNDRV_PCM_FORMAT_S16_LE); + let subformat_cap = capability_one(SNDRV_PCM_SUBFORMAT_STD); + + { + let m = mask_at_mut(&mut req.masks, PARAM_ACCESS); + fill_if_empty(m, &access_cap); + intersect_with(m, &access_cap); + if mask_is_empty(m) { + return Err(Errno::EINVAL); + } + } + { + let m = mask_at_mut(&mut req.masks, PARAM_FORMAT); + fill_if_empty(m, &format_cap); + intersect_with(m, &format_cap); + if mask_is_empty(m) { + return Err(Errno::EINVAL); + } + } + { + let m = mask_at_mut(&mut req.masks, PARAM_SUBFORMAT); + fill_if_empty(m, &subformat_cap); + intersect_with(m, &subformat_cap); + if mask_is_empty(m) { + return Err(Errno::EINVAL); + } + } + + // --- intervals --------------------------------------------------- + let channels = refine_interval( + &mut req.intervals[PARAM_CHANNELS], + MIN_CHANNELS, + MAX_CHANNELS, + )?; + let rate = refine_interval( + &mut req.intervals[PARAM_RATE], + MIN_RATE, + MAX_RATE, + )?; + let period_size = refine_interval( + &mut req.intervals[PARAM_PERIOD_SIZE], + MIN_PERIOD_SIZE, + MAX_PERIOD_SIZE, + )?; + let buffer_size = refine_interval( + &mut req.intervals[PARAM_BUFFER_SIZE], + MIN_BUFFER_SIZE, + MAX_BUFFER_SIZE, + )?; + + // --- derived intervals ------------------------------------------ + req.intervals[PARAM_SAMPLE_BITS] = WpkSndInterval { + min: SAMPLE_BITS_S16_LE, + max: SAMPLE_BITS_S16_LE, + flags: 0, + }; + let frame_bits = SAMPLE_BITS_S16_LE * channels; + req.intervals[PARAM_FRAME_BITS] = WpkSndInterval { + min: frame_bits, + max: frame_bits, + flags: 0, + }; + let periods = if period_size == 0 { 1 } else { buffer_size / period_size }; + let periods = periods.max(1); + req.intervals[PARAM_PERIODS] = WpkSndInterval { + min: periods, + max: periods, + flags: 0, + }; + + req.rate_num = rate; + req.rate_den = 1; + req.msbits = SAMPLE_BITS_S16_LE; + req.info = SNDRV_PCM_INFO_MMAP + | SNDRV_PCM_INFO_MMAP_VALID + | SNDRV_PCM_INFO_INTERLEAVED + | SNDRV_PCM_INFO_BLOCK_TRANSFER + | SNDRV_PCM_INFO_PAUSE; + Ok(()) +} + +fn extract_access(req: &WpkAlsaPcmHwParams) -> Result { + mask_first_set(mask_at(&req.masks, PARAM_ACCESS)).ok_or(Errno::EINVAL) +} + +fn extract_format(req: &WpkAlsaPcmHwParams) -> Result { + let bit = mask_first_set(mask_at(&req.masks, PARAM_FORMAT)) + .ok_or(Errno::EINVAL)?; + if bit != SNDRV_PCM_FORMAT_S16_LE { + return Err(Errno::EINVAL); + } + Ok(bit) +} + +fn extract_channels(req: &WpkAlsaPcmHwParams) -> Result { + let v = read_interval_single(&req.intervals[PARAM_CHANNELS])?; + if !(MIN_CHANNELS..=MAX_CHANNELS).contains(&v) { + return Err(Errno::EINVAL); + } + Ok(v) +} + +fn extract_rate(req: &WpkAlsaPcmHwParams) -> Result { + let v = read_interval_single(&req.intervals[PARAM_RATE])?; + if !(MIN_RATE..=MAX_RATE).contains(&v) { + return Err(Errno::EINVAL); + } + Ok(v) +} + +fn extract_period_size(req: &WpkAlsaPcmHwParams) -> Result { + let v = read_interval_single(&req.intervals[PARAM_PERIOD_SIZE])?; + if !(MIN_PERIOD_SIZE..=MAX_PERIOD_SIZE).contains(&v) { + return Err(Errno::EINVAL); + } + Ok(v as u64) +} + +fn extract_buffer_size(req: &WpkAlsaPcmHwParams) -> Result { + let v = read_interval_single(&req.intervals[PARAM_BUFFER_SIZE])?; + if !(MIN_BUFFER_SIZE..=MAX_BUFFER_SIZE).contains(&v) { + return Err(Errno::EINVAL); + } + Ok(v as u64) +} + +fn extract_periods(req: &WpkAlsaPcmHwParams) -> Result { + let v = read_interval_single(&req.intervals[PARAM_PERIODS])?; + if v == 0 { + return Err(Errno::EINVAL); + } + Ok(v) +} + +// -------------------------------------------------------------------- +// OFD borrow helpers. +// -------------------------------------------------------------------- + +fn audio_mut<'a>( + proc: &'a mut Process, + ofd_idx: usize, +) -> Result<&'a mut AlsaFdState, Errno> { + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .audio_mut() + .ok_or(Errno::EBADFD) +} + +fn audio_ref<'a>( + proc: &'a Process, + ofd_idx: usize, +) -> Result<&'a AlsaFdState, Errno> { + proc.ofd_table + .get(ofd_idx) + .ok_or(Errno::EBADF)? + .audio() + .ok_or(Errno::EBADFD) +} + +fn monotonic_secs_nsecs(host: &mut dyn HostIO) -> (i64, i64) { + host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_MONOTONIC) + .unwrap_or((0, 0)) +} + +// -------------------------------------------------------------------- +// Dispatcher. +// -------------------------------------------------------------------- + +/// Entry point invoked by [`crate::syscalls::sys_ioctl`] when an ioctl +/// targets an OFD with an attached [`AlsaFdState`] sidecar (i.e. the OFD +/// was opened against `/dev/snd/pcmC0D0p`). +pub fn handle_alsa_pcm_ioctl( + proc: &mut Process, + host: &mut dyn HostIO, + ofd_idx: usize, + request: u32, + buf: &mut [u8], +) -> Result<(), Errno> { + match request { + SNDRV_PCM_IOCTL_PVERSION => write_u32(buf, SNDRV_PROTOCOL_VERSION), + + SNDRV_PCM_IOCTL_INFO => { + let audio = audio_ref(proc, ofd_idx)?; + let mut info = WpkAlsaPcmInfo { + device: audio.device as u32, + subdevice: audio.sub as u32, + stream: SNDRV_PCM_STREAM_PLAYBACK as i32, + card: audio.card as i32, + dev_class: SNDRV_PCM_CLASS_GENERIC, + subdevices_count: 1, + subdevices_avail: 1, + ..Default::default() + }; + copy_into_array(&mut info.id, b"wpk"); + copy_into_array(&mut info.name, b"wpk virtual playback"); + copy_into_array(&mut info.subname, b"subdevice #0"); + write_struct(buf, &info) + } + + SNDRV_PCM_IOCTL_HW_REFINE => { + let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; + refine_hw_params(&mut req)?; + write_struct(buf, &req) + } + + SNDRV_PCM_IOCTL_HW_PARAMS => { + let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; + refine_hw_params(&mut req)?; + let cache = HwParamsCache { + format: extract_format(&req)?, + access: extract_access(&req)?, + channels: extract_channels(&req)?, + rate: extract_rate(&req)?, + period_size: extract_period_size(&req)?, + buffer_size: extract_buffer_size(&req)?, + periods: extract_periods(&req)?, + }; + let audio = audio_mut(proc, ofd_idx)?; + if audio.state != SNDRV_PCM_STATE_OPEN + && audio.state != SNDRV_PCM_STATE_SETUP + { + return Err(Errno::EBADFD); + } + audio.hw_params = Some(Box::new(cache)); + audio.state = SNDRV_PCM_STATE_SETUP; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = SNDRV_PCM_STATE_SETUP; + } + write_struct(buf, &req) + } + + SNDRV_PCM_IOCTL_HW_FREE => { + let audio = audio_mut(proc, ofd_idx)?; + audio.hw_params = None; + audio.sw_params = None; + audio.state = SNDRV_PCM_STATE_OPEN; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = SNDRV_PCM_STATE_OPEN; + status.hw_ptr = 0; + } + if let Some(ctl) = audio.mmap_control.as_mut() { + ctl.appl_ptr = 0; + } + Ok(()) + } + + SNDRV_PCM_IOCTL_SW_PARAMS => { + let req: WpkAlsaPcmSwParams = read_struct(buf)?; + let audio = audio_mut(proc, ofd_idx)?; + if audio.hw_params.is_none() { + return Err(Errno::EBADFD); + } + audio.sw_params = Some(Box::new(SwParamsCache { + avail_min: req.avail_min, + start_threshold: req.start_threshold, + stop_threshold: req.stop_threshold, + boundary: req.boundary, + })); + Ok(()) + } + + SNDRV_PCM_IOCTL_PREPARE => { + let audio = audio_mut(proc, ofd_idx)?; + if audio.hw_params.is_none() { + return Err(Errno::EBADFD); + } + audio.state = SNDRV_PCM_STATE_PREPARED; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = SNDRV_PCM_STATE_PREPARED; + status.hw_ptr = 0; + } + if let Some(ctl) = audio.mmap_control.as_mut() { + ctl.appl_ptr = 0; + } + Ok(()) + } + + SNDRV_PCM_IOCTL_START => { + let (sec, nsec) = monotonic_secs_nsecs(host); + let audio = audio_mut(proc, ofd_idx)?; + if audio.state != SNDRV_PCM_STATE_PREPARED { + return Err(Errno::EBADFD); + } + audio.state = SNDRV_PCM_STATE_RUNNING; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = SNDRV_PCM_STATE_RUNNING; + status.tstamp_sec = sec; + status.tstamp_nsec = nsec; + } + Ok(()) + } + + SNDRV_PCM_IOCTL_DROP => { + let audio = audio_mut(proc, ofd_idx)?; + // Linux accepts DROP from RUNNING / PREPARED / PAUSED / XRUN. + // OPEN (no hw_params committed yet) is the only invalid source. + if audio.state == SNDRV_PCM_STATE_OPEN { + return Err(Errno::EBADFD); + } + audio.state = SNDRV_PCM_STATE_SETUP; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = SNDRV_PCM_STATE_SETUP; + } + Ok(()) + } + + SNDRV_PCM_IOCTL_PAUSE => { + let value = read_u32(buf)?; + let audio = audio_mut(proc, ofd_idx)?; + let new_state = if value != 0 { + if audio.state != SNDRV_PCM_STATE_RUNNING { + return Err(Errno::EBADFD); + } + SNDRV_PCM_STATE_PAUSED + } else { + if audio.state != SNDRV_PCM_STATE_PAUSED { + return Err(Errno::EBADFD); + } + SNDRV_PCM_STATE_RUNNING + }; + audio.state = new_state; + if let Some(status) = audio.mmap_status.as_mut() { + status.state = new_state; + } + Ok(()) + } + + SNDRV_PCM_IOCTL_STATUS => { + let (sec, nsec) = monotonic_secs_nsecs(host); + let audio = audio_ref(proc, ofd_idx)?; + let hw_ptr = audio.mmap_status.as_ref().map(|s| s.hw_ptr).unwrap_or(0); + let appl_ptr = audio.mmap_control.as_ref().map(|c| c.appl_ptr).unwrap_or(0); + let buffer_size = audio + .hw_params + .as_ref() + .map(|h| h.buffer_size as i64) + .unwrap_or(0); + let delay = appl_ptr - hw_ptr; + let avail = if buffer_size > 0 { + (buffer_size - delay).max(0) as u64 + } else { + 0 + }; + let status = WpkAlsaPcmStatus { + state: audio.state, + _pad0: 0, + trigger_tstamp_sec: 0, + trigger_tstamp_nsec: 0, + tstamp_sec: sec, + tstamp_nsec: nsec, + appl_ptr, + hw_ptr, + delay, + avail, + avail_max: buffer_size as u64, + overrange: 0, + suspended_state: 0, + audio_tstamp_data: 0, + audio_tstamp_sec: 0, + audio_tstamp_nsec: 0, + _reserved: [0u8; 16], + }; + write_struct(buf, &status) + } + + SNDRV_PCM_IOCTL_WRITEI_FRAMES => handle_writei(proc, host, ofd_idx, buf), + + _ => Err(Errno::ENOTTY), + } +} + +/// Plan for one `WRITEI_FRAMES` call. Computed under an immutable +/// borrow of the OFD so the subsequent `proc_read_bytes` (which needs +/// `&mut HostIO`) and the `appl_ptr` advance (which needs `&mut OFD`) +/// don't fight the borrow checker. +struct WriteiPlan { + pcm_id: u32, + channels: usize, + ring_frames: usize, + appl_frame_offset: usize, + to_write: usize, +} + +/// `SNDRV_PCM_IOCTL_WRITEI_FRAMES` handler. The non-mmap data path: +/// userspace hands us a pointer + frame count and the kernel copies +/// the samples into the SAB-backed ring at `appl_ptr % ring_frames`. +/// +/// Short writes are normal — when the ring is full (`avail == 0`), +/// the call returns `result = 0` rather than blocking (v1 has no +/// wait queue for audio; A6 wires `kernel_audio_period_tick` → +/// POLLOUT wake which a future revision can use to park the caller). +fn handle_writei( + proc: &mut Process, + host: &mut dyn HostIO, + ofd_idx: usize, + buf: &mut [u8], +) -> Result<(), Errno> { + let mut req: WpkAlsaXferi = read_struct(buf)?; + let frames_req = req.frames as usize; + let pid = proc.pid as i32; + + // ---------- stage 1: validate + plan ---------- + let plan = { + let audio = audio_ref(proc, ofd_idx)?; + let hw = audio.hw_params.as_deref().ok_or(Errno::EBADFD)?; + if hw.format != SNDRV_PCM_FORMAT_S16_LE { + return Err(Errno::EINVAL); + } + if hw.channels == 0 { + return Err(Errno::EINVAL); + } + let channels = hw.channels as usize; + let bytes_per_frame = channels * core::mem::size_of::(); + + let slice = crate::audio::sab::lookup(audio.pcm_id).ok_or(Errno::ENODEV)?; + let ring_frames = slice.len / bytes_per_frame; + if ring_frames == 0 { + return Err(Errno::ENODEV); + } + + let appl = audio + .mmap_control + .as_deref() + .ok_or(Errno::EBADFD)? + .appl_ptr; + let hw_ptr = audio + .mmap_status + .as_deref() + .ok_or(Errno::EBADFD)? + .hw_ptr; + + let delay = appl - hw_ptr; + let avail = (ring_frames as i64 - delay).max(0) as usize; + let to_write = frames_req.min(avail); + let appl_frame_offset = appl.rem_euclid(ring_frames as i64) as usize; + + WriteiPlan { + pcm_id: audio.pcm_id, + channels, + ring_frames, + appl_frame_offset, + to_write, + } + }; + + // ---------- stage 2: copy user → SAB ring ---------- + if plan.to_write > 0 { + let bytes_per_frame = plan.channels * core::mem::size_of::(); + let total_bytes = plan.to_write * bytes_per_frame; + let mut scratch: alloc::vec::Vec = alloc::vec![0u8; total_bytes]; + let rc = host.proc_read_bytes(pid, req.buf as u32, &mut scratch); + if rc < 0 { + return Err(Errno::EFAULT); + } + + // SAFETY: the host registered the SAB via `kernel_audio_init_sab` + // and the ring outlives this call. Within one syscall the + // kernel is the sole producer; the AudioWorklet only consumes + // bytes at offsets below `appl_ptr` per the alsa-lib protocol. + let ring = unsafe { crate::audio::sab::ring_mut_s16(plan.pcm_id) } + .ok_or(Errno::ENODEV)?; + for f in 0..plan.to_write { + let dst_frame = (plan.appl_frame_offset + f) % plan.ring_frames; + for c in 0..plan.channels { + let src_byte = (f * plan.channels + c) * 2; + let sample = + i16::from_le_bytes([scratch[src_byte], scratch[src_byte + 1]]); + ring[dst_frame * plan.channels + c] = sample; + } + } + } + + // ---------- stage 3: advance appl_ptr ---------- + { + let audio = audio_mut(proc, ofd_idx)?; + if let Some(ctl) = audio.mmap_control.as_mut() { + ctl.appl_ptr += plan.to_write as i64; + } + } + + // ---------- stage 4: stamp result ---------- + req.result = plan.to_write as i64; + write_struct(buf, &req) +} + +/// Copy `src` into `dst`, NUL-padding any remaining tail. Truncates +/// `src` if it exceeds `dst.len()` (the trailing NUL is preserved by +/// the cap, so alsa-lib's strlen-based readers still find the +/// terminator). +fn copy_into_array(dst: &mut [u8], src: &[u8]) { + let n = src.len().min(dst.len().saturating_sub(1)); + dst[..n].copy_from_slice(&src[..n]); + for byte in &mut dst[n..] { + *byte = 0; + } +} + +// -------------------------------------------------------------------- +// Tests. +// -------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::ofd::{AlsaFdState, FileType, PcmDir}; + use crate::process::Process; + use crate::process::test_host::NoopHost; + use crate::syscalls::VirtualDevice; + + /// Build a freshly-opened OFD with an `AlsaFdState` sidecar attached + /// at the returned OFD index. Always populates mmap_status + + /// mmap_control so the state-machine arms exercise those branches. + /// (A4 wires those allocations via real mmap; for the dispatcher + /// tests we hand them in pre-populated.) + fn install_pcm(proc: &mut Process) -> usize { + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + } + .host_handle(); + let idx = proc.ofd_table.create( + FileType::CharDevice, + 0, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + let ofd = proc.ofd_table.get_mut(idx).expect("created ofd"); + ofd.audio = Some(Box::new(AlsaFdState { + mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus::default())), + mmap_control: Some(Box::new(WpkAlsaPcmMmapControl::default())), + ..AlsaFdState::default() + })); + idx + } + + /// A wildcard hw_params: all-zero, mirroring what alsa-lib hands the + /// kernel after `snd_pcm_hw_params_any`. + fn wildcard_hw_params() -> WpkAlsaPcmHwParams { + WpkAlsaPcmHwParams::default() + } + + fn refined_hw_params() -> WpkAlsaPcmHwParams { + let mut p = wildcard_hw_params(); + refine_hw_params(&mut p).expect("refine wildcard"); + p + } + + fn run_ioctl( + proc: &mut Process, + host: &mut NoopHost, + ofd_idx: usize, + request: u32, + buf: &mut [u8], + ) -> Result<(), Errno> { + handle_alsa_pcm_ioctl(proc, host, ofd_idx, request, buf) + } + + // --- PVERSION --------------------------------------------------- + + #[test] + fn pcm_pversion_returns_alsa_v13() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let mut buf = [0u8; 4]; + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PVERSION, &mut buf) + .expect("PVERSION"); + assert_eq!( + u32::from_le_bytes(buf), + SNDRV_PROTOCOL_VERSION, + "PVERSION must report 0x000d_0000 — alsa-lib bails on higher", + ); + } + + // --- INFO ------------------------------------------------------- + + #[test] + fn pcm_info_returns_playback_stream_card0_device0() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let mut buf = [0u8; core::mem::size_of::()]; + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_INFO, &mut buf) + .expect("INFO"); + let info: WpkAlsaPcmInfo = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(info.card, 0); + assert_eq!(info.device, 0); + assert_eq!(info.subdevice, 0); + assert_eq!(info.stream, SNDRV_PCM_STREAM_PLAYBACK as i32); + assert!(info.name.starts_with(b"wpk virtual playback")); + assert_eq!(info.dev_class, SNDRV_PCM_CLASS_GENERIC); + assert_eq!(info.subdevices_count, 1); + assert_eq!(info.subdevices_avail, 1); + } + + // --- HW_REFINE -------------------------------------------------- + + #[test] + fn pcm_hw_refine_clamps_unsupported_format_to_s16_le() { + // User requests S32_LE only; refine must reject. + let mut p = wildcard_hw_params(); + let m = mask_at_mut(&mut p.masks, PARAM_FORMAT); + m.copy_from_slice(&capability_one(SNDRV_PCM_FORMAT_S32_LE)); + let err = refine_hw_params(&mut p).expect_err("S32-only must EINVAL"); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn pcm_hw_refine_wildcard_narrows_to_v1_defaults() { + let mut p = wildcard_hw_params(); + refine_hw_params(&mut p).expect("wildcard refine"); + let format = mask_first_set(mask_at(&p.masks, PARAM_FORMAT)).unwrap(); + assert_eq!(format, SNDRV_PCM_FORMAT_S16_LE); + // Each interval narrowed to capability-min (min=min, max=min). + assert_eq!(p.intervals[PARAM_CHANNELS].min, MIN_CHANNELS); + assert_eq!(p.intervals[PARAM_CHANNELS].max, MIN_CHANNELS); + assert_eq!(p.intervals[PARAM_RATE].min, MIN_RATE); + assert_eq!(p.intervals[PARAM_RATE].max, MIN_RATE); + assert_eq!(p.intervals[PARAM_PERIOD_SIZE].min, MIN_PERIOD_SIZE); + assert_eq!(p.intervals[PARAM_BUFFER_SIZE].min, MIN_BUFFER_SIZE); + assert_eq!(p.intervals[PARAM_SAMPLE_BITS].min, SAMPLE_BITS_S16_LE); + assert_eq!(p.rate_num, MIN_RATE); + assert_eq!(p.rate_den, 1); + } + + #[test] + fn pcm_hw_refine_user_constrained_rate_is_respected() { + let mut p = wildcard_hw_params(); + p.intervals[PARAM_RATE] = WpkSndInterval { + min: 44100, + max: 44100, + flags: 0, + }; + refine_hw_params(&mut p).expect("respect user rate"); + assert_eq!(p.intervals[PARAM_RATE].min, 44100); + assert_eq!(p.intervals[PARAM_RATE].max, 44100); + assert_eq!(p.rate_num, 44100); + } + + // --- HW_PARAMS -------------------------------------------------- + + #[test] + fn pcm_hw_params_transitions_open_to_setup() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let p = refined_hw_params(); + let mut buf = struct_buf(&p); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_HW_PARAMS, &mut buf) + .expect("HW_PARAMS"); + let st = audio_ref(&proc, idx).unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); + let cache = st.hw_params.as_deref().expect("hw_params cached"); + assert_eq!(cache.format, SNDRV_PCM_FORMAT_S16_LE); + assert_eq!(cache.rate, MIN_RATE); + assert_eq!(cache.channels, MIN_CHANNELS); + // mmap_status (defaulted to OPEN by AlsaFdState::default) should + // also flip — refresh keeps userspace consistent. + assert_eq!( + st.mmap_status.as_deref().unwrap().state, + SNDRV_PCM_STATE_SETUP, + ); + } + + #[test] + fn pcm_hw_params_without_format_returns_einval() { + // Build a "refined-looking" struct with a zero FORMAT mask — + // refine_hw_params (called inside HW_PARAMS) re-fills empties + // with the capability set, so we have to actively poison the + // format dimension to drive this. Set the format mask to a + // disallowed bit (S32_LE) so the intersection collapses to + // empty. + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let mut p = wildcard_hw_params(); + let m = mask_at_mut(&mut p.masks, PARAM_FORMAT); + m.copy_from_slice(&capability_one(SNDRV_PCM_FORMAT_S32_LE)); + let mut buf = struct_buf(&p); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_HW_PARAMS, + &mut buf, + ) + .expect_err("S32-only must EINVAL"); + assert_eq!(err, Errno::EINVAL); + assert_eq!(audio_ref(&proc, idx).unwrap().state, SNDRV_PCM_STATE_OPEN); + } + + #[test] + fn pcm_hw_free_returns_to_open() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + let mut buf = []; + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_HW_FREE, &mut buf) + .expect("HW_FREE"); + let st = audio_ref(&proc, idx).unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_OPEN); + assert!(st.hw_params.is_none()); + assert!(st.sw_params.is_none()); + } + + // --- SW_PARAMS -------------------------------------------------- + + #[test] + fn pcm_sw_params_without_hw_params_returns_einval() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let sw = WpkAlsaPcmSwParams { avail_min: 256, ..Default::default() }; + let mut buf = struct_buf(&sw); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_SW_PARAMS, + &mut buf, + ) + .expect_err("SW_PARAMS before HW_PARAMS must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + #[test] + fn pcm_sw_params_caches_thresholds() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + let sw = WpkAlsaPcmSwParams { + avail_min: 512, + start_threshold: 1024, + stop_threshold: 4096, + boundary: 1 << 30, + ..Default::default() + }; + let mut buf = struct_buf(&sw); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_SW_PARAMS, &mut buf) + .expect("SW_PARAMS"); + let st = audio_ref(&proc, idx).unwrap(); + let cache = st.sw_params.as_deref().unwrap(); + assert_eq!(cache.avail_min, 512); + assert_eq!(cache.start_threshold, 1024); + assert_eq!(cache.stop_threshold, 4096); + assert_eq!(cache.boundary, 1 << 30); + } + + // --- PREPARE / START / DROP / PAUSE ----------------------------- + + #[test] + fn pcm_prepare_after_hw_params_transitions_to_prepared() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // Seed appl_ptr so PREPARE can reset it. + proc.ofd_table + .get_mut(idx) + .unwrap() + .audio_mut() + .unwrap() + .mmap_control + .as_mut() + .unwrap() + .appl_ptr = 1234; + let mut buf = []; + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut buf) + .expect("PREPARE"); + let st = audio_ref(&proc, idx).unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_PREPARED); + assert_eq!(st.mmap_control.as_ref().unwrap().appl_ptr, 0); + assert_eq!(st.mmap_status.as_ref().unwrap().hw_ptr, 0); + } + + #[test] + fn pcm_prepare_without_hw_params_returns_ebadfd() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_PREPARE, + &mut [], + ) + .expect_err("PREPARE in OPEN must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + #[test] + fn pcm_start_from_prepared_transitions_to_running() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) + .unwrap(); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) + .expect("START"); + let st = audio_ref(&proc, idx).unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_RUNNING); + let status = st.mmap_status.as_ref().unwrap(); + assert_eq!(status.state, SNDRV_PCM_STATE_RUNNING); + // NoopHost's clock returns (0, 0); we only assert the start + // path stamped *something* via host_clock_gettime — the exact + // value depends on the host. Picking >= 0 verifies the call + // wasn't bypassed (uninitialised memory would be UB). + assert!(status.tstamp_sec >= 0); + assert!(status.tstamp_nsec >= 0); + } + + #[test] + fn pcm_start_without_prepare_returns_ebadfd() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // SETUP, not PREPARED — START must reject. + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_START, + &mut [], + ) + .expect_err("START from SETUP must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + #[test] + fn pcm_drop_from_running_transitions_to_setup() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) + .unwrap(); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) + .unwrap(); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_DROP, &mut []) + .expect("DROP"); + let st = audio_ref(&proc, idx).unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); + assert_eq!( + st.mmap_status.as_ref().unwrap().state, + SNDRV_PCM_STATE_SETUP, + ); + } + + #[test] + fn pcm_drop_from_open_returns_ebadfd() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let err = run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_DROP, &mut []) + .expect_err("DROP from OPEN must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + #[test] + fn pcm_pause_then_resume_round_trips() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) + .unwrap(); + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) + .unwrap(); + let mut buf = 1u32.to_le_bytes(); + run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_PAUSE, + &mut buf, + ) + .expect("PAUSE pause"); + assert_eq!(audio_ref(&proc, idx).unwrap().state, SNDRV_PCM_STATE_PAUSED); + let mut buf = 0u32.to_le_bytes(); + run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_PAUSE, + &mut buf, + ) + .expect("PAUSE resume"); + assert_eq!( + audio_ref(&proc, idx).unwrap().state, + SNDRV_PCM_STATE_RUNNING, + ); + } + + #[test] + fn pcm_pause_from_setup_returns_ebadfd() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + let mut buf = 1u32.to_le_bytes(); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_PAUSE, + &mut buf, + ) + .expect_err("PAUSE from SETUP must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + // --- STATUS ----------------------------------------------------- + + #[test] + fn pcm_status_reflects_appl_ptr_hw_ptr_delta() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // Seed appl_ptr and hw_ptr to simulate a partially-consumed buffer. + { + let st = audio_mut(&mut proc, idx).unwrap(); + st.mmap_control.as_mut().unwrap().appl_ptr = 1024; + st.mmap_status.as_mut().unwrap().hw_ptr = 256; + } + let mut buf = [0u8; core::mem::size_of::()]; + run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_STATUS, &mut buf) + .expect("STATUS"); + let status: WpkAlsaPcmStatus = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(status.state, SNDRV_PCM_STATE_SETUP); + assert_eq!(status.appl_ptr, 1024); + assert_eq!(status.hw_ptr, 256); + assert_eq!(status.delay, 1024 - 256); + // buffer_size committed via wildcard refine == MIN_BUFFER_SIZE. + assert_eq!(status.avail_max, MIN_BUFFER_SIZE as u64); + assert!(status.tstamp_sec >= 0); + assert!(status.tstamp_nsec >= 0); + } + + // --- WRITEI_FRAMES (A4) ----------------------------------------- + + /// Leak a fresh i16 ring sized to hold `frames * channels` + /// samples. The pointer is then registered with + /// [`crate::audio::sab`] and stays live for the test's lifetime. + fn install_sab_ring(pcm_id: u32, frames: usize, channels: usize) -> *mut i16 { + let total = frames * channels; + let vec = alloc::vec![0i16; total].into_boxed_slice(); + let leaked: &'static mut [i16] = alloc::boxed::Box::leak(vec); + let base = leaked.as_mut_ptr(); + let len_bytes = total * core::mem::size_of::(); + crate::audio::sab::register( + pcm_id, + crate::audio::sab::SabSlice { + base: base as usize, + len: len_bytes, + }, + ) + .expect("sab register"); + base + } + + /// Read the ring back into an owned Vec for assertion. The caller + /// MUST still hold the SAB lock so no concurrent producer mutates + /// the leaked region. + fn read_ring(ptr: *mut i16, frames: usize, channels: usize) -> alloc::vec::Vec { + let total = frames * channels; + let mut out = alloc::vec![0i16; total]; + unsafe { + core::ptr::copy_nonoverlapping(ptr, out.as_mut_ptr(), total); + } + out + } + + /// Bytes of `count` interleaved S16-LE frames at `channels`, with + /// the i'th sample = `seed + i` (so we can verify the ordering + /// survives the copy + wrap). Seeded so the all-zero "no host + /// copy" path can't accidentally pass the assertion. + fn synth_frames(count: usize, channels: usize, seed: i16) -> alloc::vec::Vec { + let samples = count * channels; + let mut out = alloc::vec::Vec::with_capacity(samples * 2); + for i in 0..samples as i16 { + out.extend_from_slice(&(seed + i).to_le_bytes()); + } + out + } + + fn fresh_sab() -> std::sync::MutexGuard<'static, ()> { + let g = crate::audio::sab::TEST_SAB_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::audio::sab::reset_table(); + *crate::process::test_host::PROC_READ_SOURCE + .lock() + .unwrap_or_else(|e| e.into_inner()) = alloc::vec::Vec::new(); + g + } + + fn set_proc_read_source(bytes: alloc::vec::Vec) { + *crate::process::test_host::PROC_READ_SOURCE + .lock() + .unwrap_or_else(|e| e.into_inner()) = bytes; + } + + #[test] + fn writei_in_open_state_returns_ebadfd() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + // No HW_PARAMS commit → hw_params is None → WRITEI must EBADFD + // before the SAB lookup runs. + let xferi = WpkAlsaXferi { + result: 0, + buf: 0, + frames: 32, + }; + let mut buf = struct_buf(&xferi); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect_err("WRITEI in OPEN must EBADFD"); + assert_eq!(err, Errno::EBADFD); + } + + #[test] + fn writei_with_unsupported_format_returns_einval() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // Poison the committed format so the WRITEI guard fires + // before any SAB lookup or copy runs. The dispatcher's + // HW_PARAMS path can't produce this directly (extract_format + // gates on S16_LE) but a future XRUN-recovery path could. + audio_mut(&mut proc, idx) + .unwrap() + .hw_params + .as_mut() + .unwrap() + .format = SNDRV_PCM_FORMAT_S32_LE; + let xferi = WpkAlsaXferi { result: 0, buf: 0, frames: 16 }; + let mut buf = struct_buf(&xferi); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect_err("non-S16_LE must EINVAL"); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn writei_without_sab_registered_returns_enodev() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // hw_params committed but SAB table empty (no + // kernel_audio_init_sab yet). WRITEI must surface ENODEV so + // a caller can tell "host hasn't wired audio yet" from + // "transport error". + let xferi = WpkAlsaXferi { result: 0, buf: 0, frames: 16 }; + let mut buf = struct_buf(&xferi); + let err = run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect_err("no SAB → ENODEV"); + assert_eq!(err, Errno::ENODEV); + } + + #[test] + fn writei_appends_frames_to_sab_ring() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + // Refined wildcard: channels=MIN(1), buffer_size=MIN(256). + let channels = MIN_CHANNELS as usize; + let ring_frames = MIN_BUFFER_SIZE as usize; + let ring_ptr = install_sab_ring(0, ring_frames, channels); + // Drive 8 frames of synthesised samples through the host + // bridge (seed=10 → samples 10,11,…,17). + let frames_to_write = 8usize; + set_proc_read_source(synth_frames(frames_to_write, channels, 10)); + let xferi = WpkAlsaXferi { + result: 0, + // Any non-zero address works — NoopHost ignores it and + // copies from PROC_READ_SOURCE. + buf: 0x4000_0000, + frames: frames_to_write as u64, + }; + let mut buf = struct_buf(&xferi); + run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect("WRITEI"); + let result: WpkAlsaXferi = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(result.result, frames_to_write as i64); + // appl_ptr advanced. + let appl = + audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; + assert_eq!(appl, frames_to_write as i64); + // Ring head holds the synthesised samples; tail is still 0. + let ring = read_ring(ring_ptr, ring_frames, channels); + for i in 0..frames_to_write { + assert_eq!(ring[i], 10 + i as i16, "frame {i}"); + } + for i in frames_to_write..ring_frames { + assert_eq!(ring[i], 0, "tail must stay zero at {i}"); + } + } + + #[test] + fn writei_wraps_appl_ptr_at_buffer_boundary() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + let channels = MIN_CHANNELS as usize; + let ring_frames = MIN_BUFFER_SIZE as usize; + let ring_ptr = install_sab_ring(0, ring_frames, channels); + // Seed appl_ptr at ring_frames - 4 and hw_ptr at appl - 0 so + // there's effectively a full buffer of space ahead (we + // simulate the host having drained everything). Write 8 + // frames — the first 4 land at positions [ring_frames - 4, + // ring_frames - 1] and the next 4 wrap to [0, 3]. + { + let audio = audio_mut(&mut proc, idx).unwrap(); + audio.mmap_control.as_mut().unwrap().appl_ptr = (ring_frames - 4) as i64; + audio.mmap_status.as_mut().unwrap().hw_ptr = (ring_frames - 4) as i64; + } + let frames_to_write = 8usize; + set_proc_read_source(synth_frames(frames_to_write, channels, 100)); + let xferi = WpkAlsaXferi { + result: 0, + buf: 0x4000_0000, + frames: frames_to_write as u64, + }; + let mut buf = struct_buf(&xferi); + run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect("WRITEI wrap"); + let result: WpkAlsaXferi = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(result.result, frames_to_write as i64); + // appl_ptr advances monotonically past the wrap boundary. + let appl = + audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; + assert_eq!(appl, (ring_frames - 4 + frames_to_write) as i64); + // First 4 frames at tail of ring. + let ring = read_ring(ring_ptr, ring_frames, channels); + for i in 0..4 { + assert_eq!(ring[ring_frames - 4 + i], 100 + i as i16); + } + // Next 4 frames at head of ring (wrap). + for i in 0..4 { + assert_eq!(ring[i], 100 + (4 + i) as i16); + } + } + + #[test] + fn writei_when_ring_full_writes_zero_frames() { + let _g = fresh_sab(); + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + commit_setup(&mut proc, &mut host, idx); + let channels = MIN_CHANNELS as usize; + let ring_frames = MIN_BUFFER_SIZE as usize; + let _ring_ptr = install_sab_ring(0, ring_frames, channels); + // Saturate: appl_ptr is ring_frames ahead of hw_ptr → avail=0. + // v1 has no audio wait queue (A6 territory), so the call + // returns 0 frames written rather than blocking. + { + let audio = audio_mut(&mut proc, idx).unwrap(); + audio.mmap_status.as_mut().unwrap().hw_ptr = 0; + audio.mmap_control.as_mut().unwrap().appl_ptr = ring_frames as i64; + } + let xferi = WpkAlsaXferi { + result: 0, + buf: 0x4000_0000, + frames: 64, + }; + let mut buf = struct_buf(&xferi); + run_ioctl( + &mut proc, + &mut host, + idx, + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + &mut buf, + ) + .expect("WRITEI full ring is not an error"); + let result: WpkAlsaXferi = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + assert_eq!(result.result, 0); + // appl_ptr unchanged. + let appl = + audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; + assert_eq!(appl, ring_frames as i64); + } + + #[test] + fn pcm_unknown_ioctl_returns_enotty() { + let mut proc = Process::new(1); + let mut host = NoopHost; + let idx = install_pcm(&mut proc); + let err = run_ioctl(&mut proc, &mut host, idx, 0xdead_beef, &mut []) + .expect_err("unknown ioctl must ENOTTY"); + assert_eq!(err, Errno::ENOTTY); + } + + // --- helpers ---------------------------------------------------- + + /// Drive PVERSION / INFO / wildcard HW_REFINE / HW_PARAMS so the + /// fd ends in SETUP with committed hw_params, ready for the + /// state-machine tests above. + fn commit_setup( + proc: &mut Process, + host: &mut NoopHost, + idx: usize, + ) { + let p = refined_hw_params(); + let mut buf = struct_buf(&p); + run_ioctl(proc, host, idx, SNDRV_PCM_IOCTL_HW_PARAMS, &mut buf) + .expect("HW_PARAMS"); + } + + fn struct_buf(value: &T) -> alloc::vec::Vec { + let mut buf = alloc::vec![0u8; core::mem::size_of::()]; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut T, *value); + } + buf + } +} diff --git a/crates/kernel/src/audio/sab.rs b/crates/kernel/src/audio/sab.rs new file mode 100644 index 0000000000..23baebfb61 --- /dev/null +++ b/crates/kernel/src/audio/sab.rs @@ -0,0 +1,163 @@ +//! Host-provided SharedArrayBuffer registry for ALSA PCM data rings. +//! +//! Each `/dev/snd/pcmC0D0p` opens against a numbered PCM (`pcm_id`). +//! Before any [`crate::audio::pcm_ioctl::handle_alsa_pcm_ioctl`] data +//! call can succeed, the host must hand the kernel a pointer to the +//! SAB-backed ring for that PCM via the `kernel_audio_init_sab` export +//! ([`crate::wasm_api`]). +//! +//! The ring is shared with the AudioWorklet on the host side; the +//! synchronisation protocol is alsa-lib's lock-free +//! producer/consumer (`mmap_status->hw_ptr` consumed by the host, +//! `mmap_control->appl_ptr` produced by userspace via `WRITEI` or by +//! direct mmap writes). This module is just the address book — +//! `(pcm_id) → (base, len)`. +//! +//! v1 ships at most four PCMs (`pcmC0D0p..pcmC0D3p`); the table is a +//! fixed `[Option; 4]` so registration is O(1) and the +//! kernel never allocates. + +use core::cell::UnsafeCell; + +use wasm_posix_shared::Errno; + +/// Address book entry for one PCM's SAB-backed data ring. +#[derive(Clone, Copy, Debug)] +pub struct SabSlice { + /// Base byte address into the kernel-visible linear memory window + /// the host imported for this SAB. The kernel treats it as a raw + /// `&mut [i16]` view via [`ring_mut_s16`]; cross-process + /// synchronisation is the caller's responsibility (alsa-lib's + /// hw_ptr/appl_ptr pair). + pub base: usize, + /// Length of the ring in bytes (must be a multiple of + /// `channels * sizeof(i16)`). + pub len: usize, +} + +const MAX_PCMS: usize = 4; + +struct GlobalSabTable(UnsafeCell<[Option; MAX_PCMS]>); + +// SAFETY: the centralized kernel processes one syscall at a time +// from the JS event loop; concurrent mutation is impossible at +// runtime. Cargo tests serialize via [`TEST_SAB_LOCK`]. +unsafe impl Sync for GlobalSabTable {} + +static SAB_TABLE: GlobalSabTable = GlobalSabTable(UnsafeCell::new([None; MAX_PCMS])); + +fn with_table(f: impl FnOnce(&mut [Option; MAX_PCMS]) -> R) -> R { + f(unsafe { &mut *SAB_TABLE.0.get() }) +} + +/// Bind `pcm_id` to a SAB slice. Re-registering an already-bound +/// `pcm_id` returns `EBUSY` — the second `kernel_audio_init_sab` +/// from the host is a no-op rather than a silent re-map. +pub fn register(pcm_id: u32, slice: SabSlice) -> Result<(), Errno> { + let idx = pcm_id as usize; + if idx >= MAX_PCMS { + return Err(Errno::EINVAL); + } + with_table(|tbl| { + if tbl[idx].is_some() { + return Err(Errno::EBUSY); + } + tbl[idx] = Some(slice); + Ok(()) + }) +} + +pub fn lookup(pcm_id: u32) -> Option { + let idx = pcm_id as usize; + if idx >= MAX_PCMS { + return None; + } + with_table(|tbl| tbl[idx]) +} + +/// Kernel-side `&mut [i16]` view of the PCM ring. Unsafe because the +/// host's AudioWorklet mutates the same memory concurrently; +/// callers respect alsa-lib's `hw_ptr` / `appl_ptr` protocol. +/// +/// Returns `None` when no SAB has been registered for `pcm_id`. +/// +/// # Safety +/// +/// The host MUST have called `kernel_audio_init_sab(pcm_id, base, len)` +/// with a `base..base+len` range that is valid for the kernel's +/// lifetime and is the same memory the AudioWorklet draws from. +pub unsafe fn ring_mut_s16(pcm_id: u32) -> Option<&'static mut [i16]> { + let SabSlice { base, len } = lookup(pcm_id)?; + Some(unsafe { + core::slice::from_raw_parts_mut( + base as *mut i16, + len / core::mem::size_of::(), + ) + }) +} + +/// Serializes cargo tests that touch the global SAB table. Same +/// pattern as `dri::bo::TEST_REGISTRY_LOCK`. Public-in-test only. +#[cfg(test)] +pub static TEST_SAB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub(crate) fn reset_table() { + with_table(|tbl| { + for slot in tbl.iter_mut() { + *slot = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh() -> std::sync::MutexGuard<'static, ()> { + let g = TEST_SAB_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_table(); + g + } + + #[test] + fn register_then_lookup_round_trips() { + let _g = fresh(); + register(2, SabSlice { base: 0x1000, len: 8192 }).expect("register"); + let s = lookup(2).expect("lookup"); + assert_eq!(s.base, 0x1000); + assert_eq!(s.len, 8192); + } + + #[test] + fn lookup_returns_none_when_unregistered() { + let _g = fresh(); + assert!(lookup(0).is_none()); + assert!(lookup(3).is_none()); + } + + #[test] + fn register_out_of_range_pcm_id_returns_einval() { + let _g = fresh(); + let err = register(4, SabSlice { base: 0, len: 0 }).expect_err("oob"); + assert_eq!(err, Errno::EINVAL); + } + + #[test] + fn double_register_returns_ebusy() { + let _g = fresh(); + register(0, SabSlice { base: 0x1000, len: 1024 }).expect("first"); + let err = register(0, SabSlice { base: 0x2000, len: 1024 }) + .expect_err("second must EBUSY"); + assert_eq!(err, Errno::EBUSY); + // The original entry survives. + assert_eq!(lookup(0).unwrap().base, 0x1000); + } + + #[test] + fn lookup_out_of_range_returns_none() { + let _g = fresh(); + assert!(lookup(MAX_PCMS as u32).is_none()); + assert!(lookup(u32::MAX).is_none()); + } +} diff --git a/crates/kernel/src/audio/tick.rs b/crates/kernel/src/audio/tick.rs new file mode 100644 index 0000000000..0505484f6a --- /dev/null +++ b/crates/kernel/src/audio/tick.rs @@ -0,0 +1,268 @@ +//! Period-tick producer for ALSA PCM fds. +//! +//! [`tick`] is called from the `kernel_audio_period_tick` export +//! ([`crate::wasm_api`]) on every AudioWorklet quantum (browser) or +//! `setInterval` tick (Node) after the host driver pulled +//! `frames_consumed` frames from the SAB ring. It walks every open +//! `/dev/snd/pcmC0Dp` OFD whose state is `STATE_RUNNING`; +//! advances `mmap_status.hw_ptr` by `frames_consumed`; stamps +//! `tstamp_sec` / `tstamp_nsec`; detects XRUN (`hw_ptr > appl_ptr`); +//! and wakes POLLOUT waiters via [`super::wait::wake_pollout`]. +//! +//! Lock order — mirrors [`crate::dri::drain_pending_flips`]: hold the +//! process-table briefly to walk OFDs + advance state, collect +//! wake-target idxs into a local `Vec`, drop the lock, then drive +//! [`super::wait::wake_pollout`] outside the lock so the wake path +//! never re-enters under the table guard. +//! +//! The kernel-side `Box` on each OFD is the +//! source of truth for `hw_ptr` / `state`; user-page mirroring is a +//! Phase B host-bridge concern (see the ALSA plan §"Architecturally +//! load-bearing decisions"). + +use alloc::vec::Vec; + +use wasm_posix_shared::audio::{SNDRV_PCM_STATE_RUNNING, SNDRV_PCM_STATE_XRUN}; + +use crate::audio::wait; + +/// Advance `hw_ptr` by `frames_consumed` on every RUNNING OFD bound +/// to `pcm_id`, stamp the monotonic timestamp, detect XRUN, then +/// wake POLLOUT waiters. +/// +/// `tv_sec` / `tv_nsec` are supplied by the caller so this function +/// stays testable without a `HostIO`; the `kernel_audio_period_tick` +/// export fetches them once via `WasmHostIO::host_clock_gettime` and +/// passes them down. +/// Read the current `mmap_control.appl_ptr` for any OFD bound to +/// `pcm_id` (max across matches; in practice ≤1 writer per PCM). +/// Backs [`crate::wasm_api::kernel_audio_get_appl_ptr`] — the host's +/// browser audio driver forwards this into the AudioWorklet so the +/// worklet can gate `hwPtr` advance on producer progress (silence +/// past `appl_ptr`). Returns 0 when no OFD is bound. +pub fn current_appl_ptr(pcm_id: u32) -> i64 { + let mut result: i64 = 0; + crate::process_table::with_processes(|procs| { + for proc in procs { + for (_idx, ofd) in proc.ofd_table.iter_mut() { + let Some(audio) = ofd.audio_mut() else { continue }; + if audio.pcm_id != pcm_id { + continue; + } + if let Some(ctl) = audio.mmap_control.as_ref() { + if ctl.appl_ptr > result { + result = ctl.appl_ptr; + } + } + } + } + }); + result +} + +pub fn tick(pcm_id: u32, frames_consumed: u32, tv_sec: i64, tv_nsec: i64) { + let mut woken: Vec = Vec::new(); + crate::process_table::with_processes(|procs| { + for proc in procs { + for (idx, ofd) in proc.ofd_table.iter_mut() { + let Some(audio) = ofd.audio_mut() else { continue }; + if audio.pcm_id != pcm_id { + continue; + } + if audio.state != SNDRV_PCM_STATE_RUNNING { + continue; + } + if let Some(status) = audio.mmap_status.as_mut() { + status.hw_ptr = status.hw_ptr.saturating_add(frames_consumed as i64); + status.tstamp_sec = tv_sec; + status.tstamp_nsec = tv_nsec; + let new_hw_ptr = status.hw_ptr; + // `status` borrow ends here so `audio.mmap_control` + // and `audio.state` can be touched mutably. + let appl = audio + .mmap_control + .as_ref() + .map(|c| c.appl_ptr) + .unwrap_or(0); + if new_hw_ptr > appl { + audio.state = SNDRV_PCM_STATE_XRUN; + if let Some(s) = audio.mmap_status.as_mut() { + s.state = SNDRV_PCM_STATE_XRUN; + } + } + } + woken.push(idx); + } + } + }); + for ofd_idx in woken { + wait::wake_pollout(ofd_idx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audio::wait::{drain_wake_count, reset as reset_wakes, TEST_WAKE_LOCK}; + use crate::ofd::{AlsaFdState, FileType, PcmDir}; + use crate::process::Process; + use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; + use crate::syscalls::VirtualDevice; + use alloc::boxed::Box; + use wasm_posix_shared::audio::{ + SNDRV_PCM_STATE_PREPARED, WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, + }; + use wasm_posix_shared::flags::O_WRONLY; + + fn install_process(pid: u32) -> &'static mut Process { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let _ = table.create_process(pid); + let proc = table.processes.get_mut(&pid).unwrap(); + unsafe { &mut *(proc as *mut Process) } + } + + fn remove_process(pid: u32) { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.processes.remove(&pid); + } + + fn install_pcm(proc: &mut Process, pcm_id: u32, state: u32) -> usize { + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: pcm_id as u8, + sub: 0, + kind: PcmDir::Playback, + } + .host_handle(); + let idx = proc.ofd_table.create( + FileType::CharDevice, + O_WRONLY, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + let ofd = proc.ofd_table.get_mut(idx).unwrap(); + ofd.audio = Some(Box::new(AlsaFdState { + pcm_id, + state, + mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus::default())), + // Large appl_ptr so the default-init hw_ptr advance does + // not trip XRUN unless a test rewrites the pointers. + mmap_control: Some(Box::new(WpkAlsaPcmMmapControl { + appl_ptr: 1_000_000_000, + ..WpkAlsaPcmMmapControl::default() + })), + ..AlsaFdState::default() + })); + idx + } + + fn fresh() -> std::sync::MutexGuard<'static, ()> { + let g = TEST_WAKE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_wakes(); + g + } + + #[test] + fn tick_advances_hw_ptr_by_frames_consumed() { + let _g = fresh(); + let proc = install_process(8001); + let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); + + tick(0, 256, 12_345, 678_901_234); + + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + let status = audio.mmap_status.as_ref().unwrap(); + assert_eq!(status.hw_ptr, 256); + assert_eq!(status.tstamp_sec, 12_345); + assert_eq!(status.tstamp_nsec, 678_901_234); + assert_eq!(audio.state, SNDRV_PCM_STATE_RUNNING); + remove_process(8001); + } + + #[test] + fn tick_on_non_running_pcm_is_a_noop() { + let _g = fresh(); + let proc = install_process(8002); + let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_PREPARED); + { + let audio = proc.ofd_table.get_mut(idx).unwrap().audio_mut().unwrap(); + audio.mmap_status.as_mut().unwrap().hw_ptr = 42; + } + + tick(0, 256, 1, 1); + + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + let status = audio.mmap_status.as_ref().unwrap(); + assert_eq!(status.hw_ptr, 42, "PREPARED PCM must not advance hw_ptr"); + assert_eq!(audio.state, SNDRV_PCM_STATE_PREPARED); + assert_eq!( + drain_wake_count(idx), + 0, + "non-RUNNING OFD must not wake POLLOUT" + ); + remove_process(8002); + } + + #[test] + fn tick_underrun_transitions_state_to_xrun() { + let _g = fresh(); + let proc = install_process(8003); + let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); + // appl_ptr=1000, hw_ptr starts at 900 → advance by 200 → + // 1100 > 1000 → XRUN. + { + let audio = proc.ofd_table.get_mut(idx).unwrap().audio_mut().unwrap(); + audio.mmap_status.as_mut().unwrap().hw_ptr = 900; + audio.mmap_control.as_mut().unwrap().appl_ptr = 1000; + } + + tick(0, 200, 0, 0); + + let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); + let status = audio.mmap_status.as_ref().unwrap(); + assert_eq!(status.hw_ptr, 1100); + assert_eq!(audio.state, SNDRV_PCM_STATE_XRUN, "OFD state must latch XRUN"); + assert_eq!( + status.state, SNDRV_PCM_STATE_XRUN, + "mmap_status.state must mirror so user-page readers see XRUN", + ); + remove_process(8003); + } + + #[test] + fn tick_wakes_blocked_pollout_waiter() { + let _g = fresh(); + let proc = install_process(8004); + let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); + + tick(0, 100, 0, 0); + + assert_eq!( + drain_wake_count(idx), + 1, + "RUNNING OFD on the ticked pcm_id must wake POLLOUT exactly once", + ); + remove_process(8004); + } + + #[test] + fn tick_skips_ofds_on_a_different_pcm_id() { + let _g = fresh(); + let proc = install_process(8005); + let idx_zero = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); + let idx_one = install_pcm(proc, 1, SNDRV_PCM_STATE_RUNNING); + + tick(0, 256, 0, 0); + + let zero = proc.ofd_table.get(idx_zero).unwrap().audio().unwrap(); + let one = proc.ofd_table.get(idx_one).unwrap().audio().unwrap(); + assert_eq!(zero.mmap_status.as_ref().unwrap().hw_ptr, 256); + assert_eq!( + one.mmap_status.as_ref().unwrap().hw_ptr, 0, + "tick on pcm_id=0 must not touch pcm_id=1", + ); + assert_eq!(drain_wake_count(idx_zero), 1); + assert_eq!(drain_wake_count(idx_one), 0); + remove_process(8005); + } +} diff --git a/crates/kernel/src/audio/wait.rs b/crates/kernel/src/audio/wait.rs new file mode 100644 index 0000000000..c218c1ad04 --- /dev/null +++ b/crates/kernel/src/audio/wait.rs @@ -0,0 +1,86 @@ +//! POLLOUT wake primitives for ALSA PCM fds. +//! +//! [`super::tick::tick`] calls [`wake_pollout`] on every still-RUNNING +//! OFD after advancing `hw_ptr`. The wake pushes a +//! [`crate::wakeup::WAKE_WRITABLE`] event onto the global wakeup +//! buffer so the host drains the AlsaPcm wake alongside the existing +//! pipe / accept wakeup loop. +//! +//! A7 wires up the actual `poll(POLLOUT)` arm in `sys_poll` that +//! consumes the wake; v1 keeps the consumer side a stub. Tests can +//! observe the wake via [`drain_wake_count`] under [`TEST_WAKE_LOCK`]. + +use alloc::collections::BTreeMap; +use core::cell::UnsafeCell; + +struct WakeTracker { + counts: UnsafeCell>, +} + +// SAFETY: the centralized kernel processes one syscall at a time; +// cargo tests serialize via [`TEST_WAKE_LOCK`]. +unsafe impl Sync for WakeTracker {} + +static POLLOUT_WAKES: WakeTracker = WakeTracker { + counts: UnsafeCell::new(BTreeMap::new()), +}; + +/// Signal that `ofd_idx`'s `poll(POLLOUT)` condition may now be +/// satisfied. Pushes a [`crate::wakeup::WAKE_WRITABLE`] onto the +/// global wakeup buffer for host-side drain; tests can call +/// [`drain_wake_count`] (under [`TEST_WAKE_LOCK`]) to verify the +/// signal fired. +pub fn wake_pollout(ofd_idx: usize) { + let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; + *map.entry(ofd_idx).or_insert(0) += 1; + crate::wakeup::push(ofd_idx as u32, crate::wakeup::WAKE_WRITABLE); +} + +#[cfg(test)] +pub(crate) fn drain_wake_count(ofd_idx: usize) -> u32 { + let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; + map.remove(&ofd_idx).unwrap_or(0) +} + +#[cfg(test)] +pub(crate) fn reset() { + let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; + map.clear(); +} + +/// Serializes tests that touch the global POLLOUT_WAKES tracker (and, +/// transitively, the [`crate::process_table::GLOBAL_PROCESS_TABLE`] +/// reachable via [`super::tick::tick`]). Same pattern as +/// [`crate::audio::sab::TEST_SAB_LOCK`]. +#[cfg(test)] +pub static TEST_WAKE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh() -> std::sync::MutexGuard<'static, ()> { + let g = TEST_WAKE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset(); + g + } + + #[test] + fn wake_pollout_increments_per_ofd_count() { + let _g = fresh(); + wake_pollout(7); + wake_pollout(7); + wake_pollout(11); + assert_eq!(drain_wake_count(7), 2); + assert_eq!(drain_wake_count(11), 1); + assert_eq!(drain_wake_count(99), 0); + } + + #[test] + fn drain_resets_the_counter_for_the_ofd() { + let _g = fresh(); + wake_pollout(3); + assert_eq!(drain_wake_count(3), 1); + assert_eq!(drain_wake_count(3), 0); + } +} diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index 616efa07c2..18f5598a05 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -35,6 +35,8 @@ pub enum DevfsEntry { InputDir, /// /dev/dri DriDir, + /// /dev/snd + SndDir, } /// Match a resolved path to a devfs directory entry. @@ -47,6 +49,7 @@ pub fn match_devfs_dir(path: &[u8]) -> Option { b"/dev/fd" => Some(DevfsEntry::FdDir), b"/dev/input" => Some(DevfsEntry::InputDir), b"/dev/dri" => Some(DevfsEntry::DriDir), + b"/dev/snd" => Some(DevfsEntry::SndDir), _ => None, } } @@ -163,6 +166,7 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec { // /dev/input/mice — Linux-compatible PS/2 mouse stream. @@ -182,6 +186,14 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec { + // /dev/snd/controlC0 — ALSA control surface (plan 6). + // /dev/snd/pcmC0D0p — ALSA PCM playback (plan 6). Capture + // (`pcmC0D0c`) is deliberately not listed; opens for it + // get ENODEV from disabled_virtual_device. + entries.push((b"controlC0".into(), DT_CHR, devfs_ino(b"/dev/snd/controlC0"))); + entries.push((b"pcmC0D0p".into(), DT_CHR, devfs_ino(b"/dev/snd/pcmC0D0p"))); + } DevfsEntry::PtsDir => { // List active PTY slaves for i in 0..crate::pty::MAX_PTYS { @@ -527,6 +539,37 @@ mod tests { ); } + #[test] + fn snd_dir_is_listed_under_dev() { + let proc = crate::process::Process::new(1); + let entries = dir_entries(&proc, &DevfsEntry::Root); + let mut found = false; + for (name, dtype, _) in entries.iter() { + if name.as_slice() == b"snd" { + assert_eq!(*dtype, DT_DIR); + found = true; + } + } + assert!(found, "snd subdir missing from /dev listing"); + } + + #[test] + fn snd_dir_lists_controlc0_and_pcmc0d0p() { + let proc = crate::process::Process::new(1); + let entries = dir_entries(&proc, &DevfsEntry::SndDir); + let names: Vec<&[u8]> = entries.iter().map(|(n, _, _)| n.as_slice()).collect(); + assert!(names.iter().any(|n| *n == b"controlC0"), "controlC0 missing: {:?}", names); + assert!(names.iter().any(|n| *n == b"pcmC0D0p"), "pcmC0D0p missing: {:?}", names); + // pcmC0D0c is deliberately NOT listed — v1 ships playback only. + assert!(!names.iter().any(|n| *n == b"pcmC0D0c")); + for (_, dtype, _) in entries.iter() { + assert_eq!(*dtype, DT_CHR); + } + // /dev/snd itself stats as a directory. + let st = match_devfs_stat(b"/dev/snd", 0, 0).unwrap(); + assert_eq!(st.st_mode & 0o170000, S_IFDIR); + } + #[test] fn event0_and_event1_listed_in_dev_input_dir() { let proc = crate::process::Process::new(1); diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 8e423adde1..fa915745b4 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -642,6 +642,15 @@ const DRI_TAG_PRIME_BO: u8 = 3; const INPUT_TAG_NONE: u8 = 0; const INPUT_TAG_SOME: u8 = 1; +const AUDIO_TAG_NONE: u8 = 0; +const AUDIO_TAG_SOME: u8 = 1; + +const AUDIO_CTL_TAG_NONE: u8 = 0; +const AUDIO_CTL_TAG_SOME: u8 = 1; + +const PCM_DIR_PLAYBACK: u8 = 0; +const PCM_DIR_CAPTURE: u8 = 1; + fn write_dri_fd_state(w: &mut Writer<'_>, dri: &crate::ofd::DriFdState) -> Result<(), Errno> { w.write_u32(dri.handles.len() as u32)?; for (handle, bo_id) in &dri.handles { @@ -731,6 +740,98 @@ fn write_input_state( Ok(()) } +/// Serialise the ALSA `/dev/snd/pcmC0D0p` sidecar across a fork/exec. +/// The child inherits the full state machine snapshot (state + +/// hw_params + sw_params + mmap pages + pcm_id). The SAB registry is a +/// global keyed by `pcm_id`; the kernel-side ring is not copied — both +/// sides keep referring to the same host-allocated buffer. +fn write_audio_state( + w: &mut Writer<'_>, + state: Option<&crate::ofd::AlsaFdState>, +) -> Result<(), Errno> { + let Some(audio) = state else { + return w.write_u8(AUDIO_TAG_NONE); + }; + w.write_u8(AUDIO_TAG_SOME)?; + w.write_u8(audio.card)?; + w.write_u8(audio.device)?; + w.write_u8(audio.sub)?; + w.write_u8(match audio.kind { + crate::ofd::PcmDir::Playback => PCM_DIR_PLAYBACK, + crate::ofd::PcmDir::Capture => PCM_DIR_CAPTURE, + })?; + w.write_u32(audio.state)?; + match audio.hw_params.as_deref() { + None => w.write_u8(0)?, + Some(hw) => { + w.write_u8(1)?; + w.write_u32(hw.format)?; + w.write_u32(hw.access)?; + w.write_u32(hw.channels)?; + w.write_u32(hw.rate)?; + w.write_u64(hw.period_size)?; + w.write_u64(hw.buffer_size)?; + w.write_u32(hw.periods)?; + } + } + match audio.sw_params.as_deref() { + None => w.write_u8(0)?, + Some(sw) => { + w.write_u8(1)?; + w.write_u64(sw.avail_min)?; + w.write_u64(sw.start_threshold)?; + w.write_u64(sw.stop_threshold)?; + w.write_u64(sw.boundary)?; + } + } + match audio.mmap_status.as_deref() { + None => w.write_u8(0)?, + Some(s) => { + w.write_u8(1)?; + w.write_u32(s.state)?; + w.write_u32(s._pad0)?; + w.write_i64(s.hw_ptr)?; + w.write_i64(s.tstamp_sec)?; + w.write_i64(s.tstamp_nsec)?; + w.write_u32(s.suspended_state)?; + w.write_u32(s.audio_tstamp_data)?; + w.write_i64(s.audio_tstamp_sec)?; + w.write_i64(s.audio_tstamp_nsec)?; + for &b in &s._reserved_tail { + w.write_u8(b)?; + } + } + } + match audio.mmap_control.as_deref() { + None => w.write_u8(0)?, + Some(c) => { + w.write_u8(1)?; + w.write_i64(c.appl_ptr)?; + w.write_i64(c.avail_min)?; + for &b in &c._reserved { + w.write_u8(b)?; + } + } + } + w.write_u32(audio.pcm_id)?; + Ok(()) +} + +/// Serialise the ALSA `/dev/snd/controlC0` sidecar across a fork/exec. +/// v1 carries only a card binding — `CARD_INFO` / `ELEM_LIST` serve +/// from kernel globals, so no further state. +fn write_audio_ctl_state( + w: &mut Writer<'_>, + state: Option<&crate::ofd::AlsaControlFdState>, +) -> Result<(), Errno> { + let Some(ctl) = state else { + return w.write_u8(AUDIO_CTL_TAG_NONE); + }; + w.write_u8(AUDIO_CTL_TAG_SOME)?; + w.write_u8(ctl.card)?; + Ok(()) +} + /// Read a `DriFdState` from the wire and incref every referenced bo /// in the global registry so the new OFD has its own refcount. The /// caller may still drop the entire OFD if the surrounding deserialize @@ -850,6 +951,126 @@ fn read_input_state( } } +fn read_audio_state( + r: &mut Reader<'_>, +) -> Result>, Errno> { + use wasm_posix_shared::audio::{WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus}; + let tag = r.read_u8()?; + match tag { + AUDIO_TAG_NONE => Ok(None), + AUDIO_TAG_SOME => { + let card = r.read_u8()?; + let device = r.read_u8()?; + let sub = r.read_u8()?; + let kind = match r.read_u8()? { + PCM_DIR_PLAYBACK => crate::ofd::PcmDir::Playback, + PCM_DIR_CAPTURE => crate::ofd::PcmDir::Capture, + _ => return Err(Errno::EINVAL), + }; + let state = r.read_u32()?; + let hw_params = match r.read_u8()? { + 0 => None, + 1 => Some(alloc::boxed::Box::new(crate::ofd::HwParamsCache { + format: r.read_u32()?, + access: r.read_u32()?, + channels: r.read_u32()?, + rate: r.read_u32()?, + period_size: r.read_u64()?, + buffer_size: r.read_u64()?, + periods: r.read_u32()?, + })), + _ => return Err(Errno::EINVAL), + }; + let sw_params = match r.read_u8()? { + 0 => None, + 1 => Some(alloc::boxed::Box::new(crate::ofd::SwParamsCache { + avail_min: r.read_u64()?, + start_threshold: r.read_u64()?, + stop_threshold: r.read_u64()?, + boundary: r.read_u64()?, + })), + _ => return Err(Errno::EINVAL), + }; + let mmap_status = match r.read_u8()? { + 0 => None, + 1 => { + let s_state = r.read_u32()?; + let pad0 = r.read_u32()?; + let hw_ptr = r.read_i64()?; + let tstamp_sec = r.read_i64()?; + let tstamp_nsec = r.read_i64()?; + let suspended_state = r.read_u32()?; + let audio_tstamp_data = r.read_u32()?; + let audio_tstamp_sec = r.read_i64()?; + let audio_tstamp_nsec = r.read_i64()?; + let mut tail = [0u8; 8]; + for byte in tail.iter_mut() { + *byte = r.read_u8()?; + } + Some(alloc::boxed::Box::new(WpkAlsaPcmMmapStatus { + state: s_state, + _pad0: pad0, + hw_ptr, + tstamp_sec, + tstamp_nsec, + suspended_state, + audio_tstamp_data, + audio_tstamp_sec, + audio_tstamp_nsec, + _reserved_tail: tail, + })) + } + _ => return Err(Errno::EINVAL), + }; + let mmap_control = match r.read_u8()? { + 0 => None, + 1 => { + let appl_ptr = r.read_i64()?; + let avail_min = r.read_i64()?; + let mut reserved = [0u8; 48]; + for byte in reserved.iter_mut() { + *byte = r.read_u8()?; + } + Some(alloc::boxed::Box::new(WpkAlsaPcmMmapControl { + appl_ptr, + avail_min, + _reserved: reserved, + })) + } + _ => return Err(Errno::EINVAL), + }; + let pcm_id = r.read_u32()?; + Ok(Some(alloc::boxed::Box::new(crate::ofd::AlsaFdState { + card, + device, + sub, + kind, + state, + hw_params, + sw_params, + mmap_status, + mmap_control, + pcm_id, + }))) + } + _ => Err(Errno::EINVAL), + } +} + +fn read_audio_ctl_state( + r: &mut Reader<'_>, +) -> Result>, Errno> { + let tag = r.read_u8()?; + match tag { + AUDIO_CTL_TAG_NONE => Ok(None), + AUDIO_CTL_TAG_SOME => { + let card = r.read_u8()?; + Ok(Some(alloc::boxed::Box::new(crate::ofd::AlsaControlFdState { card }))) + } + _ => Err(Errno::EINVAL), + } +} + fn read_dri_state( r: &mut Reader<'_>, ) -> Result>, Errno> { @@ -992,6 +1213,11 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result<(), Er } let dri_state = read_dri_state(&mut r)?; let input_state = read_input_state(&mut r)?; + let audio = read_audio_state(&mut r)?; + let audio_ctl = read_audio_ctl_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -1343,6 +1571,8 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er dir_pending_entry: None, dri_state, input_state, + audio, + audio_ctl, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -1828,6 +2058,9 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result { } let dri_state = read_dri_state(&mut r)?; let input_state = read_input_state(&mut r)?; + let audio = read_audio_state(&mut r)?; + let audio_ctl = read_audio_ctl_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -2024,6 +2259,8 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { dir_pending_entry: None, dri_state, input_state, + audio, + audio_ctl, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -3568,4 +3805,221 @@ mod tests { "exec keeps the same process identity; KMS master should survive" ); } + + // ── ALSA fork/exec inheritance tests ────────────────────────────────── + + /// Build an AlsaFdState with the full state machine populated: + /// committed HW/SW params, mmap pages with seeded ptrs, and a + /// non-zero pcm_id. Lets the round-trip tests assert every field + /// survives without relying on defaults. + fn populated_alsa_pcm_state() -> crate::ofd::AlsaFdState { + use crate::ofd::{AlsaFdState, HwParamsCache, PcmDir, SwParamsCache}; + use wasm_posix_shared::audio::{ + WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, SNDRV_PCM_FORMAT_S16_LE, + SNDRV_PCM_STATE_RUNNING, + }; + AlsaFdState { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + state: SNDRV_PCM_STATE_RUNNING, + hw_params: Some(alloc::boxed::Box::new(HwParamsCache { + format: SNDRV_PCM_FORMAT_S16_LE, + access: 0, + channels: 2, + rate: 48000, + period_size: 1024, + buffer_size: 4096, + periods: 4, + })), + sw_params: Some(alloc::boxed::Box::new(SwParamsCache { + avail_min: 1024, + start_threshold: 2048, + stop_threshold: 4096, + boundary: 1 << 30, + })), + mmap_status: Some(alloc::boxed::Box::new(WpkAlsaPcmMmapStatus { + state: SNDRV_PCM_STATE_RUNNING, + hw_ptr: 512, + tstamp_sec: 7, + tstamp_nsec: 123_456_789, + ..WpkAlsaPcmMmapStatus::default() + })), + mmap_control: Some(alloc::boxed::Box::new(WpkAlsaPcmMmapControl { + appl_ptr: 1536, + avail_min: 1024, + _reserved: [0u8; 48], + })), + pcm_id: 0, + } + } + + #[test] + fn fork_inherits_alsa_pcm_state() { + use crate::syscalls::VirtualDevice; + use wasm_posix_shared::audio::{ + SNDRV_PCM_FORMAT_S16_LE, SNDRV_PCM_STATE_RUNNING, + }; + + let mut proc = Process::new(1); + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + } + .host_handle(); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + proc.ofd_table.get_mut(ofd_idx).unwrap().audio = + Some(alloc::boxed::Box::new(populated_alsa_pcm_state())); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 99).unwrap(); + + let child_audio = child + .ofd_table + .get(ofd_idx) + .unwrap() + .audio() + .expect("child must inherit AlsaFdState"); + assert_eq!(child_audio.card, 0); + assert_eq!(child_audio.device, 0); + assert_eq!(child_audio.sub, 0); + assert_eq!(child_audio.kind, crate::ofd::PcmDir::Playback); + assert_eq!(child_audio.state, SNDRV_PCM_STATE_RUNNING); + let hw = child_audio.hw_params.as_deref().expect("hw_params survives"); + assert_eq!(hw.format, SNDRV_PCM_FORMAT_S16_LE); + assert_eq!(hw.channels, 2); + assert_eq!(hw.rate, 48000); + assert_eq!(hw.period_size, 1024); + assert_eq!(hw.buffer_size, 4096); + assert_eq!(hw.periods, 4); + let sw = child_audio.sw_params.as_deref().expect("sw_params survives"); + assert_eq!(sw.avail_min, 1024); + assert_eq!(sw.start_threshold, 2048); + assert_eq!(sw.stop_threshold, 4096); + assert_eq!(sw.boundary, 1 << 30); + let status = child_audio.mmap_status.as_deref().expect("status survives"); + assert_eq!(status.state, SNDRV_PCM_STATE_RUNNING); + assert_eq!(status.hw_ptr, 512); + assert_eq!(status.tstamp_sec, 7); + assert_eq!(status.tstamp_nsec, 123_456_789); + let ctl = child_audio.mmap_control.as_deref().expect("control survives"); + assert_eq!(ctl.appl_ptr, 1536); + assert_eq!(ctl.avail_min, 1024); + // pcm_id is per-fd; the SAB registry it indexes is a global keyed + // by this id, so inheriting the id is enough — no per-fork copy. + assert_eq!(child_audio.pcm_id, 0); + } + + #[test] + fn fork_inherits_alsa_control_state() { + use crate::ofd::AlsaControlFdState; + use crate::syscalls::VirtualDevice; + + let mut proc = Process::new(1); + let host_handle = VirtualDevice::AlsaControl { card: 0 }.host_handle(); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + host_handle, + b"/dev/snd/controlC0".to_vec(), + ); + proc.ofd_table.get_mut(ofd_idx).unwrap().audio_ctl = + Some(alloc::boxed::Box::new(AlsaControlFdState { card: 0 })); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 99).unwrap(); + + let child_ctl = child + .ofd_table + .get(ofd_idx) + .unwrap() + .audio_ctl() + .expect("child must inherit AlsaControlFdState"); + assert_eq!(child_ctl.card, 0); + } + + #[test] + fn exec_preserves_alsa_pcm_state() { + use crate::syscalls::VirtualDevice; + use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; + + let mut proc = Process::new(1); + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + } + .host_handle(); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + proc.ofd_table.get_mut(ofd_idx).unwrap().audio = + Some(alloc::boxed::Box::new(populated_alsa_pcm_state())); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_exec_state(&proc, &mut buf).unwrap(); + let post = deserialize_exec_state(&buf[..written], proc.pid).unwrap(); + + // exec keeps the same process identity — the PCM state machine + // snapshot, hw/sw params, mmap pages, and pcm_id must all + // survive byte-for-byte. + let post_audio = post + .ofd_table + .get(ofd_idx) + .unwrap() + .audio() + .expect("exec must keep AlsaFdState"); + assert_eq!(post_audio.state, SNDRV_PCM_STATE_RUNNING); + assert_eq!(post_audio.hw_params.as_deref().unwrap().rate, 48000); + assert_eq!(post_audio.mmap_status.as_deref().unwrap().hw_ptr, 512); + assert_eq!(post_audio.mmap_control.as_deref().unwrap().appl_ptr, 1536); + } + + #[test] + fn fork_audio_none_round_trips() { + // An OFD without an audio sidecar (the common case for non-snd + // fds) must encode + decode losslessly. Catches a stray byte + // misread that would corrupt the wire stream for the next OFD. + let mut proc = Process::new(1); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::Regular, + 0, + 5, + b"/tmp/file".to_vec(), + ); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 99).unwrap(); + + let child_ofd = child.ofd_table.get(ofd_idx).unwrap(); + assert!(child_ofd.audio.is_none()); + assert!(child_ofd.audio_ctl.is_none()); + } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 4c76b39fa2..a2eea93e44 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -316,6 +316,118 @@ pub struct InputFdState { pub dropped: bool, } +/// PCM stream direction. v1 only ships [`PcmDir::Playback`]; opening +/// `/dev/snd/pcmC0D0c` returns `ENODEV` rather than installing a +/// [`PcmDir::Capture`] OFD. The variant is kept so the type signature +/// of [`VirtualDevice::AlsaPcm`] survives v2 without ABI churn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PcmDir { + #[default] + Playback, + Capture, +} + +/// HW_PARAMS cache — populated by `SNDRV_PCM_IOCTL_HW_PARAMS`, read by +/// every subsequent ioctl. Stored as the narrowed concrete shape, not +/// the wildcard `WpkAlsaPcmHwParams` reply, so the state machine +/// doesn't have to re-walk the masks/intervals on every transition. +#[derive(Default, Clone, Debug)] +pub struct HwParamsCache { + /// `SNDRV_PCM_FORMAT_*`. v1: `S16_LE` only. + pub format: u32, + /// `SNDRV_PCM_ACCESS_*`. v1: `MMAP_INTERLEAVED` or `RW_INTERLEAVED`. + pub access: u32, + pub channels: u32, + pub rate: u32, + /// Frames per period. + pub period_size: u64, + /// Frames per ring buffer (= `period_size * periods`). + pub buffer_size: u64, + pub periods: u32, +} + +/// SW_PARAMS cache — populated by `SNDRV_PCM_IOCTL_SW_PARAMS`. +#[derive(Default, Clone, Debug)] +pub struct SwParamsCache { + pub avail_min: u64, + pub start_threshold: u64, + pub stop_threshold: u64, + pub boundary: u64, +} + +/// Per-fd state for `/dev/snd/pcmC0D0p` opens. +/// +/// Disjoint from [`DriOfdState`] and [`InputFdState`] — audio fds +/// carry no DRI bo state and no input ring state. Mirrors the +/// "one `Option>` per device class" factoring [`InputFdState`] +/// established for plan 5; plan 6 reuses the pattern so the OFD's +/// non-audio cost is one pointer slot. +/// +/// PCM state machine: `OPEN` → (`HW_PARAMS`) → `SETUP` → (`PREPARE`) +/// → `PREPARED` → (`START`) → `RUNNING` → `XRUN` / `PAUSED`. +/// `HW_FREE` returns to `OPEN`; `DROP` returns to `SETUP`. +#[derive(Clone, Debug)] +pub struct AlsaFdState { + pub card: u8, + pub device: u8, + pub sub: u8, + pub kind: PcmDir, + + /// PCM state machine. `SNDRV_PCM_STATE_*` (see + /// `wasm_posix_shared::audio`). + pub state: u32, + + /// HW_PARAMS cache; `None` until `HW_PARAMS` lands. + pub hw_params: Option>, + + /// SW_PARAMS cache; `None` until `SW_PARAMS` lands. `HW_PARAMS` + /// is a prerequisite — `SW_PARAMS` against a `hw_params: None` + /// fd returns `EBADFD`. + pub sw_params: Option>, + + /// `snd_pcm_mmap_status` page — kernel-writes, userspace-reads. + /// Allocated on first mmap(`SNDRV_PCM_MMAP_OFFSET_STATUS`). + pub mmap_status: Option>, + + /// `snd_pcm_mmap_control` page — userspace-writes, kernel-reads. + /// Allocated on first mmap(`SNDRV_PCM_MMAP_OFFSET_CONTROL`). + pub mmap_control: Option>, + + /// Identifier into the host `audio::sab_table` for this PCM's + /// SAB-backed data ring. `0` until `kernel_audio_init_sab` runs. + pub pcm_id: u32, +} + +impl Default for AlsaFdState { + fn default() -> Self { + AlsaFdState { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + state: wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN, + hw_params: None, + sw_params: None, + mmap_status: None, + mmap_control: None, + pcm_id: 0, + } + } +} + +/// Per-fd state for `/dev/snd/controlC0` opens. +/// +/// v1 `controlC0` is a read-only handle: `CARD_INFO` / `ELEM_LIST` +/// serve from kernel globals, so per-fd state is just the card +/// binding. Carried as a separate `Option>` rather than folded +/// into [`AlsaFdState`] because a single OFD is never both a PCM and a +/// control surface — opening `controlC0` and `pcmC0D0p` always +/// produces two distinct fds. +#[derive(Default, Clone, Debug)] +pub struct AlsaControlFdState { + pub card: u8, +} + #[derive(Clone)] pub struct OpenFileDesc { /// Machine-wide identity of this open file description. Independent @@ -358,6 +470,13 @@ pub struct OpenFileDesc { /// [`InputFdState`]. Boxed so non-evdev OFDs pay only one pointer /// slot. Parallel to [`Self::dri_state`] (disjoint state machines). pub input_state: Option>, + /// ALSA PCM sidecar for `/dev/snd/pcmC0D0p` OFDs; see + /// [`AlsaFdState`]. Parallel to [`Self::input_state`]. + pub audio: Option>, + /// ALSA control sidecar for `/dev/snd/controlC0` OFDs; see + /// [`AlsaControlFdState`]. Disjoint from [`Self::audio`] — a + /// single fd is never both a PCM and a control surface. + pub audio_ctl: Option>, } struct SharedOfdStateInner { @@ -592,6 +711,25 @@ impl OpenFileDesc { pub fn input_mut(&mut self) -> Option<&mut InputFdState> { self.input_state.as_deref_mut() } + + /// Borrow the `AlsaFdState` for `/dev/snd/pcmC0D0p` OFDs. + /// Returns `None` for any other OFD. + pub fn audio(&self) -> Option<&AlsaFdState> { + self.audio.as_deref() + } + + pub fn audio_mut(&mut self) -> Option<&mut AlsaFdState> { + self.audio.as_deref_mut() + } + + /// Borrow the `AlsaControlFdState` for `/dev/snd/controlC0` OFDs. + pub fn audio_ctl(&self) -> Option<&AlsaControlFdState> { + self.audio_ctl.as_deref() + } + + pub fn audio_ctl_mut(&mut self) -> Option<&mut AlsaControlFdState> { + self.audio_ctl.as_deref_mut() + } } #[derive(Clone)] @@ -630,6 +768,8 @@ impl OfdTable { dir_pending_entry: None, dri_state: None, input_state: None, + audio: None, + audio_ctl: None, }; self.insert(ofd) @@ -1020,6 +1160,8 @@ mod tests { dir_pending_entry: None, dri_state: None, input_state: None, + audio: None, + audio_ctl: None, }); } @@ -1258,6 +1400,58 @@ mod tests { assert_eq!(INPUT_RING_MAX_BYTES, 24 * 1024); } + #[test] + fn ofd_default_has_no_audio_state() { + let mut table = OfdTable::new(); + let idx = table.create(FileType::CharDevice, O_WRONLY, -13, b"/dev/snd/pcmC0D0p".to_vec()); + let ofd = table.get(idx).unwrap(); + assert!(ofd.audio.is_none()); + assert!(ofd.audio_ctl.is_none()); + assert!(ofd.audio().is_none()); + assert!(ofd.audio_ctl().is_none()); + } + + #[test] + fn audio_accessors_route_to_attached_state() { + let mut table = OfdTable::new(); + let idx = table.create(FileType::CharDevice, O_WRONLY, -13, b"/dev/snd/pcmC0D0p".to_vec()); + table.get_mut(idx).unwrap().audio = Some(Box::new(AlsaFdState { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + ..Default::default() + })); + + let st = table.get(idx).unwrap().audio().unwrap(); + assert_eq!(st.kind, PcmDir::Playback); + assert_eq!(st.state, wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN); + assert!(st.hw_params.is_none()); + assert!(st.sw_params.is_none()); + assert_eq!(st.pcm_id, 0); + + let st = table.get_mut(idx).unwrap().audio_mut().unwrap(); + st.state = wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP; + assert_eq!( + table.get(idx).unwrap().audio().unwrap().state, + wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP + ); + } + + #[test] + fn audio_ctl_accessors_route_to_attached_state() { + let mut table = OfdTable::new(); + let idx = table.create(FileType::CharDevice, O_RDWR, -12, b"/dev/snd/controlC0".to_vec()); + table.get_mut(idx).unwrap().audio_ctl = + Some(Box::new(AlsaControlFdState { card: 0 })); + + let st = table.get(idx).unwrap().audio_ctl().unwrap(); + assert_eq!(st.card, 0); + // PCM and control sidecars are disjoint — installing audio_ctl + // must NOT also populate audio. + assert!(table.get(idx).unwrap().audio().is_none()); + } + #[test] fn iter_mut_visits_every_live_ofd() { let mut table = OfdTable::new(); diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index fe44ae2caf..99684130a0 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -2494,7 +2494,30 @@ pub(crate) mod test_host { } fn unbind_framebuffer(&mut self, _p: i32) {} fn fb_write(&mut self, _p: i32, _o: usize, _b: &[u8]) {} + + /// Test-only hook: when [`PROC_READ_SOURCE`] holds a non-empty + /// buffer, copy it (up to `dst.len()`) into `dst`. Otherwise + /// behaves like the trait default (returns 0, leaves `dst` + /// untouched). Lets tests for kernel paths that copy user + /// memory (e.g. `WRITEI_FRAMES`) drive byte content without a + /// bespoke `HostIO` impl. + fn proc_read_bytes(&mut self, _pid: i32, _addr: u32, dst: &mut [u8]) -> i32 { + let src = PROC_READ_SOURCE.lock().unwrap_or_else(|e| e.into_inner()); + let n = dst.len().min(src.len()); + if n > 0 { + dst[..n].copy_from_slice(&src[..n]); + } + 0 + } } + + /// Source buffer for [`NoopHost::proc_read_bytes`]. Empty by + /// default; tests that need to drive byte content into a kernel + /// path overwrite it under the relevant subsystem's serialization + /// lock (`audio::sab::TEST_SAB_LOCK`, etc.) and reset it back to + /// empty before releasing the lock. + pub static PROC_READ_SOURCE: std::sync::Mutex> = + std::sync::Mutex::new(alloc::vec::Vec::new()); } #[cfg(test)] diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 033fa6bd9c..079354e18d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -163,6 +163,18 @@ pub enum VirtualDevice { /// `device = 1` → ptr (host_handle -11). v1 exposes exactly these /// two; `/dev/input/eventN` for N≥2 is not synthesised. InputEvent { device: u8 }, + /// `/dev/snd/controlC0` (host_handle = -12). v1 ships card 0 only. + AlsaControl { card: u8 }, + /// `/dev/snd/pcmC0D0p` (host_handle = -13). v1 ships card 0 device 0 + /// sub 0 playback only — `pcmC0D0c` (capture) is rejected at open + /// with `ENODEV`, so [`PcmDir::Capture`] never reaches a live + /// OFD via this constructor. + AlsaPcm { + card: u8, + device: u8, + sub: u8, + kind: crate::ofd::PcmDir, + }, } impl VirtualDevice { @@ -179,6 +191,16 @@ impl VirtualDevice { VirtualDevice::DriRenderD128 => -8, VirtualDevice::DriCard0 => -9, VirtualDevice::InputEvent { device } => -10 - device as i64, + VirtualDevice::AlsaControl { card: 0 } => -12, + VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + } => -13, + // v1 only synthesises card 0 / device 0 / sub 0 playback; + // anything else would have failed at match_virtual_device. + VirtualDevice::AlsaControl { .. } | VirtualDevice::AlsaPcm { .. } => -1, } } @@ -196,6 +218,13 @@ impl VirtualDevice { -9 => Some(VirtualDevice::DriCard0), -10 => Some(VirtualDevice::InputEvent { device: 0 }), -11 => Some(VirtualDevice::InputEvent { device: 1 }), + -12 => Some(VirtualDevice::AlsaControl { card: 0 }), + -13 => Some(VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + }), _ => None, } } @@ -213,6 +242,12 @@ impl VirtualDevice { VirtualDevice::DriRenderD128 => 8, VirtualDevice::DriCard0 => 9, VirtualDevice::InputEvent { device } => 10 + device as u64, + VirtualDevice::AlsaControl { card } => 12 + card as u64, + VirtualDevice::AlsaPcm { card, device, sub, .. } => { + // Card-major then device-minor then sub. v1 only uses (0,0,0,Playback) + // so the formula's exactness past the first triple doesn't matter yet. + 13 + (card as u64) * 256 + (device as u64) * 16 + (sub as u64) + } } } } @@ -236,6 +271,28 @@ fn match_virtual_device(path: &[u8]) -> Option { b"/dev/dri/card0" => Some(VirtualDevice::DriCard0), b"/dev/input/event0" => Some(VirtualDevice::InputEvent { device: 0 }), b"/dev/input/event1" => Some(VirtualDevice::InputEvent { device: 1 }), + b"/dev/snd/controlC0" => Some(VirtualDevice::AlsaControl { card: 0 }), + b"/dev/snd/pcmC0D0p" => Some(VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + }), + _ => None, + } +} + +/// Paths under synthetic device trees that the kernel deliberately +/// refuses to open — distinct from "doesn't exist". Returns the errno +/// the caller should propagate, or `None` to fall through to the +/// regular [`match_virtual_device`] / on-disk path. +/// +/// Used for `/dev/snd/pcmC0D0c` so the kernel reports `ENODEV` +/// ("device exists but is disabled") instead of `ENOENT`, mirroring +/// what alsa-lib expects when probing a capture-only direction. +fn disabled_virtual_device(path: &[u8]) -> Option { + match path { + b"/dev/snd/pcmC0D0c" => Some(Errno::ENODEV), _ => None, } } @@ -751,6 +808,34 @@ fn install_input_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualD } } +/// Install the ALSA sidecar on a freshly-allocated OFD for an +/// `/dev/snd/{pcmC0D0p,controlC0}` open. No-op for any other virtual +/// device. The PCM and control variants land in two disjoint OFD +/// fields (`audio` / `audio_ctl`) per [`crate::ofd::AlsaControlFdState`]'s +/// rationale. +fn install_audio_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualDevice) { + match dev { + VirtualDevice::AlsaPcm { card, device, sub, kind } => { + if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { + ofd.audio = Some(alloc::boxed::Box::new(crate::ofd::AlsaFdState { + card, + device, + sub, + kind, + ..Default::default() + })); + } + } + VirtualDevice::AlsaControl { card } => { + if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { + ofd.audio_ctl = + Some(alloc::boxed::Box::new(crate::ofd::AlsaControlFdState { card })); + } + } + _ => {} + } +} + /// Borrow the `DriFdState` hung off the OFD at `ofd_idx`, returning /// `EBADF` if the OFD doesn't have one or is a prime-bo. Used by /// renderD128- and card0-targeted ioctls that manipulate per-fd GEM @@ -3191,6 +3276,12 @@ pub fn sys_open( }; } + // Paths that exist in the synthetic tree but are deliberately + // disabled (e.g. /dev/snd/pcmC0D0c — v1 ships playback only). + if let Some(errno) = disabled_virtual_device(&resolved) { + return Err(errno); + } + // Virtual device nodes — handle in-kernel, no host call if let Some(dev) = match_virtual_device(&resolved) { if dev == VirtualDevice::Fb0 { @@ -3230,6 +3321,7 @@ pub fn sys_open( ); install_dri_state_on_open(proc, ofd_idx, dev); install_input_state_on_open(proc, ofd_idx, dev); + install_audio_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -4794,6 +4886,12 @@ pub fn sys_read( } n } + // ALSA fds carry data via ioctl (WRITEI_FRAMES) + // or the mmap data page, never user-space + // read(). Return 0 so alsa-lib's defensive + // probe reads observe EOF instead of EBADF; + // A3+ refine this if a real consumer surfaces. + VirtualDevice::AlsaPcm { .. } | VirtualDevice::AlsaControl { .. } => 0, }; return Ok(n); } @@ -9813,6 +9911,20 @@ pub fn sys_mmap( }); return Ok(addr_out); } + + // /dev/snd/pcmC0D

p: alsa-lib calls mmap() three times right + // after HW_PARAMS, one each for status / control / data. The + // dispatcher in `audio::mmap` decodes the offset, lazily + // allocates the kernel-side status/control Boxes, and (for the + // DATA page) verifies a SAB was registered via + // `kernel_audio_init_sab`. The user-space pages themselves + // come from the generic `mmap_anonymous` allocator. + if ofd.audio.is_some() { + let ofd_idx = entry.ofd_ref.0; + return crate::audio::mmap::handle_alsa_pcm_mmap( + proc, ofd_idx, addr, len, prot, flags, offset, + ); + } } // Allocate the region. Both anonymous and file-backed use the same @@ -13787,6 +13899,59 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } } } + } else if ofd.file_type == FileType::CharDevice + && matches!( + VirtualDevice::from_host_handle(ofd.host_handle), + Some(VirtualDevice::AlsaPcm { .. }) + ) + { + // /dev/snd/pcmC0Dp — alsa-lib polls POLLOUT + // waiting for ring space. + // + // avail = buffer_size - (appl_ptr - hw_ptr) + // ready iff avail >= sw_params.avail_min + // + // hw_ptr / appl_ptr come from the kernel-side + // mmap Boxes (A5); buffer_size / avail_min from + // the HW/SW_PARAMS caches (A3). XRUN reflects as + // POLLERR so alsa-lib's recovery path triggers a + // PREPARE without spinning. + // + // v1 reports ready / not-ready only — kernel + // doesn't park; userspace re-polls (same pattern + // as every other AlsaPcm sibling in this match). + if let Some(audio) = ofd.audio() { + let buffer = audio + .hw_params + .as_ref() + .map(|h| h.buffer_size as i64) + .unwrap_or(0); + let appl = audio + .mmap_control + .as_ref() + .map(|c| c.appl_ptr) + .unwrap_or(0); + let hw_ptr = audio + .mmap_status + .as_ref() + .map(|s| s.hw_ptr) + .unwrap_or(0); + let avail_min = audio + .sw_params + .as_ref() + .map(|s| s.avail_min as i64) + .unwrap_or(1); + let avail = buffer - (appl - hw_ptr); + if pollfd.events & POLLOUT != 0 && avail >= avail_min { + revents |= POLLOUT; + } + if audio.state + == wasm_posix_shared::audio::SNDRV_PCM_STATE_XRUN + { + revents |= POLLERR; + } + } + // AlsaPcm is write-only — never report POLLIN. } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -14144,6 +14309,12 @@ pub fn sys_openat( }; } + // Paths that exist in the synthetic tree but are deliberately + // disabled (e.g. /dev/snd/pcmC0D0c — v1 ships playback only). + if let Some(errno) = disabled_virtual_device(&resolved) { + return Err(errno); + } + // Virtual device nodes — handle in-kernel, no host call if let Some(dev) = match_virtual_device(&resolved) { if dev == VirtualDevice::Fb0 { @@ -14183,6 +14354,7 @@ pub fn sys_openat( ); install_dri_state_on_open(proc, ofd_idx, dev); install_input_state_on_open(proc, ofd_idx, dev); + install_audio_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -14855,6 +15027,21 @@ pub fn sys_ioctl( } } + // --- /dev/snd/pcmC0D0p ioctls — ALSA SNDRV_PCM_* surface --- + { + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + if ofd.file_type == FileType::CharDevice + && matches!( + VirtualDevice::from_host_handle(ofd.host_handle), + Some(VirtualDevice::AlsaPcm { .. }) + ) + { + return crate::audio::pcm_ioctl::handle_alsa_pcm_ioctl( + proc, host, ofd_idx, request, buf, + ); + } + } + // --- Linux VT keyboard ioctls (KDGKBTYPE / KDGKBMODE / KDSKBMODE) --- // // fbDOOM (and other Linux-VT-targeted software) calls these on a @@ -28968,6 +29155,149 @@ mod tests { assert_eq!(pollfds[1].revents, 0); } + fn install_alsa_pcm_fd( + proc: &mut Process, + state: u32, + buffer_size: u64, + appl_ptr: i64, + hw_ptr: i64, + avail_min: u64, + ) -> i32 { + use crate::ofd::{AlsaFdState, FileType, HwParamsCache, PcmDir, SwParamsCache}; + use alloc::boxed::Box; + use wasm_posix_shared::audio::{WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus}; + + let host_handle = VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: PcmDir::Playback, + } + .host_handle(); + let ofd_idx = proc.ofd_table.create( + FileType::CharDevice, + O_WRONLY, + host_handle, + b"/dev/snd/pcmC0D0p".to_vec(), + ); + let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); + ofd.audio = Some(Box::new(AlsaFdState { + pcm_id: 0, + state, + hw_params: Some(Box::new(HwParamsCache { + buffer_size, + ..HwParamsCache::default() + })), + sw_params: Some(Box::new(SwParamsCache { + avail_min, + ..SwParamsCache::default() + })), + mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus { + hw_ptr, + ..WpkAlsaPcmMmapStatus::default() + })), + mmap_control: Some(Box::new(WpkAlsaPcmMmapControl { + appl_ptr, + ..WpkAlsaPcmMmapControl::default() + })), + ..AlsaFdState::default() + })); + proc.fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .expect("alloc fd") + } + + #[test] + fn test_poll_alsa_pcm_pollout_ready_when_avail_above_threshold() { + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; + use wasm_posix_shared::poll::*; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // appl=2000, hw=1000, buffer=4096 → avail=3096 >= avail_min=1024 ⇒ ready. + let fd = install_alsa_pcm_fd( + &mut proc, + SNDRV_PCM_STATE_RUNNING, + 4096, + 2000, + 1000, + 1024, + ); + + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; + let n = + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!(pollfd.revents & POLLERR, 0); + } + + #[test] + fn test_poll_alsa_pcm_pollout_not_ready_when_buffer_full() { + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; + use wasm_posix_shared::poll::*; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // appl=5000, hw=1000, buffer=4000 → avail=0 < avail_min=1 ⇒ not ready. + let fd = install_alsa_pcm_fd( + &mut proc, + SNDRV_PCM_STATE_RUNNING, + 4000, + 5000, + 1000, + 1, + ); + + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; + let n = + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + // sys_poll returns EAGAIN in centralized mode; in this single-shot + // test we run with default mode (non-centralized + timeout=0) so + // poll_check returns 0 ready ⇒ sys_poll Ok(0). + assert_eq!(n, 0); + assert_eq!(pollfd.revents & POLLOUT, 0); + } + + #[test] + fn test_poll_alsa_pcm_pollerr_set_on_xrun_state() { + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::audio::SNDRV_PCM_STATE_XRUN; + use wasm_posix_shared::poll::*; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // XRUN — POLLERR latches regardless of avail. + let fd = install_alsa_pcm_fd( + &mut proc, + SNDRV_PCM_STATE_XRUN, + 4096, + 2000, + 1000, + 1024, + ); + + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; + let n = + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLERR, 0); + } + #[test] fn test_lseek_seek_end_on_pipe() { let mut proc = Process::new(1); @@ -35107,6 +35437,13 @@ mod tests { VirtualDevice::DriCard0, VirtualDevice::InputEvent { device: 0 }, VirtualDevice::InputEvent { device: 1 }, + VirtualDevice::AlsaControl { card: 0 }, + VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + }, ] { assert_eq!( VirtualDevice::from_host_handle(dev.host_handle()), @@ -35115,8 +35452,8 @@ mod tests { } assert_eq!(VirtualDevice::from_host_handle(0), None); // First sentinel past the allocated range — must not roundtrip. - // -10 and -11 are now allocated for InputEvent{0,1}. - assert_eq!(VirtualDevice::from_host_handle(-12), None); + // -12 = AlsaControl{card:0}, -13 = AlsaPcm{0,0,0,Playback}. + assert_eq!(VirtualDevice::from_host_handle(-14), None); } // ===== Loopback socket tests ===== @@ -45370,6 +45707,137 @@ mod tests { assert!(r.is_err(), "/dev/input/event2 must NOT open as a virtual device"); } + #[test] + fn match_virtual_device_recognizes_alsa_paths() { + assert_eq!( + match_virtual_device(b"/dev/snd/controlC0"), + Some(VirtualDevice::AlsaControl { card: 0 }) + ); + assert_eq!( + match_virtual_device(b"/dev/snd/pcmC0D0p"), + Some(VirtualDevice::AlsaPcm { + card: 0, + device: 0, + sub: 0, + kind: crate::ofd::PcmDir::Playback, + }) + ); + // pcmC0D0c is "disabled", not "matched as a virtual device". + assert_eq!(match_virtual_device(b"/dev/snd/pcmC0D0c"), None); + // No additional cards/devices in v1. + assert_eq!(match_virtual_device(b"/dev/snd/controlC1"), None); + assert_eq!(match_virtual_device(b"/dev/snd/pcmC0D1p"), None); + } + + #[test] + fn open_pcm_playback_yields_audio_state_in_open_state() { + use wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN; + let mut proc = Process::new(701); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let st = ofd.audio().expect("audio sidecar should be installed"); + assert_eq!(st.kind, crate::ofd::PcmDir::Playback); + assert_eq!(st.state, SNDRV_PCM_STATE_OPEN); + assert!(st.hw_params.is_none()); + assert!(st.sw_params.is_none()); + assert!(st.mmap_status.is_none()); + assert!(st.mmap_control.is_none()); + assert_eq!(st.pcm_id, 0); + // PCM and control sidecars are disjoint state machines. + assert!(ofd.audio_ctl().is_none()); + assert!(ofd.dri_state.is_none()); + assert!(ofd.input_state.is_none()); + } + + #[test] + fn open_control_yields_audio_ctl_state() { + let mut proc = Process::new(702); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/snd/controlC0", O_RDWR, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let st = ofd.audio_ctl().expect("audio_ctl sidecar should be installed"); + assert_eq!(st.card, 0); + // The PCM sidecar must NOT be populated for a control open. + assert!(ofd.audio().is_none()); + } + + #[test] + fn open_pcm_capture_returns_enodev() { + let mut proc = Process::new(703); + let mut host = MockHostIO::new(); + let err = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0c", O_RDONLY, 0) + .expect_err("pcmC0D0c (capture) must not open in v1"); + assert_eq!(err, Errno::ENODEV); + } + + #[test] + fn open_pcm_is_multi_process_no_busy() { + // Unlike single-owner /dev/fb0 / /dev/dsp, ALSA PCM accepts + // multiple opens — every process attaches its own per-OFD + // state. (Cross-process arbitration is a future-plan concern.) + let mut proc1 = Process::new(704); + let mut proc2 = Process::new(705); + let mut host = MockHostIO::new(); + assert!(sys_open(&mut proc1, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).is_ok()); + assert!(sys_open(&mut proc2, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).is_ok()); + } + + #[test] + fn dup_inherits_audio_state_via_ofd_share() { + // Per-OFD audio state means dup-share works for free: two fds + // pointing at the same OFD see the same AlsaFdState. fork-time + // inheritance reuses this property once A7 wires audio fork + // serialisation. + use wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP; + let mut proc = Process::new(706); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); + let dup_fd = sys_dup(&mut proc, fd).unwrap(); + assert_ne!(fd, dup_fd); + + let dup_entry = proc.fd_table.get(dup_fd).unwrap(); + let dup_ofd_idx = dup_entry.ofd_ref.0; + { + let st = proc + .ofd_table + .get_mut(dup_ofd_idx) + .and_then(|o| o.audio_mut()) + .unwrap(); + st.state = SNDRV_PCM_STATE_SETUP; + } + + let orig_entry = proc.fd_table.get(fd).unwrap(); + assert_eq!(orig_entry.ofd_ref.0, dup_ofd_idx); + let st = proc + .ofd_table + .get(orig_entry.ofd_ref.0) + .and_then(|o| o.audio()) + .unwrap(); + assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); + } + + #[test] + fn fresh_open_of_pcm_yields_distinct_audio_state() { + use wasm_posix_shared::audio::{SNDRV_PCM_STATE_OPEN, SNDRV_PCM_STATE_SETUP}; + let mut proc = Process::new(707); + let mut host = MockHostIO::new(); + let fd1 = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); + let fd2 = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); + let ofd1_idx = proc.fd_table.get(fd1).unwrap().ofd_ref.0; + let ofd2_idx = proc.fd_table.get(fd2).unwrap().ofd_ref.0; + assert_ne!(ofd1_idx, ofd2_idx); + + proc.ofd_table.get_mut(ofd1_idx).unwrap().audio_mut().unwrap().state = + SNDRV_PCM_STATE_SETUP; + assert_eq!( + proc.ofd_table.get(ofd2_idx).and_then(|o| o.audio()).unwrap().state, + SNDRV_PCM_STATE_OPEN + ); + } + #[test] fn read_eventN_returns_zero_before_any_event() { let mut proc = Process::new(401); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index b3df35d589..8057624436 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13975,6 +13975,66 @@ pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { crate::input::set_canvas_dims(width, height); } +/// Bind a host-allocated SharedArrayBuffer to an ALSA PCM. `sab_base` +/// is the kernel-visible byte address of the SAB-imported window and +/// `sab_len` is its length. After this call, +/// `SNDRV_PCM_IOCTL_WRITEI_FRAMES` against any fd opened on +/// `/dev/snd/pcmC0Dp` lands frames into the SAB ring at +/// `appl_ptr % ring_frames` and advances `mmap_control.appl_ptr`. +/// +/// Errors (out-of-range `pcm_id`, already-registered slot) are +/// swallowed: a second `kernel_audio_init_sab` for the same PCM is a +/// no-op so the host can re-issue without un-registering first. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_audio_init_sab(pcm_id: u32, sab_base: u64, sab_len: u32) { + let _ = crate::audio::sab::register( + pcm_id, + crate::audio::sab::SabSlice { + base: sab_base as usize, + len: sab_len as usize, + }, + ); +} + +/// Called by the host on every AudioWorklet quantum (browser) or +/// `setInterval` tick (Node) after the host driver pulled +/// `frames_consumed` frames from the SAB ring. Walks every open +/// `/dev/snd/pcmC0Dp` OFD whose state is `STATE_RUNNING`, +/// advances `mmap_status.hw_ptr`, stamps the monotonic timestamp, +/// detects XRUN, and wakes POLLOUT waiters. +/// +/// The timestamp is fetched once via [`WasmHostIO::host_clock_gettime`] +/// and passed down so [`crate::audio::tick::tick`] stays testable +/// without a `HostIO`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_audio_period_tick(pcm_id: u32, frames_consumed: u32) { + let mut host = WasmHostIO; + let (tv_sec, tv_nsec) = + match host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_MONOTONIC) { + Ok((sec, nsec)) => (sec, nsec), + Err(_) => (0i64, 0i64), + }; + crate::audio::tick::tick(pcm_id, frames_consumed, tv_sec, tv_nsec); +} + +/// Return the current `mmap_control.appl_ptr` for any OFD bound to +/// `pcm_id` (the maximum across matching OFDs — in practice there is +/// at most one writer per PCM). The host's `BrowserAudioDriver` polls +/// this each AudioWorklet quantum and forwards the value into the +/// worklet so it can gate `hwPtr` advance: the worklet emits silence +/// past `appl_ptr` and only consumes ring positions userspace has +/// actually written. Without this, the worklet's `hwPtr` drifts ahead +/// of the kernel's appl_ptr during userspace setup latency and the +/// first chunks of audio (e.g. espeak-ng's "Welcome to" preamble) +/// land at ring offsets the worklet has already passed. +/// +/// Returns 0 if no OFD is bound to this `pcm_id`. Additive ABI; no +/// `ABI_VERSION` bump required (preserves existing exports). +#[unsafe(no_mangle)] +pub extern "C" fn kernel_audio_get_appl_ptr(pcm_id: u32) -> i64 { + crate::audio::tick::current_appl_ptr(pcm_id) +} + /// Number of successful page-flip commits on the given crtc. /// /// Useful for the host-side stats UI ("how many frames has the diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 66c5372e93..6eec033afa 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -850,6 +850,7 @@ pub enum Errno { EIDRM = 43, ENODATA = 61, EOVERFLOW = 75, + EBADFD = 77, ENOTSOCK = 88, EDESTADDRREQ = 89, EMSGSIZE = 90, @@ -920,6 +921,7 @@ impl Errno { 43 => Some(Errno::EIDRM), 61 => Some(Errno::ENODATA), 75 => Some(Errno::EOVERFLOW), + 77 => Some(Errno::EBADFD), 88 => Some(Errno::ENOTSOCK), 89 => Some(Errno::EDESTADDRREQ), 90 => Some(Errno::EMSGSIZE), @@ -5311,6 +5313,256 @@ pub mod input { } } +pub mod audio { + // --- PCM ioctl numbers ('A' magic, Linux UAPI verbatim) -------------- + + pub const SNDRV_PCM_IOCTL_PVERSION: u32 = 0x8004_4100; + pub const SNDRV_PCM_IOCTL_INFO: u32 = 0x8120_4101; + pub const SNDRV_PCM_IOCTL_HW_REFINE: u32 = 0xc260_4110; + pub const SNDRV_PCM_IOCTL_HW_PARAMS: u32 = 0xc260_4111; + pub const SNDRV_PCM_IOCTL_HW_FREE: u32 = 0x0000_4112; + pub const SNDRV_PCM_IOCTL_SW_PARAMS: u32 = 0xc088_4113; + pub const SNDRV_PCM_IOCTL_STATUS: u32 = 0x8080_4120; + pub const SNDRV_PCM_IOCTL_PREPARE: u32 = 0x0000_4140; + pub const SNDRV_PCM_IOCTL_START: u32 = 0x0000_4142; + pub const SNDRV_PCM_IOCTL_DROP: u32 = 0x0000_4143; + pub const SNDRV_PCM_IOCTL_PAUSE: u32 = 0x4004_4145; + pub const SNDRV_PCM_IOCTL_WRITEI_FRAMES: u32 = 0x4018_4150; + + // --- PCM state constants --------------------------------------------- + + pub const SNDRV_PCM_STATE_OPEN: u32 = 0; + pub const SNDRV_PCM_STATE_SETUP: u32 = 1; + pub const SNDRV_PCM_STATE_PREPARED: u32 = 2; + pub const SNDRV_PCM_STATE_RUNNING: u32 = 3; + pub const SNDRV_PCM_STATE_XRUN: u32 = 4; + pub const SNDRV_PCM_STATE_PAUSED: u32 = 6; + + // --- PCM format constants (S16_LE is v1's only support) -------------- + + pub const SNDRV_PCM_FORMAT_S16_LE: u32 = 2; + pub const SNDRV_PCM_FORMAT_S32_LE: u32 = 10; + pub const SNDRV_PCM_FORMAT_FLOAT_LE: u32 = 14; + + // --- PCM access constants -------------------------------------------- + + pub const SNDRV_PCM_ACCESS_MMAP_INTERLEAVED: u32 = 0; + pub const SNDRV_PCM_ACCESS_RW_INTERLEAVED: u32 = 3; + + // --- PCM stream direction -------------------------------------------- + + pub const SNDRV_PCM_STREAM_PLAYBACK: u32 = 0; + pub const SNDRV_PCM_STREAM_CAPTURE: u32 = 1; + + // --- MMAP offsets (passed to mmap(pcm_fd, ...) to select a page) ---- + + pub const SNDRV_PCM_MMAP_OFFSET_DATA: u64 = 0x0000_0000; + pub const SNDRV_PCM_MMAP_OFFSET_STATUS: u64 = 0x8000_0000; + pub const SNDRV_PCM_MMAP_OFFSET_CONTROL: u64 = 0x8100_0000; + + /// `struct snd_interval` — value-range descriptor inside + /// `snd_pcm_hw_params.intervals[]`. Linux packs four flag bits + /// (openmin / openmax / integer / empty) into a trailing u32; we + /// store them as a plain u32 to match Linux's 12-byte UAPI size. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkSndInterval { + pub min: u32, + pub max: u32, + /// Bit 0 = openmin, 1 = openmax, 2 = integer, 3 = empty. + pub flags: u32, + } + + /// `struct snd_pcm_hw_params`. Layout-locked against Linux v6.10 + /// `include/uapi/sound/asound.h`; `masks[64]` covers the 3 active + + /// 5 reserved snd_masks (each is u32[8]), `intervals[21]` covers + /// the 12 active + 9 reserved snd_intervals. Phase C's vendored + /// `` mirrors this byte-for-byte. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmHwParams { + pub flags: u32, + pub masks: [u32; 64], + pub intervals: [WpkSndInterval; 21], + pub rmask: u32, + pub cmask: u32, + pub info: u32, + pub msbits: u32, + pub rate_num: u32, + pub rate_den: u32, + pub fifo_size: u64, + pub reserved: [u8; 64], + } + + impl Default for WpkAlsaPcmHwParams { + fn default() -> Self { + Self { + flags: 0, + masks: [0; 64], + intervals: [WpkSndInterval::default(); 21], + rmask: 0, + cmask: 0, + info: 0, + msbits: 0, + rate_num: 0, + rate_den: 0, + fifo_size: 0, + reserved: [0; 64], + } + } + } + + /// `struct snd_pcm_sw_params`. Layout-locked against Linux v6.10. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmSwParams { + pub tstamp_mode: u32, + pub period_step: u32, + pub sleep_min: u32, + pub _pad0: u32, + pub avail_min: u64, + pub xfer_align: u64, + pub start_threshold: u64, + pub stop_threshold: u64, + pub silence_threshold: u64, + pub silence_size: u64, + pub boundary: u64, + pub proto: u32, + pub tstamp_type: u32, + pub reserved: [u8; 56], + } + + impl Default for WpkAlsaPcmSwParams { + fn default() -> Self { + Self { + tstamp_mode: 0, + period_step: 0, + sleep_min: 0, + _pad0: 0, + avail_min: 0, + xfer_align: 0, + start_threshold: 0, + stop_threshold: 0, + silence_threshold: 0, + silence_size: 0, + boundary: 0, + proto: 0, + tstamp_type: 0, + reserved: [0; 56], + } + } + } + + /// `struct snd_pcm_status`. All timestamps stamped from + /// `CLOCK_MONOTONIC` so userspace can correlate audio underruns + /// with vblank + input timestamps. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaPcmStatus { + pub state: u32, + pub _pad0: u32, + pub trigger_tstamp_sec: i64, + pub trigger_tstamp_nsec: i64, + pub tstamp_sec: i64, + pub tstamp_nsec: i64, + pub appl_ptr: i64, + pub hw_ptr: i64, + pub delay: i64, + pub avail: u64, + pub avail_max: u64, + pub overrange: u64, + pub suspended_state: u32, + pub audio_tstamp_data: u32, + pub audio_tstamp_sec: i64, + pub audio_tstamp_nsec: i64, + pub _reserved: [u8; 16], + } + + /// `struct snd_pcm_info`. Returned by `SNDRV_PCM_IOCTL_INFO`. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct WpkAlsaPcmInfo { + pub device: u32, + pub subdevice: u32, + pub stream: i32, + pub card: i32, + pub id: [u8; 64], + pub name: [u8; 80], + pub subname: [u8; 32], + pub dev_class: u32, + pub dev_subclass: u32, + pub subdevices_count: u32, + pub subdevices_avail: u32, + pub sync: [u8; 16], + pub reserved: [u8; 64], + } + + impl Default for WpkAlsaPcmInfo { + fn default() -> Self { + Self { + device: 0, + subdevice: 0, + stream: 0, + card: 0, + id: [0; 64], + name: [0; 80], + subname: [0; 32], + dev_class: 0, + dev_subclass: 0, + subdevices_count: 0, + subdevices_avail: 0, + sync: [0; 16], + reserved: [0; 64], + } + } + } + + /// `struct snd_pcm_mmap_status`. Kernel-writes, userspace-reads. + /// Mapped at `SNDRV_PCM_MMAP_OFFSET_STATUS`. Field offsets are + /// load-bearing — userspace reads `hw_ptr` via direct memory access + /// on the mapped page, not through an ioctl. + #[repr(C)] + #[derive(Clone, Copy, Default, Debug)] + pub struct WpkAlsaPcmMmapStatus { + pub state: u32, + pub _pad0: u32, + pub hw_ptr: i64, + pub tstamp_sec: i64, + pub tstamp_nsec: i64, + pub suspended_state: u32, + pub audio_tstamp_data: u32, + pub audio_tstamp_sec: i64, + pub audio_tstamp_nsec: i64, + pub _reserved_tail: [u8; 8], + } + + /// `struct snd_pcm_mmap_control`. Userspace-writes, kernel-reads. + /// Mapped at `SNDRV_PCM_MMAP_OFFSET_CONTROL`. + #[repr(C)] + #[derive(Clone, Copy, Debug)] + pub struct WpkAlsaPcmMmapControl { + pub appl_ptr: i64, + pub avail_min: i64, + pub _reserved: [u8; 48], + } + + impl Default for WpkAlsaPcmMmapControl { + fn default() -> Self { + Self { appl_ptr: 0, avail_min: 0, _reserved: [0; 48] } + } + } + + /// `struct snd_xferi` — argument to `WRITEI_FRAMES` / `READI_FRAMES`. + #[repr(C)] + #[derive(Clone, Copy, Default)] + pub struct WpkAlsaXferi { + pub result: i64, + pub buf: u64, + pub frames: u64, + } + +} + #[cfg(test)] mod dri_tests { use super::dri::*; @@ -5721,3 +5973,77 @@ mod input_tests { assert_eq!(EVIOCGABS_NR_BASE, 0x40); } } + +#[cfg(test)] +mod audio_tests { + use super::audio::*; + use core::mem::size_of; + + const fn ioc(dir: u32, magic: u32, nr: u32, size: u32) -> u32 { + (dir << 30) | (size << 16) | (magic << 8) | nr + } + const IOC_READ: u32 = 2; + const IOC_WRITE: u32 = 1; + const IOC_RW: u32 = 3; + + #[test] + fn audio_struct_sizes_match_wasm32_repr_c() { + // These numbers lock the wasm32 `repr(C)` layout; Phase C's + // vendored `` mirrors them byte-for-byte. + // HwParams = 608 follows Linux v6.10: snd_mask[8] (= u32[64]) + // + snd_interval[21] (= 12 bytes each) + 6 u32s + the 4-byte + // trailing-u32 pad Rust inserts before fifo_size + reserved[64]. + assert_eq!(size_of::(), 12); + assert_eq!(size_of::(), 608); + assert_eq!(size_of::(), 136); + assert_eq!(size_of::(), 128); + assert_eq!(size_of::(), 288); + assert_eq!(size_of::(), 64); + assert_eq!(size_of::(), 64); + assert_eq!(size_of::(), 24); + } + + #[test] + fn audio_mmap_status_field_offsets() { + // The mmap_status page is read by userspace via direct memory + // access — userspace polls hw_ptr without an ioctl round-trip. + let s = WpkAlsaPcmMmapStatus::default(); + let base = (&s as *const _) as usize; + assert_eq!((&s.state as *const _ as usize) - base, 0); + assert_eq!((&s.hw_ptr as *const _ as usize) - base, 8); + assert_eq!((&s.tstamp_sec as *const _ as usize) - base, 16); + } + + #[test] + fn audio_mmap_control_field_offsets() { + let c = WpkAlsaPcmMmapControl::default(); + let base = (&c as *const _) as usize; + assert_eq!((&c.appl_ptr as *const _ as usize) - base, 0); + assert_eq!((&c.avail_min as *const _ as usize) - base, 8); + } + + #[test] + fn pcm_ioctl_numbers_match_linux_uapi() { + assert_eq!( + SNDRV_PCM_IOCTL_PVERSION, + ioc(IOC_READ, 'A' as u32, 0x00, 4) + ); + assert_eq!( + SNDRV_PCM_IOCTL_HW_PARAMS, + ioc(IOC_RW, 'A' as u32, 0x11, size_of::() as u32) + ); + assert_eq!( + SNDRV_PCM_IOCTL_SW_PARAMS, + ioc(IOC_RW, 'A' as u32, 0x13, size_of::() as u32) + ); + assert_eq!( + SNDRV_PCM_IOCTL_STATUS, + ioc(IOC_READ, 'A' as u32, 0x20, size_of::() as u32) + ); + assert_eq!( + SNDRV_PCM_IOCTL_WRITEI_FRAMES, + ioc(IOC_WRITE, 'A' as u32, 0x50, size_of::() as u32) + ); + } + +} diff --git a/host/src/audio/audio-driver.ts b/host/src/audio/audio-driver.ts new file mode 100644 index 0000000000..2a9f612bdf --- /dev/null +++ b/host/src/audio/audio-driver.ts @@ -0,0 +1,48 @@ +/** + * `AudioDriver` — host-side abstraction over an ALSA PCM consumer. + * One implementation per host: `BrowserAudioDriver` pulls samples on + * each AudioWorklet quantum and routes them to a `WebAudio` + * `AudioContext`; `NodeAudioDriver` is a `setInterval`-driven dummy + * for headless tests. The host wires `start` after the kernel has + * exported a SAB-backed ring via `kernel_audio_init_sab`, and routes + * `kernelTick` (`kernel.exports.kernel_audio_period_tick`) back into + * the kernel once per ALSA period — same shape Linux's hw_ptr + * advancement uses. + */ + +/** Where the SAB ring lives in kernel-visible memory. `buffer` is the + * kernel's WebAssembly.Memory backing store (a `SharedArrayBuffer` in + * the shared-memory build); `byteOffset` + `byteLength` cover the + * region the kernel registered via `kernel_audio_init_sab`. */ +export interface AudioRing { + buffer: SharedArrayBuffer | ArrayBuffer; + byteOffset: number; + byteLength: number; +} + +export interface AudioDriver { + /** Begin pulling frames from the SAB ring registered for `pcmId`. + * `kernelTick` is the bound `kernel.exports.kernel_audio_period_tick` + * proxy; the driver invokes it once `periodFrames` worth of frames + * have been consumed so the kernel can advance `mmap_status.hw_ptr` + * and wake POLLOUT waiters. Idempotent: calling `start` again with + * the same `pcmId` is a no-op. */ + start( + pcmId: number, + sampleRate: number, + channels: number, + periodFrames: number, + ring: AudioRing, + kernelTick: (pcmId: number, framesConsumed: number) => void, + /** Bound `kernel.exports.kernel_audio_get_appl_ptr` proxy. The + * browser driver polls this each AudioWorklet quantum and forwards + * the value into the worklet so it can gate `hwPtr` advance — + * silence past `appl_ptr`, advance only over written ring + * positions. Headless drivers (NodeAudioDriver) accept this for + * dual-host signature parity and ignore the value. */ + getApplPtr: (pcmId: number) => number, + ): Promise; + + /** Stop pulling; tear down audio context / clear timers. Idempotent. */ + stop(pcmId: number): void; +} diff --git a/host/src/audio/browser-audio-driver.ts b/host/src/audio/browser-audio-driver.ts new file mode 100644 index 0000000000..51c8f60747 --- /dev/null +++ b/host/src/audio/browser-audio-driver.ts @@ -0,0 +1,183 @@ +/** + * `BrowserAudioDriver` — pulls S16-interleaved frames from a + * kernel-memory SAB ring on every `AudioWorklet` quantum (128 frames) + * and routes them to a `WebAudio` `AudioContext` for playback. After + * every `periodFrames` worth of consumed frames it invokes the bound + * `kernel_audio_period_tick` proxy so the kernel can advance + * `mmap_status.hw_ptr` and wake `POLLOUT` waiters. + * + * The worklet runs on the audio thread and can't call kernel exports + * directly; instead it posts a `{ framesConsumed }` message on each + * quantum. The main thread accumulates those quanta and calls + * `kernelTick` once per ALSA period. + * + * The ring lives inside the kernel's `WebAssembly.Memory` (a + * `SharedArrayBuffer` in the shared-memory build) so the worklet and + * the kernel see the same bytes. The kernel registered the + * `(base, len)` window via `kernel_audio_init_sab` at boot; the host + * just forwards it into the worklet's `processorOptions`. + */ + +import type { AudioDriver, AudioRing } from "./audio-driver.js"; + +/** Public for tests: URL the worklet processor is registered at. + * Apps embedding this driver are expected to host the worklet js at + * this path (the file ships next to this module). */ +export const WPK_AUDIO_WORKLET_URL = "/audio/wpk-audio-worklet.js"; + +interface PcmContext { + audioCtx: AudioContext; + worklet: AudioWorkletNode; + ring: AudioRing; + sampleRate: number; + channels: number; + periodFrames: number; + framesSinceTick: number; + /** Cumulative frames played by the worklet (sum of all per-quantum + * `framesConsumed`). Used by `stop()` to estimate how much tail is + * still buffered in the ring so it can wait that long before closing + * the AudioContext — without this, the last word of a phrase gets + * truncated. */ + totalFramesConsumed: number; + /** Latest `appl_ptr` posted to the worklet (also the producer + * upper-bound — when `totalFramesConsumed` reaches `lastApplPtr`, + * playback has caught up). */ + lastApplPtr: number; + kernelTick: (pcmId: number, frames: number) => void; + getApplPtr: (pcmId: number) => number; + /** Poll handle that pushes the latest `appl_ptr` into the worklet so + * the worklet emits silence past producer progress instead of racing + * ahead during userspace setup latency (e.g. espeak-ng's data-file + * load before its first WRITEI). */ + applPtrPollHandle: ReturnType; +} + +export class BrowserAudioDriver implements AudioDriver { + private contexts = new Map(); + + constructor(private workletUrl: string = WPK_AUDIO_WORKLET_URL) {} + + async start( + pcmId: number, + sampleRate: number, + channels: number, + periodFrames: number, + ring: AudioRing, + kernelTick: (pcmId: number, framesConsumed: number) => void, + getApplPtr: (pcmId: number) => number, + ): Promise { + if (this.contexts.has(pcmId)) return; + + const audioCtx = new AudioContext({ sampleRate }); + await audioCtx.audioWorklet.addModule(this.workletUrl); + const worklet = new AudioWorkletNode(audioCtx, "wpk-pcm-pull", { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [channels], + processorOptions: { + buffer: ring.buffer, + byteOffset: ring.byteOffset, + byteLength: ring.byteLength, + channels, + }, + }); + worklet.connect(audioCtx.destination); + + // Poll appl_ptr on a 10 ms interval and push to the worklet. The + // worklet uses it to gate `hwPtr` advance so it never advances + // past producer progress — without this, the worklet's local + // `hwPtr` ticks at AudioContext rate from the moment the node + // connects (~5 ms after this call), and any userspace setup + // latency before the first WRITEI (espeak-ng spends ~500 ms + // loading data files before its first synth chunk lands) becomes + // a chunk of the head audio buried at ring offsets the worklet + // has already passed. + const ctx: PcmContext = { + audioCtx, + worklet, + ring, + sampleRate, + channels, + periodFrames, + framesSinceTick: 0, + totalFramesConsumed: 0, + lastApplPtr: 0, + kernelTick, + getApplPtr, + // Filled in below — declared before setInterval so the callback + // can reference `ctx` without a temporal-dead-zone error. + applPtrPollHandle: 0 as unknown as ReturnType, + }; + ctx.applPtrPollHandle = setInterval(() => { + const applPtr = getApplPtr(pcmId); + ctx.lastApplPtr = applPtr; + worklet.port.postMessage({ applPtr }); + }, 10); + worklet.port.onmessage = ( + e: MessageEvent<{ framesConsumed?: number }>, + ) => { + const data = e.data; + if (typeof data.framesConsumed !== "number") return; + ctx.framesSinceTick += data.framesConsumed; + ctx.totalFramesConsumed += data.framesConsumed; + while (ctx.framesSinceTick >= ctx.periodFrames) { + ctx.kernelTick(pcmId, ctx.periodFrames); + ctx.framesSinceTick -= ctx.periodFrames; + } + }; + this.contexts.set(pcmId, ctx); + } + + /** + * Drain the buffered tail before closing the AudioContext. When + * `stop()` is called, the kernel's `appl_ptr` typically leads the + * worklet's `totalFramesConsumed` by up to one ring's worth of + * frames — userspace `WRITEI` can fill ahead of realtime playback up + * to the SAB ring capacity. Closing the AudioContext immediately + * truncates that tail. Instead, we keep polling appl_ptr until it + * stops growing (producer done), then sleep just long enough for the + * worklet to play the residual delta, and only then close. + * Synchronous return — the actual teardown happens on a timer. + */ + stop(pcmId: number): void { + const ctx = this.contexts.get(pcmId); + if (!ctx) return; + this.contexts.delete(pcmId); + // Stop pushing applPtr into the worklet; the worklet keeps the + // last value we sent and plays out to it. We still need to read + // applPtr from the kernel a few more times to confirm the + // producer is done, then wait for the consumer to catch up. + clearInterval(ctx.applPtrPollHandle); + + const finalApplPtr = ctx.getApplPtr(pcmId); + if (finalApplPtr > ctx.lastApplPtr) { + ctx.lastApplPtr = finalApplPtr; + // Push the final value so the worklet can play it out. + ctx.worklet.port.postMessage({ applPtr: finalApplPtr }); + } + + const close = () => { + ctx.worklet.port.onmessage = null; + ctx.worklet.disconnect(); + void ctx.audioCtx.close(); + }; + + const pending = Math.max(0, ctx.lastApplPtr - ctx.totalFramesConsumed); + if (pending === 0) { + close(); + return; + } + // Wait for the worklet to play out the pending frames at audio + // rate, plus a 100 ms safety margin. The margin covers the + // browser's AudioContext output-queue latency (the worklet + // posts `framesConsumed` immediately, but the samples then sit + // in the platform audio buffer for a browser-dependent interval + // before the speaker emits them). 100 ms is empirically + // sufficient on Chrome/Safari/Firefox for the espeak demo; the + // worklet quantum (128 frames ≈ 2.67 ms @ 48 kHz) is far + // smaller than this margin. `applPtr` is stable at this point + // — espeak-ng's drain has already returned, so we don't re-poll. + const drainMs = (pending / ctx.sampleRate) * 1000 + 100; + setTimeout(close, drainMs); + } +} diff --git a/host/src/audio/instrumented-audio-driver.ts b/host/src/audio/instrumented-audio-driver.ts new file mode 100644 index 0000000000..d38204b865 --- /dev/null +++ b/host/src/audio/instrumented-audio-driver.ts @@ -0,0 +1,56 @@ +/** + * Wraps any `AudioDriver` so callers can observe accumulated frames + * played by the underlying worklet without reaching inside the + * driver. The Playwright spec for `/?demo=espeak` uses this to assert + * non-zero playback (`window.__alsaFramesConsumed`). + * + * The forwarding has bit us once: session 42 shipped a wrapper whose + * `start()` dropped the new `getApplPtr` parameter when calling + * `inner.start()`, silently disabling the producer-pointer gate that + * prevents head-truncation. The regression spec + * (`host/test/instrumented-audio-driver.test.ts`) pins forwarding of + * every argument the `AudioDriver` interface declares. + */ +import type { AudioDriver, AudioRing } from "./audio-driver.js"; + +export interface InstrumentedAudioDriver extends AudioDriver { + framesConsumed(): number; +} + +export function instrumentAudioDriver( + inner: AudioDriver, + onFramesConsumed?: (frames: number, total: number) => void, +): InstrumentedAudioDriver { + let total = 0; + return { + async start( + pcmId: number, + sampleRate: number, + channels: number, + periodFrames: number, + ring: AudioRing, + kernelTick: (id: number, frames: number) => void, + getApplPtr: (id: number) => number, + ): Promise { + await inner.start( + pcmId, + sampleRate, + channels, + periodFrames, + ring, + (id, frames) => { + total += frames; + onFramesConsumed?.(frames, total); + kernelTick(id, frames); + }, + getApplPtr, + ); + }, + stop(pcmId: number): void { + inner.stop(pcmId); + }, + framesConsumed(): number { + return total; + }, + }; +} diff --git a/host/src/audio/node-audio-driver.ts b/host/src/audio/node-audio-driver.ts new file mode 100644 index 0000000000..8c62594579 --- /dev/null +++ b/host/src/audio/node-audio-driver.ts @@ -0,0 +1,53 @@ +/** + * `NodeAudioDriver` — headless dummy that schedules a `setInterval` + * matching the ALSA period cadence and calls the bound + * `kernel_audio_period_tick` proxy each fire. There's no real audio + * sink on Node; the SAB ring is consumed in name only — the kernel + * uses the period_tick to advance `mmap_status.hw_ptr` and wake + * `POLLOUT` waiters so userspace can keep writing. + * + * Used by Vitest's audio specs and by any kandelo CLI run that + * exercises ALSA-shaped programs without a WebAudio output. Mirrors + * `BrowserAudioDriver` so the Node + browser host init paths stay + * symmetric per CLAUDE.md §"Two hosts". + */ + +import type { AudioDriver, AudioRing } from "./audio-driver.js"; + +interface PcmTimer { + intervalHandle: ReturnType; + ring: AudioRing; + periodFrames: number; +} + +export class NodeAudioDriver implements AudioDriver { + private timers = new Map(); + + async start( + pcmId: number, + sampleRate: number, + _channels: number, + periodFrames: number, + ring: AudioRing, + kernelTick: (pcmId: number, framesConsumed: number) => void, + // Headless driver has no AudioWorklet to gate — the kernel-side + // hw_ptr advance is what `kernelTick` drives. Kept in the signature + // for dual-host parity per `AudioDriver`. + _getApplPtr: (pcmId: number) => number, + ): Promise { + if (this.timers.has(pcmId)) return; + const intervalMs = (periodFrames * 1000) / sampleRate; + const handle = setInterval( + () => kernelTick(pcmId, periodFrames), + intervalMs, + ); + this.timers.set(pcmId, { intervalHandle: handle, ring, periodFrames }); + } + + stop(pcmId: number): void { + const t = this.timers.get(pcmId); + if (!t) return; + clearInterval(t.intervalHandle); + this.timers.delete(pcmId); + } +} diff --git a/host/src/audio/wpk-audio-worklet.js b/host/src/audio/wpk-audio-worklet.js new file mode 100644 index 0000000000..819745f864 --- /dev/null +++ b/host/src/audio/wpk-audio-worklet.js @@ -0,0 +1,78 @@ +/** + * `wpk-pcm-pull` — AudioWorklet processor that reads S16-interleaved + * frames from a kernel-memory ring (a SharedArrayBuffer slice exposed + * via `kernel_audio_init_sab`) and pushes them onto the AudioContext + * output bus. + * + * Producer/consumer gating: the worklet's local `hwPtr` is monotonic + * (absolute frame count since attach). On every quantum it consumes + * up to 128 frames, but never past the kernel's `appl_ptr` — the + * BrowserAudioDriver polls `kernel_audio_get_appl_ptr` on a 10 ms + * interval and posts the value via `{ applPtr }`. Frames past + * `appl_ptr` emit silence and don't advance `hwPtr`. The kernel-side + * `kernel_audio_period_tick` is driven by `framesConsumed` so the + * kernel only advances `mmap_status.hw_ptr` by frames the worklet + * actually played — non-RUNNING / non-written quanta don't count + * against avail, and no spurious XRUN fires. + * + * This gating fixes the head-truncation race observed in espeak-ng: + * the worklet starts immediately on attach, but espeak-ng's ~500 ms + * data-file load + synth init means the kernel's appl_ptr stays at + * 0 for the first ~11 000 frames at 22 050 Hz. Without gating, the + * worklet's hwPtr ticks past those ring offsets, so when the first + * WRITEI lands at ring[0..], the worklet has already passed them + * and won't revisit until ring wraparound — the head of the + * synthesised phrase is buried. + */ + +class WpkPcmPullProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const { buffer, byteOffset, byteLength, channels } = + options.processorOptions; + // s16 interleaved view onto the kernel ring window. + this.ring = new Int16Array(buffer, byteOffset, byteLength / 2); + this.ringFrames = this.ring.length / channels; + this.channels = channels; + // Absolute frame count consumed; modulo ringFrames when indexing + // into the SAB. + this.hwPtr = 0; + // Latest producer position posted by BrowserAudioDriver. The + // worklet never reads or advances past this. + this.applPtr = 0; + this.port.onmessage = (e) => { + const data = e.data; + if (data && typeof data.applPtr === "number") { + this.applPtr = data.applPtr; + } + }; + } + + process(_inputs, outputs) { + const out = outputs[0]; // out[channel][sample] + const frames = out[0].length; // always 128 + const ringFrames = this.ringFrames; + const ch = this.channels; + const ring = this.ring; + const applPtr = this.applPtr; + const hw = this.hwPtr; + // Number of frames available to consume this quantum (capped by + // producer progress, never negative). + const available = Math.max(0, Math.min(frames, applPtr - hw)); + for (let f = 0; f < available; f++) { + const ringOff = ((hw + f) % ringFrames) * ch; + for (let c = 0; c < ch; c++) { + // s16 → f32 conversion for the WebAudio output bus. + out[c][f] = ring[ringOff + c] / 0x8000; + } + } + for (let f = available; f < frames; f++) { + for (let c = 0; c < ch; c++) out[c][f] = 0; + } + this.hwPtr = hw + available; + this.port.postMessage({ framesConsumed: available }); + return true; + } +} + +registerProcessor("wpk-pcm-pull", WpkPcmPullProcessor); diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 7e86794e2e..733818e4dd 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -25,6 +25,7 @@ import { validateBrowserCorsProxyConfig, } from "./networking/browser-cors-proxy"; import type { InputSource } from "./input/input-source"; +import type { AudioDriver, AudioRing } from "./audio/audio-driver"; export type { HttpRequest, HttpResponse }; import workerEntryUrl from "./worker-entry-browser.ts?worker&url"; @@ -1109,6 +1110,120 @@ export class BrowserKernel { ); } + /** + * Allocate a kernel-memory SAB ring for `pcmId` of `byteLen` bytes + * and bind it via `kernel_audio_init_sab`. Returns the ring window + * the host AudioDriver should view as `Int16Array(buffer, offset, …)`. + * Mirrors the Node-side method of the same name — dual-host parity + * per CLAUDE.md §"Two hosts". + */ + async audioAllocRing(pcmId: number, byteLen: number): Promise { + const requestId = this.nextRequestId++; + const result = await this.request(requestId, { + type: "audio_alloc_ring", + requestId, + pcmId, + byteLen, + }); + return result as AudioRing; + } + + /** + * Fire-and-forget period tick into the kernel. The + * `BrowserAudioDriver` invokes this from its worklet-message handler + * once per ALSA period boundary; the kernel advances + * `mmap_status.hw_ptr` and wakes any `POLLOUT` waiter parked on + * `/dev/snd/pcmC0Dp`. + */ + audioPeriodTick(pcmId: number, framesConsumed: number): void { + this.sendToKernel({ + type: "audio_period_tick", + pcmId, + framesConsumed, + }); + } + + /** + * Read the current `mmap_control.appl_ptr` for any OFD bound to + * `pcmId`. Polled by `BrowserAudioDriver` to gate the worklet's + * `hwPtr` advance on producer progress. The async hop to the + * kernel worker resolves in ~1–5 ms; the driver polls every 10 ms. + */ + async audioGetApplPtr(pcmId: number): Promise { + const requestId = this.nextRequestId++; + const result = await this.request(requestId, { + type: "audio_get_appl_ptr", + requestId, + pcmId, + }); + return result as number; + } + + /** + * Wire an `AudioDriver` into the kernel: allocates a SAB ring, + * registers it, then starts the driver with a `kernelTick` callback + * that funnels each period boundary into `kernel_audio_period_tick`. + * Mirrors `NodeKernelHost.attachAudioDriver` — dual-host parity per + * CLAUDE.md §"Two hosts". + * + * On the browser the driver is a `BrowserAudioDriver` which spins up + * an `AudioContext` + `AudioWorkletNode` to pull samples on each + * quantum. The worklet posts `framesConsumed` back here per quantum; + * the driver accumulates to a period and ticks the kernel. + */ + async attachAudioDriver( + driver: AudioDriver, + opts: { + pcmId?: number; + sampleRate?: number; + channels?: number; + periodFrames?: number; + ringBytes?: number; + } = {}, + ): Promise { + const pcmId = opts.pcmId ?? 0; + const sampleRate = opts.sampleRate ?? 48_000; + const channels = opts.channels ?? 2; + const periodFrames = opts.periodFrames ?? 1024; + const ringBytes = opts.ringBytes ?? 64 * 1024; + const ring = await this.audioAllocRing(pcmId, ringBytes); + // The browser driver wants a synchronous returns-number callback + // (no awaits inside the AudioWorklet `process()` call path) — but + // the kernel runs in a worker, so `audioGetApplPtr` is async. We + // cache the latest value here and refresh it on each invocation; + // returns the previous cached value while the next request is in + // flight. The cache always converges within one poll interval + // (10 ms) which is far below one ALSA period (~46 ms @ 22050 Hz), + // so the worklet sees fresh enough producer progress. + let cachedApplPtr = 0; + let inFlight = false; + const getApplPtr = (id: number): number => { + if (!inFlight) { + inFlight = true; + this.audioGetApplPtr(id) + .then((v) => { + cachedApplPtr = v; + }) + .catch(() => { + /* swallow — keep last good value */ + }) + .finally(() => { + inFlight = false; + }); + } + return cachedApplPtr; + }; + await driver.start( + pcmId, + sampleRate, + channels, + periodFrames, + ring, + (id, frames) => this.audioPeriodTick(id, frames), + getApplPtr, + ); + } + /** * Hand an `OffscreenCanvas` to the kernel worker as the scanout * target for KMS CRTC `crtcId`. The worker's vblank pump blits the diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 0ac99e8d46..702a8301b5 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -353,6 +353,52 @@ export interface AudioDrainMessage { maxBytes: number; } +/** + * Main-thread → kernel-worker request to allocate a kernel-memory + * SAB ring for `pcmId` of `byteLen` bytes and bind it via + * `kernel_audio_init_sab`. The worker replies via `ResponseMessage` + * with `{ buffer, byteOffset, byteLength }` so the main-thread + * AudioDriver can mount an `Int16Array` view at the same offset. + * Mirrors the Node-side message of the same name — dual-host parity + * per CLAUDE.md §"Two hosts". + */ +export interface AudioAllocRingRequestMessage { + type: "audio_alloc_ring"; + requestId: number; + pcmId: number; + byteLen: number; +} + +/** + * Main-thread → kernel-worker period tick. The main-thread + * `BrowserAudioDriver` accumulates AudioWorklet quanta until one ALSA + * period's worth of frames is consumed, then sends this message. The + * worker calls `kernel_audio_period_tick` which advances + * `mmap_status.hw_ptr`, detects XRUN, and wakes any `POLLOUT` waiter + * parked on `/dev/snd/pcmC0Dp`. Fire-and-forget. + */ +export interface AudioPeriodTickMessage { + type: "audio_period_tick"; + pcmId: number; + framesConsumed: number; +} + +/** + * Main-thread → kernel-worker request to read the current + * `mmap_control.appl_ptr` for any OFD bound to `pcmId`. The + * `BrowserAudioDriver` polls this on a 10 ms interval and forwards + * the result into the `wpk-pcm-pull` AudioWorklet so the worklet + * gates `hwPtr` advance on producer progress (silence past + * `appl_ptr`). The worker replies via `ResponseMessage` with a + * `number` (i64 truncated through `Number()` — within JS safe-int + * range for any realistic session). Returns 0 if no OFD is bound. + */ +export interface AudioGetApplPtrRequestMessage { + type: "audio_get_appl_ptr"; + requestId: number; + pcmId: number; +} + export interface RegisterLazyArchivesMessage { type: "register_lazy_archives"; requestId?: number; @@ -493,6 +539,9 @@ export type MainToKernelMessage = | InputEventInjectMessage | SetInputCanvasDimsMessage | AudioDrainMessage + | AudioAllocRingRequestMessage + | AudioPeriodTickMessage + | AudioGetApplPtrRequestMessage | EnumProcsRequestMessage | ReadProcMapsRequestMessage | SetSyscallTraceMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 8bb2d8dfb0..d563645663 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -4415,6 +4415,21 @@ sw.onmessage = (e: MessageEvent) => { case "set_input_canvas_dims": kernelWorker.setInputCanvasDims(msg.width, msg.height); break; + case "audio_alloc_ring": { + const ring = kernelWorker.audioInitRing(msg.pcmId, msg.byteLen); + if (!ring) { + respondError(msg.requestId, "audio_alloc_ring: kernel allocator declined"); + } else { + respond(msg.requestId, ring); + } + break; + } + case "audio_period_tick": + kernelWorker.audioPeriodTick(msg.pcmId, msg.framesConsumed); + break; + case "audio_get_appl_ptr": + respond(msg.requestId, kernelWorker.audioGetApplPtr(msg.pcmId)); + break; default: { // Every typed MainToKernelMessage must have a case above. Browser // tooling also sends a few deliberately out-of-band control messages, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index f7c29c811c..8bf12d3368 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30472,6 +30472,50 @@ export class CentralizedKernelWorker { ); } + /** + * Allocate a kernel-memory window for an ALSA PCM SAB ring of + * `byteLen` bytes and bind it to `pcmId`. Returns the kernel-memory + * `(byteOffset, byteLength)` pair plus the underlying kernel + * `SharedArrayBuffer`, so the host-thread AudioDriver can view it + * with `new Int16Array(buffer, byteOffset, byteLength / 2)`. Returns + * `null` if the kernel allocator declined. + */ + audioInitRing( + pcmId: number, + byteLen: number, + ): { + buffer: SharedArrayBuffer | ArrayBuffer; + byteOffset: number; + byteLength: number; + } | null { + const base = this.kernel.audioAllocRing(byteLen); + if (base === 0) return null; + this.kernel.audioInitSab(pcmId, base, byteLen); + const buffer = this.kernel.getKernelMemoryBuffer(); + if (!buffer) return null; + return { buffer, byteOffset: base, byteLength: byteLen }; + } + + /** + * Forward an audio period tick from the host AudioDriver into the + * kernel: advances `mmap_status.hw_ptr`, detects XRUN, wakes any + * process parked on `POLLOUT` against `/dev/snd/pcmC0Dp`. + */ + audioPeriodTick(pcmId: number, framesConsumed: number): void { + this.kernel.audioPeriodTick(pcmId, framesConsumed); + this.scheduleWakeBlockedRetries(); + } + + /** + * Return the current `mmap_control.appl_ptr` for any OFD bound to + * `pcmId`. The browser AudioDriver polls this each 10 ms and pushes + * the value into the AudioWorklet so the worklet gates `hwPtr` + * advance on producer progress. + */ + audioGetApplPtr(pcmId: number): number { + return this.kernel.audioGetApplPtr(pcmId); + } + /** * ABI version the kernel advertised at startup via its * `__abi_version` export. Worker processes compare against this diff --git a/host/src/kernel.ts b/host/src/kernel.ts index da9b5d3365..5331dbcd02 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -1417,6 +1417,82 @@ export class WasmPosixKernel { return fn ? fn() : 0; } + // --------------------------------------------------------------------------- + // /dev/snd/pcmC0Dp — host-fed PCM ring (ALSA) + // --------------------------------------------------------------------------- + + /** + * Allocate a `byteLen`-sized region inside kernel-visible memory for + * use as the ALSA PCM SAB ring. Returns the kernel-memory offset + * (suitable for `kernel_audio_init_sab`), or 0 if the kernel is not + * instantiated or the allocator declined. The region is never freed; + * callers should allocate one per `pcm_id` at boot and reuse. + */ + audioAllocRing(byteLen: number): number { + const exports = this.instance?.exports as Record | undefined; + const alloc = exports?.kernel_alloc_scratch as + | ((size: number) => bigint | number) + | undefined; + if (!alloc) return 0; + return Number(alloc(byteLen)); + } + + /** + * Bind a kernel-memory window as the SAB-backed PCM ring for + * `pcmId`. After this call, `SNDRV_PCM_IOCTL_WRITEI_FRAMES` lands + * frames into the ring and the host-side AudioDriver pulls them + * back out. Re-issuing for the same `pcmId` is a no-op kernel-side. + * Silently dropped if the kernel module is not instantiated yet. + */ + audioInitSab(pcmId: number, base: number, len: number): void { + const fn = this.instance?.exports?.kernel_audio_init_sab as + | ((pcmId: number, base: bigint, len: number) => void) + | undefined; + if (!fn) return; + fn(pcmId, BigInt(base), len); + } + + /** + * Tell the kernel the host-side driver consumed `framesConsumed` + * frames from the SAB ring. Advances `mmap_status.hw_ptr`, stamps + * the monotonic timestamp, detects XRUN, and wakes any process + * parked on `POLLOUT` for `/dev/snd/pcmC0Dp`. Silently + * dropped if the kernel module is not instantiated yet. + */ + audioPeriodTick(pcmId: number, framesConsumed: number): void { + const fn = this.instance?.exports?.kernel_audio_period_tick as + | ((pcmId: number, framesConsumed: number) => void) + | undefined; + if (!fn) return; + fn(pcmId, framesConsumed); + } + + /** + * Return the current `mmap_control.appl_ptr` for any OFD bound to + * `pcmId` (max across matches; in practice ≤1 writer per PCM). The + * browser `AudioDriver` polls this and forwards the value into the + * `wpk-pcm-pull` AudioWorklet so the worklet emits silence past + * `appl_ptr` instead of racing ahead of the producer. 0 if the + * kernel module is not instantiated or no OFD is bound. + */ + audioGetApplPtr(pcmId: number): number { + const fn = this.instance?.exports?.kernel_audio_get_appl_ptr as + | ((pcmId: number) => bigint) + | undefined; + if (!fn) return 0; + return Number(fn(pcmId)); + } + + /** + * Underlying kernel-memory `ArrayBuffer` (`SharedArrayBuffer` in the + * shared-memory build). Returned by reference so the AudioDriver and + * the kernel see the same bytes for the ALSA PCM ring window. Null + * if the kernel module is not instantiated yet. + */ + getKernelMemoryBuffer(): SharedArrayBuffer | ArrayBuffer | null { + return (this.memory?.buffer as SharedArrayBuffer | ArrayBuffer) ?? null; + } + registerSharedPipe(handle: number, sab: SharedArrayBuffer, end: "read" | "write"): void { this.sharedPipes.set(handle, { pipe: SharedPipeBuffer.fromSharedBuffer(sab), end }); } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 652e008942..494164bd95 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -52,6 +52,7 @@ import { type PublishedPrivilegedProgramProduct, } from "./vfs/privileged-projection"; import type { InputSource } from "./input/input-source"; +import type { AudioDriver, AudioRing } from "./audio/audio-driver"; export type { HttpRequest, HttpResponse }; @@ -740,6 +741,109 @@ export class NodeKernelHost { ); } + /** + * Allocate a kernel-memory SAB ring for `pcmId` of `byteLen` bytes + * and bind it via `kernel_audio_init_sab`. Returns the ring window + * the host AudioDriver should view as `Int16Array(buffer, offset, …)`. + * Mirrors the Browser-side method of the same name — dual-host + * parity per CLAUDE.md §"Two hosts". + */ + async audioAllocRing(pcmId: number, byteLen: number): Promise { + const requestId = this._nextRequestId++; + const result = await this.request(requestId, { + type: "audio_alloc_ring", + requestId, + pcmId, + byteLen, + }); + return result as AudioRing; + } + + /** + * Fire-and-forget period tick into the kernel. Used by the + * AudioDriver's `kernelTick` callback to advance `mmap_status.hw_ptr` + * and wake `POLLOUT` waiters parked on `/dev/snd/pcmC0Dp`. + */ + audioPeriodTick(pcmId: number, framesConsumed: number): void { + this.sendToWorker({ + type: "audio_period_tick", + pcmId, + framesConsumed, + }); + } + + /** + * Read the current `mmap_control.appl_ptr` for any OFD bound to + * `pcmId`. Kept on the Node host for dual-host parity even though + * `NodeAudioDriver` does not currently poll — vitest specs and + * future Node-side drivers can use it. + */ + async audioGetApplPtr(pcmId: number): Promise { + const requestId = this._nextRequestId++; + const result = await this.request(requestId, { + type: "audio_get_appl_ptr", + requestId, + pcmId, + }); + return result as number; + } + + /** + * Wire an `AudioDriver` into the kernel: allocates a SAB ring, + * registers it, then starts the driver with a `kernelTick` callback + * that funnels each period boundary into `kernel_audio_period_tick`. + * Mirrors `BrowserKernel.attachAudioDriver` — dual-host parity per + * CLAUDE.md §"Two hosts". + * + * On the Node host the driver is typically a `NodeAudioDriver` + * (setInterval-driven dummy) so the init path is symmetric with the + * browser; tests can call `audioPeriodTick` directly afterwards. + */ + async attachAudioDriver( + driver: AudioDriver, + opts: { + pcmId?: number; + sampleRate?: number; + channels?: number; + periodFrames?: number; + ringBytes?: number; + } = {}, + ): Promise { + const pcmId = opts.pcmId ?? 0; + const sampleRate = opts.sampleRate ?? 48_000; + const channels = opts.channels ?? 2; + const periodFrames = opts.periodFrames ?? 1024; + const ringBytes = opts.ringBytes ?? 64 * 1024; + const ring = await this.audioAllocRing(pcmId, ringBytes); + let cachedApplPtr = 0; + let inFlight = false; + const getApplPtr = (id: number): number => { + if (!inFlight) { + inFlight = true; + this.audioGetApplPtr(id) + .then((v) => { + cachedApplPtr = v; + }) + .catch(() => { + /* swallow — keep last good value */ + }) + .finally(() => { + inFlight = false; + }); + } + return cachedApplPtr; + }; + await driver.start( + pcmId, + sampleRate, + channels, + periodFrames, + ring, + (id, frames) => this.audioPeriodTick(id, frames), + getApplPtr, + ); + } + /** * Send an HTTP request to a server running inside the kernel and return * the parsed response. Bypasses real TCP by using the kernel's injected diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 15d7ceac15..537363cdfe 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -365,6 +365,46 @@ export interface SetInputCanvasDimsMessage { height: number; } +/** + * Main-thread → kernel-worker request to allocate a kernel-memory + * SAB ring for `pcmId` of `byteLen` bytes and bind it via + * `kernel_audio_init_sab`. The worker replies via `ResponseMessage` + * with `{ buffer, byteOffset, byteLength }` so the main-thread + * AudioDriver can mount an `Int16Array` view at the same offset. + */ +export interface AudioAllocRingRequestMessage { + type: "audio_alloc_ring"; + requestId: number; + pcmId: number; + byteLen: number; +} + +/** + * Main-thread → kernel-worker period tick. Routes to + * `CentralizedKernelWorker.audioPeriodTick` which calls + * `kernel_audio_period_tick` and wakes any `POLLOUT` waiter parked on + * `/dev/snd/pcmC0Dp`. Fire-and-forget. + */ +export interface AudioPeriodTickMessage { + type: "audio_period_tick"; + pcmId: number; + framesConsumed: number; +} + +/** + * Main-thread → kernel-worker request to read the current + * `mmap_control.appl_ptr` for any OFD bound to `pcmId`. The browser + * driver polls this to gate the AudioWorklet's `hwPtr` advance on + * producer progress. The worker replies via `ResponseMessage` with a + * `number`. Kept on the Node side for dual-host parity even though + * `NodeAudioDriver` doesn't currently poll. + */ +export interface AudioGetApplPtrRequestMessage { + type: "audio_get_appl_ptr"; + requestId: number; + pcmId: number; +} + export type MainToKernelMessage = | InitMessage | SpawnMessage @@ -399,7 +439,10 @@ export type MainToKernelMessage = | KmsAttachCanvasMessage | KmsAttachStatsMessage | InputEventInjectMessage - | SetInputCanvasDimsMessage; + | SetInputCanvasDimsMessage + | AudioAllocRingRequestMessage + | AudioPeriodTickMessage + | AudioGetApplPtrRequestMessage; // ── Kernel Worker → Main Thread ── diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index ba65a0379c..32df29eaf5 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -3878,6 +3878,21 @@ port.on("message", (msg: MainToKernelMessage) => { case "set_input_canvas_dims": kernelWorker.setInputCanvasDims(msg.width, msg.height); break; + case "audio_alloc_ring": { + const ring = kernelWorker.audioInitRing(msg.pcmId, msg.byteLen); + if (!ring) { + respondError(msg.requestId, "audio_alloc_ring: kernel allocator declined"); + } else { + respond(msg.requestId, ring); + } + break; + } + case "audio_period_tick": + kernelWorker.audioPeriodTick(msg.pcmId, msg.framesConsumed); + break; + case "audio_get_appl_ptr": + respond(msg.requestId, kernelWorker.audioGetApplPtr(msg.pcmId)); + break; default: { const exhaustive: never = msg; void exhaustive; diff --git a/host/test/audio-driver.test.ts b/host/test/audio-driver.test.ts new file mode 100644 index 0000000000..37b346e41a --- /dev/null +++ b/host/test/audio-driver.test.ts @@ -0,0 +1,161 @@ +/** + * Phase B end-to-end coverage for the ALSA host AudioDriver. + * + * Three layers: + * + * 1. `NodeAudioDriver` cadence: `setInterval`-driven tick fires once + * per `periodFrames / sampleRate` ms with `framesConsumed == + * periodFrames`. + * + * 2. `CentralizedKernelWorker.audioInitRing`: boots a kernel against + * the real `kandelo-kernel.wasm`, allocates a 64 KiB SAB ring for + * `pcm_id = 0`, asserts the returned `(buffer, byteOffset, + * byteLength)` triple points into the kernel's + * `WebAssembly.Memory` (the `buffer` is the kernel SAB; the + * offset is non-zero and 16-byte aligned per the kernel + * allocator). + * + * 3. End-to-end: `audioPeriodTick` against a freshly-initialised + * kernel returns cleanly (no panic; no error logged). We can't + * observe `hw_ptr` here without opening an OFD against + * `/dev/snd/pcmC0D0p`, but the smoke test catches kernel-side + * regressions in the export wiring. + * + * The harness mirrors `audio-integration.test.ts` but skips process + * spawn — this exercises host-side wiring; OFD-driven flows land + * in the espeak-ng end-to-end Playwright spec. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { NodePlatformIO } from "../src/platform/node"; +import { NodeAudioDriver } from "../src/audio/node-audio-driver"; +import type { AudioRing } from "../src/audio/audio-driver"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const kernelBinary = join(__dirname, "../wasm/kandelo-kernel.wasm"); + +function loadKernelWasm(): ArrayBuffer { + const buf = readFileSync(kernelBinary); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +describe("NodeAudioDriver", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const fakeRing: AudioRing = { + buffer: new ArrayBuffer(64 * 1024), + byteOffset: 0, + byteLength: 64 * 1024, + }; + + it("ticks once per period at the period/sampleRate cadence", async () => { + const driver = new NodeAudioDriver(); + const ticks: Array<{ pcmId: number; frames: number }> = []; + await driver.start( + 0, + 48_000, + 2, + 1024, + fakeRing, + (pcmId, frames) => ticks.push({ pcmId, frames }), + () => 0, + ); + // 1024 frames @ 48 kHz = ~21.33 ms. + // Advance by 4 periods worth. + vi.advanceTimersByTime(85); + expect(ticks.length).toBe(4); + expect(ticks[0]).toEqual({ pcmId: 0, frames: 1024 }); + driver.stop(0); + }); + + it("stop() clears the interval and no further ticks fire", async () => { + const driver = new NodeAudioDriver(); + let ticks = 0; + await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); + vi.advanceTimersByTime(43); // ~2 periods + expect(ticks).toBe(2); + driver.stop(0); + vi.advanceTimersByTime(100); + expect(ticks).toBe(2); + }); + + it("starting the same pcmId twice is a no-op", async () => { + const driver = new NodeAudioDriver(); + let ticks = 0; + await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); + await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); + vi.advanceTimersByTime(22); // ~1 period + // Only the FIRST callback was registered, so we should see exactly 1 tick, + // not 2 (if duplicate intervals leaked through). + expect(ticks).toBe(1); + driver.stop(0); + }); +}); + +describe.skipIf(!existsSync(kernelBinary))( + "CentralizedKernelWorker.audioInitRing", + () => { + let kernel: CentralizedKernelWorker; + beforeEach(async () => { + const io = new NodePlatformIO(); + kernel = new CentralizedKernelWorker( + { + maxWorkers: 1, + dataBufferSize: 65536, + useSharedMemory: true, + enableSyscallLog: false, + }, + io, + {}, + ); + await kernel.init(loadKernelWasm()); + }); + + it("returns a ring window into kernel-visible memory", () => { + const ring = kernel.audioInitRing(0, 64 * 1024); + expect(ring).not.toBeNull(); + const r = ring!; + expect(r.byteLength).toBe(64 * 1024); + expect(r.byteOffset).toBeGreaterThan(0); + // Kernel allocator (kernel_alloc_scratch) aligns to 16. + expect(r.byteOffset % 16).toBe(0); + // Must fit inside the kernel memory window. + expect(r.byteOffset + r.byteLength).toBeLessThanOrEqual(r.buffer.byteLength); + // The ring is zeroed by the kernel allocator. + const view = new Int16Array(r.buffer, r.byteOffset, 8); + for (const sample of view) expect(sample).toBe(0); + }); + + it("audioPeriodTick on a fresh kernel is a no-op (no exception)", () => { + // No OFD is open against /dev/snd/pcmC0D0p, so tick walks zero + // OFDs and returns cleanly. We're proving the export plumbing + // doesn't trap when nothing is listening. + kernel.audioInitRing(0, 64 * 1024); + expect(() => kernel.audioPeriodTick(0, 1024)).not.toThrow(); + }); + + it("a host-thread Int16Array view sees a writable, kernel-visible region", () => { + const ring = kernel.audioInitRing(0, 64 * 1024)!; + const view = new Int16Array(ring.buffer, ring.byteOffset, ring.byteLength / 2); + view[0] = 0x1234; + view[view.length - 1] = -0x4321; + // Re-mount from the same buffer to confirm bytes hit the SAB, + // not a copy. + const view2 = new Int16Array( + ring.buffer, + ring.byteOffset, + ring.byteLength / 2, + ); + expect(view2[0]).toBe(0x1234); + expect(view2[view.length - 1]).toBe(-0x4321); + }); + }, +); diff --git a/host/test/browser-audio-driver-drain.test.ts b/host/test/browser-audio-driver-drain.test.ts new file mode 100644 index 0000000000..0b39ed69ba --- /dev/null +++ b/host/test/browser-audio-driver-drain.test.ts @@ -0,0 +1,180 @@ +/** + * Regression spec for `BrowserAudioDriver.stop()`'s deferred-close + * drain. Without the drain, the AudioContext closes synchronously + * and the platform audio queue truncates the last word of any phrase + * the worklet hadn't yet emitted to the speaker — this was the + * tail-truncation bug fixed in the session-43 work. + * + * The drain logic: + * pending = max(0, lastApplPtr - totalFramesConsumed) + * if pending == 0 → close immediately + * else → setTimeout(close, pending/rate*1000 + 100) + * + * Tests here stub the WebAudio globals (`AudioContext`, + * `AudioWorkletNode`) just enough for `BrowserAudioDriver.start()` + * to construct a context and a worklet, then drive the worklet + * mailbox by invoking the captured `onmessage` callback directly. + */ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from "vitest"; +import { BrowserAudioDriver } from "../src/audio/browser-audio-driver"; +import type { AudioRing } from "../src/audio/audio-driver"; + +interface MockPort { + onmessage: + | ((e: { data: { framesConsumed?: number; applPtr?: number } }) => void) + | null; + postMessage: (msg: unknown) => void; +} + +interface MockWorklet { + port: MockPort; + connect: (dest: unknown) => void; + disconnect: () => void; +} + +interface MockAudioContext { + sampleRate: number; + destination: { __isDestination: true }; + audioWorklet: { addModule: (url: string) => Promise }; + close: () => Promise; +} + +function stubAudioGlobals() { + const closes: Array<() => Promise> = []; + const disconnects: Array<() => void> = []; + const workletCreated: MockWorklet[] = []; + + class AudioContextStub implements MockAudioContext { + sampleRate: number; + destination = { __isDestination: true as const }; + audioWorklet = { addModule: async (_url: string) => undefined }; + close: () => Promise; + constructor(opts: { sampleRate: number }) { + this.sampleRate = opts.sampleRate; + this.close = vi.fn(async () => undefined); + closes.push(this.close); + } + } + + class AudioWorkletNodeStub implements MockWorklet { + port: MockPort; + connect: (dest: unknown) => void; + disconnect: () => void; + constructor(_ctx: MockAudioContext, _name: string, _opts: unknown) { + this.port = { + onmessage: null, + postMessage: vi.fn(), + }; + this.connect = vi.fn(); + this.disconnect = vi.fn(); + disconnects.push(this.disconnect); + workletCreated.push(this); + } + } + + (globalThis as unknown as { AudioContext: typeof AudioContextStub }) + .AudioContext = AudioContextStub; + (globalThis as unknown as { AudioWorkletNode: typeof AudioWorkletNodeStub }) + .AudioWorkletNode = AudioWorkletNodeStub; + + return { closes, disconnects, workletCreated }; +} + +const fakeRing: AudioRing = { + buffer: new ArrayBuffer(64 * 1024), + byteOffset: 0, + byteLength: 64 * 1024, +}; + +describe("BrowserAudioDriver.stop() drain", () => { + let env: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + env = stubAudioGlobals(); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (globalThis as unknown as { AudioContext?: unknown }).AudioContext; + delete ( + globalThis as unknown as { AudioWorkletNode?: unknown } + ).AudioWorkletNode; + }); + + it("closes synchronously when no frames are pending", async () => { + const driver = new BrowserAudioDriver("/stub-worklet.js"); + await driver.start(0, 48_000, 2, 1024, fakeRing, () => {}, () => 0); + // appl_ptr is still 0 and totalFramesConsumed is 0 → pending = 0. + driver.stop(0); + expect(env.closes[0]).toHaveBeenCalledTimes(1); + expect(env.disconnects[0]).toHaveBeenCalledTimes(1); + }); + + it( + "defers close by (pending/sampleRate)*1000 + 100 ms when frames are pending", + async () => { + const driver = new BrowserAudioDriver("/stub-worklet.js"); + // Producer reports appl_ptr = 22050 (1 s of audio @ 22050 Hz). + let applPtr = 22050; + await driver.start( + 0, + 22_050, + 2, + 1024, + fakeRing, + () => {}, + () => applPtr, + ); + // The 10 ms applPtr poll fires once to populate ctx.lastApplPtr. + vi.advanceTimersByTime(10); + // Worklet has played 11025 frames so far (half the buffer). + const port = env.workletCreated[0].port; + port.onmessage?.({ data: { framesConsumed: 11025 } }); + + driver.stop(0); + // pending = 22050 - 11025 = 11025 frames @ 22050 Hz = 500 ms + // drainMs = 500 + 100 = 600 ms. + expect(env.closes[0]).not.toHaveBeenCalled(); + vi.advanceTimersByTime(599); + expect(env.closes[0]).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(env.closes[0]).toHaveBeenCalledTimes(1); + expect(env.disconnects[0]).toHaveBeenCalledTimes(1); + }, + ); + + it("clears the applPtr poll interval on stop even when close is deferred", async () => { + const driver = new BrowserAudioDriver("/stub-worklet.js"); + let applPtrCalls = 0; + await driver.start( + 0, + 22_050, + 2, + 1024, + fakeRing, + () => {}, + () => { + applPtrCalls++; + return 22050; + }, + ); + vi.advanceTimersByTime(10); + const baselineCalls = applPtrCalls; + expect(baselineCalls).toBeGreaterThan(0); + driver.stop(0); + // stop() reads applPtr ONCE more (finalApplPtr probe). + const afterStop = applPtrCalls; + expect(afterStop).toBe(baselineCalls + 1); + // 100 ms further: no more polls if clearInterval worked. + vi.advanceTimersByTime(100); + expect(applPtrCalls).toBe(afterStop); + }); +}); diff --git a/host/test/instrumented-audio-driver.test.ts b/host/test/instrumented-audio-driver.test.ts new file mode 100644 index 0000000000..ce772e5562 --- /dev/null +++ b/host/test/instrumented-audio-driver.test.ts @@ -0,0 +1,121 @@ +/** + * Regression spec for `instrumentAudioDriver`'s forwarding contract. + * + * Session 42 shipped a wrapper whose `start()` dropped the new + * `getApplPtr` argument when delegating to the inner driver, which + * silently disabled the AudioWorklet's producer-pointer gate and + * cut the head off every spoken phrase. These tests pin every + * `AudioDriver.start()` parameter so a future signature drift fails + * loudly instead of producing silence. + */ +import { describe, it, expect, vi } from "vitest"; +import { instrumentAudioDriver } from "../src/audio/instrumented-audio-driver"; +import type { AudioDriver, AudioRing } from "../src/audio/audio-driver"; + +function makeMockInner(): AudioDriver & { + startSpy: ReturnType; + stopSpy: ReturnType; +} { + const startSpy = vi.fn(async () => undefined); + const stopSpy = vi.fn(); + return { + start: startSpy as unknown as AudioDriver["start"], + stop: stopSpy, + startSpy, + stopSpy, + }; +} + +const fakeRing: AudioRing = { + buffer: new ArrayBuffer(64 * 1024), + byteOffset: 0, + byteLength: 64 * 1024, +}; + +describe("instrumentAudioDriver", () => { + it("forwards every start() argument to the inner driver", async () => { + const inner = makeMockInner(); + const wrapper = instrumentAudioDriver(inner); + const kernelTick = vi.fn(); + const getApplPtr = vi.fn(() => 4242); + + await wrapper.start(7, 22_050, 2, 1024, fakeRing, kernelTick, getApplPtr); + + expect(inner.startSpy).toHaveBeenCalledTimes(1); + const args = inner.startSpy.mock.calls[0]; + expect(args[0]).toBe(7); + expect(args[1]).toBe(22_050); + expect(args[2]).toBe(2); + expect(args[3]).toBe(1024); + expect(args[4]).toBe(fakeRing); + expect(typeof args[5]).toBe("function"); + expect(args[6]).toBe(getApplPtr); + }); + + it("preserves the getApplPtr reference identity (not wrapped)", async () => { + const inner = makeMockInner(); + const wrapper = instrumentAudioDriver(inner); + const sentinelApplPtr = vi.fn(() => 0); + + await wrapper.start(0, 48_000, 2, 1024, fakeRing, () => {}, sentinelApplPtr); + + const fwd = inner.startSpy.mock.calls[0][6]; + expect(fwd).toBe(sentinelApplPtr); + expect(fwd(99)).toBe(0); + expect(sentinelApplPtr).toHaveBeenCalledWith(99); + }); + + it("accumulates framesConsumed across inner ticks and exposes the running total", async () => { + const inner = makeMockInner(); + const wrapper = instrumentAudioDriver(inner); + let observed = -1; + + await wrapper.start( + 0, + 48_000, + 2, + 1024, + fakeRing, + (_id, frames) => { + observed = frames; + }, + () => 0, + ); + + const wrappedTick = inner.startSpy.mock.calls[0][5] as ( + id: number, + frames: number, + ) => void; + + wrappedTick(0, 1024); + expect(wrapper.framesConsumed()).toBe(1024); + expect(observed).toBe(1024); + + wrappedTick(0, 1024); + expect(wrapper.framesConsumed()).toBe(2048); + }); + + it("notifies the observer with both the delta and the running total", async () => { + const inner = makeMockInner(); + const observer = vi.fn(); + const wrapper = instrumentAudioDriver(inner, observer); + + await wrapper.start(0, 48_000, 2, 1024, fakeRing, () => {}, () => 0); + const wrappedTick = inner.startSpy.mock.calls[0][5] as ( + id: number, + frames: number, + ) => void; + + wrappedTick(0, 256); + expect(observer).toHaveBeenLastCalledWith(256, 256); + wrappedTick(0, 768); + expect(observer).toHaveBeenLastCalledWith(768, 1024); + }); + + it("delegates stop() to the inner driver", () => { + const inner = makeMockInner(); + const wrapper = instrumentAudioDriver(inner); + wrapper.stop(3); + expect(inner.stopSpy).toHaveBeenCalledWith(3); + }); +}); diff --git a/images/vfs/scripts/build-shell-vfs-image.sh b/images/vfs/scripts/build-shell-vfs-image.sh index dc42a1942d..52d0c4677c 100755 --- a/images/vfs/scripts/build-shell-vfs-image.sh +++ b/images/vfs/scripts/build-shell-vfs-image.sh @@ -7,3 +7,8 @@ echo "==> Building Shell VFS image..." npx tsx "$SCRIPT_DIR/build-shell-vfs-image.ts" echo "==> Done." ls -lh apps/browser-demos/public/shell.vfs.zst + +# Mirror into local-binaries/ so the @binaries/ Vite alias resolves for +# pages/kandelo/kernel-host/live-setup.ts. See sibling build-nginx-vfs-image.sh for rationale. +source "$REPO_ROOT/scripts/install-local-binary.sh" +install_local_binary shell "$REPO_ROOT/apps/browser-demos/public/shell.vfs.zst" diff --git a/images/vfs/scripts/build-shell-vfs-image.ts b/images/vfs/scripts/build-shell-vfs-image.ts index dbf4073335..4a880b6b8a 100644 --- a/images/vfs/scripts/build-shell-vfs-image.ts +++ b/images/vfs/scripts/build-shell-vfs-image.ts @@ -8,11 +8,17 @@ * * Usage: npx tsx images/vfs/scripts/build-shell-vfs-image.ts */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); import { resolveBinary } from "../../../host/src/binary-resolver"; import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; import { + ensureDirRecursive, saveImage, + walkAndWrite, writeVfsBinary, } from "./vfs-image-helpers"; import { populateShellEnvironment, resolveVfsArtifact } from "./shell-vfs-build"; @@ -43,6 +49,8 @@ async function main() { populateDoomRuntime(fs); console.log("Populating modeset runtime..."); populateModesetRuntime(fs); + console.log("Populating espeak-ng runtime..."); + populateEspeakRuntime(fs); writeMainShellDemoConfig(fs); await saveImage(fs, OUT_FILE); @@ -62,3 +70,28 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { const modesetBytes = readFileSync(resolveVfsArtifact("programs/modeset.wasm", "modeset")); writeVfsBinary(fs, "/usr/local/bin/modeset", new Uint8Array(modesetBytes), 0o755); } + +function populateEspeakRuntime(fs: MemoryFileSystem): void { + const espeakBytes = readFileSync(resolveVfsArtifact("programs/espeak-ng.wasm", "espeak-ng")); + writeVfsBinary(fs, "/usr/bin/espeak-ng", new Uint8Array(espeakBytes), 0o755); + + // espeak-ng's PATH_ESPEAK_DATA macro is baked at build time to + // /usr/share/espeak-ng-data (set via CMAKE_INSTALL_PREFIX=/usr in + // build-espeak-ng.sh). The runtime walks lang//, + // voices/!v/*, phondata, phonindex, phontab, intonations, and per- + // language *_dict files — copying the whole tree is the simplest + // shape and the trimmed English-only data dir is only ~1.9 MB. + const dataDir = path.join( + SCRIPT_DIR, + "../../../packages/registry/espeak-ng/espeak-ng-install/share/espeak-ng-data", + ); + if (!existsSync(dataDir)) { + throw new Error( + `populateEspeakRuntime: espeak-ng-data not found at ${dataDir}. ` + + `Run \`bash packages/registry/espeak-ng/build-espeak-ng.sh\` first.`, + ); + } + ensureDirRecursive(fs, "/usr/share/espeak-ng-data"); + const fileCount = walkAndWrite(fs, dataDir, "/usr/share/espeak-ng-data"); + console.log(` staged ${fileCount} espeak-ng-data files at /usr/share/espeak-ng-data`); +} diff --git a/libc/musl-overlay/include/sound/asound.h b/libc/musl-overlay/include/sound/asound.h new file mode 100644 index 0000000000..81d4cbc3cc --- /dev/null +++ b/libc/musl-overlay/include/sound/asound.h @@ -0,0 +1,308 @@ +/* + * Subset of matching what crates/shared/src/lib.rs::audio + * marshals. Mirrors Linux UAPI v6.10 `include/uapi/sound/asound.h` for the + * fields kandelo's v1 ALSA surface implements: + * + * ioctls PVERSION INFO HW_REFINE HW_PARAMS HW_FREE SW_PARAMS STATUS + * PREPARE START DROP PAUSE WRITEI_FRAMES (PCM) + * PVERSION CARD_INFO ELEM_LIST (control) + * + * structs snd_pcm_hw_params (608B) snd_pcm_sw_params (136B) + * snd_pcm_status (128B) snd_pcm_info (288B) + * snd_pcm_mmap_status (64B) snd_pcm_mmap_control (64B) + * snd_xferi (24B) snd_ctl_card_info (256B) + * snd_ctl_elem_id (64B) snd_ctl_elem_list (80B) + * + * Capture, the sequencer, the timer, mixers beyond CARD_INFO/ELEM_LIST, + * FLOAT_LE/S32_LE formats, and async signal delivery are all omitted — + * the v1 plan in docs/plans/2026-06-22-dri-alsa-plan.md is playback-only, + * S16_LE-only, host-driven cadence via kernel_audio_period_tick. + */ +#ifndef _SOUND_ASOUND_H +#define _SOUND_ASOUND_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint64_t snd_pcm_uframes_t; +typedef int64_t snd_pcm_sframes_t; + +/* --- PCM state ------------------------------------------------------- */ + +#define SNDRV_PCM_STATE_OPEN 0 +#define SNDRV_PCM_STATE_SETUP 1 +#define SNDRV_PCM_STATE_PREPARED 2 +#define SNDRV_PCM_STATE_RUNNING 3 +#define SNDRV_PCM_STATE_XRUN 4 +#define SNDRV_PCM_STATE_DRAINING 5 +#define SNDRV_PCM_STATE_PAUSED 6 +#define SNDRV_PCM_STATE_SUSPENDED 7 +#define SNDRV_PCM_STATE_DISCONNECTED 8 + +/* --- PCM format (v1 ships S16_LE only; the others are listed for ABI + * symmetry with the kernel-side `audio::` module). ----------------- */ + +#define SNDRV_PCM_FORMAT_S8 0 +#define SNDRV_PCM_FORMAT_U8 1 +#define SNDRV_PCM_FORMAT_S16_LE 2 +#define SNDRV_PCM_FORMAT_S16_BE 3 +#define SNDRV_PCM_FORMAT_U16_LE 4 +#define SNDRV_PCM_FORMAT_U16_BE 5 +#define SNDRV_PCM_FORMAT_S24_LE 6 +#define SNDRV_PCM_FORMAT_S24_BE 7 +#define SNDRV_PCM_FORMAT_U24_LE 8 +#define SNDRV_PCM_FORMAT_U24_BE 9 +#define SNDRV_PCM_FORMAT_S32_LE 10 +#define SNDRV_PCM_FORMAT_S32_BE 11 +#define SNDRV_PCM_FORMAT_U32_LE 12 +#define SNDRV_PCM_FORMAT_U32_BE 13 +#define SNDRV_PCM_FORMAT_FLOAT_LE 14 + +#define SNDRV_PCM_SUBFORMAT_STD 0 + +/* --- PCM access ------------------------------------------------------- */ + +#define SNDRV_PCM_ACCESS_MMAP_INTERLEAVED 0 +#define SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED 1 +#define SNDRV_PCM_ACCESS_MMAP_COMPLEX 2 +#define SNDRV_PCM_ACCESS_RW_INTERLEAVED 3 +#define SNDRV_PCM_ACCESS_RW_NONINTERLEAVED 4 + +/* --- PCM stream direction -------------------------------------------- */ + +#define SNDRV_PCM_STREAM_PLAYBACK 0 +#define SNDRV_PCM_STREAM_CAPTURE 1 + +/* --- snd_pcm_hw_params parameter indices ----------------------------- * + * The Linux UAPI splits hw_params into three masks + thirteen intervals, + * indexed by these enums. Kandelo's kernel reads PARAM_ACCESS (0), + * PARAM_FORMAT (1), PARAM_SUBFORMAT (2) from masks[] and PARAM_CHANNELS + * (2), PARAM_RATE (3), PARAM_PERIOD_SIZE (5), PARAM_PERIODS (7), + * PARAM_BUFFER_SIZE (9) from intervals[]. See refine_hw_params() in + * crates/kernel/src/audio/pcm_ioctl.rs. + */ + +#define SNDRV_PCM_HW_PARAM_ACCESS 0 +#define SNDRV_PCM_HW_PARAM_FORMAT 1 +#define SNDRV_PCM_HW_PARAM_SUBFORMAT 2 +#define SNDRV_PCM_HW_PARAM_FIRST_MASK SNDRV_PCM_HW_PARAM_ACCESS +#define SNDRV_PCM_HW_PARAM_LAST_MASK SNDRV_PCM_HW_PARAM_SUBFORMAT + +#define SNDRV_PCM_HW_PARAM_SAMPLE_BITS 8 +#define SNDRV_PCM_HW_PARAM_FRAME_BITS 9 +#define SNDRV_PCM_HW_PARAM_CHANNELS 10 +#define SNDRV_PCM_HW_PARAM_RATE 11 +#define SNDRV_PCM_HW_PARAM_PERIOD_TIME 12 +#define SNDRV_PCM_HW_PARAM_PERIOD_SIZE 13 +#define SNDRV_PCM_HW_PARAM_PERIOD_BYTES 14 +#define SNDRV_PCM_HW_PARAM_PERIODS 15 +#define SNDRV_PCM_HW_PARAM_BUFFER_TIME 16 +#define SNDRV_PCM_HW_PARAM_BUFFER_SIZE 17 +#define SNDRV_PCM_HW_PARAM_BUFFER_BYTES 18 +#define SNDRV_PCM_HW_PARAM_TICK_TIME 19 +#define SNDRV_PCM_HW_PARAM_FIRST_INTERVAL SNDRV_PCM_HW_PARAM_SAMPLE_BITS +#define SNDRV_PCM_HW_PARAM_LAST_INTERVAL SNDRV_PCM_HW_PARAM_TICK_TIME + +/* The kernel indexes masks[] and intervals[] starting at PARAM_ACCESS=0 + * and PARAM_SAMPLE_BITS=0 respectively, so userspace helpers subtract + * the first-* offsets when picking a slot. */ +#define WPK_ALSA_MASK_INDEX(name) ((name) - SNDRV_PCM_HW_PARAM_FIRST_MASK) +#define WPK_ALSA_INTERVAL_INDEX(name) ((name) - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL) + +/* --- mmap page offsets (passed as the mmap(2) offset arg) ------------ * + * v1 mmap policy (per handoff-39): direct mmap(STATUS|CONTROL) returns + * anonymous user pages with no kernel-side mirror. WRITEI_FRAMES is the + * only data path. mmap-of-DATA is a future API surface; v1 demos must + * not rely on it. */ + +#define SNDRV_PCM_MMAP_OFFSET_DATA 0x00000000UL +#define SNDRV_PCM_MMAP_OFFSET_STATUS 0x80000000UL +#define SNDRV_PCM_MMAP_OFFSET_CONTROL 0x81000000UL + +/* --- snd_interval ----------------------------------------------------- * + * Linux packs four flag bits (openmin / openmax / integer / empty) into + * a trailing u32. We keep them as a plain u32 here so the struct size + * matches the kernel-side WpkSndInterval (12B) byte-for-byte. */ + +struct snd_interval { + uint32_t min; + uint32_t max; + /* bit 0 = openmin, 1 = openmax, 2 = integer, 3 = empty */ + uint32_t flags; +}; + +/* --- snd_pcm_hw_params (608 bytes on wasm32) ------------------------- */ + +struct snd_pcm_hw_params { + uint32_t flags; + uint32_t masks[64]; /* 8 snd_mask × u32[8] */ + struct snd_interval intervals[21]; /* 12 active + 9 reserved */ + uint32_t rmask; + uint32_t cmask; + uint32_t info; + uint32_t msbits; + uint32_t rate_num; + uint32_t rate_den; + uint64_t fifo_size; + uint8_t reserved[64]; +}; + +/* --- snd_pcm_sw_params (136 bytes) ----------------------------------- */ + +struct snd_pcm_sw_params { + uint32_t tstamp_mode; + uint32_t period_step; + uint32_t sleep_min; + uint32_t _pad0; + uint64_t avail_min; + uint64_t xfer_align; + uint64_t start_threshold; + uint64_t stop_threshold; + uint64_t silence_threshold; + uint64_t silence_size; + uint64_t boundary; + uint32_t proto; + uint32_t tstamp_type; + uint8_t reserved[56]; +}; + +/* --- snd_pcm_status (128 bytes) -------------------------------------- */ + +struct snd_pcm_status { + uint32_t state; + uint32_t _pad0; + int64_t trigger_tstamp_sec; + int64_t trigger_tstamp_nsec; + int64_t tstamp_sec; + int64_t tstamp_nsec; + int64_t appl_ptr; + int64_t hw_ptr; + int64_t delay; + uint64_t avail; + uint64_t avail_max; + uint64_t overrange; + uint32_t suspended_state; + uint32_t audio_tstamp_data; + int64_t audio_tstamp_sec; + int64_t audio_tstamp_nsec; + uint8_t reserved[16]; +}; + +/* --- snd_pcm_info (288 bytes) ---------------------------------------- */ + +struct snd_pcm_info { + uint32_t device; + uint32_t subdevice; + int32_t stream; + int32_t card; + uint8_t id[64]; + uint8_t name[80]; + uint8_t subname[32]; + uint32_t dev_class; + uint32_t dev_subclass; + uint32_t subdevices_count; + uint32_t subdevices_avail; + uint8_t sync[16]; + uint8_t reserved[64]; +}; + +/* --- snd_pcm_mmap_status (64B) — kernel-writes, userspace-reads ------ */ + +struct snd_pcm_mmap_status { + uint32_t state; + uint32_t _pad0; + int64_t hw_ptr; + int64_t tstamp_sec; + int64_t tstamp_nsec; + uint32_t suspended_state; + uint32_t audio_tstamp_data; + int64_t audio_tstamp_sec; + int64_t audio_tstamp_nsec; + uint8_t reserved[8]; +}; + +/* --- snd_pcm_mmap_control (64B) — userspace-writes, kernel-reads ----- */ + +struct snd_pcm_mmap_control { + int64_t appl_ptr; + int64_t avail_min; + uint8_t reserved[48]; +}; + +/* --- snd_xferi (24B) — argument to WRITEI_FRAMES/READI_FRAMES -------- */ + +struct snd_xferi { + int64_t result; + uint64_t buf; + uint64_t frames; +}; + +/* --- snd_ctl_card_info (256B) ---------------------------------------- */ + +struct snd_ctl_card_info { + int32_t card; + int32_t pad; + uint8_t id[16]; + uint8_t driver[16]; + uint8_t name[32]; + uint8_t longname[80]; + uint8_t reserved_[16]; + uint8_t mixername[80]; + uint8_t components[8]; +}; + +/* --- snd_ctl_elem_id (64B) ------------------------------------------- */ + +struct snd_ctl_elem_id { + uint32_t numid; + uint32_t iface; + uint32_t device; + uint32_t subdevice; + uint8_t name[44]; + uint32_t index; +}; + +/* --- snd_ctl_elem_list (80B) ----------------------------------------- */ + +struct snd_ctl_elem_list { + uint32_t offset; + uint32_t space; + uint32_t used; + uint32_t count; + uint64_t pids; + uint8_t reserved[50]; +}; + +/* --- PCM ioctl numbers ('A' magic) ----------------------------------- * + * Verbatim Linux UAPI v6.10. The kernel-side `audio::` module pins these + * via static-assert against `ioc(...)` to fail loudly if the in-tree + * struct sizes drift. */ + +#define SNDRV_PCM_IOCTL_PVERSION _IOR('A', 0x00, int) +#define SNDRV_PCM_IOCTL_INFO _IOR('A', 0x01, struct snd_pcm_info) +#define SNDRV_PCM_IOCTL_HW_REFINE _IOWR('A', 0x10, struct snd_pcm_hw_params) +#define SNDRV_PCM_IOCTL_HW_PARAMS _IOWR('A', 0x11, struct snd_pcm_hw_params) +#define SNDRV_PCM_IOCTL_HW_FREE _IO('A', 0x12) +#define SNDRV_PCM_IOCTL_SW_PARAMS _IOWR('A', 0x13, struct snd_pcm_sw_params) +#define SNDRV_PCM_IOCTL_STATUS _IOR('A', 0x20, struct snd_pcm_status) +#define SNDRV_PCM_IOCTL_PREPARE _IO('A', 0x40) +#define SNDRV_PCM_IOCTL_START _IO('A', 0x42) +#define SNDRV_PCM_IOCTL_DROP _IO('A', 0x43) +#define SNDRV_PCM_IOCTL_PAUSE _IOW('A', 0x45, int) +#define SNDRV_PCM_IOCTL_WRITEI_FRAMES _IOW('A', 0x50, struct snd_xferi) + +/* --- Control ioctl numbers ('U' magic) ------------------------------- */ + +#define SNDRV_CTL_IOCTL_PVERSION _IOR('U', 0x00, int) +#define SNDRV_CTL_IOCTL_CARD_INFO _IOR('U', 0x01, struct snd_ctl_card_info) +#define SNDRV_CTL_IOCTL_ELEM_LIST _IOWR('U', 0x10, struct snd_ctl_elem_list) + +#ifdef __cplusplus +} +#endif + +#endif /* _SOUND_ASOUND_H */ diff --git a/packages/registry/espeak-ng/build-espeak-ng.sh b/packages/registry/espeak-ng/build-espeak-ng.sh new file mode 100755 index 0000000000..2f7f2f6ab9 --- /dev/null +++ b/packages/registry/espeak-ng/build-espeak-ng.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# +# Build espeak-ng for wasm32-posix-kernel. +# +# Two-pass build: +# +# 1. Native build of espeak-ng on the host. We only need its +# binary (espeak-ng) to compile phoneme + intonation data +# out of phsource/ + dictsource/ via the --compile-* commands. +# No data files are written until the cross-build's `data` +# target runs. +# 2. Cross build of espeak-ng for wasm32. Uses our patched +# pcaudiolib (kandelo backend baked into create_audio_device_object) +# so the resulting espeak-ng.wasm opens /dev/snd/pcmC0D0p directly +# and produces audible speech inside the kandelo browser preset. +# +# Honors the dep-resolver build-script contract — see +# packages/registry/libxml2/build-libxml2.sh for the pattern. +# +# Output layout: +# +# $INSTALL_DIR/ +# bin/espeak-ng.wasm (executable wasm binary) +# share/espeak-ng-data/ (phoneme + voice data dir, +# compiled by the native bin) +# +# Default install dir for legacy / ad-hoc invocation is +# ./espeak-ng-install/ next to this script. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +PCAUDIO_SRC_DIR="$HERE/pcaudiolib-src" +SRC_DIR="$HERE/espeak-ng-src" + +# --- Resolver-contract env / legacy fallbacks --- +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$HERE/espeak-ng-install}" + +# Languages to compile. The full upstream list is ~80 languages and +# bloats the VFS image by ~25 MB. Default to English-only for the demo; +# override at build time with e.g. ESPEAK_LANG_LIST="en de fr". +ESPEAK_LANG_LIST="${ESPEAK_LANG_LIST:-en}" + +# --- SDK + sysroot --- +# Source this worktree's SDK directly instead of relying on `npm link`. +source "$REPO_ROOT/sdk/activate.sh" +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" + +if ! command -v wasm32posix-cc >/dev/null; then + echo "ERROR: wasm32posix-cc not found on PATH after sourcing sdk/activate.sh." >&2 + exit 1 +fi +if [ ! -f "$SYSROOT/lib/libc.a" ]; then + echo "ERROR: kandelo sysroot not built at $SYSROOT. Run bash scripts/build-musl.sh first." >&2 + exit 1 +fi + +# --- Locate host LLVM (for glue obj compile + native build) --- +LLVM_PREFIX="${LLVM_PREFIX:-$(brew --prefix llvm 2>/dev/null || echo /opt/homebrew/opt/llvm)}" +LLVM_CLANG="$LLVM_PREFIX/bin/clang" + +# --- Phase 0: kandelo glue objs ---------------------------------------- +# Mirrors mariadb's mariadb-glue-objs/. crt1.o comes from the sysroot; +# the channel_syscall + compiler_rt objects come from the kandelo libc +# glue and are linked into every user program at exec time. +GLUE_OBJ_DIR="$HERE/glue-objs" +GLUE_SRC_DIR="$REPO_ROOT/libc/glue" +mkdir -p "$GLUE_OBJ_DIR" +if [ ! -f "$GLUE_OBJ_DIR/channel_syscall.o" ] || \ + [ "$GLUE_SRC_DIR/channel_syscall.c" -nt "$GLUE_OBJ_DIR/channel_syscall.o" ]; then + echo "==> Compiling kandelo glue objs..." + WASM_COMPILE_FLAGS="--target=wasm32-unknown-unknown -matomics -mbulk-memory -mexception-handling -mllvm -wasm-enable-sjlj -fno-trapping-math --sysroot=$SYSROOT" + # shellcheck disable=SC2086 + "$LLVM_CLANG" $WASM_COMPILE_FLAGS -O2 -c "$GLUE_SRC_DIR/channel_syscall.c" -o "$GLUE_OBJ_DIR/channel_syscall.o" + # shellcheck disable=SC2086 + "$LLVM_CLANG" $WASM_COMPILE_FLAGS -O2 -c "$GLUE_SRC_DIR/compiler_rt.c" -o "$GLUE_OBJ_DIR/compiler_rt.o" +fi + +# --- Phase 1: libpcaudio.a (kandelo backend) --------------------------- +# We don't run pcaudiolib's autotools / libtool — for two files we just +# compile and archive directly. See packages/registry/libxml2/ +# build-libxml2.sh for the same "skip libtool" rationale. +PCAUDIO_BUILD_DIR="$HERE/pcaudiolib-build" +mkdir -p "$PCAUDIO_BUILD_DIR" + +echo "==> Building libpcaudio.a (kandelo backend)..." +PCAUDIO_CFLAGS=( + -O2 + -DHAVE_KANDELO + -I"$PCAUDIO_SRC_DIR/src" + -I"$PCAUDIO_SRC_DIR/src/include" +) +wasm32posix-cc "${PCAUDIO_CFLAGS[@]}" -c "$PCAUDIO_SRC_DIR/src/audio.c" -o "$PCAUDIO_BUILD_DIR/audio.o" +wasm32posix-cc "${PCAUDIO_CFLAGS[@]}" -c "$PCAUDIO_SRC_DIR/src/audio_kandelo.c" -o "$PCAUDIO_BUILD_DIR/audio_kandelo.o" +wasm32posix-ar rcs "$PCAUDIO_BUILD_DIR/libpcaudio.a" \ + "$PCAUDIO_BUILD_DIR/audio.o" "$PCAUDIO_BUILD_DIR/audio_kandelo.o" + +# --- Phase 2: native build of espeak-ng (for data-dir generation) ------ +# The cross-build's `data` target runs the native espeak-ng under +# CMAKE_CROSSCOMPILING with --compile-intonations / --compile-phonemes / +# --compile= to write the phondata / phonindex / phontab / +# intonations / _dict binary files. We just need the binary; we +# don't ship anything from this build. +NATIVE_BUILD_DIR="$HERE/espeak-ng-host-build" +if [ ! -x "$NATIVE_BUILD_DIR/src/espeak-ng" ]; then + echo "==> Native build of espeak-ng (for data tools)..." + mkdir -p "$NATIVE_BUILD_DIR" + cmake -S "$SRC_DIR" -B "$NATIVE_BUILD_DIR" \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DBUILD_SHARED_LIBS=OFF \ + -DUSE_MBROLA=OFF \ + -DUSE_LIBSONIC=OFF \ + -DUSE_LIBPCAUDIO=OFF \ + -DCOMPILE_INTONATIONS=OFF \ + -DESPEAK_COMPAT=OFF \ + -DENABLE_TESTS=OFF \ + > /dev/null + cmake --build "$NATIVE_BUILD_DIR" --target espeak-ng-bin -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" +fi + +# Short-circuit the FetchContent of sonic in upstream cmake/deps.cmake. +# The upstream file unconditionally clones github.com/waywardgeek/sonic +# when find_library doesn't locate libsonic — which it won't on host +# or wasm32 — and that requires network at configure time and pulls +# a stale dep into both builds. We don't use libsonic anyway +# (USE_LIBSONIC=OFF). Replace the whole sonic block with a no-op. +DEPS_CMAKE="$SRC_DIR/cmake/deps.cmake" +DEPS_CMAKE_BACKUP="$DEPS_CMAKE.kandelo.orig" +if [ ! -f "$DEPS_CMAKE_BACKUP" ]; then + cp "$DEPS_CMAKE" "$DEPS_CMAKE_BACKUP" +fi +python3 - "$DEPS_CMAKE_BACKUP" "$DEPS_CMAKE" <<'PYEOF' +import sys, re +src_path, dst_path = sys.argv[1], sys.argv[2] +text = open(src_path).read() +text = re.sub( + r"if \(SONIC_LIB AND SONIC_INC\).*?endif\(\)", + "if (SONIC_LIB AND SONIC_INC)\n set(HAVE_LIBSONIC ON)\nendif()", + text, + count=1, + flags=re.DOTALL, +) +open(dst_path, "w").write(text) +PYEOF + +# Trim the dict list down to ESPEAK_LANG_LIST for the cross build so we +# don't bloat the VFS image with ~80 languages. data.cmake is the upstream +# file we mutate; the change is one find-and-replace and we keep a backup. +DATA_CMAKE="$SRC_DIR/cmake/data.cmake" +DATA_CMAKE_BACKUP="$DATA_CMAKE.kandelo.orig" +if [ ! -f "$DATA_CMAKE_BACKUP" ]; then + cp "$DATA_CMAKE" "$DATA_CMAKE_BACKUP" +fi +echo "==> Restricting data.cmake to languages: $ESPEAK_LANG_LIST" +# Rewrite the _dict_compile_list literal. The upstream definition spans +# many lines; we replace the whole block with a single-line one. +python3 - "$DATA_CMAKE_BACKUP" "$DATA_CMAKE" "$ESPEAK_LANG_LIST" <<'PYEOF' +import sys, re +src_path, dst_path, langs = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(src_path).read() +new_block = "list(APPEND _dict_compile_list " + langs + ")\n" +text = re.sub( + r"list\(APPEND _dict_compile_list[^)]*\)\s*", + new_block, + text, + count=1, + flags=re.DOTALL, +) +open(dst_path, "w").write(text) +PYEOF + +# --- Phase 3: cross build of espeak-ng --------------------------------- +CROSS_BUILD_DIR="$HERE/espeak-ng-cross-build" +mkdir -p "$CROSS_BUILD_DIR" + +echo "==> Cross-compiling espeak-ng for wasm32..." +cmake -S "$SRC_DIR" -B "$CROSS_BUILD_DIR" \ + -DCMAKE_TOOLCHAIN_FILE="$HERE/wasm32-posix-toolchain.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DBUILD_SHARED_LIBS=OFF \ + -DUSE_MBROLA=OFF \ + -DUSE_LIBSONIC=OFF \ + -DUSE_LIBPCAUDIO=ON \ + -DUSE_KLATT=ON \ + -DUSE_SPEECHPLAYER=ON \ + -DUSE_ASYNC=OFF \ + -DENABLE_TESTS=OFF \ + -DCOMPILE_INTONATIONS=ON \ + -DESPEAK_COMPAT=OFF \ + -DNativeBuild_DIR="$NATIVE_BUILD_DIR/src" \ + -DNativeBuild="$NATIVE_BUILD_DIR/src" \ + -DPCAUDIO_LIB="$PCAUDIO_BUILD_DIR/libpcaudio.a" \ + -DPCAUDIO_INC="$PCAUDIO_SRC_DIR/src/include" \ + -DHAVE_LIBPCAUDIO=ON \ + -DHAVE_PTHREAD=OFF + +cmake --build "$CROSS_BUILD_DIR" --target espeak-ng-bin -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" +cmake --build "$CROSS_BUILD_DIR" --target data -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +# --- Phase 4: stage outputs -------------------------------------------- +echo "==> Staging into $INSTALL_DIR..." +mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/share" + +# espeak-ng-bin produces a "espeak-ng" file with no extension; rename +# to .wasm for the package resolver's binary contract. +cp "$CROSS_BUILD_DIR/src/espeak-ng" "$INSTALL_DIR/bin/espeak-ng.wasm" + +# Data dir: the cross build wrote it under CROSS_BUILD_DIR/espeak-ng-data/. +rm -rf "$INSTALL_DIR/share/espeak-ng-data" +cp -R "$CROSS_BUILD_DIR/espeak-ng-data" "$INSTALL_DIR/share/espeak-ng-data" + +# Restore data.cmake + deps.cmake so the source tree stays clean for next build. +mv "$DATA_CMAKE_BACKUP" "$DATA_CMAKE" +mv "$DEPS_CMAKE_BACKUP" "$DEPS_CMAKE" + +# Register the wasm binary in local-binaries so the resolver picks it +# up alongside released archives. The data dir is consumed by the +# shell-VFS builder directly out of $INSTALL_DIR/share/ — no +# install_local_binary path since the data dir is not a wasm output +# (single [[outputs]] entry only). +source "$REPO_ROOT/scripts/install-local-binary.sh" +install_local_binary espeak-ng "$INSTALL_DIR/bin/espeak-ng.wasm" + +echo "==> Done. Outputs:" +echo " $INSTALL_DIR/bin/espeak-ng.wasm" +echo " $INSTALL_DIR/share/espeak-ng-data/" diff --git a/packages/registry/espeak-ng/build.toml b/packages/registry/espeak-ng/build.toml new file mode 100644 index 0000000000..82388dd00c --- /dev/null +++ b/packages/registry/espeak-ng/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/espeak-ng/build-espeak-ng.sh" +inputs = [ + "packages/registry/espeak-ng/build-espeak-ng.sh", + "packages/registry/espeak-ng/wasm32-posix-toolchain.cmake", + "packages/registry/espeak-ng/pcaudiolib-src/src/audio.c", + "packages/registry/espeak-ng/pcaudiolib-src/src/audio_kandelo.c", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/espeak-ng/package.toml b/packages/registry/espeak-ng/package.toml new file mode 100644 index 0000000000..b4ddc45614 --- /dev/null +++ b/packages/registry/espeak-ng/package.toml @@ -0,0 +1,32 @@ +kind = "program" +name = "espeak-ng" +version = "1.52.0.1" +# Demo-impact session 41: vendor pcaudiolib + espeak-ng, route audio +# through a new kandelo backend in pcaudiolib (open /dev/snd/pcmC0D0p +# directly via the WRITEI loop from programs/alsa_demo.c), bundle a +# minimal English-only data dir. Image installs the binary at +# /usr/bin/espeak-ng and the data dir at /usr/share/espeak-ng-data, +# matching CMAKE_INSTALL_PREFIX=/usr so libespeak-ng's PATH_ESPEAK_DATA +# resolves correctly. +kernel_abi = 7 +depends_on = [] + +# Upstream is the espeak-ng master branch + pcaudiolib master. We +# vendor both depth-1 under espeak-ng-src/ + pcaudiolib-src/ and +# rebuild from there (mirrors the mariadb package's vendoring shape). +# Source SHA is a placeholder until the matrix workflow pins a release +# tarball — same convention as fbdoom. +[source] +url = "https://github.com/espeak-ng/espeak-ng" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "GPL-3.0-or-later" +url = "https://github.com/espeak-ng/espeak-ng/blob/master/COPYING" + +[build] +script_path = "packages/registry/espeak-ng/build-espeak-ng.sh" + +[[outputs]] +name = "espeak-ng" +wasm = "espeak-ng.wasm" diff --git a/packages/registry/espeak-ng/wasm32-posix-toolchain.cmake b/packages/registry/espeak-ng/wasm32-posix-toolchain.cmake new file mode 100644 index 0000000000..caf05476b7 --- /dev/null +++ b/packages/registry/espeak-ng/wasm32-posix-toolchain.cmake @@ -0,0 +1,134 @@ +# CMake toolchain file for cross-compiling espeak-ng + libpcaudio (with +# the kandelo backend) to wasm32 via the kandelo SDK. +# +# Adapted from packages/registry/mariadb/wasm32-posix-toolchain.cmake. +# espeak-ng doesn't probe nearly as many host features as MariaDB so we +# omit the long HAVE_* override list. + +cmake_minimum_required(VERSION 3.13) + +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR wasm32) +set(CMAKE_CROSSCOMPILING TRUE) + +# --- Locate LLVM clang --- +set(_LLVM_SEARCH_PATHS) +if(DEFINED ENV{LLVM_BIN}) + list(APPEND _LLVM_SEARCH_PATHS "$ENV{LLVM_BIN}") +endif() +if(DEFINED ENV{LLVM_PREFIX}) + list(APPEND _LLVM_SEARCH_PATHS "$ENV{LLVM_PREFIX}/bin") +endif() +list(APPEND _LLVM_SEARCH_PATHS + /opt/homebrew/opt/llvm/bin + /usr/local/opt/llvm/bin +) + +find_program(LLVM_CLANG NAMES clang PATHS ${_LLVM_SEARCH_PATHS} NO_DEFAULT_PATH) +if(NOT LLVM_CLANG) + message(FATAL_ERROR + "LLVM clang not found. Searched: ${_LLVM_SEARCH_PATHS}. " + "Set LLVM_BIN (Nix dev shell exports this) or install Homebrew LLVM." + ) +endif() +find_program(LLVM_AR NAMES llvm-ar PATHS ${_LLVM_SEARCH_PATHS} NO_DEFAULT_PATH) +find_program(LLVM_RANLIB NAMES llvm-ranlib PATHS ${_LLVM_SEARCH_PATHS} NO_DEFAULT_PATH) +find_program(LLVM_NM NAMES llvm-nm PATHS ${_LLVM_SEARCH_PATHS} NO_DEFAULT_PATH) + +# --- Sysroot --- +if(NOT WASM_POSIX_SYSROOT) + if(DEFINED ENV{WASM_POSIX_SYSROOT}) + set(WASM_POSIX_SYSROOT "$ENV{WASM_POSIX_SYSROOT}") + else() + get_filename_component(_TOOLCHAIN_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) + get_filename_component(WASM_POSIX_SYSROOT "${_TOOLCHAIN_DIR}/../../../sysroot" ABSOLUTE) + endif() +endif() + +if(NOT EXISTS "${WASM_POSIX_SYSROOT}/lib/libc.a") + message(FATAL_ERROR "Sysroot not found at ${WASM_POSIX_SYSROOT}. Run scripts/build-musl.sh first.") +endif() + +set(CMAKE_SYSROOT "${WASM_POSIX_SYSROOT}") + +# --- Compilers --- +set(CMAKE_C_COMPILER "${LLVM_CLANG}") +set(CMAKE_CXX_COMPILER "${LLVM_CLANG}") +set(CMAKE_AR "${LLVM_AR}" CACHE FILEPATH "Archiver") +set(CMAKE_RANLIB "${LLVM_RANLIB}" CACHE FILEPATH "Ranlib") +set(CMAKE_NM "${LLVM_NM}" CACHE FILEPATH "NM") + +# --- Compiler flags (mirror sdk/src/lib/flags.ts COMPILE_FLAGS) --- +set(WASM32_FLAGS + "--target=wasm32-unknown-unknown" + "-matomics" + "-mbulk-memory" + "-mexception-handling" + "-mllvm" "-wasm-enable-sjlj" + "-fno-trapping-math" + "--sysroot=${WASM_POSIX_SYSROOT}" +) +string(REPLACE ";" " " WASM32_FLAGS_STR "${WASM32_FLAGS}") +set(CMAKE_C_FLAGS_INIT "${WASM32_FLAGS_STR}") +set(CMAKE_CXX_FLAGS_INIT "${WASM32_FLAGS_STR}") + +# --- Linker flags (mirror sdk/src/lib/flags.ts LINK_FLAGS) --- +# Path to the kandelo glue objs that the SDK normally injects. We hand +# them to CMake via CMAKE_EXE_LINKER_FLAGS_INIT so cmake's link rule +# picks them up for `add_executable` targets (espeak-ng-bin). +get_filename_component(_TOOLCHAIN_DIR2 "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) +set(_GLUE_OBJ_DIR "${_TOOLCHAIN_DIR2}/glue-objs") + +set(WASM32_LINK_FLAGS + "-nostdlib" + "-Wl,--entry=_start" + "-Wl,--export=_start" + "-Wl,--export=__heap_base" + "-Wl,--import-memory" + "-Wl,--shared-memory" + "-Wl,--max-memory=1073741824" + "-Wl,--allow-undefined" + "-Wl,--global-base=1114112" + "-Wl,--table-base=3" + "-Wl,--export-table" + "-Wl,--growable-table" + "-Wl,--export=__wasm_init_tls" + "-Wl,--export=__tls_base" + "-Wl,--export=__tls_size" + "-Wl,--export=__tls_align" + "-Wl,--export=__stack_pointer" + "-Wl,--export=__wasm_thread_init" + "-Wl,-z,stack-size=1048576" +) +string(REPLACE ";" " " WASM32_LINK_FLAGS_STR "${WASM32_LINK_FLAGS}") + +set(CMAKE_EXE_LINKER_FLAGS_INIT + "${WASM32_LINK_FLAGS_STR} ${WASM_POSIX_SYSROOT}/lib/crt1.o ${_GLUE_OBJ_DIR}/channel_syscall.o ${_GLUE_OBJ_DIR}/compiler_rt.o -lc" +) + +# --- Type sizes for wasm32 ILP32 --- +set(CMAKE_SIZEOF_VOID_P 4) +set(CMAKE_C_SIZEOF_DATA_PTR 4) +set(CMAKE_CXX_SIZEOF_DATA_PTR 4) + +# --- Search paths --- +set(CMAKE_FIND_ROOT_PATH "${WASM_POSIX_SYSROOT}") +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + +# Disable try_run; espeak-ng's check_symbol_exists / check_include_file +# only need to compile, not link. +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + +# espeak-ng's USE_ASYNC option gates on find_package(Threads). Kandelo +# libc provides pthread but CMake's Threads detection runs a try_compile +# that may falsely conclude pthreads is missing under cross-compile. We +# advertise it explicitly. +set(THREADS_PTHREAD_ARG "0" CACHE STRING "" FORCE) +set(CMAKE_THREAD_LIBS_INIT "-lpthread" CACHE STRING "" FORCE) +set(CMAKE_HAVE_THREADS_LIBRARY 1 CACHE BOOL "" FORCE) +set(CMAKE_USE_WIN32_THREADS_INIT 0 CACHE BOOL "" FORCE) +set(CMAKE_USE_PTHREADS_INIT 1 CACHE BOOL "" FORCE) +set(THREADS_FOUND TRUE CACHE BOOL "" FORCE) diff --git a/tests/package-system/shell-vfs-install.test.ts b/tests/package-system/shell-vfs-install.test.ts new file mode 100644 index 0000000000..1186099260 --- /dev/null +++ b/tests/package-system/shell-vfs-install.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +describe("build-shell-vfs-image.sh", () => { + it("installs shell.vfs.zst into local-binaries so the @binaries import resolves", () => { + const script = readFileSync( + join(repoRoot, "images/vfs/scripts/build-shell-vfs-image.sh"), + "utf8", + ); + expect(script).toMatch( + /install_local_binary\s+shell\s+"\$REPO_ROOT\/apps\/browser-demos\/public\/shell\.vfs\.zst"/, + ); + }); +}); From 131a96e4f0aecbd651a246fa4e2c75e3d866289d Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 17 Aug 2026 15:29:51 +0200 Subject: [PATCH 18/27] fix(rebase): reconnect the evdev + audio work to main's kernel APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kernel/src/audio/tick.rs | 2 +- crates/kernel/src/input/dispatch.rs | 2 +- crates/kernel/src/ofd.rs | 3 +++ crates/kernel/src/process_table.rs | 11 +++++++++++ crates/kernel/src/syscalls.rs | 10 +++++++++- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/kernel/src/audio/tick.rs b/crates/kernel/src/audio/tick.rs index 0505484f6a..14e5ae9dc9 100644 --- a/crates/kernel/src/audio/tick.rs +++ b/crates/kernel/src/audio/tick.rs @@ -116,7 +116,7 @@ mod tests { fn install_process(pid: u32) -> &'static mut Process { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let _ = table.create_process(pid); + table.processes.insert(pid, Process::new(pid)); let proc = table.processes.get_mut(&pid).unwrap(); unsafe { &mut *(proc as *mut Process) } } diff --git a/crates/kernel/src/input/dispatch.rs b/crates/kernel/src/input/dispatch.rs index 74a9f1856d..2e66512e3a 100644 --- a/crates/kernel/src/input/dispatch.rs +++ b/crates/kernel/src/input/dispatch.rs @@ -79,7 +79,7 @@ mod tests { // own pids, so concurrent runs are independent. fn install_process(pid: u32) -> &'static mut Process { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let _ = table.create_process(pid); + table.processes.insert(pid, Process::new(pid)); let proc = table.processes.get_mut(&pid).unwrap(); unsafe { &mut *(proc as *mut Process) } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index a2eea93e44..b90e810d97 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -809,6 +809,9 @@ impl OfdTable { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, + audio: None, + audio_ctl: None, }; ofd.reset_directory_iterator_for_reopen(); self.insert(ofd) diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 657193c2f7..be2246a9a5 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -1704,6 +1704,17 @@ impl ProcessTable { } } +/// Run `f` over every live process. The audio period tick and the evdev +/// fan-out both need to reach each process's OFD table from outside a +/// syscall, where no `&mut Process` is in scope. +pub fn with_processes(f: F) +where + F: FnOnce(alloc::collections::btree_map::ValuesMut<'_, u32, Process>), +{ + let table = unsafe { &mut *GLOBAL_PROCESS_TABLE.0.get() }; + f(table.processes.values_mut()); +} + #[cfg(test)] mod wait_tests { use super::*; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 079354e18d..f1ac3a5769 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -3244,7 +3244,15 @@ pub fn sys_open( allow_missing_directory: false, use_real_ids: false, }; - let resolved_entry = resolve_namespace_path(proc, host, path, resolve_options)?; + // A deliberately-disabled node is not listed in the synthetic tree, so + // resolution reports it missing. Report why it cannot open instead. + let resolved_entry = match resolve_namespace_path(proc, host, path, resolve_options) { + Err(Errno::ENOENT) => match disabled_virtual_device(path) { + Some(errno) => return Err(errno), + None => return Err(Errno::ENOENT), + }, + other => other?, + }; if resolved_entry .stat .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFLNK) From b30f81849668b4316bd5e565166653c50578d066 Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 17 Aug 2026 16:08:43 +0200 Subject: [PATCH 19/27] =?UTF-8?q?refactor(audio):=20drop=20the=20kernel=20?= =?UTF-8?q?ALSA=20surface=20=E2=80=94=20/dev/dsp=20is=20the=20PCM=20contra?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pages/kandelo/kernel-host/live-setup.ts | 61 +- crates/kernel/src/{audio/oss.rs => audio.rs} | 0 crates/kernel/src/audio/mmap.rs | 335 ---- crates/kernel/src/audio/mod.rs | 21 - crates/kernel/src/audio/pcm_ioctl.rs | 1507 ----------------- crates/kernel/src/audio/sab.rs | 163 -- crates/kernel/src/audio/tick.rs | 268 --- crates/kernel/src/audio/wait.rs | 86 - crates/kernel/src/devfs.rs | 43 - crates/kernel/src/fork.rs | 446 ----- crates/kernel/src/ofd.rs | 195 --- crates/kernel/src/syscalls.rs | 504 +----- crates/kernel/src/wasm_api.rs | 60 - crates/shared/src/lib.rs | 324 ---- host/src/audio/audio-driver.ts | 48 - host/src/audio/browser-audio-driver.ts | 183 -- host/src/audio/instrumented-audio-driver.ts | 56 - host/src/audio/node-audio-driver.ts | 53 - host/src/audio/wpk-audio-worklet.js | 78 - host/src/browser-kernel-host.ts | 115 -- host/src/browser-kernel-protocol.ts | 49 - host/src/browser-kernel-worker-entry.ts | 15 - host/src/kernel-worker.ts | 44 - host/src/kernel.ts | 76 - host/src/node-kernel-host.ts | 104 -- host/src/node-kernel-protocol.ts | 45 +- host/src/node-kernel-worker-entry.ts | 15 - host/test/audio-driver.test.ts | 161 -- host/test/browser-audio-driver-drain.test.ts | 180 -- host/test/instrumented-audio-driver.test.ts | 121 -- libc/musl-overlay/include/sound/asound.h | 308 ---- 31 files changed, 12 insertions(+), 5652 deletions(-) rename crates/kernel/src/{audio/oss.rs => audio.rs} (100%) delete mode 100644 crates/kernel/src/audio/mmap.rs delete mode 100644 crates/kernel/src/audio/mod.rs delete mode 100644 crates/kernel/src/audio/pcm_ioctl.rs delete mode 100644 crates/kernel/src/audio/sab.rs delete mode 100644 crates/kernel/src/audio/tick.rs delete mode 100644 crates/kernel/src/audio/wait.rs delete mode 100644 host/src/audio/audio-driver.ts delete mode 100644 host/src/audio/browser-audio-driver.ts delete mode 100644 host/src/audio/instrumented-audio-driver.ts delete mode 100644 host/src/audio/node-audio-driver.ts delete mode 100644 host/src/audio/wpk-audio-worklet.js delete mode 100644 host/test/audio-driver.test.ts delete mode 100644 host/test/browser-audio-driver-drain.test.ts delete mode 100644 host/test/instrumented-audio-driver.test.ts delete mode 100644 libc/musl-overlay/include/sound/asound.h diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 6ce3ed70cc..0cd808c800 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -8,12 +8,6 @@ import { type ImageOwnedRuntimeLazyAssets, } from "../../../lib/init/image-owned-runtime-urls"; import { BrowserInputSource } from "../../../../../host/src/input/browser-input-source"; -import { BrowserAudioDriver } from "../../../../../host/src/audio/browser-audio-driver"; -import { - instrumentAudioDriver, - type InstrumentedAudioDriver, -} from "../../../../../host/src/audio/instrumented-audio-driver"; -import wpkAudioWorkletUrl from "../../../../../host/src/audio/wpk-audio-worklet.js?url"; import { ensureServiceWorkerReady, initServiceWorkerBridge, @@ -575,11 +569,10 @@ interface LiveProfile { */ evdevDemo: boolean; /** - * Attach a `BrowserAudioDriver` and spawn `espeak-ng "..."` from - * the booted shell. espeak-ng links against our patched pcaudiolib - * whose `create_audio_device_object` is wired to the kandelo - * backend (open /dev/snd/pcmC0D0p + WRITEI loop), so a single - * binary invocation produces audible synthesised speech without + * Spawn `espeak-ng "..."` from the booted shell. espeak-ng links + * against our patched pcaudiolib whose `create_audio_device_object` + * is wired to the kandelo backend, so a single binary invocation + * produces audible synthesised speech through `/dev/dsp` without * any host-side pipeline. The binary + data dir are baked into * the shell VFS image via `populateEspeakRuntime`. */ @@ -1903,32 +1896,15 @@ async function bootProfile( } else if (profile.espeakDemo) { // espeak-ng + its data dir are baked into the shell VFS image // (see populateEspeakRuntime in build-shell-vfs-image.ts), so - // no runtime binary staging is needed. The audio driver MUST be - // attached before the binary opens /dev/snd/pcmC0D0p — the - // WRITEI path returns EBADFD until the SAB ring is registered. - // espeak-ng emits at 22050 Hz mono (its internal synth rate); - // the worklet resamples to the AudioContext rate. - const kernelForEspeak = kernel; + // no runtime binary staging is needed. Playback rides the same + // /dev/dsp path every other sound demo uses. void (async () => { try { - tick("attaching audio driver..."); - const audioDriver = createInstrumentedAudioDriver(); - await kernelForEspeak.attachAudioDriver(audioDriver, { - pcmId: 0, - sampleRate: 22_050, - channels: 1, - periodFrames: 1024, - ringBytes: 64 * 1024, - }); tick("running espeak-ng..."); - try { - await host.runShellCommand( - `/usr/bin/espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`, - ); - tick("espeak-ng exited"); - } finally { - audioDriver.stop(0); - } + await host.runShellCommand( + `/usr/bin/espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`, + ); + tick("espeak-ng exited"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); tick(`espeak-ng failed: ${msg}`); @@ -2004,23 +1980,6 @@ async function bootProfile( } } -/** - * Wraps `BrowserAudioDriver` so the per-period tick callback also - * bumps `window.__alsaFramesConsumed`. Playwright reads that counter - * to confirm the AudioWorklet is alive and the kernel is being - * ticked. The forwarding contract is exercised by - * `host/test/instrumented-audio-driver.test.ts`. - */ -function createInstrumentedAudioDriver(): InstrumentedAudioDriver { - return instrumentAudioDriver( - new BrowserAudioDriver(wpkAudioWorkletUrl), - (_frames, total) => { - (window as unknown as { __alsaFramesConsumed?: number }) - .__alsaFramesConsumed = total; - }, - ); -} - function genericPresentationForProfile(profile: LiveProfile): DemoPresentation { if (profile.init?.web) return genericDemoPresentation("web"); if (profile.descriptor.runtime.features.includes("kms")) { diff --git a/crates/kernel/src/audio/oss.rs b/crates/kernel/src/audio.rs similarity index 100% rename from crates/kernel/src/audio/oss.rs rename to crates/kernel/src/audio.rs diff --git a/crates/kernel/src/audio/mmap.rs b/crates/kernel/src/audio/mmap.rs deleted file mode 100644 index 4c3420cb6a..0000000000 --- a/crates/kernel/src/audio/mmap.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! `mmap()` dispatcher for `/dev/snd/pcmC0D

p` open file descriptions. -//! -//! alsa-lib calls `mmap(pcm_fd, ..., offset)` three times right after -//! `HW_PARAMS`, one per page: -//! -//! - [`SNDRV_PCM_MMAP_OFFSET_STATUS`] — `snd_pcm_mmap_status`: -//! kernel-writes / userspace-reads. Lazily allocated as -//! [`AlsaFdState::mmap_status`]. -//! - [`SNDRV_PCM_MMAP_OFFSET_CONTROL`] — `snd_pcm_mmap_control`: -//! userspace-writes / kernel-reads. Lazily allocated as -//! [`AlsaFdState::mmap_control`]. -//! - [`SNDRV_PCM_MMAP_OFFSET_DATA`] — the SAB-backed PCM ring registered -//! via `kernel_audio_init_sab`. Returns [`Errno::ENODEV`] before the -//! host has issued that call. -//! -//! In v1 the user-space allocation is a plain anonymous wasm-page -//! reservation: alsa-lib gets back a base pointer it can pass to its -//! mmap-based reads, and the kernel-side `Box`es / SAB hold the actual -//! state. Mirroring the kernel-side state into the user pages is host -//! work (Phase B) — A5 just sets up the lazy allocation and dispatch. - -use alloc::boxed::Box; - -use wasm_posix_shared::Errno; -use wasm_posix_shared::audio::{ - SNDRV_PCM_MMAP_OFFSET_CONTROL, SNDRV_PCM_MMAP_OFFSET_DATA, SNDRV_PCM_MMAP_OFFSET_STATUS, - WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, -}; -use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_FAILED}; - -use crate::process::Process; - -/// Entry point invoked by [`crate::syscalls::sys_mmap`] when the target -/// fd has an attached [`crate::ofd::AlsaFdState`] sidecar (i.e. it was -/// opened against `/dev/snd/pcmC0D

p`). -pub fn handle_alsa_pcm_mmap( - proc: &mut Process, - ofd_idx: usize, - addr: usize, - len: usize, - prot: u32, - flags: u32, - offset: i64, -) -> Result { - if offset < 0 { - return Err(Errno::EINVAL); - } - match offset as u64 { - SNDRV_PCM_MMAP_OFFSET_STATUS => map_status_page(proc, ofd_idx, addr, len, prot, flags), - SNDRV_PCM_MMAP_OFFSET_CONTROL => map_control_page(proc, ofd_idx, addr, len, prot, flags), - SNDRV_PCM_MMAP_OFFSET_DATA => map_data_page(proc, ofd_idx, addr, len, prot, flags), - _ => Err(Errno::EINVAL), - } -} - -fn allocate_user_pages( - proc: &mut Process, - addr: usize, - len: usize, - prot: u32, - flags: u32, -) -> Result { - let alloc_flags = flags | MAP_ANONYMOUS; - let result = proc.memory.mmap_anonymous(addr, len, prot, alloc_flags); - if result == MAP_FAILED { - return Err(Errno::ENOMEM); - } - Ok(result) -} - -fn map_status_page( - proc: &mut Process, - ofd_idx: usize, - addr: usize, - len: usize, - prot: u32, - flags: u32, -) -> Result { - let user_addr = allocate_user_pages(proc, addr, len, prot, flags)?; - let audio = proc - .ofd_table - .get_mut(ofd_idx) - .ok_or(Errno::EBADF)? - .audio_mut() - .ok_or(Errno::EBADFD)?; - if audio.mmap_status.is_none() { - audio.mmap_status = Some(Box::new(WpkAlsaPcmMmapStatus::default())); - } - Ok(user_addr) -} - -fn map_control_page( - proc: &mut Process, - ofd_idx: usize, - addr: usize, - len: usize, - prot: u32, - flags: u32, -) -> Result { - let user_addr = allocate_user_pages(proc, addr, len, prot, flags)?; - let audio = proc - .ofd_table - .get_mut(ofd_idx) - .ok_or(Errno::EBADF)? - .audio_mut() - .ok_or(Errno::EBADFD)?; - if audio.mmap_control.is_none() { - audio.mmap_control = Some(Box::new(WpkAlsaPcmMmapControl::default())); - } - Ok(user_addr) -} - -fn map_data_page( - proc: &mut Process, - ofd_idx: usize, - addr: usize, - len: usize, - prot: u32, - flags: u32, -) -> Result { - // ENODEV before the SAB is registered. Read pcm_id off the OFD via - // an immutable borrow so the later mmap_anonymous can re-borrow - // proc mutably without aliasing. - let pcm_id = proc - .ofd_table - .get(ofd_idx) - .ok_or(Errno::EBADF)? - .audio() - .ok_or(Errno::EBADFD)? - .pcm_id; - if crate::audio::sab::lookup(pcm_id).is_none() { - return Err(Errno::ENODEV); - } - allocate_user_pages(proc, addr, len, prot, flags) -} - -// -------------------------------------------------------------------- -// Tests. -// -------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::ofd::{AlsaFdState, FileType, PcmDir}; - use crate::process::Process; - use crate::syscalls::VirtualDevice; - - fn install_pcm(proc: &mut Process) -> usize { - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - } - .host_handle(); - let idx = proc.ofd_table.create( - FileType::CharDevice, - 0, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - let ofd = proc.ofd_table.get_mut(idx).expect("created ofd"); - // Unlike the pcm_ioctl tests, leave mmap_status / mmap_control - // unset so A5 can prove it allocates them on first mmap. - ofd.audio = Some(Box::new(AlsaFdState { - pcm_id: 0, - ..AlsaFdState::default() - })); - idx - } - - fn fresh_sab() -> std::sync::MutexGuard<'static, ()> { - let g = crate::audio::sab::TEST_SAB_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - crate::audio::sab::reset_table(); - g - } - - #[test] - fn mmap_status_page_allocates_box_and_returns_user_addr() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let user_addr = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, // PROT_READ | PROT_WRITE - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_STATUS as i64, - ) - .expect("mmap STATUS"); - assert!(user_addr >= 0x04000000, "addr {:#x} below MMAP_BASE", user_addr); - let ofd = proc.ofd_table.get(idx).expect("ofd"); - let audio = ofd.audio().expect("audio sidecar"); - assert!(audio.mmap_status.is_some(), "STATUS box must be allocated"); - assert!(audio.mmap_control.is_none(), "CONTROL untouched by STATUS mmap"); - } - - #[test] - fn mmap_control_page_allocates_box_and_returns_user_addr() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let user_addr = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_CONTROL as i64, - ) - .expect("mmap CONTROL"); - assert!(user_addr >= 0x04000000); - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - assert!(audio.mmap_control.is_some(), "CONTROL box must be allocated"); - assert!(audio.mmap_status.is_none(), "STATUS untouched by CONTROL mmap"); - } - - #[test] - fn mmap_data_page_returns_user_addr_when_sab_registered() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - // Register a (fake) SAB so DATA mmap can succeed. - crate::audio::sab::register( - 0, - crate::audio::sab::SabSlice { - base: 0xdead_beef, - len: 8192, - }, - ) - .expect("sab register"); - let user_addr = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_DATA as i64, - ) - .expect("mmap DATA"); - assert!(user_addr >= 0x04000000); - } - - #[test] - fn mmap_data_page_before_init_sab_returns_enodev() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let err = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_DATA as i64, - ) - .expect_err("DATA without SAB must ENODEV"); - assert_eq!(err, Errno::ENODEV); - } - - #[test] - fn mmap_unknown_offset_returns_einval() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let err = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - 0x4000_0000, // not STATUS / CONTROL / DATA - ) - .expect_err("unknown offset"); - assert_eq!(err, Errno::EINVAL); - } - - #[test] - fn mmap_negative_offset_returns_einval() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let err = handle_alsa_pcm_mmap(&mut proc, idx, 0, 0x10000, 3, 0, -1) - .expect_err("negative offset"); - assert_eq!(err, Errno::EINVAL); - } - - #[test] - fn mmap_status_is_idempotent_does_not_realloc_box() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let idx = install_pcm(&mut proc); - let _ = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_STATUS as i64, - ) - .expect("first STATUS mmap"); - // Stash the Box pointer; the second mmap must NOT replace it. - let ptr_before = { - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - audio.mmap_status.as_ref().unwrap().as_ref() as *const _ as usize - }; - let _ = handle_alsa_pcm_mmap( - &mut proc, - idx, - 0, - 0x10000, - 3, - wasm_posix_shared::mmap::MAP_SHARED, - SNDRV_PCM_MMAP_OFFSET_STATUS as i64, - ) - .expect("second STATUS mmap"); - let ptr_after = { - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - audio.mmap_status.as_ref().unwrap().as_ref() as *const _ as usize - }; - assert_eq!( - ptr_before, ptr_after, - "second mmap must NOT reallocate the Box — alsa-lib expects a stable pointer", - ); - } -} diff --git a/crates/kernel/src/audio/mod.rs b/crates/kernel/src/audio/mod.rs deleted file mode 100644 index ca80146d1d..0000000000 --- a/crates/kernel/src/audio/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Audio subsystems. -//! -//! - [`oss`] implements the legacy `/dev/dsp` single-owner PCM sink -//! (OSS-style `ioctl`s + raw S16-LE writes; drained by the host via -//! `kernel_drain_audio`). Existing call sites reach OSS symbols -//! directly through `crate::audio::*` via the re-export below. -//! - ALSA modules (`pcm_ioctl`, `sab`, `mmap`, `tick`, `wait`) serve -//! `/dev/snd/pcmC0Dp`. `/dev/snd/controlC0` opens succeed via the -//! devfs node (so libasound's first probe doesn't crash), but no -//! ioctl dispatch lives here — espeak-ng/pcaudiolib never touches -//! the control surface, so a dedicated path would be code without -//! a caller. - -pub mod mmap; -pub mod oss; -pub mod pcm_ioctl; -pub mod sab; -pub mod tick; -pub mod wait; - -pub(crate) use oss::*; diff --git a/crates/kernel/src/audio/pcm_ioctl.rs b/crates/kernel/src/audio/pcm_ioctl.rs deleted file mode 100644 index b418c459fd..0000000000 --- a/crates/kernel/src/audio/pcm_ioctl.rs +++ /dev/null @@ -1,1507 +0,0 @@ -//! ALSA `/dev/snd/pcmC0D0p` ioctl dispatch. -//! -//! Implements the SNDRV_PCM_IOCTL_* surface that alsa-lib exercises -//! during open / configure / playback startup: -//! -//! ```text -//! PVERSION return the ALSA protocol version (alsa-lib bails if -//! this exceeds the runtime version) -//! INFO describe the device (card / device / stream / name) -//! HW_REFINE narrow a wildcard hw_params request to a single -//! concrete combination (S16_LE, 1..2 ch, 8000..48000 Hz, -//! period 64..4096 frames, buffer 256..16384 frames) -//! HW_PARAMS commit a refined hw_params (OPEN/SETUP → SETUP) -//! HW_FREE drop the committed hw/sw params (→ OPEN) -//! SW_PARAMS cache the avail_min / thresholds / boundary -//! PREPARE reset hw_ptr/appl_ptr (→ PREPARED) -//! START begin streaming (PREPARED → RUNNING) -//! DROP halt + return to SETUP -//! PAUSE toggle RUNNING ↔ PAUSED based on argument -//! STATUS snapshot state + pointers + monotonic timestamp -//! ``` -//! -//! State machine: -//! -//! ```text -//! OPEN ──(HW_PARAMS)──▶ SETUP ──(PREPARE)──▶ PREPARED ──(START)──▶ RUNNING -//! ▲ │ │ ▲ -//! │ │ │ │ -//! └──(HW_FREE)─────────── ┘ (PAUSE 1/0) ──▶ PAUSED -//! ▲ │ -//! └──────────(DROP)──────────────────────────┘ -//! ``` -//! -//! WRITEI_FRAMES, mmap, ctl ioctls, and the `kernel_audio_period_tick` -//! producer all land in subsequent tasks (A4 / A5 / A6). - -use alloc::boxed::Box; -use wasm_posix_shared::audio::*; -use wasm_posix_shared::Errno; - -use crate::ofd::{AlsaFdState, HwParamsCache, SwParamsCache}; -use crate::process::{HostIO, Process}; - -/// ALSA protocol version reported by `SNDRV_PCM_IOCTL_PVERSION`. -/// -/// alsa-lib bails when the kernel's protocol version is *higher* than -/// the runtime it was linked against; 13.0.0 is the floor that current -/// alsa-lib (1.2.x) negotiates with. -const SNDRV_PROTOCOL_VERSION: u32 = 0x000d_0000; - -// SND_PCM_INFO_* flags relevant to v1's playback surface. -const SNDRV_PCM_INFO_MMAP: u32 = 0x0000_0001; -const SNDRV_PCM_INFO_MMAP_VALID: u32 = 0x0000_0002; -const SNDRV_PCM_INFO_INTERLEAVED: u32 = 0x0000_0100; -const SNDRV_PCM_INFO_BLOCK_TRANSFER: u32 = 0x0000_0010; -const SNDRV_PCM_INFO_PAUSE: u32 = 0x0000_0080; -const SNDRV_PCM_CLASS_GENERIC: u32 = 0; - -// snd_pcm_hw_params mask indices — see Linux UAPI `enum snd_pcm_hw_param`. -const PARAM_ACCESS: usize = 0; -const PARAM_FORMAT: usize = 1; -const PARAM_SUBFORMAT: usize = 2; - -// snd_pcm_hw_params interval indices. -const PARAM_SAMPLE_BITS: usize = 0; -const PARAM_FRAME_BITS: usize = 1; -const PARAM_CHANNELS: usize = 2; -const PARAM_RATE: usize = 3; -const PARAM_PERIOD_SIZE: usize = 5; -const PARAM_PERIODS: usize = 7; -const PARAM_BUFFER_SIZE: usize = 9; - -const SNDRV_PCM_SUBFORMAT_STD: u32 = 0; - -// v1 capability bounds. -const MIN_CHANNELS: u32 = 1; -const MAX_CHANNELS: u32 = 2; -const MIN_RATE: u32 = 8000; -const MAX_RATE: u32 = 48000; -const MIN_PERIOD_SIZE: u32 = 64; -const MAX_PERIOD_SIZE: u32 = 4096; -const MIN_BUFFER_SIZE: u32 = 256; -const MAX_BUFFER_SIZE: u32 = 16384; -const SAMPLE_BITS_S16_LE: u32 = 16; - -// -------------------------------------------------------------------- -// Byte-buffer helpers. -// -------------------------------------------------------------------- - -fn read_struct(buf: &[u8]) -> Result { - if buf.len() < core::mem::size_of::() { - return Err(Errno::EINVAL); - } - Ok(unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const T) }) -} - -fn write_struct(buf: &mut [u8], value: &T) -> Result<(), Errno> { - if buf.len() < core::mem::size_of::() { - return Err(Errno::EINVAL); - } - unsafe { - core::ptr::write_unaligned(buf.as_mut_ptr() as *mut T, *value); - } - Ok(()) -} - -fn read_u32(buf: &[u8]) -> Result { - if buf.len() < 4 { - return Err(Errno::EINVAL); - } - Ok(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]])) -} - -fn write_u32(buf: &mut [u8], value: u32) -> Result<(), Errno> { - if buf.len() < 4 { - return Err(Errno::EINVAL); - } - buf[..4].copy_from_slice(&value.to_le_bytes()); - Ok(()) -} - -// -------------------------------------------------------------------- -// snd_mask helpers (each snd_mask is u32[8] inside hw_params.masks[64]). -// -------------------------------------------------------------------- - -const MASK_WORDS: usize = 8; - -fn mask_at(masks: &[u32; 64], idx: usize) -> &[u32] { - &masks[idx * MASK_WORDS..idx * MASK_WORDS + MASK_WORDS] -} - -fn mask_at_mut(masks: &mut [u32; 64], idx: usize) -> &mut [u32] { - &mut masks[idx * MASK_WORDS..idx * MASK_WORDS + MASK_WORDS] -} - -fn mask_is_empty(m: &[u32]) -> bool { - m.iter().all(|&w| w == 0) -} - -/// Treat a fully-zero mask as a wildcard ("user did not constrain this -/// dimension") and stamp the capability set in. After the intersection -/// downstream, that becomes the v1-allowed set. -fn fill_if_empty(m: &mut [u32], capability: &[u32; MASK_WORDS]) { - if mask_is_empty(m) { - m.copy_from_slice(capability); - } -} - -fn intersect_with(m: &mut [u32], capability: &[u32; MASK_WORDS]) { - for (w, c) in m.iter_mut().zip(capability.iter()) { - *w &= *c; - } -} - -fn capability_one(bit: u32) -> [u32; MASK_WORDS] { - let mut out = [0u32; MASK_WORDS]; - let word = (bit / 32) as usize; - out[word] |= 1u32 << (bit % 32); - out -} - -fn capability_two(a: u32, b: u32) -> [u32; MASK_WORDS] { - let mut out = [0u32; MASK_WORDS]; - out[(a / 32) as usize] |= 1u32 << (a % 32); - out[(b / 32) as usize] |= 1u32 << (b % 32); - out -} - -fn mask_first_set(m: &[u32]) -> Option { - for (i, &w) in m.iter().enumerate() { - if w != 0 { - return Some(i as u32 * 32 + w.trailing_zeros()); - } - } - None -} - -// -------------------------------------------------------------------- -// snd_interval helpers. -// -------------------------------------------------------------------- - -/// Clamp `interval` to `[min, max]` and narrow it to a single value -/// (set both endpoints to the chosen value — the alsa-lib refine -/// contract is "return one concrete combination"). -/// -/// Wildcard interpretation: a default-initialised `WpkSndInterval` -/// (`min == 0 && max == 0`) is treated as "user hasn't constrained -/// this", so we expand it to the capability range before clamping. -fn refine_interval( - interval: &mut WpkSndInterval, - min: u32, - max: u32, -) -> Result { - if interval.min == 0 && interval.max == 0 { - interval.min = min; - interval.max = max; - } - if interval.max == 0 || interval.max > max { - interval.max = max; - } - if interval.min < min { - interval.min = min; - } - if interval.min > interval.max { - return Err(Errno::EINVAL); - } - // Narrow to the lower bound. Deterministic + matches alsa-lib's - // common "prefer smaller buffer" preference for low-latency apps. - let chosen = interval.min; - interval.max = chosen; - interval.flags = 0; - Ok(chosen) -} - -fn read_interval_single(interval: &WpkSndInterval) -> Result { - if interval.min == 0 { - return Err(Errno::EINVAL); - } - if interval.max != 0 && interval.max != interval.min { - return Err(Errno::EINVAL); - } - Ok(interval.min) -} - -// -------------------------------------------------------------------- -// hw_params refine + extract. -// -------------------------------------------------------------------- - -/// Refine a wildcard / partially-constrained `hw_params` request against -/// v1 capabilities. On success the struct is mutated in place to hold -/// the single concrete combination the kernel commits to. EINVAL if no -/// combination fits (e.g. user asked for S32_LE, which we don't ship). -fn refine_hw_params(req: &mut WpkAlsaPcmHwParams) -> Result<(), Errno> { - // --- masks ------------------------------------------------------- - let access_cap = capability_two( - SNDRV_PCM_ACCESS_MMAP_INTERLEAVED, - SNDRV_PCM_ACCESS_RW_INTERLEAVED, - ); - let format_cap = capability_one(SNDRV_PCM_FORMAT_S16_LE); - let subformat_cap = capability_one(SNDRV_PCM_SUBFORMAT_STD); - - { - let m = mask_at_mut(&mut req.masks, PARAM_ACCESS); - fill_if_empty(m, &access_cap); - intersect_with(m, &access_cap); - if mask_is_empty(m) { - return Err(Errno::EINVAL); - } - } - { - let m = mask_at_mut(&mut req.masks, PARAM_FORMAT); - fill_if_empty(m, &format_cap); - intersect_with(m, &format_cap); - if mask_is_empty(m) { - return Err(Errno::EINVAL); - } - } - { - let m = mask_at_mut(&mut req.masks, PARAM_SUBFORMAT); - fill_if_empty(m, &subformat_cap); - intersect_with(m, &subformat_cap); - if mask_is_empty(m) { - return Err(Errno::EINVAL); - } - } - - // --- intervals --------------------------------------------------- - let channels = refine_interval( - &mut req.intervals[PARAM_CHANNELS], - MIN_CHANNELS, - MAX_CHANNELS, - )?; - let rate = refine_interval( - &mut req.intervals[PARAM_RATE], - MIN_RATE, - MAX_RATE, - )?; - let period_size = refine_interval( - &mut req.intervals[PARAM_PERIOD_SIZE], - MIN_PERIOD_SIZE, - MAX_PERIOD_SIZE, - )?; - let buffer_size = refine_interval( - &mut req.intervals[PARAM_BUFFER_SIZE], - MIN_BUFFER_SIZE, - MAX_BUFFER_SIZE, - )?; - - // --- derived intervals ------------------------------------------ - req.intervals[PARAM_SAMPLE_BITS] = WpkSndInterval { - min: SAMPLE_BITS_S16_LE, - max: SAMPLE_BITS_S16_LE, - flags: 0, - }; - let frame_bits = SAMPLE_BITS_S16_LE * channels; - req.intervals[PARAM_FRAME_BITS] = WpkSndInterval { - min: frame_bits, - max: frame_bits, - flags: 0, - }; - let periods = if period_size == 0 { 1 } else { buffer_size / period_size }; - let periods = periods.max(1); - req.intervals[PARAM_PERIODS] = WpkSndInterval { - min: periods, - max: periods, - flags: 0, - }; - - req.rate_num = rate; - req.rate_den = 1; - req.msbits = SAMPLE_BITS_S16_LE; - req.info = SNDRV_PCM_INFO_MMAP - | SNDRV_PCM_INFO_MMAP_VALID - | SNDRV_PCM_INFO_INTERLEAVED - | SNDRV_PCM_INFO_BLOCK_TRANSFER - | SNDRV_PCM_INFO_PAUSE; - Ok(()) -} - -fn extract_access(req: &WpkAlsaPcmHwParams) -> Result { - mask_first_set(mask_at(&req.masks, PARAM_ACCESS)).ok_or(Errno::EINVAL) -} - -fn extract_format(req: &WpkAlsaPcmHwParams) -> Result { - let bit = mask_first_set(mask_at(&req.masks, PARAM_FORMAT)) - .ok_or(Errno::EINVAL)?; - if bit != SNDRV_PCM_FORMAT_S16_LE { - return Err(Errno::EINVAL); - } - Ok(bit) -} - -fn extract_channels(req: &WpkAlsaPcmHwParams) -> Result { - let v = read_interval_single(&req.intervals[PARAM_CHANNELS])?; - if !(MIN_CHANNELS..=MAX_CHANNELS).contains(&v) { - return Err(Errno::EINVAL); - } - Ok(v) -} - -fn extract_rate(req: &WpkAlsaPcmHwParams) -> Result { - let v = read_interval_single(&req.intervals[PARAM_RATE])?; - if !(MIN_RATE..=MAX_RATE).contains(&v) { - return Err(Errno::EINVAL); - } - Ok(v) -} - -fn extract_period_size(req: &WpkAlsaPcmHwParams) -> Result { - let v = read_interval_single(&req.intervals[PARAM_PERIOD_SIZE])?; - if !(MIN_PERIOD_SIZE..=MAX_PERIOD_SIZE).contains(&v) { - return Err(Errno::EINVAL); - } - Ok(v as u64) -} - -fn extract_buffer_size(req: &WpkAlsaPcmHwParams) -> Result { - let v = read_interval_single(&req.intervals[PARAM_BUFFER_SIZE])?; - if !(MIN_BUFFER_SIZE..=MAX_BUFFER_SIZE).contains(&v) { - return Err(Errno::EINVAL); - } - Ok(v as u64) -} - -fn extract_periods(req: &WpkAlsaPcmHwParams) -> Result { - let v = read_interval_single(&req.intervals[PARAM_PERIODS])?; - if v == 0 { - return Err(Errno::EINVAL); - } - Ok(v) -} - -// -------------------------------------------------------------------- -// OFD borrow helpers. -// -------------------------------------------------------------------- - -fn audio_mut<'a>( - proc: &'a mut Process, - ofd_idx: usize, -) -> Result<&'a mut AlsaFdState, Errno> { - proc.ofd_table - .get_mut(ofd_idx) - .ok_or(Errno::EBADF)? - .audio_mut() - .ok_or(Errno::EBADFD) -} - -fn audio_ref<'a>( - proc: &'a Process, - ofd_idx: usize, -) -> Result<&'a AlsaFdState, Errno> { - proc.ofd_table - .get(ofd_idx) - .ok_or(Errno::EBADF)? - .audio() - .ok_or(Errno::EBADFD) -} - -fn monotonic_secs_nsecs(host: &mut dyn HostIO) -> (i64, i64) { - host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_MONOTONIC) - .unwrap_or((0, 0)) -} - -// -------------------------------------------------------------------- -// Dispatcher. -// -------------------------------------------------------------------- - -/// Entry point invoked by [`crate::syscalls::sys_ioctl`] when an ioctl -/// targets an OFD with an attached [`AlsaFdState`] sidecar (i.e. the OFD -/// was opened against `/dev/snd/pcmC0D0p`). -pub fn handle_alsa_pcm_ioctl( - proc: &mut Process, - host: &mut dyn HostIO, - ofd_idx: usize, - request: u32, - buf: &mut [u8], -) -> Result<(), Errno> { - match request { - SNDRV_PCM_IOCTL_PVERSION => write_u32(buf, SNDRV_PROTOCOL_VERSION), - - SNDRV_PCM_IOCTL_INFO => { - let audio = audio_ref(proc, ofd_idx)?; - let mut info = WpkAlsaPcmInfo { - device: audio.device as u32, - subdevice: audio.sub as u32, - stream: SNDRV_PCM_STREAM_PLAYBACK as i32, - card: audio.card as i32, - dev_class: SNDRV_PCM_CLASS_GENERIC, - subdevices_count: 1, - subdevices_avail: 1, - ..Default::default() - }; - copy_into_array(&mut info.id, b"wpk"); - copy_into_array(&mut info.name, b"wpk virtual playback"); - copy_into_array(&mut info.subname, b"subdevice #0"); - write_struct(buf, &info) - } - - SNDRV_PCM_IOCTL_HW_REFINE => { - let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; - refine_hw_params(&mut req)?; - write_struct(buf, &req) - } - - SNDRV_PCM_IOCTL_HW_PARAMS => { - let mut req: WpkAlsaPcmHwParams = read_struct(buf)?; - refine_hw_params(&mut req)?; - let cache = HwParamsCache { - format: extract_format(&req)?, - access: extract_access(&req)?, - channels: extract_channels(&req)?, - rate: extract_rate(&req)?, - period_size: extract_period_size(&req)?, - buffer_size: extract_buffer_size(&req)?, - periods: extract_periods(&req)?, - }; - let audio = audio_mut(proc, ofd_idx)?; - if audio.state != SNDRV_PCM_STATE_OPEN - && audio.state != SNDRV_PCM_STATE_SETUP - { - return Err(Errno::EBADFD); - } - audio.hw_params = Some(Box::new(cache)); - audio.state = SNDRV_PCM_STATE_SETUP; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = SNDRV_PCM_STATE_SETUP; - } - write_struct(buf, &req) - } - - SNDRV_PCM_IOCTL_HW_FREE => { - let audio = audio_mut(proc, ofd_idx)?; - audio.hw_params = None; - audio.sw_params = None; - audio.state = SNDRV_PCM_STATE_OPEN; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = SNDRV_PCM_STATE_OPEN; - status.hw_ptr = 0; - } - if let Some(ctl) = audio.mmap_control.as_mut() { - ctl.appl_ptr = 0; - } - Ok(()) - } - - SNDRV_PCM_IOCTL_SW_PARAMS => { - let req: WpkAlsaPcmSwParams = read_struct(buf)?; - let audio = audio_mut(proc, ofd_idx)?; - if audio.hw_params.is_none() { - return Err(Errno::EBADFD); - } - audio.sw_params = Some(Box::new(SwParamsCache { - avail_min: req.avail_min, - start_threshold: req.start_threshold, - stop_threshold: req.stop_threshold, - boundary: req.boundary, - })); - Ok(()) - } - - SNDRV_PCM_IOCTL_PREPARE => { - let audio = audio_mut(proc, ofd_idx)?; - if audio.hw_params.is_none() { - return Err(Errno::EBADFD); - } - audio.state = SNDRV_PCM_STATE_PREPARED; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = SNDRV_PCM_STATE_PREPARED; - status.hw_ptr = 0; - } - if let Some(ctl) = audio.mmap_control.as_mut() { - ctl.appl_ptr = 0; - } - Ok(()) - } - - SNDRV_PCM_IOCTL_START => { - let (sec, nsec) = monotonic_secs_nsecs(host); - let audio = audio_mut(proc, ofd_idx)?; - if audio.state != SNDRV_PCM_STATE_PREPARED { - return Err(Errno::EBADFD); - } - audio.state = SNDRV_PCM_STATE_RUNNING; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = SNDRV_PCM_STATE_RUNNING; - status.tstamp_sec = sec; - status.tstamp_nsec = nsec; - } - Ok(()) - } - - SNDRV_PCM_IOCTL_DROP => { - let audio = audio_mut(proc, ofd_idx)?; - // Linux accepts DROP from RUNNING / PREPARED / PAUSED / XRUN. - // OPEN (no hw_params committed yet) is the only invalid source. - if audio.state == SNDRV_PCM_STATE_OPEN { - return Err(Errno::EBADFD); - } - audio.state = SNDRV_PCM_STATE_SETUP; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = SNDRV_PCM_STATE_SETUP; - } - Ok(()) - } - - SNDRV_PCM_IOCTL_PAUSE => { - let value = read_u32(buf)?; - let audio = audio_mut(proc, ofd_idx)?; - let new_state = if value != 0 { - if audio.state != SNDRV_PCM_STATE_RUNNING { - return Err(Errno::EBADFD); - } - SNDRV_PCM_STATE_PAUSED - } else { - if audio.state != SNDRV_PCM_STATE_PAUSED { - return Err(Errno::EBADFD); - } - SNDRV_PCM_STATE_RUNNING - }; - audio.state = new_state; - if let Some(status) = audio.mmap_status.as_mut() { - status.state = new_state; - } - Ok(()) - } - - SNDRV_PCM_IOCTL_STATUS => { - let (sec, nsec) = monotonic_secs_nsecs(host); - let audio = audio_ref(proc, ofd_idx)?; - let hw_ptr = audio.mmap_status.as_ref().map(|s| s.hw_ptr).unwrap_or(0); - let appl_ptr = audio.mmap_control.as_ref().map(|c| c.appl_ptr).unwrap_or(0); - let buffer_size = audio - .hw_params - .as_ref() - .map(|h| h.buffer_size as i64) - .unwrap_or(0); - let delay = appl_ptr - hw_ptr; - let avail = if buffer_size > 0 { - (buffer_size - delay).max(0) as u64 - } else { - 0 - }; - let status = WpkAlsaPcmStatus { - state: audio.state, - _pad0: 0, - trigger_tstamp_sec: 0, - trigger_tstamp_nsec: 0, - tstamp_sec: sec, - tstamp_nsec: nsec, - appl_ptr, - hw_ptr, - delay, - avail, - avail_max: buffer_size as u64, - overrange: 0, - suspended_state: 0, - audio_tstamp_data: 0, - audio_tstamp_sec: 0, - audio_tstamp_nsec: 0, - _reserved: [0u8; 16], - }; - write_struct(buf, &status) - } - - SNDRV_PCM_IOCTL_WRITEI_FRAMES => handle_writei(proc, host, ofd_idx, buf), - - _ => Err(Errno::ENOTTY), - } -} - -/// Plan for one `WRITEI_FRAMES` call. Computed under an immutable -/// borrow of the OFD so the subsequent `proc_read_bytes` (which needs -/// `&mut HostIO`) and the `appl_ptr` advance (which needs `&mut OFD`) -/// don't fight the borrow checker. -struct WriteiPlan { - pcm_id: u32, - channels: usize, - ring_frames: usize, - appl_frame_offset: usize, - to_write: usize, -} - -/// `SNDRV_PCM_IOCTL_WRITEI_FRAMES` handler. The non-mmap data path: -/// userspace hands us a pointer + frame count and the kernel copies -/// the samples into the SAB-backed ring at `appl_ptr % ring_frames`. -/// -/// Short writes are normal — when the ring is full (`avail == 0`), -/// the call returns `result = 0` rather than blocking (v1 has no -/// wait queue for audio; A6 wires `kernel_audio_period_tick` → -/// POLLOUT wake which a future revision can use to park the caller). -fn handle_writei( - proc: &mut Process, - host: &mut dyn HostIO, - ofd_idx: usize, - buf: &mut [u8], -) -> Result<(), Errno> { - let mut req: WpkAlsaXferi = read_struct(buf)?; - let frames_req = req.frames as usize; - let pid = proc.pid as i32; - - // ---------- stage 1: validate + plan ---------- - let plan = { - let audio = audio_ref(proc, ofd_idx)?; - let hw = audio.hw_params.as_deref().ok_or(Errno::EBADFD)?; - if hw.format != SNDRV_PCM_FORMAT_S16_LE { - return Err(Errno::EINVAL); - } - if hw.channels == 0 { - return Err(Errno::EINVAL); - } - let channels = hw.channels as usize; - let bytes_per_frame = channels * core::mem::size_of::(); - - let slice = crate::audio::sab::lookup(audio.pcm_id).ok_or(Errno::ENODEV)?; - let ring_frames = slice.len / bytes_per_frame; - if ring_frames == 0 { - return Err(Errno::ENODEV); - } - - let appl = audio - .mmap_control - .as_deref() - .ok_or(Errno::EBADFD)? - .appl_ptr; - let hw_ptr = audio - .mmap_status - .as_deref() - .ok_or(Errno::EBADFD)? - .hw_ptr; - - let delay = appl - hw_ptr; - let avail = (ring_frames as i64 - delay).max(0) as usize; - let to_write = frames_req.min(avail); - let appl_frame_offset = appl.rem_euclid(ring_frames as i64) as usize; - - WriteiPlan { - pcm_id: audio.pcm_id, - channels, - ring_frames, - appl_frame_offset, - to_write, - } - }; - - // ---------- stage 2: copy user → SAB ring ---------- - if plan.to_write > 0 { - let bytes_per_frame = plan.channels * core::mem::size_of::(); - let total_bytes = plan.to_write * bytes_per_frame; - let mut scratch: alloc::vec::Vec = alloc::vec![0u8; total_bytes]; - let rc = host.proc_read_bytes(pid, req.buf as u32, &mut scratch); - if rc < 0 { - return Err(Errno::EFAULT); - } - - // SAFETY: the host registered the SAB via `kernel_audio_init_sab` - // and the ring outlives this call. Within one syscall the - // kernel is the sole producer; the AudioWorklet only consumes - // bytes at offsets below `appl_ptr` per the alsa-lib protocol. - let ring = unsafe { crate::audio::sab::ring_mut_s16(plan.pcm_id) } - .ok_or(Errno::ENODEV)?; - for f in 0..plan.to_write { - let dst_frame = (plan.appl_frame_offset + f) % plan.ring_frames; - for c in 0..plan.channels { - let src_byte = (f * plan.channels + c) * 2; - let sample = - i16::from_le_bytes([scratch[src_byte], scratch[src_byte + 1]]); - ring[dst_frame * plan.channels + c] = sample; - } - } - } - - // ---------- stage 3: advance appl_ptr ---------- - { - let audio = audio_mut(proc, ofd_idx)?; - if let Some(ctl) = audio.mmap_control.as_mut() { - ctl.appl_ptr += plan.to_write as i64; - } - } - - // ---------- stage 4: stamp result ---------- - req.result = plan.to_write as i64; - write_struct(buf, &req) -} - -/// Copy `src` into `dst`, NUL-padding any remaining tail. Truncates -/// `src` if it exceeds `dst.len()` (the trailing NUL is preserved by -/// the cap, so alsa-lib's strlen-based readers still find the -/// terminator). -fn copy_into_array(dst: &mut [u8], src: &[u8]) { - let n = src.len().min(dst.len().saturating_sub(1)); - dst[..n].copy_from_slice(&src[..n]); - for byte in &mut dst[n..] { - *byte = 0; - } -} - -// -------------------------------------------------------------------- -// Tests. -// -------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::ofd::{AlsaFdState, FileType, PcmDir}; - use crate::process::Process; - use crate::process::test_host::NoopHost; - use crate::syscalls::VirtualDevice; - - /// Build a freshly-opened OFD with an `AlsaFdState` sidecar attached - /// at the returned OFD index. Always populates mmap_status + - /// mmap_control so the state-machine arms exercise those branches. - /// (A4 wires those allocations via real mmap; for the dispatcher - /// tests we hand them in pre-populated.) - fn install_pcm(proc: &mut Process) -> usize { - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - } - .host_handle(); - let idx = proc.ofd_table.create( - FileType::CharDevice, - 0, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - let ofd = proc.ofd_table.get_mut(idx).expect("created ofd"); - ofd.audio = Some(Box::new(AlsaFdState { - mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus::default())), - mmap_control: Some(Box::new(WpkAlsaPcmMmapControl::default())), - ..AlsaFdState::default() - })); - idx - } - - /// A wildcard hw_params: all-zero, mirroring what alsa-lib hands the - /// kernel after `snd_pcm_hw_params_any`. - fn wildcard_hw_params() -> WpkAlsaPcmHwParams { - WpkAlsaPcmHwParams::default() - } - - fn refined_hw_params() -> WpkAlsaPcmHwParams { - let mut p = wildcard_hw_params(); - refine_hw_params(&mut p).expect("refine wildcard"); - p - } - - fn run_ioctl( - proc: &mut Process, - host: &mut NoopHost, - ofd_idx: usize, - request: u32, - buf: &mut [u8], - ) -> Result<(), Errno> { - handle_alsa_pcm_ioctl(proc, host, ofd_idx, request, buf) - } - - // --- PVERSION --------------------------------------------------- - - #[test] - fn pcm_pversion_returns_alsa_v13() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let mut buf = [0u8; 4]; - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PVERSION, &mut buf) - .expect("PVERSION"); - assert_eq!( - u32::from_le_bytes(buf), - SNDRV_PROTOCOL_VERSION, - "PVERSION must report 0x000d_0000 — alsa-lib bails on higher", - ); - } - - // --- INFO ------------------------------------------------------- - - #[test] - fn pcm_info_returns_playback_stream_card0_device0() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let mut buf = [0u8; core::mem::size_of::()]; - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_INFO, &mut buf) - .expect("INFO"); - let info: WpkAlsaPcmInfo = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - assert_eq!(info.card, 0); - assert_eq!(info.device, 0); - assert_eq!(info.subdevice, 0); - assert_eq!(info.stream, SNDRV_PCM_STREAM_PLAYBACK as i32); - assert!(info.name.starts_with(b"wpk virtual playback")); - assert_eq!(info.dev_class, SNDRV_PCM_CLASS_GENERIC); - assert_eq!(info.subdevices_count, 1); - assert_eq!(info.subdevices_avail, 1); - } - - // --- HW_REFINE -------------------------------------------------- - - #[test] - fn pcm_hw_refine_clamps_unsupported_format_to_s16_le() { - // User requests S32_LE only; refine must reject. - let mut p = wildcard_hw_params(); - let m = mask_at_mut(&mut p.masks, PARAM_FORMAT); - m.copy_from_slice(&capability_one(SNDRV_PCM_FORMAT_S32_LE)); - let err = refine_hw_params(&mut p).expect_err("S32-only must EINVAL"); - assert_eq!(err, Errno::EINVAL); - } - - #[test] - fn pcm_hw_refine_wildcard_narrows_to_v1_defaults() { - let mut p = wildcard_hw_params(); - refine_hw_params(&mut p).expect("wildcard refine"); - let format = mask_first_set(mask_at(&p.masks, PARAM_FORMAT)).unwrap(); - assert_eq!(format, SNDRV_PCM_FORMAT_S16_LE); - // Each interval narrowed to capability-min (min=min, max=min). - assert_eq!(p.intervals[PARAM_CHANNELS].min, MIN_CHANNELS); - assert_eq!(p.intervals[PARAM_CHANNELS].max, MIN_CHANNELS); - assert_eq!(p.intervals[PARAM_RATE].min, MIN_RATE); - assert_eq!(p.intervals[PARAM_RATE].max, MIN_RATE); - assert_eq!(p.intervals[PARAM_PERIOD_SIZE].min, MIN_PERIOD_SIZE); - assert_eq!(p.intervals[PARAM_BUFFER_SIZE].min, MIN_BUFFER_SIZE); - assert_eq!(p.intervals[PARAM_SAMPLE_BITS].min, SAMPLE_BITS_S16_LE); - assert_eq!(p.rate_num, MIN_RATE); - assert_eq!(p.rate_den, 1); - } - - #[test] - fn pcm_hw_refine_user_constrained_rate_is_respected() { - let mut p = wildcard_hw_params(); - p.intervals[PARAM_RATE] = WpkSndInterval { - min: 44100, - max: 44100, - flags: 0, - }; - refine_hw_params(&mut p).expect("respect user rate"); - assert_eq!(p.intervals[PARAM_RATE].min, 44100); - assert_eq!(p.intervals[PARAM_RATE].max, 44100); - assert_eq!(p.rate_num, 44100); - } - - // --- HW_PARAMS -------------------------------------------------- - - #[test] - fn pcm_hw_params_transitions_open_to_setup() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let p = refined_hw_params(); - let mut buf = struct_buf(&p); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_HW_PARAMS, &mut buf) - .expect("HW_PARAMS"); - let st = audio_ref(&proc, idx).unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); - let cache = st.hw_params.as_deref().expect("hw_params cached"); - assert_eq!(cache.format, SNDRV_PCM_FORMAT_S16_LE); - assert_eq!(cache.rate, MIN_RATE); - assert_eq!(cache.channels, MIN_CHANNELS); - // mmap_status (defaulted to OPEN by AlsaFdState::default) should - // also flip — refresh keeps userspace consistent. - assert_eq!( - st.mmap_status.as_deref().unwrap().state, - SNDRV_PCM_STATE_SETUP, - ); - } - - #[test] - fn pcm_hw_params_without_format_returns_einval() { - // Build a "refined-looking" struct with a zero FORMAT mask — - // refine_hw_params (called inside HW_PARAMS) re-fills empties - // with the capability set, so we have to actively poison the - // format dimension to drive this. Set the format mask to a - // disallowed bit (S32_LE) so the intersection collapses to - // empty. - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let mut p = wildcard_hw_params(); - let m = mask_at_mut(&mut p.masks, PARAM_FORMAT); - m.copy_from_slice(&capability_one(SNDRV_PCM_FORMAT_S32_LE)); - let mut buf = struct_buf(&p); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_HW_PARAMS, - &mut buf, - ) - .expect_err("S32-only must EINVAL"); - assert_eq!(err, Errno::EINVAL); - assert_eq!(audio_ref(&proc, idx).unwrap().state, SNDRV_PCM_STATE_OPEN); - } - - #[test] - fn pcm_hw_free_returns_to_open() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - let mut buf = []; - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_HW_FREE, &mut buf) - .expect("HW_FREE"); - let st = audio_ref(&proc, idx).unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_OPEN); - assert!(st.hw_params.is_none()); - assert!(st.sw_params.is_none()); - } - - // --- SW_PARAMS -------------------------------------------------- - - #[test] - fn pcm_sw_params_without_hw_params_returns_einval() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let sw = WpkAlsaPcmSwParams { avail_min: 256, ..Default::default() }; - let mut buf = struct_buf(&sw); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_SW_PARAMS, - &mut buf, - ) - .expect_err("SW_PARAMS before HW_PARAMS must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - #[test] - fn pcm_sw_params_caches_thresholds() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - let sw = WpkAlsaPcmSwParams { - avail_min: 512, - start_threshold: 1024, - stop_threshold: 4096, - boundary: 1 << 30, - ..Default::default() - }; - let mut buf = struct_buf(&sw); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_SW_PARAMS, &mut buf) - .expect("SW_PARAMS"); - let st = audio_ref(&proc, idx).unwrap(); - let cache = st.sw_params.as_deref().unwrap(); - assert_eq!(cache.avail_min, 512); - assert_eq!(cache.start_threshold, 1024); - assert_eq!(cache.stop_threshold, 4096); - assert_eq!(cache.boundary, 1 << 30); - } - - // --- PREPARE / START / DROP / PAUSE ----------------------------- - - #[test] - fn pcm_prepare_after_hw_params_transitions_to_prepared() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // Seed appl_ptr so PREPARE can reset it. - proc.ofd_table - .get_mut(idx) - .unwrap() - .audio_mut() - .unwrap() - .mmap_control - .as_mut() - .unwrap() - .appl_ptr = 1234; - let mut buf = []; - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut buf) - .expect("PREPARE"); - let st = audio_ref(&proc, idx).unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_PREPARED); - assert_eq!(st.mmap_control.as_ref().unwrap().appl_ptr, 0); - assert_eq!(st.mmap_status.as_ref().unwrap().hw_ptr, 0); - } - - #[test] - fn pcm_prepare_without_hw_params_returns_ebadfd() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_PREPARE, - &mut [], - ) - .expect_err("PREPARE in OPEN must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - #[test] - fn pcm_start_from_prepared_transitions_to_running() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) - .unwrap(); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) - .expect("START"); - let st = audio_ref(&proc, idx).unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_RUNNING); - let status = st.mmap_status.as_ref().unwrap(); - assert_eq!(status.state, SNDRV_PCM_STATE_RUNNING); - // NoopHost's clock returns (0, 0); we only assert the start - // path stamped *something* via host_clock_gettime — the exact - // value depends on the host. Picking >= 0 verifies the call - // wasn't bypassed (uninitialised memory would be UB). - assert!(status.tstamp_sec >= 0); - assert!(status.tstamp_nsec >= 0); - } - - #[test] - fn pcm_start_without_prepare_returns_ebadfd() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // SETUP, not PREPARED — START must reject. - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_START, - &mut [], - ) - .expect_err("START from SETUP must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - #[test] - fn pcm_drop_from_running_transitions_to_setup() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) - .unwrap(); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) - .unwrap(); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_DROP, &mut []) - .expect("DROP"); - let st = audio_ref(&proc, idx).unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); - assert_eq!( - st.mmap_status.as_ref().unwrap().state, - SNDRV_PCM_STATE_SETUP, - ); - } - - #[test] - fn pcm_drop_from_open_returns_ebadfd() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let err = run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_DROP, &mut []) - .expect_err("DROP from OPEN must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - #[test] - fn pcm_pause_then_resume_round_trips() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_PREPARE, &mut []) - .unwrap(); - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_START, &mut []) - .unwrap(); - let mut buf = 1u32.to_le_bytes(); - run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_PAUSE, - &mut buf, - ) - .expect("PAUSE pause"); - assert_eq!(audio_ref(&proc, idx).unwrap().state, SNDRV_PCM_STATE_PAUSED); - let mut buf = 0u32.to_le_bytes(); - run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_PAUSE, - &mut buf, - ) - .expect("PAUSE resume"); - assert_eq!( - audio_ref(&proc, idx).unwrap().state, - SNDRV_PCM_STATE_RUNNING, - ); - } - - #[test] - fn pcm_pause_from_setup_returns_ebadfd() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - let mut buf = 1u32.to_le_bytes(); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_PAUSE, - &mut buf, - ) - .expect_err("PAUSE from SETUP must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - // --- STATUS ----------------------------------------------------- - - #[test] - fn pcm_status_reflects_appl_ptr_hw_ptr_delta() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // Seed appl_ptr and hw_ptr to simulate a partially-consumed buffer. - { - let st = audio_mut(&mut proc, idx).unwrap(); - st.mmap_control.as_mut().unwrap().appl_ptr = 1024; - st.mmap_status.as_mut().unwrap().hw_ptr = 256; - } - let mut buf = [0u8; core::mem::size_of::()]; - run_ioctl(&mut proc, &mut host, idx, SNDRV_PCM_IOCTL_STATUS, &mut buf) - .expect("STATUS"); - let status: WpkAlsaPcmStatus = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - assert_eq!(status.state, SNDRV_PCM_STATE_SETUP); - assert_eq!(status.appl_ptr, 1024); - assert_eq!(status.hw_ptr, 256); - assert_eq!(status.delay, 1024 - 256); - // buffer_size committed via wildcard refine == MIN_BUFFER_SIZE. - assert_eq!(status.avail_max, MIN_BUFFER_SIZE as u64); - assert!(status.tstamp_sec >= 0); - assert!(status.tstamp_nsec >= 0); - } - - // --- WRITEI_FRAMES (A4) ----------------------------------------- - - /// Leak a fresh i16 ring sized to hold `frames * channels` - /// samples. The pointer is then registered with - /// [`crate::audio::sab`] and stays live for the test's lifetime. - fn install_sab_ring(pcm_id: u32, frames: usize, channels: usize) -> *mut i16 { - let total = frames * channels; - let vec = alloc::vec![0i16; total].into_boxed_slice(); - let leaked: &'static mut [i16] = alloc::boxed::Box::leak(vec); - let base = leaked.as_mut_ptr(); - let len_bytes = total * core::mem::size_of::(); - crate::audio::sab::register( - pcm_id, - crate::audio::sab::SabSlice { - base: base as usize, - len: len_bytes, - }, - ) - .expect("sab register"); - base - } - - /// Read the ring back into an owned Vec for assertion. The caller - /// MUST still hold the SAB lock so no concurrent producer mutates - /// the leaked region. - fn read_ring(ptr: *mut i16, frames: usize, channels: usize) -> alloc::vec::Vec { - let total = frames * channels; - let mut out = alloc::vec![0i16; total]; - unsafe { - core::ptr::copy_nonoverlapping(ptr, out.as_mut_ptr(), total); - } - out - } - - /// Bytes of `count` interleaved S16-LE frames at `channels`, with - /// the i'th sample = `seed + i` (so we can verify the ordering - /// survives the copy + wrap). Seeded so the all-zero "no host - /// copy" path can't accidentally pass the assertion. - fn synth_frames(count: usize, channels: usize, seed: i16) -> alloc::vec::Vec { - let samples = count * channels; - let mut out = alloc::vec::Vec::with_capacity(samples * 2); - for i in 0..samples as i16 { - out.extend_from_slice(&(seed + i).to_le_bytes()); - } - out - } - - fn fresh_sab() -> std::sync::MutexGuard<'static, ()> { - let g = crate::audio::sab::TEST_SAB_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - crate::audio::sab::reset_table(); - *crate::process::test_host::PROC_READ_SOURCE - .lock() - .unwrap_or_else(|e| e.into_inner()) = alloc::vec::Vec::new(); - g - } - - fn set_proc_read_source(bytes: alloc::vec::Vec) { - *crate::process::test_host::PROC_READ_SOURCE - .lock() - .unwrap_or_else(|e| e.into_inner()) = bytes; - } - - #[test] - fn writei_in_open_state_returns_ebadfd() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - // No HW_PARAMS commit → hw_params is None → WRITEI must EBADFD - // before the SAB lookup runs. - let xferi = WpkAlsaXferi { - result: 0, - buf: 0, - frames: 32, - }; - let mut buf = struct_buf(&xferi); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect_err("WRITEI in OPEN must EBADFD"); - assert_eq!(err, Errno::EBADFD); - } - - #[test] - fn writei_with_unsupported_format_returns_einval() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // Poison the committed format so the WRITEI guard fires - // before any SAB lookup or copy runs. The dispatcher's - // HW_PARAMS path can't produce this directly (extract_format - // gates on S16_LE) but a future XRUN-recovery path could. - audio_mut(&mut proc, idx) - .unwrap() - .hw_params - .as_mut() - .unwrap() - .format = SNDRV_PCM_FORMAT_S32_LE; - let xferi = WpkAlsaXferi { result: 0, buf: 0, frames: 16 }; - let mut buf = struct_buf(&xferi); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect_err("non-S16_LE must EINVAL"); - assert_eq!(err, Errno::EINVAL); - } - - #[test] - fn writei_without_sab_registered_returns_enodev() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // hw_params committed but SAB table empty (no - // kernel_audio_init_sab yet). WRITEI must surface ENODEV so - // a caller can tell "host hasn't wired audio yet" from - // "transport error". - let xferi = WpkAlsaXferi { result: 0, buf: 0, frames: 16 }; - let mut buf = struct_buf(&xferi); - let err = run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect_err("no SAB → ENODEV"); - assert_eq!(err, Errno::ENODEV); - } - - #[test] - fn writei_appends_frames_to_sab_ring() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - // Refined wildcard: channels=MIN(1), buffer_size=MIN(256). - let channels = MIN_CHANNELS as usize; - let ring_frames = MIN_BUFFER_SIZE as usize; - let ring_ptr = install_sab_ring(0, ring_frames, channels); - // Drive 8 frames of synthesised samples through the host - // bridge (seed=10 → samples 10,11,…,17). - let frames_to_write = 8usize; - set_proc_read_source(synth_frames(frames_to_write, channels, 10)); - let xferi = WpkAlsaXferi { - result: 0, - // Any non-zero address works — NoopHost ignores it and - // copies from PROC_READ_SOURCE. - buf: 0x4000_0000, - frames: frames_to_write as u64, - }; - let mut buf = struct_buf(&xferi); - run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect("WRITEI"); - let result: WpkAlsaXferi = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - assert_eq!(result.result, frames_to_write as i64); - // appl_ptr advanced. - let appl = - audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; - assert_eq!(appl, frames_to_write as i64); - // Ring head holds the synthesised samples; tail is still 0. - let ring = read_ring(ring_ptr, ring_frames, channels); - for i in 0..frames_to_write { - assert_eq!(ring[i], 10 + i as i16, "frame {i}"); - } - for i in frames_to_write..ring_frames { - assert_eq!(ring[i], 0, "tail must stay zero at {i}"); - } - } - - #[test] - fn writei_wraps_appl_ptr_at_buffer_boundary() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - let channels = MIN_CHANNELS as usize; - let ring_frames = MIN_BUFFER_SIZE as usize; - let ring_ptr = install_sab_ring(0, ring_frames, channels); - // Seed appl_ptr at ring_frames - 4 and hw_ptr at appl - 0 so - // there's effectively a full buffer of space ahead (we - // simulate the host having drained everything). Write 8 - // frames — the first 4 land at positions [ring_frames - 4, - // ring_frames - 1] and the next 4 wrap to [0, 3]. - { - let audio = audio_mut(&mut proc, idx).unwrap(); - audio.mmap_control.as_mut().unwrap().appl_ptr = (ring_frames - 4) as i64; - audio.mmap_status.as_mut().unwrap().hw_ptr = (ring_frames - 4) as i64; - } - let frames_to_write = 8usize; - set_proc_read_source(synth_frames(frames_to_write, channels, 100)); - let xferi = WpkAlsaXferi { - result: 0, - buf: 0x4000_0000, - frames: frames_to_write as u64, - }; - let mut buf = struct_buf(&xferi); - run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect("WRITEI wrap"); - let result: WpkAlsaXferi = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - assert_eq!(result.result, frames_to_write as i64); - // appl_ptr advances monotonically past the wrap boundary. - let appl = - audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; - assert_eq!(appl, (ring_frames - 4 + frames_to_write) as i64); - // First 4 frames at tail of ring. - let ring = read_ring(ring_ptr, ring_frames, channels); - for i in 0..4 { - assert_eq!(ring[ring_frames - 4 + i], 100 + i as i16); - } - // Next 4 frames at head of ring (wrap). - for i in 0..4 { - assert_eq!(ring[i], 100 + (4 + i) as i16); - } - } - - #[test] - fn writei_when_ring_full_writes_zero_frames() { - let _g = fresh_sab(); - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - commit_setup(&mut proc, &mut host, idx); - let channels = MIN_CHANNELS as usize; - let ring_frames = MIN_BUFFER_SIZE as usize; - let _ring_ptr = install_sab_ring(0, ring_frames, channels); - // Saturate: appl_ptr is ring_frames ahead of hw_ptr → avail=0. - // v1 has no audio wait queue (A6 territory), so the call - // returns 0 frames written rather than blocking. - { - let audio = audio_mut(&mut proc, idx).unwrap(); - audio.mmap_status.as_mut().unwrap().hw_ptr = 0; - audio.mmap_control.as_mut().unwrap().appl_ptr = ring_frames as i64; - } - let xferi = WpkAlsaXferi { - result: 0, - buf: 0x4000_0000, - frames: 64, - }; - let mut buf = struct_buf(&xferi); - run_ioctl( - &mut proc, - &mut host, - idx, - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - &mut buf, - ) - .expect("WRITEI full ring is not an error"); - let result: WpkAlsaXferi = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - assert_eq!(result.result, 0); - // appl_ptr unchanged. - let appl = - audio_ref(&proc, idx).unwrap().mmap_control.as_ref().unwrap().appl_ptr; - assert_eq!(appl, ring_frames as i64); - } - - #[test] - fn pcm_unknown_ioctl_returns_enotty() { - let mut proc = Process::new(1); - let mut host = NoopHost; - let idx = install_pcm(&mut proc); - let err = run_ioctl(&mut proc, &mut host, idx, 0xdead_beef, &mut []) - .expect_err("unknown ioctl must ENOTTY"); - assert_eq!(err, Errno::ENOTTY); - } - - // --- helpers ---------------------------------------------------- - - /// Drive PVERSION / INFO / wildcard HW_REFINE / HW_PARAMS so the - /// fd ends in SETUP with committed hw_params, ready for the - /// state-machine tests above. - fn commit_setup( - proc: &mut Process, - host: &mut NoopHost, - idx: usize, - ) { - let p = refined_hw_params(); - let mut buf = struct_buf(&p); - run_ioctl(proc, host, idx, SNDRV_PCM_IOCTL_HW_PARAMS, &mut buf) - .expect("HW_PARAMS"); - } - - fn struct_buf(value: &T) -> alloc::vec::Vec { - let mut buf = alloc::vec![0u8; core::mem::size_of::()]; - unsafe { - core::ptr::write_unaligned(buf.as_mut_ptr() as *mut T, *value); - } - buf - } -} diff --git a/crates/kernel/src/audio/sab.rs b/crates/kernel/src/audio/sab.rs deleted file mode 100644 index 23baebfb61..0000000000 --- a/crates/kernel/src/audio/sab.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Host-provided SharedArrayBuffer registry for ALSA PCM data rings. -//! -//! Each `/dev/snd/pcmC0D0p` opens against a numbered PCM (`pcm_id`). -//! Before any [`crate::audio::pcm_ioctl::handle_alsa_pcm_ioctl`] data -//! call can succeed, the host must hand the kernel a pointer to the -//! SAB-backed ring for that PCM via the `kernel_audio_init_sab` export -//! ([`crate::wasm_api`]). -//! -//! The ring is shared with the AudioWorklet on the host side; the -//! synchronisation protocol is alsa-lib's lock-free -//! producer/consumer (`mmap_status->hw_ptr` consumed by the host, -//! `mmap_control->appl_ptr` produced by userspace via `WRITEI` or by -//! direct mmap writes). This module is just the address book — -//! `(pcm_id) → (base, len)`. -//! -//! v1 ships at most four PCMs (`pcmC0D0p..pcmC0D3p`); the table is a -//! fixed `[Option; 4]` so registration is O(1) and the -//! kernel never allocates. - -use core::cell::UnsafeCell; - -use wasm_posix_shared::Errno; - -/// Address book entry for one PCM's SAB-backed data ring. -#[derive(Clone, Copy, Debug)] -pub struct SabSlice { - /// Base byte address into the kernel-visible linear memory window - /// the host imported for this SAB. The kernel treats it as a raw - /// `&mut [i16]` view via [`ring_mut_s16`]; cross-process - /// synchronisation is the caller's responsibility (alsa-lib's - /// hw_ptr/appl_ptr pair). - pub base: usize, - /// Length of the ring in bytes (must be a multiple of - /// `channels * sizeof(i16)`). - pub len: usize, -} - -const MAX_PCMS: usize = 4; - -struct GlobalSabTable(UnsafeCell<[Option; MAX_PCMS]>); - -// SAFETY: the centralized kernel processes one syscall at a time -// from the JS event loop; concurrent mutation is impossible at -// runtime. Cargo tests serialize via [`TEST_SAB_LOCK`]. -unsafe impl Sync for GlobalSabTable {} - -static SAB_TABLE: GlobalSabTable = GlobalSabTable(UnsafeCell::new([None; MAX_PCMS])); - -fn with_table(f: impl FnOnce(&mut [Option; MAX_PCMS]) -> R) -> R { - f(unsafe { &mut *SAB_TABLE.0.get() }) -} - -/// Bind `pcm_id` to a SAB slice. Re-registering an already-bound -/// `pcm_id` returns `EBUSY` — the second `kernel_audio_init_sab` -/// from the host is a no-op rather than a silent re-map. -pub fn register(pcm_id: u32, slice: SabSlice) -> Result<(), Errno> { - let idx = pcm_id as usize; - if idx >= MAX_PCMS { - return Err(Errno::EINVAL); - } - with_table(|tbl| { - if tbl[idx].is_some() { - return Err(Errno::EBUSY); - } - tbl[idx] = Some(slice); - Ok(()) - }) -} - -pub fn lookup(pcm_id: u32) -> Option { - let idx = pcm_id as usize; - if idx >= MAX_PCMS { - return None; - } - with_table(|tbl| tbl[idx]) -} - -/// Kernel-side `&mut [i16]` view of the PCM ring. Unsafe because the -/// host's AudioWorklet mutates the same memory concurrently; -/// callers respect alsa-lib's `hw_ptr` / `appl_ptr` protocol. -/// -/// Returns `None` when no SAB has been registered for `pcm_id`. -/// -/// # Safety -/// -/// The host MUST have called `kernel_audio_init_sab(pcm_id, base, len)` -/// with a `base..base+len` range that is valid for the kernel's -/// lifetime and is the same memory the AudioWorklet draws from. -pub unsafe fn ring_mut_s16(pcm_id: u32) -> Option<&'static mut [i16]> { - let SabSlice { base, len } = lookup(pcm_id)?; - Some(unsafe { - core::slice::from_raw_parts_mut( - base as *mut i16, - len / core::mem::size_of::(), - ) - }) -} - -/// Serializes cargo tests that touch the global SAB table. Same -/// pattern as `dri::bo::TEST_REGISTRY_LOCK`. Public-in-test only. -#[cfg(test)] -pub static TEST_SAB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[cfg(test)] -pub(crate) fn reset_table() { - with_table(|tbl| { - for slot in tbl.iter_mut() { - *slot = None; - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fresh() -> std::sync::MutexGuard<'static, ()> { - let g = TEST_SAB_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - reset_table(); - g - } - - #[test] - fn register_then_lookup_round_trips() { - let _g = fresh(); - register(2, SabSlice { base: 0x1000, len: 8192 }).expect("register"); - let s = lookup(2).expect("lookup"); - assert_eq!(s.base, 0x1000); - assert_eq!(s.len, 8192); - } - - #[test] - fn lookup_returns_none_when_unregistered() { - let _g = fresh(); - assert!(lookup(0).is_none()); - assert!(lookup(3).is_none()); - } - - #[test] - fn register_out_of_range_pcm_id_returns_einval() { - let _g = fresh(); - let err = register(4, SabSlice { base: 0, len: 0 }).expect_err("oob"); - assert_eq!(err, Errno::EINVAL); - } - - #[test] - fn double_register_returns_ebusy() { - let _g = fresh(); - register(0, SabSlice { base: 0x1000, len: 1024 }).expect("first"); - let err = register(0, SabSlice { base: 0x2000, len: 1024 }) - .expect_err("second must EBUSY"); - assert_eq!(err, Errno::EBUSY); - // The original entry survives. - assert_eq!(lookup(0).unwrap().base, 0x1000); - } - - #[test] - fn lookup_out_of_range_returns_none() { - let _g = fresh(); - assert!(lookup(MAX_PCMS as u32).is_none()); - assert!(lookup(u32::MAX).is_none()); - } -} diff --git a/crates/kernel/src/audio/tick.rs b/crates/kernel/src/audio/tick.rs deleted file mode 100644 index 14e5ae9dc9..0000000000 --- a/crates/kernel/src/audio/tick.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Period-tick producer for ALSA PCM fds. -//! -//! [`tick`] is called from the `kernel_audio_period_tick` export -//! ([`crate::wasm_api`]) on every AudioWorklet quantum (browser) or -//! `setInterval` tick (Node) after the host driver pulled -//! `frames_consumed` frames from the SAB ring. It walks every open -//! `/dev/snd/pcmC0Dp` OFD whose state is `STATE_RUNNING`; -//! advances `mmap_status.hw_ptr` by `frames_consumed`; stamps -//! `tstamp_sec` / `tstamp_nsec`; detects XRUN (`hw_ptr > appl_ptr`); -//! and wakes POLLOUT waiters via [`super::wait::wake_pollout`]. -//! -//! Lock order — mirrors [`crate::dri::drain_pending_flips`]: hold the -//! process-table briefly to walk OFDs + advance state, collect -//! wake-target idxs into a local `Vec`, drop the lock, then drive -//! [`super::wait::wake_pollout`] outside the lock so the wake path -//! never re-enters under the table guard. -//! -//! The kernel-side `Box` on each OFD is the -//! source of truth for `hw_ptr` / `state`; user-page mirroring is a -//! Phase B host-bridge concern (see the ALSA plan §"Architecturally -//! load-bearing decisions"). - -use alloc::vec::Vec; - -use wasm_posix_shared::audio::{SNDRV_PCM_STATE_RUNNING, SNDRV_PCM_STATE_XRUN}; - -use crate::audio::wait; - -/// Advance `hw_ptr` by `frames_consumed` on every RUNNING OFD bound -/// to `pcm_id`, stamp the monotonic timestamp, detect XRUN, then -/// wake POLLOUT waiters. -/// -/// `tv_sec` / `tv_nsec` are supplied by the caller so this function -/// stays testable without a `HostIO`; the `kernel_audio_period_tick` -/// export fetches them once via `WasmHostIO::host_clock_gettime` and -/// passes them down. -/// Read the current `mmap_control.appl_ptr` for any OFD bound to -/// `pcm_id` (max across matches; in practice ≤1 writer per PCM). -/// Backs [`crate::wasm_api::kernel_audio_get_appl_ptr`] — the host's -/// browser audio driver forwards this into the AudioWorklet so the -/// worklet can gate `hwPtr` advance on producer progress (silence -/// past `appl_ptr`). Returns 0 when no OFD is bound. -pub fn current_appl_ptr(pcm_id: u32) -> i64 { - let mut result: i64 = 0; - crate::process_table::with_processes(|procs| { - for proc in procs { - for (_idx, ofd) in proc.ofd_table.iter_mut() { - let Some(audio) = ofd.audio_mut() else { continue }; - if audio.pcm_id != pcm_id { - continue; - } - if let Some(ctl) = audio.mmap_control.as_ref() { - if ctl.appl_ptr > result { - result = ctl.appl_ptr; - } - } - } - } - }); - result -} - -pub fn tick(pcm_id: u32, frames_consumed: u32, tv_sec: i64, tv_nsec: i64) { - let mut woken: Vec = Vec::new(); - crate::process_table::with_processes(|procs| { - for proc in procs { - for (idx, ofd) in proc.ofd_table.iter_mut() { - let Some(audio) = ofd.audio_mut() else { continue }; - if audio.pcm_id != pcm_id { - continue; - } - if audio.state != SNDRV_PCM_STATE_RUNNING { - continue; - } - if let Some(status) = audio.mmap_status.as_mut() { - status.hw_ptr = status.hw_ptr.saturating_add(frames_consumed as i64); - status.tstamp_sec = tv_sec; - status.tstamp_nsec = tv_nsec; - let new_hw_ptr = status.hw_ptr; - // `status` borrow ends here so `audio.mmap_control` - // and `audio.state` can be touched mutably. - let appl = audio - .mmap_control - .as_ref() - .map(|c| c.appl_ptr) - .unwrap_or(0); - if new_hw_ptr > appl { - audio.state = SNDRV_PCM_STATE_XRUN; - if let Some(s) = audio.mmap_status.as_mut() { - s.state = SNDRV_PCM_STATE_XRUN; - } - } - } - woken.push(idx); - } - } - }); - for ofd_idx in woken { - wait::wake_pollout(ofd_idx); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::audio::wait::{drain_wake_count, reset as reset_wakes, TEST_WAKE_LOCK}; - use crate::ofd::{AlsaFdState, FileType, PcmDir}; - use crate::process::Process; - use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; - use crate::syscalls::VirtualDevice; - use alloc::boxed::Box; - use wasm_posix_shared::audio::{ - SNDRV_PCM_STATE_PREPARED, WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, - }; - use wasm_posix_shared::flags::O_WRONLY; - - fn install_process(pid: u32) -> &'static mut Process { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(pid, Process::new(pid)); - let proc = table.processes.get_mut(&pid).unwrap(); - unsafe { &mut *(proc as *mut Process) } - } - - fn remove_process(pid: u32) { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.remove(&pid); - } - - fn install_pcm(proc: &mut Process, pcm_id: u32, state: u32) -> usize { - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: pcm_id as u8, - sub: 0, - kind: PcmDir::Playback, - } - .host_handle(); - let idx = proc.ofd_table.create( - FileType::CharDevice, - O_WRONLY, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - let ofd = proc.ofd_table.get_mut(idx).unwrap(); - ofd.audio = Some(Box::new(AlsaFdState { - pcm_id, - state, - mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus::default())), - // Large appl_ptr so the default-init hw_ptr advance does - // not trip XRUN unless a test rewrites the pointers. - mmap_control: Some(Box::new(WpkAlsaPcmMmapControl { - appl_ptr: 1_000_000_000, - ..WpkAlsaPcmMmapControl::default() - })), - ..AlsaFdState::default() - })); - idx - } - - fn fresh() -> std::sync::MutexGuard<'static, ()> { - let g = TEST_WAKE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - reset_wakes(); - g - } - - #[test] - fn tick_advances_hw_ptr_by_frames_consumed() { - let _g = fresh(); - let proc = install_process(8001); - let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); - - tick(0, 256, 12_345, 678_901_234); - - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - let status = audio.mmap_status.as_ref().unwrap(); - assert_eq!(status.hw_ptr, 256); - assert_eq!(status.tstamp_sec, 12_345); - assert_eq!(status.tstamp_nsec, 678_901_234); - assert_eq!(audio.state, SNDRV_PCM_STATE_RUNNING); - remove_process(8001); - } - - #[test] - fn tick_on_non_running_pcm_is_a_noop() { - let _g = fresh(); - let proc = install_process(8002); - let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_PREPARED); - { - let audio = proc.ofd_table.get_mut(idx).unwrap().audio_mut().unwrap(); - audio.mmap_status.as_mut().unwrap().hw_ptr = 42; - } - - tick(0, 256, 1, 1); - - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - let status = audio.mmap_status.as_ref().unwrap(); - assert_eq!(status.hw_ptr, 42, "PREPARED PCM must not advance hw_ptr"); - assert_eq!(audio.state, SNDRV_PCM_STATE_PREPARED); - assert_eq!( - drain_wake_count(idx), - 0, - "non-RUNNING OFD must not wake POLLOUT" - ); - remove_process(8002); - } - - #[test] - fn tick_underrun_transitions_state_to_xrun() { - let _g = fresh(); - let proc = install_process(8003); - let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); - // appl_ptr=1000, hw_ptr starts at 900 → advance by 200 → - // 1100 > 1000 → XRUN. - { - let audio = proc.ofd_table.get_mut(idx).unwrap().audio_mut().unwrap(); - audio.mmap_status.as_mut().unwrap().hw_ptr = 900; - audio.mmap_control.as_mut().unwrap().appl_ptr = 1000; - } - - tick(0, 200, 0, 0); - - let audio = proc.ofd_table.get(idx).unwrap().audio().unwrap(); - let status = audio.mmap_status.as_ref().unwrap(); - assert_eq!(status.hw_ptr, 1100); - assert_eq!(audio.state, SNDRV_PCM_STATE_XRUN, "OFD state must latch XRUN"); - assert_eq!( - status.state, SNDRV_PCM_STATE_XRUN, - "mmap_status.state must mirror so user-page readers see XRUN", - ); - remove_process(8003); - } - - #[test] - fn tick_wakes_blocked_pollout_waiter() { - let _g = fresh(); - let proc = install_process(8004); - let idx = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); - - tick(0, 100, 0, 0); - - assert_eq!( - drain_wake_count(idx), - 1, - "RUNNING OFD on the ticked pcm_id must wake POLLOUT exactly once", - ); - remove_process(8004); - } - - #[test] - fn tick_skips_ofds_on_a_different_pcm_id() { - let _g = fresh(); - let proc = install_process(8005); - let idx_zero = install_pcm(proc, 0, SNDRV_PCM_STATE_RUNNING); - let idx_one = install_pcm(proc, 1, SNDRV_PCM_STATE_RUNNING); - - tick(0, 256, 0, 0); - - let zero = proc.ofd_table.get(idx_zero).unwrap().audio().unwrap(); - let one = proc.ofd_table.get(idx_one).unwrap().audio().unwrap(); - assert_eq!(zero.mmap_status.as_ref().unwrap().hw_ptr, 256); - assert_eq!( - one.mmap_status.as_ref().unwrap().hw_ptr, 0, - "tick on pcm_id=0 must not touch pcm_id=1", - ); - assert_eq!(drain_wake_count(idx_zero), 1); - assert_eq!(drain_wake_count(idx_one), 0); - remove_process(8005); - } -} diff --git a/crates/kernel/src/audio/wait.rs b/crates/kernel/src/audio/wait.rs deleted file mode 100644 index c218c1ad04..0000000000 --- a/crates/kernel/src/audio/wait.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! POLLOUT wake primitives for ALSA PCM fds. -//! -//! [`super::tick::tick`] calls [`wake_pollout`] on every still-RUNNING -//! OFD after advancing `hw_ptr`. The wake pushes a -//! [`crate::wakeup::WAKE_WRITABLE`] event onto the global wakeup -//! buffer so the host drains the AlsaPcm wake alongside the existing -//! pipe / accept wakeup loop. -//! -//! A7 wires up the actual `poll(POLLOUT)` arm in `sys_poll` that -//! consumes the wake; v1 keeps the consumer side a stub. Tests can -//! observe the wake via [`drain_wake_count`] under [`TEST_WAKE_LOCK`]. - -use alloc::collections::BTreeMap; -use core::cell::UnsafeCell; - -struct WakeTracker { - counts: UnsafeCell>, -} - -// SAFETY: the centralized kernel processes one syscall at a time; -// cargo tests serialize via [`TEST_WAKE_LOCK`]. -unsafe impl Sync for WakeTracker {} - -static POLLOUT_WAKES: WakeTracker = WakeTracker { - counts: UnsafeCell::new(BTreeMap::new()), -}; - -/// Signal that `ofd_idx`'s `poll(POLLOUT)` condition may now be -/// satisfied. Pushes a [`crate::wakeup::WAKE_WRITABLE`] onto the -/// global wakeup buffer for host-side drain; tests can call -/// [`drain_wake_count`] (under [`TEST_WAKE_LOCK`]) to verify the -/// signal fired. -pub fn wake_pollout(ofd_idx: usize) { - let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; - *map.entry(ofd_idx).or_insert(0) += 1; - crate::wakeup::push(ofd_idx as u32, crate::wakeup::WAKE_WRITABLE); -} - -#[cfg(test)] -pub(crate) fn drain_wake_count(ofd_idx: usize) -> u32 { - let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; - map.remove(&ofd_idx).unwrap_or(0) -} - -#[cfg(test)] -pub(crate) fn reset() { - let map = unsafe { &mut *POLLOUT_WAKES.counts.get() }; - map.clear(); -} - -/// Serializes tests that touch the global POLLOUT_WAKES tracker (and, -/// transitively, the [`crate::process_table::GLOBAL_PROCESS_TABLE`] -/// reachable via [`super::tick::tick`]). Same pattern as -/// [`crate::audio::sab::TEST_SAB_LOCK`]. -#[cfg(test)] -pub static TEST_WAKE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[cfg(test)] -mod tests { - use super::*; - - fn fresh() -> std::sync::MutexGuard<'static, ()> { - let g = TEST_WAKE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - reset(); - g - } - - #[test] - fn wake_pollout_increments_per_ofd_count() { - let _g = fresh(); - wake_pollout(7); - wake_pollout(7); - wake_pollout(11); - assert_eq!(drain_wake_count(7), 2); - assert_eq!(drain_wake_count(11), 1); - assert_eq!(drain_wake_count(99), 0); - } - - #[test] - fn drain_resets_the_counter_for_the_ofd() { - let _g = fresh(); - wake_pollout(3); - assert_eq!(drain_wake_count(3), 1); - assert_eq!(drain_wake_count(3), 0); - } -} diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index 18f5598a05..616efa07c2 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -35,8 +35,6 @@ pub enum DevfsEntry { InputDir, /// /dev/dri DriDir, - /// /dev/snd - SndDir, } /// Match a resolved path to a devfs directory entry. @@ -49,7 +47,6 @@ pub fn match_devfs_dir(path: &[u8]) -> Option { b"/dev/fd" => Some(DevfsEntry::FdDir), b"/dev/input" => Some(DevfsEntry::InputDir), b"/dev/dri" => Some(DevfsEntry::DriDir), - b"/dev/snd" => Some(DevfsEntry::SndDir), _ => None, } } @@ -166,7 +163,6 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec { // /dev/input/mice — Linux-compatible PS/2 mouse stream. @@ -186,14 +182,6 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec { - // /dev/snd/controlC0 — ALSA control surface (plan 6). - // /dev/snd/pcmC0D0p — ALSA PCM playback (plan 6). Capture - // (`pcmC0D0c`) is deliberately not listed; opens for it - // get ENODEV from disabled_virtual_device. - entries.push((b"controlC0".into(), DT_CHR, devfs_ino(b"/dev/snd/controlC0"))); - entries.push((b"pcmC0D0p".into(), DT_CHR, devfs_ino(b"/dev/snd/pcmC0D0p"))); - } DevfsEntry::PtsDir => { // List active PTY slaves for i in 0..crate::pty::MAX_PTYS { @@ -539,37 +527,6 @@ mod tests { ); } - #[test] - fn snd_dir_is_listed_under_dev() { - let proc = crate::process::Process::new(1); - let entries = dir_entries(&proc, &DevfsEntry::Root); - let mut found = false; - for (name, dtype, _) in entries.iter() { - if name.as_slice() == b"snd" { - assert_eq!(*dtype, DT_DIR); - found = true; - } - } - assert!(found, "snd subdir missing from /dev listing"); - } - - #[test] - fn snd_dir_lists_controlc0_and_pcmc0d0p() { - let proc = crate::process::Process::new(1); - let entries = dir_entries(&proc, &DevfsEntry::SndDir); - let names: Vec<&[u8]> = entries.iter().map(|(n, _, _)| n.as_slice()).collect(); - assert!(names.iter().any(|n| *n == b"controlC0"), "controlC0 missing: {:?}", names); - assert!(names.iter().any(|n| *n == b"pcmC0D0p"), "pcmC0D0p missing: {:?}", names); - // pcmC0D0c is deliberately NOT listed — v1 ships playback only. - assert!(!names.iter().any(|n| *n == b"pcmC0D0c")); - for (_, dtype, _) in entries.iter() { - assert_eq!(*dtype, DT_CHR); - } - // /dev/snd itself stats as a directory. - let st = match_devfs_stat(b"/dev/snd", 0, 0).unwrap(); - assert_eq!(st.st_mode & 0o170000, S_IFDIR); - } - #[test] fn event0_and_event1_listed_in_dev_input_dir() { let proc = crate::process::Process::new(1); diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index fa915745b4..2f4d92a8f2 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -642,12 +642,6 @@ const DRI_TAG_PRIME_BO: u8 = 3; const INPUT_TAG_NONE: u8 = 0; const INPUT_TAG_SOME: u8 = 1; -const AUDIO_TAG_NONE: u8 = 0; -const AUDIO_TAG_SOME: u8 = 1; - -const AUDIO_CTL_TAG_NONE: u8 = 0; -const AUDIO_CTL_TAG_SOME: u8 = 1; - const PCM_DIR_PLAYBACK: u8 = 0; const PCM_DIR_CAPTURE: u8 = 1; @@ -740,98 +734,6 @@ fn write_input_state( Ok(()) } -/// Serialise the ALSA `/dev/snd/pcmC0D0p` sidecar across a fork/exec. -/// The child inherits the full state machine snapshot (state + -/// hw_params + sw_params + mmap pages + pcm_id). The SAB registry is a -/// global keyed by `pcm_id`; the kernel-side ring is not copied — both -/// sides keep referring to the same host-allocated buffer. -fn write_audio_state( - w: &mut Writer<'_>, - state: Option<&crate::ofd::AlsaFdState>, -) -> Result<(), Errno> { - let Some(audio) = state else { - return w.write_u8(AUDIO_TAG_NONE); - }; - w.write_u8(AUDIO_TAG_SOME)?; - w.write_u8(audio.card)?; - w.write_u8(audio.device)?; - w.write_u8(audio.sub)?; - w.write_u8(match audio.kind { - crate::ofd::PcmDir::Playback => PCM_DIR_PLAYBACK, - crate::ofd::PcmDir::Capture => PCM_DIR_CAPTURE, - })?; - w.write_u32(audio.state)?; - match audio.hw_params.as_deref() { - None => w.write_u8(0)?, - Some(hw) => { - w.write_u8(1)?; - w.write_u32(hw.format)?; - w.write_u32(hw.access)?; - w.write_u32(hw.channels)?; - w.write_u32(hw.rate)?; - w.write_u64(hw.period_size)?; - w.write_u64(hw.buffer_size)?; - w.write_u32(hw.periods)?; - } - } - match audio.sw_params.as_deref() { - None => w.write_u8(0)?, - Some(sw) => { - w.write_u8(1)?; - w.write_u64(sw.avail_min)?; - w.write_u64(sw.start_threshold)?; - w.write_u64(sw.stop_threshold)?; - w.write_u64(sw.boundary)?; - } - } - match audio.mmap_status.as_deref() { - None => w.write_u8(0)?, - Some(s) => { - w.write_u8(1)?; - w.write_u32(s.state)?; - w.write_u32(s._pad0)?; - w.write_i64(s.hw_ptr)?; - w.write_i64(s.tstamp_sec)?; - w.write_i64(s.tstamp_nsec)?; - w.write_u32(s.suspended_state)?; - w.write_u32(s.audio_tstamp_data)?; - w.write_i64(s.audio_tstamp_sec)?; - w.write_i64(s.audio_tstamp_nsec)?; - for &b in &s._reserved_tail { - w.write_u8(b)?; - } - } - } - match audio.mmap_control.as_deref() { - None => w.write_u8(0)?, - Some(c) => { - w.write_u8(1)?; - w.write_i64(c.appl_ptr)?; - w.write_i64(c.avail_min)?; - for &b in &c._reserved { - w.write_u8(b)?; - } - } - } - w.write_u32(audio.pcm_id)?; - Ok(()) -} - -/// Serialise the ALSA `/dev/snd/controlC0` sidecar across a fork/exec. -/// v1 carries only a card binding — `CARD_INFO` / `ELEM_LIST` serve -/// from kernel globals, so no further state. -fn write_audio_ctl_state( - w: &mut Writer<'_>, - state: Option<&crate::ofd::AlsaControlFdState>, -) -> Result<(), Errno> { - let Some(ctl) = state else { - return w.write_u8(AUDIO_CTL_TAG_NONE); - }; - w.write_u8(AUDIO_CTL_TAG_SOME)?; - w.write_u8(ctl.card)?; - Ok(()) -} - /// Read a `DriFdState` from the wire and incref every referenced bo /// in the global registry so the new OFD has its own refcount. The /// caller may still drop the entire OFD if the surrounding deserialize @@ -951,126 +853,6 @@ fn read_input_state( } } -fn read_audio_state( - r: &mut Reader<'_>, -) -> Result>, Errno> { - use wasm_posix_shared::audio::{WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus}; - let tag = r.read_u8()?; - match tag { - AUDIO_TAG_NONE => Ok(None), - AUDIO_TAG_SOME => { - let card = r.read_u8()?; - let device = r.read_u8()?; - let sub = r.read_u8()?; - let kind = match r.read_u8()? { - PCM_DIR_PLAYBACK => crate::ofd::PcmDir::Playback, - PCM_DIR_CAPTURE => crate::ofd::PcmDir::Capture, - _ => return Err(Errno::EINVAL), - }; - let state = r.read_u32()?; - let hw_params = match r.read_u8()? { - 0 => None, - 1 => Some(alloc::boxed::Box::new(crate::ofd::HwParamsCache { - format: r.read_u32()?, - access: r.read_u32()?, - channels: r.read_u32()?, - rate: r.read_u32()?, - period_size: r.read_u64()?, - buffer_size: r.read_u64()?, - periods: r.read_u32()?, - })), - _ => return Err(Errno::EINVAL), - }; - let sw_params = match r.read_u8()? { - 0 => None, - 1 => Some(alloc::boxed::Box::new(crate::ofd::SwParamsCache { - avail_min: r.read_u64()?, - start_threshold: r.read_u64()?, - stop_threshold: r.read_u64()?, - boundary: r.read_u64()?, - })), - _ => return Err(Errno::EINVAL), - }; - let mmap_status = match r.read_u8()? { - 0 => None, - 1 => { - let s_state = r.read_u32()?; - let pad0 = r.read_u32()?; - let hw_ptr = r.read_i64()?; - let tstamp_sec = r.read_i64()?; - let tstamp_nsec = r.read_i64()?; - let suspended_state = r.read_u32()?; - let audio_tstamp_data = r.read_u32()?; - let audio_tstamp_sec = r.read_i64()?; - let audio_tstamp_nsec = r.read_i64()?; - let mut tail = [0u8; 8]; - for byte in tail.iter_mut() { - *byte = r.read_u8()?; - } - Some(alloc::boxed::Box::new(WpkAlsaPcmMmapStatus { - state: s_state, - _pad0: pad0, - hw_ptr, - tstamp_sec, - tstamp_nsec, - suspended_state, - audio_tstamp_data, - audio_tstamp_sec, - audio_tstamp_nsec, - _reserved_tail: tail, - })) - } - _ => return Err(Errno::EINVAL), - }; - let mmap_control = match r.read_u8()? { - 0 => None, - 1 => { - let appl_ptr = r.read_i64()?; - let avail_min = r.read_i64()?; - let mut reserved = [0u8; 48]; - for byte in reserved.iter_mut() { - *byte = r.read_u8()?; - } - Some(alloc::boxed::Box::new(WpkAlsaPcmMmapControl { - appl_ptr, - avail_min, - _reserved: reserved, - })) - } - _ => return Err(Errno::EINVAL), - }; - let pcm_id = r.read_u32()?; - Ok(Some(alloc::boxed::Box::new(crate::ofd::AlsaFdState { - card, - device, - sub, - kind, - state, - hw_params, - sw_params, - mmap_status, - mmap_control, - pcm_id, - }))) - } - _ => Err(Errno::EINVAL), - } -} - -fn read_audio_ctl_state( - r: &mut Reader<'_>, -) -> Result>, Errno> { - let tag = r.read_u8()?; - match tag { - AUDIO_CTL_TAG_NONE => Ok(None), - AUDIO_CTL_TAG_SOME => { - let card = r.read_u8()?; - Ok(Some(alloc::boxed::Box::new(crate::ofd::AlsaControlFdState { card }))) - } - _ => Err(Errno::EINVAL), - } -} - fn read_dri_state( r: &mut Reader<'_>, ) -> Result>, Errno> { @@ -1216,8 +998,6 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result<(), Er } let dri_state = read_dri_state(&mut r)?; let input_state = read_input_state(&mut r)?; - let audio = read_audio_state(&mut r)?; - let audio_ctl = read_audio_ctl_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -1571,8 +1349,6 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er dir_pending_entry: None, dri_state, input_state, - audio, - audio_ctl, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -2059,8 +1835,6 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result { } let dri_state = read_dri_state(&mut r)?; let input_state = read_input_state(&mut r)?; - let audio = read_audio_state(&mut r)?; - let audio_ctl = read_audio_ctl_state(&mut r)?; let mut ofd = OpenFileDesc { ofd_id, file_id, @@ -2259,8 +2031,6 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { dir_pending_entry: None, dri_state, input_state, - audio, - audio_ctl, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -3806,220 +3576,4 @@ mod tests { ); } - // ── ALSA fork/exec inheritance tests ────────────────────────────────── - - /// Build an AlsaFdState with the full state machine populated: - /// committed HW/SW params, mmap pages with seeded ptrs, and a - /// non-zero pcm_id. Lets the round-trip tests assert every field - /// survives without relying on defaults. - fn populated_alsa_pcm_state() -> crate::ofd::AlsaFdState { - use crate::ofd::{AlsaFdState, HwParamsCache, PcmDir, SwParamsCache}; - use wasm_posix_shared::audio::{ - WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus, SNDRV_PCM_FORMAT_S16_LE, - SNDRV_PCM_STATE_RUNNING, - }; - AlsaFdState { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - state: SNDRV_PCM_STATE_RUNNING, - hw_params: Some(alloc::boxed::Box::new(HwParamsCache { - format: SNDRV_PCM_FORMAT_S16_LE, - access: 0, - channels: 2, - rate: 48000, - period_size: 1024, - buffer_size: 4096, - periods: 4, - })), - sw_params: Some(alloc::boxed::Box::new(SwParamsCache { - avail_min: 1024, - start_threshold: 2048, - stop_threshold: 4096, - boundary: 1 << 30, - })), - mmap_status: Some(alloc::boxed::Box::new(WpkAlsaPcmMmapStatus { - state: SNDRV_PCM_STATE_RUNNING, - hw_ptr: 512, - tstamp_sec: 7, - tstamp_nsec: 123_456_789, - ..WpkAlsaPcmMmapStatus::default() - })), - mmap_control: Some(alloc::boxed::Box::new(WpkAlsaPcmMmapControl { - appl_ptr: 1536, - avail_min: 1024, - _reserved: [0u8; 48], - })), - pcm_id: 0, - } - } - - #[test] - fn fork_inherits_alsa_pcm_state() { - use crate::syscalls::VirtualDevice; - use wasm_posix_shared::audio::{ - SNDRV_PCM_FORMAT_S16_LE, SNDRV_PCM_STATE_RUNNING, - }; - - let mut proc = Process::new(1); - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - } - .host_handle(); - let ofd_idx = proc.ofd_table.create( - crate::ofd::FileType::CharDevice, - 0, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - proc.ofd_table.get_mut(ofd_idx).unwrap().audio = - Some(alloc::boxed::Box::new(populated_alsa_pcm_state())); - proc.fd_table - .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) - .unwrap(); - - let mut buf = vec![0u8; 64 * 1024]; - let written = serialize_fork_state(&proc, &mut buf).unwrap(); - let child = deserialize_fork_state(&buf[..written], 99).unwrap(); - - let child_audio = child - .ofd_table - .get(ofd_idx) - .unwrap() - .audio() - .expect("child must inherit AlsaFdState"); - assert_eq!(child_audio.card, 0); - assert_eq!(child_audio.device, 0); - assert_eq!(child_audio.sub, 0); - assert_eq!(child_audio.kind, crate::ofd::PcmDir::Playback); - assert_eq!(child_audio.state, SNDRV_PCM_STATE_RUNNING); - let hw = child_audio.hw_params.as_deref().expect("hw_params survives"); - assert_eq!(hw.format, SNDRV_PCM_FORMAT_S16_LE); - assert_eq!(hw.channels, 2); - assert_eq!(hw.rate, 48000); - assert_eq!(hw.period_size, 1024); - assert_eq!(hw.buffer_size, 4096); - assert_eq!(hw.periods, 4); - let sw = child_audio.sw_params.as_deref().expect("sw_params survives"); - assert_eq!(sw.avail_min, 1024); - assert_eq!(sw.start_threshold, 2048); - assert_eq!(sw.stop_threshold, 4096); - assert_eq!(sw.boundary, 1 << 30); - let status = child_audio.mmap_status.as_deref().expect("status survives"); - assert_eq!(status.state, SNDRV_PCM_STATE_RUNNING); - assert_eq!(status.hw_ptr, 512); - assert_eq!(status.tstamp_sec, 7); - assert_eq!(status.tstamp_nsec, 123_456_789); - let ctl = child_audio.mmap_control.as_deref().expect("control survives"); - assert_eq!(ctl.appl_ptr, 1536); - assert_eq!(ctl.avail_min, 1024); - // pcm_id is per-fd; the SAB registry it indexes is a global keyed - // by this id, so inheriting the id is enough — no per-fork copy. - assert_eq!(child_audio.pcm_id, 0); - } - - #[test] - fn fork_inherits_alsa_control_state() { - use crate::ofd::AlsaControlFdState; - use crate::syscalls::VirtualDevice; - - let mut proc = Process::new(1); - let host_handle = VirtualDevice::AlsaControl { card: 0 }.host_handle(); - let ofd_idx = proc.ofd_table.create( - crate::ofd::FileType::CharDevice, - 0, - host_handle, - b"/dev/snd/controlC0".to_vec(), - ); - proc.ofd_table.get_mut(ofd_idx).unwrap().audio_ctl = - Some(alloc::boxed::Box::new(AlsaControlFdState { card: 0 })); - proc.fd_table - .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) - .unwrap(); - - let mut buf = vec![0u8; 64 * 1024]; - let written = serialize_fork_state(&proc, &mut buf).unwrap(); - let child = deserialize_fork_state(&buf[..written], 99).unwrap(); - - let child_ctl = child - .ofd_table - .get(ofd_idx) - .unwrap() - .audio_ctl() - .expect("child must inherit AlsaControlFdState"); - assert_eq!(child_ctl.card, 0); - } - - #[test] - fn exec_preserves_alsa_pcm_state() { - use crate::syscalls::VirtualDevice; - use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; - - let mut proc = Process::new(1); - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - } - .host_handle(); - let ofd_idx = proc.ofd_table.create( - crate::ofd::FileType::CharDevice, - 0, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - proc.ofd_table.get_mut(ofd_idx).unwrap().audio = - Some(alloc::boxed::Box::new(populated_alsa_pcm_state())); - proc.fd_table - .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) - .unwrap(); - - let mut buf = vec![0u8; 64 * 1024]; - let written = serialize_exec_state(&proc, &mut buf).unwrap(); - let post = deserialize_exec_state(&buf[..written], proc.pid).unwrap(); - - // exec keeps the same process identity — the PCM state machine - // snapshot, hw/sw params, mmap pages, and pcm_id must all - // survive byte-for-byte. - let post_audio = post - .ofd_table - .get(ofd_idx) - .unwrap() - .audio() - .expect("exec must keep AlsaFdState"); - assert_eq!(post_audio.state, SNDRV_PCM_STATE_RUNNING); - assert_eq!(post_audio.hw_params.as_deref().unwrap().rate, 48000); - assert_eq!(post_audio.mmap_status.as_deref().unwrap().hw_ptr, 512); - assert_eq!(post_audio.mmap_control.as_deref().unwrap().appl_ptr, 1536); - } - - #[test] - fn fork_audio_none_round_trips() { - // An OFD without an audio sidecar (the common case for non-snd - // fds) must encode + decode losslessly. Catches a stray byte - // misread that would corrupt the wire stream for the next OFD. - let mut proc = Process::new(1); - let ofd_idx = proc.ofd_table.create( - crate::ofd::FileType::Regular, - 0, - 5, - b"/tmp/file".to_vec(), - ); - proc.fd_table - .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) - .unwrap(); - - let mut buf = vec![0u8; 64 * 1024]; - let written = serialize_fork_state(&proc, &mut buf).unwrap(); - let child = deserialize_fork_state(&buf[..written], 99).unwrap(); - - let child_ofd = child.ofd_table.get(ofd_idx).unwrap(); - assert!(child_ofd.audio.is_none()); - assert!(child_ofd.audio_ctl.is_none()); - } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index b90e810d97..48796fad7d 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -316,118 +316,6 @@ pub struct InputFdState { pub dropped: bool, } -/// PCM stream direction. v1 only ships [`PcmDir::Playback`]; opening -/// `/dev/snd/pcmC0D0c` returns `ENODEV` rather than installing a -/// [`PcmDir::Capture`] OFD. The variant is kept so the type signature -/// of [`VirtualDevice::AlsaPcm`] survives v2 without ABI churn. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PcmDir { - #[default] - Playback, - Capture, -} - -/// HW_PARAMS cache — populated by `SNDRV_PCM_IOCTL_HW_PARAMS`, read by -/// every subsequent ioctl. Stored as the narrowed concrete shape, not -/// the wildcard `WpkAlsaPcmHwParams` reply, so the state machine -/// doesn't have to re-walk the masks/intervals on every transition. -#[derive(Default, Clone, Debug)] -pub struct HwParamsCache { - /// `SNDRV_PCM_FORMAT_*`. v1: `S16_LE` only. - pub format: u32, - /// `SNDRV_PCM_ACCESS_*`. v1: `MMAP_INTERLEAVED` or `RW_INTERLEAVED`. - pub access: u32, - pub channels: u32, - pub rate: u32, - /// Frames per period. - pub period_size: u64, - /// Frames per ring buffer (= `period_size * periods`). - pub buffer_size: u64, - pub periods: u32, -} - -/// SW_PARAMS cache — populated by `SNDRV_PCM_IOCTL_SW_PARAMS`. -#[derive(Default, Clone, Debug)] -pub struct SwParamsCache { - pub avail_min: u64, - pub start_threshold: u64, - pub stop_threshold: u64, - pub boundary: u64, -} - -/// Per-fd state for `/dev/snd/pcmC0D0p` opens. -/// -/// Disjoint from [`DriOfdState`] and [`InputFdState`] — audio fds -/// carry no DRI bo state and no input ring state. Mirrors the -/// "one `Option>` per device class" factoring [`InputFdState`] -/// established for plan 5; plan 6 reuses the pattern so the OFD's -/// non-audio cost is one pointer slot. -/// -/// PCM state machine: `OPEN` → (`HW_PARAMS`) → `SETUP` → (`PREPARE`) -/// → `PREPARED` → (`START`) → `RUNNING` → `XRUN` / `PAUSED`. -/// `HW_FREE` returns to `OPEN`; `DROP` returns to `SETUP`. -#[derive(Clone, Debug)] -pub struct AlsaFdState { - pub card: u8, - pub device: u8, - pub sub: u8, - pub kind: PcmDir, - - /// PCM state machine. `SNDRV_PCM_STATE_*` (see - /// `wasm_posix_shared::audio`). - pub state: u32, - - /// HW_PARAMS cache; `None` until `HW_PARAMS` lands. - pub hw_params: Option>, - - /// SW_PARAMS cache; `None` until `SW_PARAMS` lands. `HW_PARAMS` - /// is a prerequisite — `SW_PARAMS` against a `hw_params: None` - /// fd returns `EBADFD`. - pub sw_params: Option>, - - /// `snd_pcm_mmap_status` page — kernel-writes, userspace-reads. - /// Allocated on first mmap(`SNDRV_PCM_MMAP_OFFSET_STATUS`). - pub mmap_status: Option>, - - /// `snd_pcm_mmap_control` page — userspace-writes, kernel-reads. - /// Allocated on first mmap(`SNDRV_PCM_MMAP_OFFSET_CONTROL`). - pub mmap_control: Option>, - - /// Identifier into the host `audio::sab_table` for this PCM's - /// SAB-backed data ring. `0` until `kernel_audio_init_sab` runs. - pub pcm_id: u32, -} - -impl Default for AlsaFdState { - fn default() -> Self { - AlsaFdState { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - state: wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN, - hw_params: None, - sw_params: None, - mmap_status: None, - mmap_control: None, - pcm_id: 0, - } - } -} - -/// Per-fd state for `/dev/snd/controlC0` opens. -/// -/// v1 `controlC0` is a read-only handle: `CARD_INFO` / `ELEM_LIST` -/// serve from kernel globals, so per-fd state is just the card -/// binding. Carried as a separate `Option>` rather than folded -/// into [`AlsaFdState`] because a single OFD is never both a PCM and a -/// control surface — opening `controlC0` and `pcmC0D0p` always -/// produces two distinct fds. -#[derive(Default, Clone, Debug)] -pub struct AlsaControlFdState { - pub card: u8, -} - #[derive(Clone)] pub struct OpenFileDesc { /// Machine-wide identity of this open file description. Independent @@ -470,13 +358,6 @@ pub struct OpenFileDesc { /// [`InputFdState`]. Boxed so non-evdev OFDs pay only one pointer /// slot. Parallel to [`Self::dri_state`] (disjoint state machines). pub input_state: Option>, - /// ALSA PCM sidecar for `/dev/snd/pcmC0D0p` OFDs; see - /// [`AlsaFdState`]. Parallel to [`Self::input_state`]. - pub audio: Option>, - /// ALSA control sidecar for `/dev/snd/controlC0` OFDs; see - /// [`AlsaControlFdState`]. Disjoint from [`Self::audio`] — a - /// single fd is never both a PCM and a control surface. - pub audio_ctl: Option>, } struct SharedOfdStateInner { @@ -712,24 +593,6 @@ impl OpenFileDesc { self.input_state.as_deref_mut() } - /// Borrow the `AlsaFdState` for `/dev/snd/pcmC0D0p` OFDs. - /// Returns `None` for any other OFD. - pub fn audio(&self) -> Option<&AlsaFdState> { - self.audio.as_deref() - } - - pub fn audio_mut(&mut self) -> Option<&mut AlsaFdState> { - self.audio.as_deref_mut() - } - - /// Borrow the `AlsaControlFdState` for `/dev/snd/controlC0` OFDs. - pub fn audio_ctl(&self) -> Option<&AlsaControlFdState> { - self.audio_ctl.as_deref() - } - - pub fn audio_ctl_mut(&mut self) -> Option<&mut AlsaControlFdState> { - self.audio_ctl.as_deref_mut() - } } #[derive(Clone)] @@ -768,8 +631,6 @@ impl OfdTable { dir_pending_entry: None, dri_state: None, input_state: None, - audio: None, - audio_ctl: None, }; self.insert(ofd) @@ -810,8 +671,6 @@ impl OfdTable { dir_pending_entry: None, dri_state: None, input_state: None, - audio: None, - audio_ctl: None, }; ofd.reset_directory_iterator_for_reopen(); self.insert(ofd) @@ -1163,8 +1022,6 @@ mod tests { dir_pending_entry: None, dri_state: None, input_state: None, - audio: None, - audio_ctl: None, }); } @@ -1403,58 +1260,6 @@ mod tests { assert_eq!(INPUT_RING_MAX_BYTES, 24 * 1024); } - #[test] - fn ofd_default_has_no_audio_state() { - let mut table = OfdTable::new(); - let idx = table.create(FileType::CharDevice, O_WRONLY, -13, b"/dev/snd/pcmC0D0p".to_vec()); - let ofd = table.get(idx).unwrap(); - assert!(ofd.audio.is_none()); - assert!(ofd.audio_ctl.is_none()); - assert!(ofd.audio().is_none()); - assert!(ofd.audio_ctl().is_none()); - } - - #[test] - fn audio_accessors_route_to_attached_state() { - let mut table = OfdTable::new(); - let idx = table.create(FileType::CharDevice, O_WRONLY, -13, b"/dev/snd/pcmC0D0p".to_vec()); - table.get_mut(idx).unwrap().audio = Some(Box::new(AlsaFdState { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - ..Default::default() - })); - - let st = table.get(idx).unwrap().audio().unwrap(); - assert_eq!(st.kind, PcmDir::Playback); - assert_eq!(st.state, wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN); - assert!(st.hw_params.is_none()); - assert!(st.sw_params.is_none()); - assert_eq!(st.pcm_id, 0); - - let st = table.get_mut(idx).unwrap().audio_mut().unwrap(); - st.state = wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP; - assert_eq!( - table.get(idx).unwrap().audio().unwrap().state, - wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP - ); - } - - #[test] - fn audio_ctl_accessors_route_to_attached_state() { - let mut table = OfdTable::new(); - let idx = table.create(FileType::CharDevice, O_RDWR, -12, b"/dev/snd/controlC0".to_vec()); - table.get_mut(idx).unwrap().audio_ctl = - Some(Box::new(AlsaControlFdState { card: 0 })); - - let st = table.get(idx).unwrap().audio_ctl().unwrap(); - assert_eq!(st.card, 0); - // PCM and control sidecars are disjoint — installing audio_ctl - // must NOT also populate audio. - assert!(table.get(idx).unwrap().audio().is_none()); - } - #[test] fn iter_mut_visits_every_live_ofd() { let mut table = OfdTable::new(); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index f1ac3a5769..d03ba6956c 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -163,18 +163,6 @@ pub enum VirtualDevice { /// `device = 1` → ptr (host_handle -11). v1 exposes exactly these /// two; `/dev/input/eventN` for N≥2 is not synthesised. InputEvent { device: u8 }, - /// `/dev/snd/controlC0` (host_handle = -12). v1 ships card 0 only. - AlsaControl { card: u8 }, - /// `/dev/snd/pcmC0D0p` (host_handle = -13). v1 ships card 0 device 0 - /// sub 0 playback only — `pcmC0D0c` (capture) is rejected at open - /// with `ENODEV`, so [`PcmDir::Capture`] never reaches a live - /// OFD via this constructor. - AlsaPcm { - card: u8, - device: u8, - sub: u8, - kind: crate::ofd::PcmDir, - }, } impl VirtualDevice { @@ -191,16 +179,6 @@ impl VirtualDevice { VirtualDevice::DriRenderD128 => -8, VirtualDevice::DriCard0 => -9, VirtualDevice::InputEvent { device } => -10 - device as i64, - VirtualDevice::AlsaControl { card: 0 } => -12, - VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - } => -13, - // v1 only synthesises card 0 / device 0 / sub 0 playback; - // anything else would have failed at match_virtual_device. - VirtualDevice::AlsaControl { .. } | VirtualDevice::AlsaPcm { .. } => -1, } } @@ -218,13 +196,6 @@ impl VirtualDevice { -9 => Some(VirtualDevice::DriCard0), -10 => Some(VirtualDevice::InputEvent { device: 0 }), -11 => Some(VirtualDevice::InputEvent { device: 1 }), - -12 => Some(VirtualDevice::AlsaControl { card: 0 }), - -13 => Some(VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - }), _ => None, } } @@ -242,12 +213,6 @@ impl VirtualDevice { VirtualDevice::DriRenderD128 => 8, VirtualDevice::DriCard0 => 9, VirtualDevice::InputEvent { device } => 10 + device as u64, - VirtualDevice::AlsaControl { card } => 12 + card as u64, - VirtualDevice::AlsaPcm { card, device, sub, .. } => { - // Card-major then device-minor then sub. v1 only uses (0,0,0,Playback) - // so the formula's exactness past the first triple doesn't matter yet. - 13 + (card as u64) * 256 + (device as u64) * 16 + (sub as u64) - } } } } @@ -271,28 +236,6 @@ fn match_virtual_device(path: &[u8]) -> Option { b"/dev/dri/card0" => Some(VirtualDevice::DriCard0), b"/dev/input/event0" => Some(VirtualDevice::InputEvent { device: 0 }), b"/dev/input/event1" => Some(VirtualDevice::InputEvent { device: 1 }), - b"/dev/snd/controlC0" => Some(VirtualDevice::AlsaControl { card: 0 }), - b"/dev/snd/pcmC0D0p" => Some(VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - }), - _ => None, - } -} - -/// Paths under synthetic device trees that the kernel deliberately -/// refuses to open — distinct from "doesn't exist". Returns the errno -/// the caller should propagate, or `None` to fall through to the -/// regular [`match_virtual_device`] / on-disk path. -/// -/// Used for `/dev/snd/pcmC0D0c` so the kernel reports `ENODEV` -/// ("device exists but is disabled") instead of `ENOENT`, mirroring -/// what alsa-lib expects when probing a capture-only direction. -fn disabled_virtual_device(path: &[u8]) -> Option { - match path { - b"/dev/snd/pcmC0D0c" => Some(Errno::ENODEV), _ => None, } } @@ -808,34 +751,6 @@ fn install_input_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualD } } -/// Install the ALSA sidecar on a freshly-allocated OFD for an -/// `/dev/snd/{pcmC0D0p,controlC0}` open. No-op for any other virtual -/// device. The PCM and control variants land in two disjoint OFD -/// fields (`audio` / `audio_ctl`) per [`crate::ofd::AlsaControlFdState`]'s -/// rationale. -fn install_audio_state_on_open(proc: &mut Process, ofd_idx: usize, dev: VirtualDevice) { - match dev { - VirtualDevice::AlsaPcm { card, device, sub, kind } => { - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.audio = Some(alloc::boxed::Box::new(crate::ofd::AlsaFdState { - card, - device, - sub, - kind, - ..Default::default() - })); - } - } - VirtualDevice::AlsaControl { card } => { - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.audio_ctl = - Some(alloc::boxed::Box::new(crate::ofd::AlsaControlFdState { card })); - } - } - _ => {} - } -} - /// Borrow the `DriFdState` hung off the OFD at `ofd_idx`, returning /// `EBADF` if the OFD doesn't have one or is a prime-bo. Used by /// renderD128- and card0-targeted ioctls that manipulate per-fd GEM @@ -3244,15 +3159,7 @@ pub fn sys_open( allow_missing_directory: false, use_real_ids: false, }; - // A deliberately-disabled node is not listed in the synthetic tree, so - // resolution reports it missing. Report why it cannot open instead. - let resolved_entry = match resolve_namespace_path(proc, host, path, resolve_options) { - Err(Errno::ENOENT) => match disabled_virtual_device(path) { - Some(errno) => return Err(errno), - None => return Err(Errno::ENOENT), - }, - other => other?, - }; + let resolved_entry = resolve_namespace_path(proc, host, path, resolve_options)?; if resolved_entry .stat .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFLNK) @@ -3284,12 +3191,6 @@ pub fn sys_open( }; } - // Paths that exist in the synthetic tree but are deliberately - // disabled (e.g. /dev/snd/pcmC0D0c — v1 ships playback only). - if let Some(errno) = disabled_virtual_device(&resolved) { - return Err(errno); - } - // Virtual device nodes — handle in-kernel, no host call if let Some(dev) = match_virtual_device(&resolved) { if dev == VirtualDevice::Fb0 { @@ -3329,7 +3230,6 @@ pub fn sys_open( ); install_dri_state_on_open(proc, ofd_idx, dev); install_input_state_on_open(proc, ofd_idx, dev); - install_audio_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -4894,12 +4794,6 @@ pub fn sys_read( } n } - // ALSA fds carry data via ioctl (WRITEI_FRAMES) - // or the mmap data page, never user-space - // read(). Return 0 so alsa-lib's defensive - // probe reads observe EOF instead of EBADF; - // A3+ refine this if a real consumer surfaces. - VirtualDevice::AlsaPcm { .. } | VirtualDevice::AlsaControl { .. } => 0, }; return Ok(n); } @@ -9919,20 +9813,6 @@ pub fn sys_mmap( }); return Ok(addr_out); } - - // /dev/snd/pcmC0D

p: alsa-lib calls mmap() three times right - // after HW_PARAMS, one each for status / control / data. The - // dispatcher in `audio::mmap` decodes the offset, lazily - // allocates the kernel-side status/control Boxes, and (for the - // DATA page) verifies a SAB was registered via - // `kernel_audio_init_sab`. The user-space pages themselves - // come from the generic `mmap_anonymous` allocator. - if ofd.audio.is_some() { - let ofd_idx = entry.ofd_ref.0; - return crate::audio::mmap::handle_alsa_pcm_mmap( - proc, ofd_idx, addr, len, prot, flags, offset, - ); - } } // Allocate the region. Both anonymous and file-backed use the same @@ -13907,59 +13787,6 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } } } - } else if ofd.file_type == FileType::CharDevice - && matches!( - VirtualDevice::from_host_handle(ofd.host_handle), - Some(VirtualDevice::AlsaPcm { .. }) - ) - { - // /dev/snd/pcmC0Dp — alsa-lib polls POLLOUT - // waiting for ring space. - // - // avail = buffer_size - (appl_ptr - hw_ptr) - // ready iff avail >= sw_params.avail_min - // - // hw_ptr / appl_ptr come from the kernel-side - // mmap Boxes (A5); buffer_size / avail_min from - // the HW/SW_PARAMS caches (A3). XRUN reflects as - // POLLERR so alsa-lib's recovery path triggers a - // PREPARE without spinning. - // - // v1 reports ready / not-ready only — kernel - // doesn't park; userspace re-polls (same pattern - // as every other AlsaPcm sibling in this match). - if let Some(audio) = ofd.audio() { - let buffer = audio - .hw_params - .as_ref() - .map(|h| h.buffer_size as i64) - .unwrap_or(0); - let appl = audio - .mmap_control - .as_ref() - .map(|c| c.appl_ptr) - .unwrap_or(0); - let hw_ptr = audio - .mmap_status - .as_ref() - .map(|s| s.hw_ptr) - .unwrap_or(0); - let avail_min = audio - .sw_params - .as_ref() - .map(|s| s.avail_min as i64) - .unwrap_or(1); - let avail = buffer - (appl - hw_ptr); - if pollfd.events & POLLOUT != 0 && avail >= avail_min { - revents |= POLLOUT; - } - if audio.state - == wasm_posix_shared::audio::SNDRV_PCM_STATE_XRUN - { - revents |= POLLERR; - } - } - // AlsaPcm is write-only — never report POLLIN. } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -14317,12 +14144,6 @@ pub fn sys_openat( }; } - // Paths that exist in the synthetic tree but are deliberately - // disabled (e.g. /dev/snd/pcmC0D0c — v1 ships playback only). - if let Some(errno) = disabled_virtual_device(&resolved) { - return Err(errno); - } - // Virtual device nodes — handle in-kernel, no host call if let Some(dev) = match_virtual_device(&resolved) { if dev == VirtualDevice::Fb0 { @@ -14362,7 +14183,6 @@ pub fn sys_openat( ); install_dri_state_on_open(proc, ofd_idx, dev); install_input_state_on_open(proc, ofd_idx, dev); - install_audio_state_on_open(proc, ofd_idx, dev); let fd_flags = oflags_to_fd_flags(oflags); let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; return Ok(fd); @@ -15035,21 +14855,6 @@ pub fn sys_ioctl( } } - // --- /dev/snd/pcmC0D0p ioctls — ALSA SNDRV_PCM_* surface --- - { - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.file_type == FileType::CharDevice - && matches!( - VirtualDevice::from_host_handle(ofd.host_handle), - Some(VirtualDevice::AlsaPcm { .. }) - ) - { - return crate::audio::pcm_ioctl::handle_alsa_pcm_ioctl( - proc, host, ofd_idx, request, buf, - ); - } - } - // --- Linux VT keyboard ioctls (KDGKBTYPE / KDGKBMODE / KDSKBMODE) --- // // fbDOOM (and other Linux-VT-targeted software) calls these on a @@ -29163,149 +28968,6 @@ mod tests { assert_eq!(pollfds[1].revents, 0); } - fn install_alsa_pcm_fd( - proc: &mut Process, - state: u32, - buffer_size: u64, - appl_ptr: i64, - hw_ptr: i64, - avail_min: u64, - ) -> i32 { - use crate::ofd::{AlsaFdState, FileType, HwParamsCache, PcmDir, SwParamsCache}; - use alloc::boxed::Box; - use wasm_posix_shared::audio::{WpkAlsaPcmMmapControl, WpkAlsaPcmMmapStatus}; - - let host_handle = VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: PcmDir::Playback, - } - .host_handle(); - let ofd_idx = proc.ofd_table.create( - FileType::CharDevice, - O_WRONLY, - host_handle, - b"/dev/snd/pcmC0D0p".to_vec(), - ); - let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); - ofd.audio = Some(Box::new(AlsaFdState { - pcm_id: 0, - state, - hw_params: Some(Box::new(HwParamsCache { - buffer_size, - ..HwParamsCache::default() - })), - sw_params: Some(Box::new(SwParamsCache { - avail_min, - ..SwParamsCache::default() - })), - mmap_status: Some(Box::new(WpkAlsaPcmMmapStatus { - hw_ptr, - ..WpkAlsaPcmMmapStatus::default() - })), - mmap_control: Some(Box::new(WpkAlsaPcmMmapControl { - appl_ptr, - ..WpkAlsaPcmMmapControl::default() - })), - ..AlsaFdState::default() - })); - proc.fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .expect("alloc fd") - } - - #[test] - fn test_poll_alsa_pcm_pollout_ready_when_avail_above_threshold() { - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; - use wasm_posix_shared::poll::*; - - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // appl=2000, hw=1000, buffer=4096 → avail=3096 >= avail_min=1024 ⇒ ready. - let fd = install_alsa_pcm_fd( - &mut proc, - SNDRV_PCM_STATE_RUNNING, - 4096, - 2000, - 1000, - 1024, - ); - - let mut pollfd = WasmPollFd { - fd, - events: POLLOUT, - revents: 0, - }; - let n = - sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLOUT, 0); - assert_eq!(pollfd.revents & POLLERR, 0); - } - - #[test] - fn test_poll_alsa_pcm_pollout_not_ready_when_buffer_full() { - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::audio::SNDRV_PCM_STATE_RUNNING; - use wasm_posix_shared::poll::*; - - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // appl=5000, hw=1000, buffer=4000 → avail=0 < avail_min=1 ⇒ not ready. - let fd = install_alsa_pcm_fd( - &mut proc, - SNDRV_PCM_STATE_RUNNING, - 4000, - 5000, - 1000, - 1, - ); - - let mut pollfd = WasmPollFd { - fd, - events: POLLOUT, - revents: 0, - }; - let n = - sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - // sys_poll returns EAGAIN in centralized mode; in this single-shot - // test we run with default mode (non-centralized + timeout=0) so - // poll_check returns 0 ready ⇒ sys_poll Ok(0). - assert_eq!(n, 0); - assert_eq!(pollfd.revents & POLLOUT, 0); - } - - #[test] - fn test_poll_alsa_pcm_pollerr_set_on_xrun_state() { - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::audio::SNDRV_PCM_STATE_XRUN; - use wasm_posix_shared::poll::*; - - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // XRUN — POLLERR latches regardless of avail. - let fd = install_alsa_pcm_fd( - &mut proc, - SNDRV_PCM_STATE_XRUN, - 4096, - 2000, - 1000, - 1024, - ); - - let mut pollfd = WasmPollFd { - fd, - events: POLLOUT, - revents: 0, - }; - let n = - sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLERR, 0); - } - #[test] fn test_lseek_seek_end_on_pipe() { let mut proc = Process::new(1); @@ -35431,39 +35093,6 @@ mod tests { assert_eq!(match_dev_fd(b"/tmp/foo"), None); } - #[test] - fn test_virtual_device_roundtrip() { - for dev in [ - VirtualDevice::Null, - VirtualDevice::Zero, - VirtualDevice::Urandom, - VirtualDevice::Full, - VirtualDevice::Fb0, - VirtualDevice::Mice, - VirtualDevice::Dsp, - VirtualDevice::DriRenderD128, - VirtualDevice::DriCard0, - VirtualDevice::InputEvent { device: 0 }, - VirtualDevice::InputEvent { device: 1 }, - VirtualDevice::AlsaControl { card: 0 }, - VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - }, - ] { - assert_eq!( - VirtualDevice::from_host_handle(dev.host_handle()), - Some(dev) - ); - } - assert_eq!(VirtualDevice::from_host_handle(0), None); - // First sentinel past the allocated range — must not roundtrip. - // -12 = AlsaControl{card:0}, -13 = AlsaPcm{0,0,0,Playback}. - assert_eq!(VirtualDevice::from_host_handle(-14), None); - } - // ===== Loopback socket tests ===== #[test] @@ -45715,137 +45344,6 @@ mod tests { assert!(r.is_err(), "/dev/input/event2 must NOT open as a virtual device"); } - #[test] - fn match_virtual_device_recognizes_alsa_paths() { - assert_eq!( - match_virtual_device(b"/dev/snd/controlC0"), - Some(VirtualDevice::AlsaControl { card: 0 }) - ); - assert_eq!( - match_virtual_device(b"/dev/snd/pcmC0D0p"), - Some(VirtualDevice::AlsaPcm { - card: 0, - device: 0, - sub: 0, - kind: crate::ofd::PcmDir::Playback, - }) - ); - // pcmC0D0c is "disabled", not "matched as a virtual device". - assert_eq!(match_virtual_device(b"/dev/snd/pcmC0D0c"), None); - // No additional cards/devices in v1. - assert_eq!(match_virtual_device(b"/dev/snd/controlC1"), None); - assert_eq!(match_virtual_device(b"/dev/snd/pcmC0D1p"), None); - } - - #[test] - fn open_pcm_playback_yields_audio_state_in_open_state() { - use wasm_posix_shared::audio::SNDRV_PCM_STATE_OPEN; - let mut proc = Process::new(701); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - let st = ofd.audio().expect("audio sidecar should be installed"); - assert_eq!(st.kind, crate::ofd::PcmDir::Playback); - assert_eq!(st.state, SNDRV_PCM_STATE_OPEN); - assert!(st.hw_params.is_none()); - assert!(st.sw_params.is_none()); - assert!(st.mmap_status.is_none()); - assert!(st.mmap_control.is_none()); - assert_eq!(st.pcm_id, 0); - // PCM and control sidecars are disjoint state machines. - assert!(ofd.audio_ctl().is_none()); - assert!(ofd.dri_state.is_none()); - assert!(ofd.input_state.is_none()); - } - - #[test] - fn open_control_yields_audio_ctl_state() { - let mut proc = Process::new(702); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/snd/controlC0", O_RDWR, 0).unwrap(); - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - let st = ofd.audio_ctl().expect("audio_ctl sidecar should be installed"); - assert_eq!(st.card, 0); - // The PCM sidecar must NOT be populated for a control open. - assert!(ofd.audio().is_none()); - } - - #[test] - fn open_pcm_capture_returns_enodev() { - let mut proc = Process::new(703); - let mut host = MockHostIO::new(); - let err = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0c", O_RDONLY, 0) - .expect_err("pcmC0D0c (capture) must not open in v1"); - assert_eq!(err, Errno::ENODEV); - } - - #[test] - fn open_pcm_is_multi_process_no_busy() { - // Unlike single-owner /dev/fb0 / /dev/dsp, ALSA PCM accepts - // multiple opens — every process attaches its own per-OFD - // state. (Cross-process arbitration is a future-plan concern.) - let mut proc1 = Process::new(704); - let mut proc2 = Process::new(705); - let mut host = MockHostIO::new(); - assert!(sys_open(&mut proc1, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).is_ok()); - assert!(sys_open(&mut proc2, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).is_ok()); - } - - #[test] - fn dup_inherits_audio_state_via_ofd_share() { - // Per-OFD audio state means dup-share works for free: two fds - // pointing at the same OFD see the same AlsaFdState. fork-time - // inheritance reuses this property once A7 wires audio fork - // serialisation. - use wasm_posix_shared::audio::SNDRV_PCM_STATE_SETUP; - let mut proc = Process::new(706); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); - let dup_fd = sys_dup(&mut proc, fd).unwrap(); - assert_ne!(fd, dup_fd); - - let dup_entry = proc.fd_table.get(dup_fd).unwrap(); - let dup_ofd_idx = dup_entry.ofd_ref.0; - { - let st = proc - .ofd_table - .get_mut(dup_ofd_idx) - .and_then(|o| o.audio_mut()) - .unwrap(); - st.state = SNDRV_PCM_STATE_SETUP; - } - - let orig_entry = proc.fd_table.get(fd).unwrap(); - assert_eq!(orig_entry.ofd_ref.0, dup_ofd_idx); - let st = proc - .ofd_table - .get(orig_entry.ofd_ref.0) - .and_then(|o| o.audio()) - .unwrap(); - assert_eq!(st.state, SNDRV_PCM_STATE_SETUP); - } - - #[test] - fn fresh_open_of_pcm_yields_distinct_audio_state() { - use wasm_posix_shared::audio::{SNDRV_PCM_STATE_OPEN, SNDRV_PCM_STATE_SETUP}; - let mut proc = Process::new(707); - let mut host = MockHostIO::new(); - let fd1 = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); - let fd2 = sys_open(&mut proc, &mut host, b"/dev/snd/pcmC0D0p", O_WRONLY, 0).unwrap(); - let ofd1_idx = proc.fd_table.get(fd1).unwrap().ofd_ref.0; - let ofd2_idx = proc.fd_table.get(fd2).unwrap().ofd_ref.0; - assert_ne!(ofd1_idx, ofd2_idx); - - proc.ofd_table.get_mut(ofd1_idx).unwrap().audio_mut().unwrap().state = - SNDRV_PCM_STATE_SETUP; - assert_eq!( - proc.ofd_table.get(ofd2_idx).and_then(|o| o.audio()).unwrap().state, - SNDRV_PCM_STATE_OPEN - ); - } - #[test] fn read_eventN_returns_zero_before_any_event() { let mut proc = Process::new(401); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 8057624436..b3df35d589 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13975,66 +13975,6 @@ pub extern "C" fn kernel_set_input_canvas_dims(width: u32, height: u32) { crate::input::set_canvas_dims(width, height); } -/// Bind a host-allocated SharedArrayBuffer to an ALSA PCM. `sab_base` -/// is the kernel-visible byte address of the SAB-imported window and -/// `sab_len` is its length. After this call, -/// `SNDRV_PCM_IOCTL_WRITEI_FRAMES` against any fd opened on -/// `/dev/snd/pcmC0Dp` lands frames into the SAB ring at -/// `appl_ptr % ring_frames` and advances `mmap_control.appl_ptr`. -/// -/// Errors (out-of-range `pcm_id`, already-registered slot) are -/// swallowed: a second `kernel_audio_init_sab` for the same PCM is a -/// no-op so the host can re-issue without un-registering first. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_audio_init_sab(pcm_id: u32, sab_base: u64, sab_len: u32) { - let _ = crate::audio::sab::register( - pcm_id, - crate::audio::sab::SabSlice { - base: sab_base as usize, - len: sab_len as usize, - }, - ); -} - -/// Called by the host on every AudioWorklet quantum (browser) or -/// `setInterval` tick (Node) after the host driver pulled -/// `frames_consumed` frames from the SAB ring. Walks every open -/// `/dev/snd/pcmC0Dp` OFD whose state is `STATE_RUNNING`, -/// advances `mmap_status.hw_ptr`, stamps the monotonic timestamp, -/// detects XRUN, and wakes POLLOUT waiters. -/// -/// The timestamp is fetched once via [`WasmHostIO::host_clock_gettime`] -/// and passed down so [`crate::audio::tick::tick`] stays testable -/// without a `HostIO`. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_audio_period_tick(pcm_id: u32, frames_consumed: u32) { - let mut host = WasmHostIO; - let (tv_sec, tv_nsec) = - match host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_MONOTONIC) { - Ok((sec, nsec)) => (sec, nsec), - Err(_) => (0i64, 0i64), - }; - crate::audio::tick::tick(pcm_id, frames_consumed, tv_sec, tv_nsec); -} - -/// Return the current `mmap_control.appl_ptr` for any OFD bound to -/// `pcm_id` (the maximum across matching OFDs — in practice there is -/// at most one writer per PCM). The host's `BrowserAudioDriver` polls -/// this each AudioWorklet quantum and forwards the value into the -/// worklet so it can gate `hwPtr` advance: the worklet emits silence -/// past `appl_ptr` and only consumes ring positions userspace has -/// actually written. Without this, the worklet's `hwPtr` drifts ahead -/// of the kernel's appl_ptr during userspace setup latency and the -/// first chunks of audio (e.g. espeak-ng's "Welcome to" preamble) -/// land at ring offsets the worklet has already passed. -/// -/// Returns 0 if no OFD is bound to this `pcm_id`. Additive ABI; no -/// `ABI_VERSION` bump required (preserves existing exports). -#[unsafe(no_mangle)] -pub extern "C" fn kernel_audio_get_appl_ptr(pcm_id: u32) -> i64 { - crate::audio::tick::current_appl_ptr(pcm_id) -} - /// Number of successful page-flip commits on the given crtc. /// /// Useful for the host-side stats UI ("how many frames has the diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 6eec033afa..99581090a4 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -5313,256 +5313,6 @@ pub mod input { } } -pub mod audio { - // --- PCM ioctl numbers ('A' magic, Linux UAPI verbatim) -------------- - - pub const SNDRV_PCM_IOCTL_PVERSION: u32 = 0x8004_4100; - pub const SNDRV_PCM_IOCTL_INFO: u32 = 0x8120_4101; - pub const SNDRV_PCM_IOCTL_HW_REFINE: u32 = 0xc260_4110; - pub const SNDRV_PCM_IOCTL_HW_PARAMS: u32 = 0xc260_4111; - pub const SNDRV_PCM_IOCTL_HW_FREE: u32 = 0x0000_4112; - pub const SNDRV_PCM_IOCTL_SW_PARAMS: u32 = 0xc088_4113; - pub const SNDRV_PCM_IOCTL_STATUS: u32 = 0x8080_4120; - pub const SNDRV_PCM_IOCTL_PREPARE: u32 = 0x0000_4140; - pub const SNDRV_PCM_IOCTL_START: u32 = 0x0000_4142; - pub const SNDRV_PCM_IOCTL_DROP: u32 = 0x0000_4143; - pub const SNDRV_PCM_IOCTL_PAUSE: u32 = 0x4004_4145; - pub const SNDRV_PCM_IOCTL_WRITEI_FRAMES: u32 = 0x4018_4150; - - // --- PCM state constants --------------------------------------------- - - pub const SNDRV_PCM_STATE_OPEN: u32 = 0; - pub const SNDRV_PCM_STATE_SETUP: u32 = 1; - pub const SNDRV_PCM_STATE_PREPARED: u32 = 2; - pub const SNDRV_PCM_STATE_RUNNING: u32 = 3; - pub const SNDRV_PCM_STATE_XRUN: u32 = 4; - pub const SNDRV_PCM_STATE_PAUSED: u32 = 6; - - // --- PCM format constants (S16_LE is v1's only support) -------------- - - pub const SNDRV_PCM_FORMAT_S16_LE: u32 = 2; - pub const SNDRV_PCM_FORMAT_S32_LE: u32 = 10; - pub const SNDRV_PCM_FORMAT_FLOAT_LE: u32 = 14; - - // --- PCM access constants -------------------------------------------- - - pub const SNDRV_PCM_ACCESS_MMAP_INTERLEAVED: u32 = 0; - pub const SNDRV_PCM_ACCESS_RW_INTERLEAVED: u32 = 3; - - // --- PCM stream direction -------------------------------------------- - - pub const SNDRV_PCM_STREAM_PLAYBACK: u32 = 0; - pub const SNDRV_PCM_STREAM_CAPTURE: u32 = 1; - - // --- MMAP offsets (passed to mmap(pcm_fd, ...) to select a page) ---- - - pub const SNDRV_PCM_MMAP_OFFSET_DATA: u64 = 0x0000_0000; - pub const SNDRV_PCM_MMAP_OFFSET_STATUS: u64 = 0x8000_0000; - pub const SNDRV_PCM_MMAP_OFFSET_CONTROL: u64 = 0x8100_0000; - - /// `struct snd_interval` — value-range descriptor inside - /// `snd_pcm_hw_params.intervals[]`. Linux packs four flag bits - /// (openmin / openmax / integer / empty) into a trailing u32; we - /// store them as a plain u32 to match Linux's 12-byte UAPI size. - #[repr(C)] - #[derive(Clone, Copy, Default)] - pub struct WpkSndInterval { - pub min: u32, - pub max: u32, - /// Bit 0 = openmin, 1 = openmax, 2 = integer, 3 = empty. - pub flags: u32, - } - - /// `struct snd_pcm_hw_params`. Layout-locked against Linux v6.10 - /// `include/uapi/sound/asound.h`; `masks[64]` covers the 3 active + - /// 5 reserved snd_masks (each is u32[8]), `intervals[21]` covers - /// the 12 active + 9 reserved snd_intervals. Phase C's vendored - /// `` mirrors this byte-for-byte. - #[repr(C)] - #[derive(Clone, Copy)] - pub struct WpkAlsaPcmHwParams { - pub flags: u32, - pub masks: [u32; 64], - pub intervals: [WpkSndInterval; 21], - pub rmask: u32, - pub cmask: u32, - pub info: u32, - pub msbits: u32, - pub rate_num: u32, - pub rate_den: u32, - pub fifo_size: u64, - pub reserved: [u8; 64], - } - - impl Default for WpkAlsaPcmHwParams { - fn default() -> Self { - Self { - flags: 0, - masks: [0; 64], - intervals: [WpkSndInterval::default(); 21], - rmask: 0, - cmask: 0, - info: 0, - msbits: 0, - rate_num: 0, - rate_den: 0, - fifo_size: 0, - reserved: [0; 64], - } - } - } - - /// `struct snd_pcm_sw_params`. Layout-locked against Linux v6.10. - #[repr(C)] - #[derive(Clone, Copy)] - pub struct WpkAlsaPcmSwParams { - pub tstamp_mode: u32, - pub period_step: u32, - pub sleep_min: u32, - pub _pad0: u32, - pub avail_min: u64, - pub xfer_align: u64, - pub start_threshold: u64, - pub stop_threshold: u64, - pub silence_threshold: u64, - pub silence_size: u64, - pub boundary: u64, - pub proto: u32, - pub tstamp_type: u32, - pub reserved: [u8; 56], - } - - impl Default for WpkAlsaPcmSwParams { - fn default() -> Self { - Self { - tstamp_mode: 0, - period_step: 0, - sleep_min: 0, - _pad0: 0, - avail_min: 0, - xfer_align: 0, - start_threshold: 0, - stop_threshold: 0, - silence_threshold: 0, - silence_size: 0, - boundary: 0, - proto: 0, - tstamp_type: 0, - reserved: [0; 56], - } - } - } - - /// `struct snd_pcm_status`. All timestamps stamped from - /// `CLOCK_MONOTONIC` so userspace can correlate audio underruns - /// with vblank + input timestamps. - #[repr(C)] - #[derive(Clone, Copy, Default)] - pub struct WpkAlsaPcmStatus { - pub state: u32, - pub _pad0: u32, - pub trigger_tstamp_sec: i64, - pub trigger_tstamp_nsec: i64, - pub tstamp_sec: i64, - pub tstamp_nsec: i64, - pub appl_ptr: i64, - pub hw_ptr: i64, - pub delay: i64, - pub avail: u64, - pub avail_max: u64, - pub overrange: u64, - pub suspended_state: u32, - pub audio_tstamp_data: u32, - pub audio_tstamp_sec: i64, - pub audio_tstamp_nsec: i64, - pub _reserved: [u8; 16], - } - - /// `struct snd_pcm_info`. Returned by `SNDRV_PCM_IOCTL_INFO`. - #[repr(C)] - #[derive(Clone, Copy)] - pub struct WpkAlsaPcmInfo { - pub device: u32, - pub subdevice: u32, - pub stream: i32, - pub card: i32, - pub id: [u8; 64], - pub name: [u8; 80], - pub subname: [u8; 32], - pub dev_class: u32, - pub dev_subclass: u32, - pub subdevices_count: u32, - pub subdevices_avail: u32, - pub sync: [u8; 16], - pub reserved: [u8; 64], - } - - impl Default for WpkAlsaPcmInfo { - fn default() -> Self { - Self { - device: 0, - subdevice: 0, - stream: 0, - card: 0, - id: [0; 64], - name: [0; 80], - subname: [0; 32], - dev_class: 0, - dev_subclass: 0, - subdevices_count: 0, - subdevices_avail: 0, - sync: [0; 16], - reserved: [0; 64], - } - } - } - - /// `struct snd_pcm_mmap_status`. Kernel-writes, userspace-reads. - /// Mapped at `SNDRV_PCM_MMAP_OFFSET_STATUS`. Field offsets are - /// load-bearing — userspace reads `hw_ptr` via direct memory access - /// on the mapped page, not through an ioctl. - #[repr(C)] - #[derive(Clone, Copy, Default, Debug)] - pub struct WpkAlsaPcmMmapStatus { - pub state: u32, - pub _pad0: u32, - pub hw_ptr: i64, - pub tstamp_sec: i64, - pub tstamp_nsec: i64, - pub suspended_state: u32, - pub audio_tstamp_data: u32, - pub audio_tstamp_sec: i64, - pub audio_tstamp_nsec: i64, - pub _reserved_tail: [u8; 8], - } - - /// `struct snd_pcm_mmap_control`. Userspace-writes, kernel-reads. - /// Mapped at `SNDRV_PCM_MMAP_OFFSET_CONTROL`. - #[repr(C)] - #[derive(Clone, Copy, Debug)] - pub struct WpkAlsaPcmMmapControl { - pub appl_ptr: i64, - pub avail_min: i64, - pub _reserved: [u8; 48], - } - - impl Default for WpkAlsaPcmMmapControl { - fn default() -> Self { - Self { appl_ptr: 0, avail_min: 0, _reserved: [0; 48] } - } - } - - /// `struct snd_xferi` — argument to `WRITEI_FRAMES` / `READI_FRAMES`. - #[repr(C)] - #[derive(Clone, Copy, Default)] - pub struct WpkAlsaXferi { - pub result: i64, - pub buf: u64, - pub frames: u64, - } - -} - #[cfg(test)] mod dri_tests { use super::dri::*; @@ -5973,77 +5723,3 @@ mod input_tests { assert_eq!(EVIOCGABS_NR_BASE, 0x40); } } - -#[cfg(test)] -mod audio_tests { - use super::audio::*; - use core::mem::size_of; - - const fn ioc(dir: u32, magic: u32, nr: u32, size: u32) -> u32 { - (dir << 30) | (size << 16) | (magic << 8) | nr - } - const IOC_READ: u32 = 2; - const IOC_WRITE: u32 = 1; - const IOC_RW: u32 = 3; - - #[test] - fn audio_struct_sizes_match_wasm32_repr_c() { - // These numbers lock the wasm32 `repr(C)` layout; Phase C's - // vendored `` mirrors them byte-for-byte. - // HwParams = 608 follows Linux v6.10: snd_mask[8] (= u32[64]) - // + snd_interval[21] (= 12 bytes each) + 6 u32s + the 4-byte - // trailing-u32 pad Rust inserts before fifo_size + reserved[64]. - assert_eq!(size_of::(), 12); - assert_eq!(size_of::(), 608); - assert_eq!(size_of::(), 136); - assert_eq!(size_of::(), 128); - assert_eq!(size_of::(), 288); - assert_eq!(size_of::(), 64); - assert_eq!(size_of::(), 64); - assert_eq!(size_of::(), 24); - } - - #[test] - fn audio_mmap_status_field_offsets() { - // The mmap_status page is read by userspace via direct memory - // access — userspace polls hw_ptr without an ioctl round-trip. - let s = WpkAlsaPcmMmapStatus::default(); - let base = (&s as *const _) as usize; - assert_eq!((&s.state as *const _ as usize) - base, 0); - assert_eq!((&s.hw_ptr as *const _ as usize) - base, 8); - assert_eq!((&s.tstamp_sec as *const _ as usize) - base, 16); - } - - #[test] - fn audio_mmap_control_field_offsets() { - let c = WpkAlsaPcmMmapControl::default(); - let base = (&c as *const _) as usize; - assert_eq!((&c.appl_ptr as *const _ as usize) - base, 0); - assert_eq!((&c.avail_min as *const _ as usize) - base, 8); - } - - #[test] - fn pcm_ioctl_numbers_match_linux_uapi() { - assert_eq!( - SNDRV_PCM_IOCTL_PVERSION, - ioc(IOC_READ, 'A' as u32, 0x00, 4) - ); - assert_eq!( - SNDRV_PCM_IOCTL_HW_PARAMS, - ioc(IOC_RW, 'A' as u32, 0x11, size_of::() as u32) - ); - assert_eq!( - SNDRV_PCM_IOCTL_SW_PARAMS, - ioc(IOC_RW, 'A' as u32, 0x13, size_of::() as u32) - ); - assert_eq!( - SNDRV_PCM_IOCTL_STATUS, - ioc(IOC_READ, 'A' as u32, 0x20, size_of::() as u32) - ); - assert_eq!( - SNDRV_PCM_IOCTL_WRITEI_FRAMES, - ioc(IOC_WRITE, 'A' as u32, 0x50, size_of::() as u32) - ); - } - -} diff --git a/host/src/audio/audio-driver.ts b/host/src/audio/audio-driver.ts deleted file mode 100644 index 2a9f612bdf..0000000000 --- a/host/src/audio/audio-driver.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * `AudioDriver` — host-side abstraction over an ALSA PCM consumer. - * One implementation per host: `BrowserAudioDriver` pulls samples on - * each AudioWorklet quantum and routes them to a `WebAudio` - * `AudioContext`; `NodeAudioDriver` is a `setInterval`-driven dummy - * for headless tests. The host wires `start` after the kernel has - * exported a SAB-backed ring via `kernel_audio_init_sab`, and routes - * `kernelTick` (`kernel.exports.kernel_audio_period_tick`) back into - * the kernel once per ALSA period — same shape Linux's hw_ptr - * advancement uses. - */ - -/** Where the SAB ring lives in kernel-visible memory. `buffer` is the - * kernel's WebAssembly.Memory backing store (a `SharedArrayBuffer` in - * the shared-memory build); `byteOffset` + `byteLength` cover the - * region the kernel registered via `kernel_audio_init_sab`. */ -export interface AudioRing { - buffer: SharedArrayBuffer | ArrayBuffer; - byteOffset: number; - byteLength: number; -} - -export interface AudioDriver { - /** Begin pulling frames from the SAB ring registered for `pcmId`. - * `kernelTick` is the bound `kernel.exports.kernel_audio_period_tick` - * proxy; the driver invokes it once `periodFrames` worth of frames - * have been consumed so the kernel can advance `mmap_status.hw_ptr` - * and wake POLLOUT waiters. Idempotent: calling `start` again with - * the same `pcmId` is a no-op. */ - start( - pcmId: number, - sampleRate: number, - channels: number, - periodFrames: number, - ring: AudioRing, - kernelTick: (pcmId: number, framesConsumed: number) => void, - /** Bound `kernel.exports.kernel_audio_get_appl_ptr` proxy. The - * browser driver polls this each AudioWorklet quantum and forwards - * the value into the worklet so it can gate `hwPtr` advance — - * silence past `appl_ptr`, advance only over written ring - * positions. Headless drivers (NodeAudioDriver) accept this for - * dual-host signature parity and ignore the value. */ - getApplPtr: (pcmId: number) => number, - ): Promise; - - /** Stop pulling; tear down audio context / clear timers. Idempotent. */ - stop(pcmId: number): void; -} diff --git a/host/src/audio/browser-audio-driver.ts b/host/src/audio/browser-audio-driver.ts deleted file mode 100644 index 51c8f60747..0000000000 --- a/host/src/audio/browser-audio-driver.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * `BrowserAudioDriver` — pulls S16-interleaved frames from a - * kernel-memory SAB ring on every `AudioWorklet` quantum (128 frames) - * and routes them to a `WebAudio` `AudioContext` for playback. After - * every `periodFrames` worth of consumed frames it invokes the bound - * `kernel_audio_period_tick` proxy so the kernel can advance - * `mmap_status.hw_ptr` and wake `POLLOUT` waiters. - * - * The worklet runs on the audio thread and can't call kernel exports - * directly; instead it posts a `{ framesConsumed }` message on each - * quantum. The main thread accumulates those quanta and calls - * `kernelTick` once per ALSA period. - * - * The ring lives inside the kernel's `WebAssembly.Memory` (a - * `SharedArrayBuffer` in the shared-memory build) so the worklet and - * the kernel see the same bytes. The kernel registered the - * `(base, len)` window via `kernel_audio_init_sab` at boot; the host - * just forwards it into the worklet's `processorOptions`. - */ - -import type { AudioDriver, AudioRing } from "./audio-driver.js"; - -/** Public for tests: URL the worklet processor is registered at. - * Apps embedding this driver are expected to host the worklet js at - * this path (the file ships next to this module). */ -export const WPK_AUDIO_WORKLET_URL = "/audio/wpk-audio-worklet.js"; - -interface PcmContext { - audioCtx: AudioContext; - worklet: AudioWorkletNode; - ring: AudioRing; - sampleRate: number; - channels: number; - periodFrames: number; - framesSinceTick: number; - /** Cumulative frames played by the worklet (sum of all per-quantum - * `framesConsumed`). Used by `stop()` to estimate how much tail is - * still buffered in the ring so it can wait that long before closing - * the AudioContext — without this, the last word of a phrase gets - * truncated. */ - totalFramesConsumed: number; - /** Latest `appl_ptr` posted to the worklet (also the producer - * upper-bound — when `totalFramesConsumed` reaches `lastApplPtr`, - * playback has caught up). */ - lastApplPtr: number; - kernelTick: (pcmId: number, frames: number) => void; - getApplPtr: (pcmId: number) => number; - /** Poll handle that pushes the latest `appl_ptr` into the worklet so - * the worklet emits silence past producer progress instead of racing - * ahead during userspace setup latency (e.g. espeak-ng's data-file - * load before its first WRITEI). */ - applPtrPollHandle: ReturnType; -} - -export class BrowserAudioDriver implements AudioDriver { - private contexts = new Map(); - - constructor(private workletUrl: string = WPK_AUDIO_WORKLET_URL) {} - - async start( - pcmId: number, - sampleRate: number, - channels: number, - periodFrames: number, - ring: AudioRing, - kernelTick: (pcmId: number, framesConsumed: number) => void, - getApplPtr: (pcmId: number) => number, - ): Promise { - if (this.contexts.has(pcmId)) return; - - const audioCtx = new AudioContext({ sampleRate }); - await audioCtx.audioWorklet.addModule(this.workletUrl); - const worklet = new AudioWorkletNode(audioCtx, "wpk-pcm-pull", { - numberOfInputs: 0, - numberOfOutputs: 1, - outputChannelCount: [channels], - processorOptions: { - buffer: ring.buffer, - byteOffset: ring.byteOffset, - byteLength: ring.byteLength, - channels, - }, - }); - worklet.connect(audioCtx.destination); - - // Poll appl_ptr on a 10 ms interval and push to the worklet. The - // worklet uses it to gate `hwPtr` advance so it never advances - // past producer progress — without this, the worklet's local - // `hwPtr` ticks at AudioContext rate from the moment the node - // connects (~5 ms after this call), and any userspace setup - // latency before the first WRITEI (espeak-ng spends ~500 ms - // loading data files before its first synth chunk lands) becomes - // a chunk of the head audio buried at ring offsets the worklet - // has already passed. - const ctx: PcmContext = { - audioCtx, - worklet, - ring, - sampleRate, - channels, - periodFrames, - framesSinceTick: 0, - totalFramesConsumed: 0, - lastApplPtr: 0, - kernelTick, - getApplPtr, - // Filled in below — declared before setInterval so the callback - // can reference `ctx` without a temporal-dead-zone error. - applPtrPollHandle: 0 as unknown as ReturnType, - }; - ctx.applPtrPollHandle = setInterval(() => { - const applPtr = getApplPtr(pcmId); - ctx.lastApplPtr = applPtr; - worklet.port.postMessage({ applPtr }); - }, 10); - worklet.port.onmessage = ( - e: MessageEvent<{ framesConsumed?: number }>, - ) => { - const data = e.data; - if (typeof data.framesConsumed !== "number") return; - ctx.framesSinceTick += data.framesConsumed; - ctx.totalFramesConsumed += data.framesConsumed; - while (ctx.framesSinceTick >= ctx.periodFrames) { - ctx.kernelTick(pcmId, ctx.periodFrames); - ctx.framesSinceTick -= ctx.periodFrames; - } - }; - this.contexts.set(pcmId, ctx); - } - - /** - * Drain the buffered tail before closing the AudioContext. When - * `stop()` is called, the kernel's `appl_ptr` typically leads the - * worklet's `totalFramesConsumed` by up to one ring's worth of - * frames — userspace `WRITEI` can fill ahead of realtime playback up - * to the SAB ring capacity. Closing the AudioContext immediately - * truncates that tail. Instead, we keep polling appl_ptr until it - * stops growing (producer done), then sleep just long enough for the - * worklet to play the residual delta, and only then close. - * Synchronous return — the actual teardown happens on a timer. - */ - stop(pcmId: number): void { - const ctx = this.contexts.get(pcmId); - if (!ctx) return; - this.contexts.delete(pcmId); - // Stop pushing applPtr into the worklet; the worklet keeps the - // last value we sent and plays out to it. We still need to read - // applPtr from the kernel a few more times to confirm the - // producer is done, then wait for the consumer to catch up. - clearInterval(ctx.applPtrPollHandle); - - const finalApplPtr = ctx.getApplPtr(pcmId); - if (finalApplPtr > ctx.lastApplPtr) { - ctx.lastApplPtr = finalApplPtr; - // Push the final value so the worklet can play it out. - ctx.worklet.port.postMessage({ applPtr: finalApplPtr }); - } - - const close = () => { - ctx.worklet.port.onmessage = null; - ctx.worklet.disconnect(); - void ctx.audioCtx.close(); - }; - - const pending = Math.max(0, ctx.lastApplPtr - ctx.totalFramesConsumed); - if (pending === 0) { - close(); - return; - } - // Wait for the worklet to play out the pending frames at audio - // rate, plus a 100 ms safety margin. The margin covers the - // browser's AudioContext output-queue latency (the worklet - // posts `framesConsumed` immediately, but the samples then sit - // in the platform audio buffer for a browser-dependent interval - // before the speaker emits them). 100 ms is empirically - // sufficient on Chrome/Safari/Firefox for the espeak demo; the - // worklet quantum (128 frames ≈ 2.67 ms @ 48 kHz) is far - // smaller than this margin. `applPtr` is stable at this point - // — espeak-ng's drain has already returned, so we don't re-poll. - const drainMs = (pending / ctx.sampleRate) * 1000 + 100; - setTimeout(close, drainMs); - } -} diff --git a/host/src/audio/instrumented-audio-driver.ts b/host/src/audio/instrumented-audio-driver.ts deleted file mode 100644 index d38204b865..0000000000 --- a/host/src/audio/instrumented-audio-driver.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Wraps any `AudioDriver` so callers can observe accumulated frames - * played by the underlying worklet without reaching inside the - * driver. The Playwright spec for `/?demo=espeak` uses this to assert - * non-zero playback (`window.__alsaFramesConsumed`). - * - * The forwarding has bit us once: session 42 shipped a wrapper whose - * `start()` dropped the new `getApplPtr` parameter when calling - * `inner.start()`, silently disabling the producer-pointer gate that - * prevents head-truncation. The regression spec - * (`host/test/instrumented-audio-driver.test.ts`) pins forwarding of - * every argument the `AudioDriver` interface declares. - */ -import type { AudioDriver, AudioRing } from "./audio-driver.js"; - -export interface InstrumentedAudioDriver extends AudioDriver { - framesConsumed(): number; -} - -export function instrumentAudioDriver( - inner: AudioDriver, - onFramesConsumed?: (frames: number, total: number) => void, -): InstrumentedAudioDriver { - let total = 0; - return { - async start( - pcmId: number, - sampleRate: number, - channels: number, - periodFrames: number, - ring: AudioRing, - kernelTick: (id: number, frames: number) => void, - getApplPtr: (id: number) => number, - ): Promise { - await inner.start( - pcmId, - sampleRate, - channels, - periodFrames, - ring, - (id, frames) => { - total += frames; - onFramesConsumed?.(frames, total); - kernelTick(id, frames); - }, - getApplPtr, - ); - }, - stop(pcmId: number): void { - inner.stop(pcmId); - }, - framesConsumed(): number { - return total; - }, - }; -} diff --git a/host/src/audio/node-audio-driver.ts b/host/src/audio/node-audio-driver.ts deleted file mode 100644 index 8c62594579..0000000000 --- a/host/src/audio/node-audio-driver.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * `NodeAudioDriver` — headless dummy that schedules a `setInterval` - * matching the ALSA period cadence and calls the bound - * `kernel_audio_period_tick` proxy each fire. There's no real audio - * sink on Node; the SAB ring is consumed in name only — the kernel - * uses the period_tick to advance `mmap_status.hw_ptr` and wake - * `POLLOUT` waiters so userspace can keep writing. - * - * Used by Vitest's audio specs and by any kandelo CLI run that - * exercises ALSA-shaped programs without a WebAudio output. Mirrors - * `BrowserAudioDriver` so the Node + browser host init paths stay - * symmetric per CLAUDE.md §"Two hosts". - */ - -import type { AudioDriver, AudioRing } from "./audio-driver.js"; - -interface PcmTimer { - intervalHandle: ReturnType; - ring: AudioRing; - periodFrames: number; -} - -export class NodeAudioDriver implements AudioDriver { - private timers = new Map(); - - async start( - pcmId: number, - sampleRate: number, - _channels: number, - periodFrames: number, - ring: AudioRing, - kernelTick: (pcmId: number, framesConsumed: number) => void, - // Headless driver has no AudioWorklet to gate — the kernel-side - // hw_ptr advance is what `kernelTick` drives. Kept in the signature - // for dual-host parity per `AudioDriver`. - _getApplPtr: (pcmId: number) => number, - ): Promise { - if (this.timers.has(pcmId)) return; - const intervalMs = (periodFrames * 1000) / sampleRate; - const handle = setInterval( - () => kernelTick(pcmId, periodFrames), - intervalMs, - ); - this.timers.set(pcmId, { intervalHandle: handle, ring, periodFrames }); - } - - stop(pcmId: number): void { - const t = this.timers.get(pcmId); - if (!t) return; - clearInterval(t.intervalHandle); - this.timers.delete(pcmId); - } -} diff --git a/host/src/audio/wpk-audio-worklet.js b/host/src/audio/wpk-audio-worklet.js deleted file mode 100644 index 819745f864..0000000000 --- a/host/src/audio/wpk-audio-worklet.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * `wpk-pcm-pull` — AudioWorklet processor that reads S16-interleaved - * frames from a kernel-memory ring (a SharedArrayBuffer slice exposed - * via `kernel_audio_init_sab`) and pushes them onto the AudioContext - * output bus. - * - * Producer/consumer gating: the worklet's local `hwPtr` is monotonic - * (absolute frame count since attach). On every quantum it consumes - * up to 128 frames, but never past the kernel's `appl_ptr` — the - * BrowserAudioDriver polls `kernel_audio_get_appl_ptr` on a 10 ms - * interval and posts the value via `{ applPtr }`. Frames past - * `appl_ptr` emit silence and don't advance `hwPtr`. The kernel-side - * `kernel_audio_period_tick` is driven by `framesConsumed` so the - * kernel only advances `mmap_status.hw_ptr` by frames the worklet - * actually played — non-RUNNING / non-written quanta don't count - * against avail, and no spurious XRUN fires. - * - * This gating fixes the head-truncation race observed in espeak-ng: - * the worklet starts immediately on attach, but espeak-ng's ~500 ms - * data-file load + synth init means the kernel's appl_ptr stays at - * 0 for the first ~11 000 frames at 22 050 Hz. Without gating, the - * worklet's hwPtr ticks past those ring offsets, so when the first - * WRITEI lands at ring[0..], the worklet has already passed them - * and won't revisit until ring wraparound — the head of the - * synthesised phrase is buried. - */ - -class WpkPcmPullProcessor extends AudioWorkletProcessor { - constructor(options) { - super(); - const { buffer, byteOffset, byteLength, channels } = - options.processorOptions; - // s16 interleaved view onto the kernel ring window. - this.ring = new Int16Array(buffer, byteOffset, byteLength / 2); - this.ringFrames = this.ring.length / channels; - this.channels = channels; - // Absolute frame count consumed; modulo ringFrames when indexing - // into the SAB. - this.hwPtr = 0; - // Latest producer position posted by BrowserAudioDriver. The - // worklet never reads or advances past this. - this.applPtr = 0; - this.port.onmessage = (e) => { - const data = e.data; - if (data && typeof data.applPtr === "number") { - this.applPtr = data.applPtr; - } - }; - } - - process(_inputs, outputs) { - const out = outputs[0]; // out[channel][sample] - const frames = out[0].length; // always 128 - const ringFrames = this.ringFrames; - const ch = this.channels; - const ring = this.ring; - const applPtr = this.applPtr; - const hw = this.hwPtr; - // Number of frames available to consume this quantum (capped by - // producer progress, never negative). - const available = Math.max(0, Math.min(frames, applPtr - hw)); - for (let f = 0; f < available; f++) { - const ringOff = ((hw + f) % ringFrames) * ch; - for (let c = 0; c < ch; c++) { - // s16 → f32 conversion for the WebAudio output bus. - out[c][f] = ring[ringOff + c] / 0x8000; - } - } - for (let f = available; f < frames; f++) { - for (let c = 0; c < ch; c++) out[c][f] = 0; - } - this.hwPtr = hw + available; - this.port.postMessage({ framesConsumed: available }); - return true; - } -} - -registerProcessor("wpk-pcm-pull", WpkPcmPullProcessor); diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 733818e4dd..7e86794e2e 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -25,7 +25,6 @@ import { validateBrowserCorsProxyConfig, } from "./networking/browser-cors-proxy"; import type { InputSource } from "./input/input-source"; -import type { AudioDriver, AudioRing } from "./audio/audio-driver"; export type { HttpRequest, HttpResponse }; import workerEntryUrl from "./worker-entry-browser.ts?worker&url"; @@ -1110,120 +1109,6 @@ export class BrowserKernel { ); } - /** - * Allocate a kernel-memory SAB ring for `pcmId` of `byteLen` bytes - * and bind it via `kernel_audio_init_sab`. Returns the ring window - * the host AudioDriver should view as `Int16Array(buffer, offset, …)`. - * Mirrors the Node-side method of the same name — dual-host parity - * per CLAUDE.md §"Two hosts". - */ - async audioAllocRing(pcmId: number, byteLen: number): Promise { - const requestId = this.nextRequestId++; - const result = await this.request(requestId, { - type: "audio_alloc_ring", - requestId, - pcmId, - byteLen, - }); - return result as AudioRing; - } - - /** - * Fire-and-forget period tick into the kernel. The - * `BrowserAudioDriver` invokes this from its worklet-message handler - * once per ALSA period boundary; the kernel advances - * `mmap_status.hw_ptr` and wakes any `POLLOUT` waiter parked on - * `/dev/snd/pcmC0Dp`. - */ - audioPeriodTick(pcmId: number, framesConsumed: number): void { - this.sendToKernel({ - type: "audio_period_tick", - pcmId, - framesConsumed, - }); - } - - /** - * Read the current `mmap_control.appl_ptr` for any OFD bound to - * `pcmId`. Polled by `BrowserAudioDriver` to gate the worklet's - * `hwPtr` advance on producer progress. The async hop to the - * kernel worker resolves in ~1–5 ms; the driver polls every 10 ms. - */ - async audioGetApplPtr(pcmId: number): Promise { - const requestId = this.nextRequestId++; - const result = await this.request(requestId, { - type: "audio_get_appl_ptr", - requestId, - pcmId, - }); - return result as number; - } - - /** - * Wire an `AudioDriver` into the kernel: allocates a SAB ring, - * registers it, then starts the driver with a `kernelTick` callback - * that funnels each period boundary into `kernel_audio_period_tick`. - * Mirrors `NodeKernelHost.attachAudioDriver` — dual-host parity per - * CLAUDE.md §"Two hosts". - * - * On the browser the driver is a `BrowserAudioDriver` which spins up - * an `AudioContext` + `AudioWorkletNode` to pull samples on each - * quantum. The worklet posts `framesConsumed` back here per quantum; - * the driver accumulates to a period and ticks the kernel. - */ - async attachAudioDriver( - driver: AudioDriver, - opts: { - pcmId?: number; - sampleRate?: number; - channels?: number; - periodFrames?: number; - ringBytes?: number; - } = {}, - ): Promise { - const pcmId = opts.pcmId ?? 0; - const sampleRate = opts.sampleRate ?? 48_000; - const channels = opts.channels ?? 2; - const periodFrames = opts.periodFrames ?? 1024; - const ringBytes = opts.ringBytes ?? 64 * 1024; - const ring = await this.audioAllocRing(pcmId, ringBytes); - // The browser driver wants a synchronous returns-number callback - // (no awaits inside the AudioWorklet `process()` call path) — but - // the kernel runs in a worker, so `audioGetApplPtr` is async. We - // cache the latest value here and refresh it on each invocation; - // returns the previous cached value while the next request is in - // flight. The cache always converges within one poll interval - // (10 ms) which is far below one ALSA period (~46 ms @ 22050 Hz), - // so the worklet sees fresh enough producer progress. - let cachedApplPtr = 0; - let inFlight = false; - const getApplPtr = (id: number): number => { - if (!inFlight) { - inFlight = true; - this.audioGetApplPtr(id) - .then((v) => { - cachedApplPtr = v; - }) - .catch(() => { - /* swallow — keep last good value */ - }) - .finally(() => { - inFlight = false; - }); - } - return cachedApplPtr; - }; - await driver.start( - pcmId, - sampleRate, - channels, - periodFrames, - ring, - (id, frames) => this.audioPeriodTick(id, frames), - getApplPtr, - ); - } - /** * Hand an `OffscreenCanvas` to the kernel worker as the scanout * target for KMS CRTC `crtcId`. The worker's vblank pump blits the diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 702a8301b5..0ac99e8d46 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -353,52 +353,6 @@ export interface AudioDrainMessage { maxBytes: number; } -/** - * Main-thread → kernel-worker request to allocate a kernel-memory - * SAB ring for `pcmId` of `byteLen` bytes and bind it via - * `kernel_audio_init_sab`. The worker replies via `ResponseMessage` - * with `{ buffer, byteOffset, byteLength }` so the main-thread - * AudioDriver can mount an `Int16Array` view at the same offset. - * Mirrors the Node-side message of the same name — dual-host parity - * per CLAUDE.md §"Two hosts". - */ -export interface AudioAllocRingRequestMessage { - type: "audio_alloc_ring"; - requestId: number; - pcmId: number; - byteLen: number; -} - -/** - * Main-thread → kernel-worker period tick. The main-thread - * `BrowserAudioDriver` accumulates AudioWorklet quanta until one ALSA - * period's worth of frames is consumed, then sends this message. The - * worker calls `kernel_audio_period_tick` which advances - * `mmap_status.hw_ptr`, detects XRUN, and wakes any `POLLOUT` waiter - * parked on `/dev/snd/pcmC0Dp`. Fire-and-forget. - */ -export interface AudioPeriodTickMessage { - type: "audio_period_tick"; - pcmId: number; - framesConsumed: number; -} - -/** - * Main-thread → kernel-worker request to read the current - * `mmap_control.appl_ptr` for any OFD bound to `pcmId`. The - * `BrowserAudioDriver` polls this on a 10 ms interval and forwards - * the result into the `wpk-pcm-pull` AudioWorklet so the worklet - * gates `hwPtr` advance on producer progress (silence past - * `appl_ptr`). The worker replies via `ResponseMessage` with a - * `number` (i64 truncated through `Number()` — within JS safe-int - * range for any realistic session). Returns 0 if no OFD is bound. - */ -export interface AudioGetApplPtrRequestMessage { - type: "audio_get_appl_ptr"; - requestId: number; - pcmId: number; -} - export interface RegisterLazyArchivesMessage { type: "register_lazy_archives"; requestId?: number; @@ -539,9 +493,6 @@ export type MainToKernelMessage = | InputEventInjectMessage | SetInputCanvasDimsMessage | AudioDrainMessage - | AudioAllocRingRequestMessage - | AudioPeriodTickMessage - | AudioGetApplPtrRequestMessage | EnumProcsRequestMessage | ReadProcMapsRequestMessage | SetSyscallTraceMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index d563645663..8bb2d8dfb0 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -4415,21 +4415,6 @@ sw.onmessage = (e: MessageEvent) => { case "set_input_canvas_dims": kernelWorker.setInputCanvasDims(msg.width, msg.height); break; - case "audio_alloc_ring": { - const ring = kernelWorker.audioInitRing(msg.pcmId, msg.byteLen); - if (!ring) { - respondError(msg.requestId, "audio_alloc_ring: kernel allocator declined"); - } else { - respond(msg.requestId, ring); - } - break; - } - case "audio_period_tick": - kernelWorker.audioPeriodTick(msg.pcmId, msg.framesConsumed); - break; - case "audio_get_appl_ptr": - respond(msg.requestId, kernelWorker.audioGetApplPtr(msg.pcmId)); - break; default: { // Every typed MainToKernelMessage must have a case above. Browser // tooling also sends a few deliberately out-of-band control messages, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 8bf12d3368..f7c29c811c 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30472,50 +30472,6 @@ export class CentralizedKernelWorker { ); } - /** - * Allocate a kernel-memory window for an ALSA PCM SAB ring of - * `byteLen` bytes and bind it to `pcmId`. Returns the kernel-memory - * `(byteOffset, byteLength)` pair plus the underlying kernel - * `SharedArrayBuffer`, so the host-thread AudioDriver can view it - * with `new Int16Array(buffer, byteOffset, byteLength / 2)`. Returns - * `null` if the kernel allocator declined. - */ - audioInitRing( - pcmId: number, - byteLen: number, - ): { - buffer: SharedArrayBuffer | ArrayBuffer; - byteOffset: number; - byteLength: number; - } | null { - const base = this.kernel.audioAllocRing(byteLen); - if (base === 0) return null; - this.kernel.audioInitSab(pcmId, base, byteLen); - const buffer = this.kernel.getKernelMemoryBuffer(); - if (!buffer) return null; - return { buffer, byteOffset: base, byteLength: byteLen }; - } - - /** - * Forward an audio period tick from the host AudioDriver into the - * kernel: advances `mmap_status.hw_ptr`, detects XRUN, wakes any - * process parked on `POLLOUT` against `/dev/snd/pcmC0Dp`. - */ - audioPeriodTick(pcmId: number, framesConsumed: number): void { - this.kernel.audioPeriodTick(pcmId, framesConsumed); - this.scheduleWakeBlockedRetries(); - } - - /** - * Return the current `mmap_control.appl_ptr` for any OFD bound to - * `pcmId`. The browser AudioDriver polls this each 10 ms and pushes - * the value into the AudioWorklet so the worklet gates `hwPtr` - * advance on producer progress. - */ - audioGetApplPtr(pcmId: number): number { - return this.kernel.audioGetApplPtr(pcmId); - } - /** * ABI version the kernel advertised at startup via its * `__abi_version` export. Worker processes compare against this diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 5331dbcd02..da9b5d3365 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -1417,82 +1417,6 @@ export class WasmPosixKernel { return fn ? fn() : 0; } - // --------------------------------------------------------------------------- - // /dev/snd/pcmC0Dp — host-fed PCM ring (ALSA) - // --------------------------------------------------------------------------- - - /** - * Allocate a `byteLen`-sized region inside kernel-visible memory for - * use as the ALSA PCM SAB ring. Returns the kernel-memory offset - * (suitable for `kernel_audio_init_sab`), or 0 if the kernel is not - * instantiated or the allocator declined. The region is never freed; - * callers should allocate one per `pcm_id` at boot and reuse. - */ - audioAllocRing(byteLen: number): number { - const exports = this.instance?.exports as Record | undefined; - const alloc = exports?.kernel_alloc_scratch as - | ((size: number) => bigint | number) - | undefined; - if (!alloc) return 0; - return Number(alloc(byteLen)); - } - - /** - * Bind a kernel-memory window as the SAB-backed PCM ring for - * `pcmId`. After this call, `SNDRV_PCM_IOCTL_WRITEI_FRAMES` lands - * frames into the ring and the host-side AudioDriver pulls them - * back out. Re-issuing for the same `pcmId` is a no-op kernel-side. - * Silently dropped if the kernel module is not instantiated yet. - */ - audioInitSab(pcmId: number, base: number, len: number): void { - const fn = this.instance?.exports?.kernel_audio_init_sab as - | ((pcmId: number, base: bigint, len: number) => void) - | undefined; - if (!fn) return; - fn(pcmId, BigInt(base), len); - } - - /** - * Tell the kernel the host-side driver consumed `framesConsumed` - * frames from the SAB ring. Advances `mmap_status.hw_ptr`, stamps - * the monotonic timestamp, detects XRUN, and wakes any process - * parked on `POLLOUT` for `/dev/snd/pcmC0Dp`. Silently - * dropped if the kernel module is not instantiated yet. - */ - audioPeriodTick(pcmId: number, framesConsumed: number): void { - const fn = this.instance?.exports?.kernel_audio_period_tick as - | ((pcmId: number, framesConsumed: number) => void) - | undefined; - if (!fn) return; - fn(pcmId, framesConsumed); - } - - /** - * Return the current `mmap_control.appl_ptr` for any OFD bound to - * `pcmId` (max across matches; in practice ≤1 writer per PCM). The - * browser `AudioDriver` polls this and forwards the value into the - * `wpk-pcm-pull` AudioWorklet so the worklet emits silence past - * `appl_ptr` instead of racing ahead of the producer. 0 if the - * kernel module is not instantiated or no OFD is bound. - */ - audioGetApplPtr(pcmId: number): number { - const fn = this.instance?.exports?.kernel_audio_get_appl_ptr as - | ((pcmId: number) => bigint) - | undefined; - if (!fn) return 0; - return Number(fn(pcmId)); - } - - /** - * Underlying kernel-memory `ArrayBuffer` (`SharedArrayBuffer` in the - * shared-memory build). Returned by reference so the AudioDriver and - * the kernel see the same bytes for the ALSA PCM ring window. Null - * if the kernel module is not instantiated yet. - */ - getKernelMemoryBuffer(): SharedArrayBuffer | ArrayBuffer | null { - return (this.memory?.buffer as SharedArrayBuffer | ArrayBuffer) ?? null; - } - registerSharedPipe(handle: number, sab: SharedArrayBuffer, end: "read" | "write"): void { this.sharedPipes.set(handle, { pipe: SharedPipeBuffer.fromSharedBuffer(sab), end }); } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 494164bd95..652e008942 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -52,7 +52,6 @@ import { type PublishedPrivilegedProgramProduct, } from "./vfs/privileged-projection"; import type { InputSource } from "./input/input-source"; -import type { AudioDriver, AudioRing } from "./audio/audio-driver"; export type { HttpRequest, HttpResponse }; @@ -741,109 +740,6 @@ export class NodeKernelHost { ); } - /** - * Allocate a kernel-memory SAB ring for `pcmId` of `byteLen` bytes - * and bind it via `kernel_audio_init_sab`. Returns the ring window - * the host AudioDriver should view as `Int16Array(buffer, offset, …)`. - * Mirrors the Browser-side method of the same name — dual-host - * parity per CLAUDE.md §"Two hosts". - */ - async audioAllocRing(pcmId: number, byteLen: number): Promise { - const requestId = this._nextRequestId++; - const result = await this.request(requestId, { - type: "audio_alloc_ring", - requestId, - pcmId, - byteLen, - }); - return result as AudioRing; - } - - /** - * Fire-and-forget period tick into the kernel. Used by the - * AudioDriver's `kernelTick` callback to advance `mmap_status.hw_ptr` - * and wake `POLLOUT` waiters parked on `/dev/snd/pcmC0Dp`. - */ - audioPeriodTick(pcmId: number, framesConsumed: number): void { - this.sendToWorker({ - type: "audio_period_tick", - pcmId, - framesConsumed, - }); - } - - /** - * Read the current `mmap_control.appl_ptr` for any OFD bound to - * `pcmId`. Kept on the Node host for dual-host parity even though - * `NodeAudioDriver` does not currently poll — vitest specs and - * future Node-side drivers can use it. - */ - async audioGetApplPtr(pcmId: number): Promise { - const requestId = this._nextRequestId++; - const result = await this.request(requestId, { - type: "audio_get_appl_ptr", - requestId, - pcmId, - }); - return result as number; - } - - /** - * Wire an `AudioDriver` into the kernel: allocates a SAB ring, - * registers it, then starts the driver with a `kernelTick` callback - * that funnels each period boundary into `kernel_audio_period_tick`. - * Mirrors `BrowserKernel.attachAudioDriver` — dual-host parity per - * CLAUDE.md §"Two hosts". - * - * On the Node host the driver is typically a `NodeAudioDriver` - * (setInterval-driven dummy) so the init path is symmetric with the - * browser; tests can call `audioPeriodTick` directly afterwards. - */ - async attachAudioDriver( - driver: AudioDriver, - opts: { - pcmId?: number; - sampleRate?: number; - channels?: number; - periodFrames?: number; - ringBytes?: number; - } = {}, - ): Promise { - const pcmId = opts.pcmId ?? 0; - const sampleRate = opts.sampleRate ?? 48_000; - const channels = opts.channels ?? 2; - const periodFrames = opts.periodFrames ?? 1024; - const ringBytes = opts.ringBytes ?? 64 * 1024; - const ring = await this.audioAllocRing(pcmId, ringBytes); - let cachedApplPtr = 0; - let inFlight = false; - const getApplPtr = (id: number): number => { - if (!inFlight) { - inFlight = true; - this.audioGetApplPtr(id) - .then((v) => { - cachedApplPtr = v; - }) - .catch(() => { - /* swallow — keep last good value */ - }) - .finally(() => { - inFlight = false; - }); - } - return cachedApplPtr; - }; - await driver.start( - pcmId, - sampleRate, - channels, - periodFrames, - ring, - (id, frames) => this.audioPeriodTick(id, frames), - getApplPtr, - ); - } - /** * Send an HTTP request to a server running inside the kernel and return * the parsed response. Bypasses real TCP by using the kernel's injected diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 537363cdfe..15d7ceac15 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -365,46 +365,6 @@ export interface SetInputCanvasDimsMessage { height: number; } -/** - * Main-thread → kernel-worker request to allocate a kernel-memory - * SAB ring for `pcmId` of `byteLen` bytes and bind it via - * `kernel_audio_init_sab`. The worker replies via `ResponseMessage` - * with `{ buffer, byteOffset, byteLength }` so the main-thread - * AudioDriver can mount an `Int16Array` view at the same offset. - */ -export interface AudioAllocRingRequestMessage { - type: "audio_alloc_ring"; - requestId: number; - pcmId: number; - byteLen: number; -} - -/** - * Main-thread → kernel-worker period tick. Routes to - * `CentralizedKernelWorker.audioPeriodTick` which calls - * `kernel_audio_period_tick` and wakes any `POLLOUT` waiter parked on - * `/dev/snd/pcmC0Dp`. Fire-and-forget. - */ -export interface AudioPeriodTickMessage { - type: "audio_period_tick"; - pcmId: number; - framesConsumed: number; -} - -/** - * Main-thread → kernel-worker request to read the current - * `mmap_control.appl_ptr` for any OFD bound to `pcmId`. The browser - * driver polls this to gate the AudioWorklet's `hwPtr` advance on - * producer progress. The worker replies via `ResponseMessage` with a - * `number`. Kept on the Node side for dual-host parity even though - * `NodeAudioDriver` doesn't currently poll. - */ -export interface AudioGetApplPtrRequestMessage { - type: "audio_get_appl_ptr"; - requestId: number; - pcmId: number; -} - export type MainToKernelMessage = | InitMessage | SpawnMessage @@ -439,10 +399,7 @@ export type MainToKernelMessage = | KmsAttachCanvasMessage | KmsAttachStatsMessage | InputEventInjectMessage - | SetInputCanvasDimsMessage - | AudioAllocRingRequestMessage - | AudioPeriodTickMessage - | AudioGetApplPtrRequestMessage; + | SetInputCanvasDimsMessage; // ── Kernel Worker → Main Thread ── diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 32df29eaf5..ba65a0379c 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -3878,21 +3878,6 @@ port.on("message", (msg: MainToKernelMessage) => { case "set_input_canvas_dims": kernelWorker.setInputCanvasDims(msg.width, msg.height); break; - case "audio_alloc_ring": { - const ring = kernelWorker.audioInitRing(msg.pcmId, msg.byteLen); - if (!ring) { - respondError(msg.requestId, "audio_alloc_ring: kernel allocator declined"); - } else { - respond(msg.requestId, ring); - } - break; - } - case "audio_period_tick": - kernelWorker.audioPeriodTick(msg.pcmId, msg.framesConsumed); - break; - case "audio_get_appl_ptr": - respond(msg.requestId, kernelWorker.audioGetApplPtr(msg.pcmId)); - break; default: { const exhaustive: never = msg; void exhaustive; diff --git a/host/test/audio-driver.test.ts b/host/test/audio-driver.test.ts deleted file mode 100644 index 37b346e41a..0000000000 --- a/host/test/audio-driver.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Phase B end-to-end coverage for the ALSA host AudioDriver. - * - * Three layers: - * - * 1. `NodeAudioDriver` cadence: `setInterval`-driven tick fires once - * per `periodFrames / sampleRate` ms with `framesConsumed == - * periodFrames`. - * - * 2. `CentralizedKernelWorker.audioInitRing`: boots a kernel against - * the real `kandelo-kernel.wasm`, allocates a 64 KiB SAB ring for - * `pcm_id = 0`, asserts the returned `(buffer, byteOffset, - * byteLength)` triple points into the kernel's - * `WebAssembly.Memory` (the `buffer` is the kernel SAB; the - * offset is non-zero and 16-byte aligned per the kernel - * allocator). - * - * 3. End-to-end: `audioPeriodTick` against a freshly-initialised - * kernel returns cleanly (no panic; no error logged). We can't - * observe `hw_ptr` here without opening an OFD against - * `/dev/snd/pcmC0D0p`, but the smoke test catches kernel-side - * regressions in the export wiring. - * - * The harness mirrors `audio-integration.test.ts` but skips process - * spawn — this exercises host-side wiring; OFD-driven flows land - * in the espeak-ng end-to-end Playwright spec. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; -import { NodePlatformIO } from "../src/platform/node"; -import { NodeAudioDriver } from "../src/audio/node-audio-driver"; -import type { AudioRing } from "../src/audio/audio-driver"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const kernelBinary = join(__dirname, "../wasm/kandelo-kernel.wasm"); - -function loadKernelWasm(): ArrayBuffer { - const buf = readFileSync(kernelBinary); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} - -describe("NodeAudioDriver", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - const fakeRing: AudioRing = { - buffer: new ArrayBuffer(64 * 1024), - byteOffset: 0, - byteLength: 64 * 1024, - }; - - it("ticks once per period at the period/sampleRate cadence", async () => { - const driver = new NodeAudioDriver(); - const ticks: Array<{ pcmId: number; frames: number }> = []; - await driver.start( - 0, - 48_000, - 2, - 1024, - fakeRing, - (pcmId, frames) => ticks.push({ pcmId, frames }), - () => 0, - ); - // 1024 frames @ 48 kHz = ~21.33 ms. - // Advance by 4 periods worth. - vi.advanceTimersByTime(85); - expect(ticks.length).toBe(4); - expect(ticks[0]).toEqual({ pcmId: 0, frames: 1024 }); - driver.stop(0); - }); - - it("stop() clears the interval and no further ticks fire", async () => { - const driver = new NodeAudioDriver(); - let ticks = 0; - await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); - vi.advanceTimersByTime(43); // ~2 periods - expect(ticks).toBe(2); - driver.stop(0); - vi.advanceTimersByTime(100); - expect(ticks).toBe(2); - }); - - it("starting the same pcmId twice is a no-op", async () => { - const driver = new NodeAudioDriver(); - let ticks = 0; - await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); - await driver.start(0, 48_000, 2, 1024, fakeRing, () => ticks++, () => 0); - vi.advanceTimersByTime(22); // ~1 period - // Only the FIRST callback was registered, so we should see exactly 1 tick, - // not 2 (if duplicate intervals leaked through). - expect(ticks).toBe(1); - driver.stop(0); - }); -}); - -describe.skipIf(!existsSync(kernelBinary))( - "CentralizedKernelWorker.audioInitRing", - () => { - let kernel: CentralizedKernelWorker; - beforeEach(async () => { - const io = new NodePlatformIO(); - kernel = new CentralizedKernelWorker( - { - maxWorkers: 1, - dataBufferSize: 65536, - useSharedMemory: true, - enableSyscallLog: false, - }, - io, - {}, - ); - await kernel.init(loadKernelWasm()); - }); - - it("returns a ring window into kernel-visible memory", () => { - const ring = kernel.audioInitRing(0, 64 * 1024); - expect(ring).not.toBeNull(); - const r = ring!; - expect(r.byteLength).toBe(64 * 1024); - expect(r.byteOffset).toBeGreaterThan(0); - // Kernel allocator (kernel_alloc_scratch) aligns to 16. - expect(r.byteOffset % 16).toBe(0); - // Must fit inside the kernel memory window. - expect(r.byteOffset + r.byteLength).toBeLessThanOrEqual(r.buffer.byteLength); - // The ring is zeroed by the kernel allocator. - const view = new Int16Array(r.buffer, r.byteOffset, 8); - for (const sample of view) expect(sample).toBe(0); - }); - - it("audioPeriodTick on a fresh kernel is a no-op (no exception)", () => { - // No OFD is open against /dev/snd/pcmC0D0p, so tick walks zero - // OFDs and returns cleanly. We're proving the export plumbing - // doesn't trap when nothing is listening. - kernel.audioInitRing(0, 64 * 1024); - expect(() => kernel.audioPeriodTick(0, 1024)).not.toThrow(); - }); - - it("a host-thread Int16Array view sees a writable, kernel-visible region", () => { - const ring = kernel.audioInitRing(0, 64 * 1024)!; - const view = new Int16Array(ring.buffer, ring.byteOffset, ring.byteLength / 2); - view[0] = 0x1234; - view[view.length - 1] = -0x4321; - // Re-mount from the same buffer to confirm bytes hit the SAB, - // not a copy. - const view2 = new Int16Array( - ring.buffer, - ring.byteOffset, - ring.byteLength / 2, - ); - expect(view2[0]).toBe(0x1234); - expect(view2[view.length - 1]).toBe(-0x4321); - }); - }, -); diff --git a/host/test/browser-audio-driver-drain.test.ts b/host/test/browser-audio-driver-drain.test.ts deleted file mode 100644 index 0b39ed69ba..0000000000 --- a/host/test/browser-audio-driver-drain.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Regression spec for `BrowserAudioDriver.stop()`'s deferred-close - * drain. Without the drain, the AudioContext closes synchronously - * and the platform audio queue truncates the last word of any phrase - * the worklet hadn't yet emitted to the speaker — this was the - * tail-truncation bug fixed in the session-43 work. - * - * The drain logic: - * pending = max(0, lastApplPtr - totalFramesConsumed) - * if pending == 0 → close immediately - * else → setTimeout(close, pending/rate*1000 + 100) - * - * Tests here stub the WebAudio globals (`AudioContext`, - * `AudioWorkletNode`) just enough for `BrowserAudioDriver.start()` - * to construct a context and a worklet, then drive the worklet - * mailbox by invoking the captured `onmessage` callback directly. - */ -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, -} from "vitest"; -import { BrowserAudioDriver } from "../src/audio/browser-audio-driver"; -import type { AudioRing } from "../src/audio/audio-driver"; - -interface MockPort { - onmessage: - | ((e: { data: { framesConsumed?: number; applPtr?: number } }) => void) - | null; - postMessage: (msg: unknown) => void; -} - -interface MockWorklet { - port: MockPort; - connect: (dest: unknown) => void; - disconnect: () => void; -} - -interface MockAudioContext { - sampleRate: number; - destination: { __isDestination: true }; - audioWorklet: { addModule: (url: string) => Promise }; - close: () => Promise; -} - -function stubAudioGlobals() { - const closes: Array<() => Promise> = []; - const disconnects: Array<() => void> = []; - const workletCreated: MockWorklet[] = []; - - class AudioContextStub implements MockAudioContext { - sampleRate: number; - destination = { __isDestination: true as const }; - audioWorklet = { addModule: async (_url: string) => undefined }; - close: () => Promise; - constructor(opts: { sampleRate: number }) { - this.sampleRate = opts.sampleRate; - this.close = vi.fn(async () => undefined); - closes.push(this.close); - } - } - - class AudioWorkletNodeStub implements MockWorklet { - port: MockPort; - connect: (dest: unknown) => void; - disconnect: () => void; - constructor(_ctx: MockAudioContext, _name: string, _opts: unknown) { - this.port = { - onmessage: null, - postMessage: vi.fn(), - }; - this.connect = vi.fn(); - this.disconnect = vi.fn(); - disconnects.push(this.disconnect); - workletCreated.push(this); - } - } - - (globalThis as unknown as { AudioContext: typeof AudioContextStub }) - .AudioContext = AudioContextStub; - (globalThis as unknown as { AudioWorkletNode: typeof AudioWorkletNodeStub }) - .AudioWorkletNode = AudioWorkletNodeStub; - - return { closes, disconnects, workletCreated }; -} - -const fakeRing: AudioRing = { - buffer: new ArrayBuffer(64 * 1024), - byteOffset: 0, - byteLength: 64 * 1024, -}; - -describe("BrowserAudioDriver.stop() drain", () => { - let env: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - env = stubAudioGlobals(); - }); - - afterEach(() => { - vi.useRealTimers(); - delete (globalThis as unknown as { AudioContext?: unknown }).AudioContext; - delete ( - globalThis as unknown as { AudioWorkletNode?: unknown } - ).AudioWorkletNode; - }); - - it("closes synchronously when no frames are pending", async () => { - const driver = new BrowserAudioDriver("/stub-worklet.js"); - await driver.start(0, 48_000, 2, 1024, fakeRing, () => {}, () => 0); - // appl_ptr is still 0 and totalFramesConsumed is 0 → pending = 0. - driver.stop(0); - expect(env.closes[0]).toHaveBeenCalledTimes(1); - expect(env.disconnects[0]).toHaveBeenCalledTimes(1); - }); - - it( - "defers close by (pending/sampleRate)*1000 + 100 ms when frames are pending", - async () => { - const driver = new BrowserAudioDriver("/stub-worklet.js"); - // Producer reports appl_ptr = 22050 (1 s of audio @ 22050 Hz). - let applPtr = 22050; - await driver.start( - 0, - 22_050, - 2, - 1024, - fakeRing, - () => {}, - () => applPtr, - ); - // The 10 ms applPtr poll fires once to populate ctx.lastApplPtr. - vi.advanceTimersByTime(10); - // Worklet has played 11025 frames so far (half the buffer). - const port = env.workletCreated[0].port; - port.onmessage?.({ data: { framesConsumed: 11025 } }); - - driver.stop(0); - // pending = 22050 - 11025 = 11025 frames @ 22050 Hz = 500 ms - // drainMs = 500 + 100 = 600 ms. - expect(env.closes[0]).not.toHaveBeenCalled(); - vi.advanceTimersByTime(599); - expect(env.closes[0]).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(env.closes[0]).toHaveBeenCalledTimes(1); - expect(env.disconnects[0]).toHaveBeenCalledTimes(1); - }, - ); - - it("clears the applPtr poll interval on stop even when close is deferred", async () => { - const driver = new BrowserAudioDriver("/stub-worklet.js"); - let applPtrCalls = 0; - await driver.start( - 0, - 22_050, - 2, - 1024, - fakeRing, - () => {}, - () => { - applPtrCalls++; - return 22050; - }, - ); - vi.advanceTimersByTime(10); - const baselineCalls = applPtrCalls; - expect(baselineCalls).toBeGreaterThan(0); - driver.stop(0); - // stop() reads applPtr ONCE more (finalApplPtr probe). - const afterStop = applPtrCalls; - expect(afterStop).toBe(baselineCalls + 1); - // 100 ms further: no more polls if clearInterval worked. - vi.advanceTimersByTime(100); - expect(applPtrCalls).toBe(afterStop); - }); -}); diff --git a/host/test/instrumented-audio-driver.test.ts b/host/test/instrumented-audio-driver.test.ts deleted file mode 100644 index ce772e5562..0000000000 --- a/host/test/instrumented-audio-driver.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Regression spec for `instrumentAudioDriver`'s forwarding contract. - * - * Session 42 shipped a wrapper whose `start()` dropped the new - * `getApplPtr` argument when delegating to the inner driver, which - * silently disabled the AudioWorklet's producer-pointer gate and - * cut the head off every spoken phrase. These tests pin every - * `AudioDriver.start()` parameter so a future signature drift fails - * loudly instead of producing silence. - */ -import { describe, it, expect, vi } from "vitest"; -import { instrumentAudioDriver } from "../src/audio/instrumented-audio-driver"; -import type { AudioDriver, AudioRing } from "../src/audio/audio-driver"; - -function makeMockInner(): AudioDriver & { - startSpy: ReturnType; - stopSpy: ReturnType; -} { - const startSpy = vi.fn(async () => undefined); - const stopSpy = vi.fn(); - return { - start: startSpy as unknown as AudioDriver["start"], - stop: stopSpy, - startSpy, - stopSpy, - }; -} - -const fakeRing: AudioRing = { - buffer: new ArrayBuffer(64 * 1024), - byteOffset: 0, - byteLength: 64 * 1024, -}; - -describe("instrumentAudioDriver", () => { - it("forwards every start() argument to the inner driver", async () => { - const inner = makeMockInner(); - const wrapper = instrumentAudioDriver(inner); - const kernelTick = vi.fn(); - const getApplPtr = vi.fn(() => 4242); - - await wrapper.start(7, 22_050, 2, 1024, fakeRing, kernelTick, getApplPtr); - - expect(inner.startSpy).toHaveBeenCalledTimes(1); - const args = inner.startSpy.mock.calls[0]; - expect(args[0]).toBe(7); - expect(args[1]).toBe(22_050); - expect(args[2]).toBe(2); - expect(args[3]).toBe(1024); - expect(args[4]).toBe(fakeRing); - expect(typeof args[5]).toBe("function"); - expect(args[6]).toBe(getApplPtr); - }); - - it("preserves the getApplPtr reference identity (not wrapped)", async () => { - const inner = makeMockInner(); - const wrapper = instrumentAudioDriver(inner); - const sentinelApplPtr = vi.fn(() => 0); - - await wrapper.start(0, 48_000, 2, 1024, fakeRing, () => {}, sentinelApplPtr); - - const fwd = inner.startSpy.mock.calls[0][6]; - expect(fwd).toBe(sentinelApplPtr); - expect(fwd(99)).toBe(0); - expect(sentinelApplPtr).toHaveBeenCalledWith(99); - }); - - it("accumulates framesConsumed across inner ticks and exposes the running total", async () => { - const inner = makeMockInner(); - const wrapper = instrumentAudioDriver(inner); - let observed = -1; - - await wrapper.start( - 0, - 48_000, - 2, - 1024, - fakeRing, - (_id, frames) => { - observed = frames; - }, - () => 0, - ); - - const wrappedTick = inner.startSpy.mock.calls[0][5] as ( - id: number, - frames: number, - ) => void; - - wrappedTick(0, 1024); - expect(wrapper.framesConsumed()).toBe(1024); - expect(observed).toBe(1024); - - wrappedTick(0, 1024); - expect(wrapper.framesConsumed()).toBe(2048); - }); - - it("notifies the observer with both the delta and the running total", async () => { - const inner = makeMockInner(); - const observer = vi.fn(); - const wrapper = instrumentAudioDriver(inner, observer); - - await wrapper.start(0, 48_000, 2, 1024, fakeRing, () => {}, () => 0); - const wrappedTick = inner.startSpy.mock.calls[0][5] as ( - id: number, - frames: number, - ) => void; - - wrappedTick(0, 256); - expect(observer).toHaveBeenLastCalledWith(256, 256); - wrappedTick(0, 768); - expect(observer).toHaveBeenLastCalledWith(768, 1024); - }); - - it("delegates stop() to the inner driver", () => { - const inner = makeMockInner(); - const wrapper = instrumentAudioDriver(inner); - wrapper.stop(3); - expect(inner.stopSpy).toHaveBeenCalledWith(3); - }); -}); diff --git a/libc/musl-overlay/include/sound/asound.h b/libc/musl-overlay/include/sound/asound.h deleted file mode 100644 index 81d4cbc3cc..0000000000 --- a/libc/musl-overlay/include/sound/asound.h +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Subset of matching what crates/shared/src/lib.rs::audio - * marshals. Mirrors Linux UAPI v6.10 `include/uapi/sound/asound.h` for the - * fields kandelo's v1 ALSA surface implements: - * - * ioctls PVERSION INFO HW_REFINE HW_PARAMS HW_FREE SW_PARAMS STATUS - * PREPARE START DROP PAUSE WRITEI_FRAMES (PCM) - * PVERSION CARD_INFO ELEM_LIST (control) - * - * structs snd_pcm_hw_params (608B) snd_pcm_sw_params (136B) - * snd_pcm_status (128B) snd_pcm_info (288B) - * snd_pcm_mmap_status (64B) snd_pcm_mmap_control (64B) - * snd_xferi (24B) snd_ctl_card_info (256B) - * snd_ctl_elem_id (64B) snd_ctl_elem_list (80B) - * - * Capture, the sequencer, the timer, mixers beyond CARD_INFO/ELEM_LIST, - * FLOAT_LE/S32_LE formats, and async signal delivery are all omitted — - * the v1 plan in docs/plans/2026-06-22-dri-alsa-plan.md is playback-only, - * S16_LE-only, host-driven cadence via kernel_audio_period_tick. - */ -#ifndef _SOUND_ASOUND_H -#define _SOUND_ASOUND_H - -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -typedef uint64_t snd_pcm_uframes_t; -typedef int64_t snd_pcm_sframes_t; - -/* --- PCM state ------------------------------------------------------- */ - -#define SNDRV_PCM_STATE_OPEN 0 -#define SNDRV_PCM_STATE_SETUP 1 -#define SNDRV_PCM_STATE_PREPARED 2 -#define SNDRV_PCM_STATE_RUNNING 3 -#define SNDRV_PCM_STATE_XRUN 4 -#define SNDRV_PCM_STATE_DRAINING 5 -#define SNDRV_PCM_STATE_PAUSED 6 -#define SNDRV_PCM_STATE_SUSPENDED 7 -#define SNDRV_PCM_STATE_DISCONNECTED 8 - -/* --- PCM format (v1 ships S16_LE only; the others are listed for ABI - * symmetry with the kernel-side `audio::` module). ----------------- */ - -#define SNDRV_PCM_FORMAT_S8 0 -#define SNDRV_PCM_FORMAT_U8 1 -#define SNDRV_PCM_FORMAT_S16_LE 2 -#define SNDRV_PCM_FORMAT_S16_BE 3 -#define SNDRV_PCM_FORMAT_U16_LE 4 -#define SNDRV_PCM_FORMAT_U16_BE 5 -#define SNDRV_PCM_FORMAT_S24_LE 6 -#define SNDRV_PCM_FORMAT_S24_BE 7 -#define SNDRV_PCM_FORMAT_U24_LE 8 -#define SNDRV_PCM_FORMAT_U24_BE 9 -#define SNDRV_PCM_FORMAT_S32_LE 10 -#define SNDRV_PCM_FORMAT_S32_BE 11 -#define SNDRV_PCM_FORMAT_U32_LE 12 -#define SNDRV_PCM_FORMAT_U32_BE 13 -#define SNDRV_PCM_FORMAT_FLOAT_LE 14 - -#define SNDRV_PCM_SUBFORMAT_STD 0 - -/* --- PCM access ------------------------------------------------------- */ - -#define SNDRV_PCM_ACCESS_MMAP_INTERLEAVED 0 -#define SNDRV_PCM_ACCESS_MMAP_NONINTERLEAVED 1 -#define SNDRV_PCM_ACCESS_MMAP_COMPLEX 2 -#define SNDRV_PCM_ACCESS_RW_INTERLEAVED 3 -#define SNDRV_PCM_ACCESS_RW_NONINTERLEAVED 4 - -/* --- PCM stream direction -------------------------------------------- */ - -#define SNDRV_PCM_STREAM_PLAYBACK 0 -#define SNDRV_PCM_STREAM_CAPTURE 1 - -/* --- snd_pcm_hw_params parameter indices ----------------------------- * - * The Linux UAPI splits hw_params into three masks + thirteen intervals, - * indexed by these enums. Kandelo's kernel reads PARAM_ACCESS (0), - * PARAM_FORMAT (1), PARAM_SUBFORMAT (2) from masks[] and PARAM_CHANNELS - * (2), PARAM_RATE (3), PARAM_PERIOD_SIZE (5), PARAM_PERIODS (7), - * PARAM_BUFFER_SIZE (9) from intervals[]. See refine_hw_params() in - * crates/kernel/src/audio/pcm_ioctl.rs. - */ - -#define SNDRV_PCM_HW_PARAM_ACCESS 0 -#define SNDRV_PCM_HW_PARAM_FORMAT 1 -#define SNDRV_PCM_HW_PARAM_SUBFORMAT 2 -#define SNDRV_PCM_HW_PARAM_FIRST_MASK SNDRV_PCM_HW_PARAM_ACCESS -#define SNDRV_PCM_HW_PARAM_LAST_MASK SNDRV_PCM_HW_PARAM_SUBFORMAT - -#define SNDRV_PCM_HW_PARAM_SAMPLE_BITS 8 -#define SNDRV_PCM_HW_PARAM_FRAME_BITS 9 -#define SNDRV_PCM_HW_PARAM_CHANNELS 10 -#define SNDRV_PCM_HW_PARAM_RATE 11 -#define SNDRV_PCM_HW_PARAM_PERIOD_TIME 12 -#define SNDRV_PCM_HW_PARAM_PERIOD_SIZE 13 -#define SNDRV_PCM_HW_PARAM_PERIOD_BYTES 14 -#define SNDRV_PCM_HW_PARAM_PERIODS 15 -#define SNDRV_PCM_HW_PARAM_BUFFER_TIME 16 -#define SNDRV_PCM_HW_PARAM_BUFFER_SIZE 17 -#define SNDRV_PCM_HW_PARAM_BUFFER_BYTES 18 -#define SNDRV_PCM_HW_PARAM_TICK_TIME 19 -#define SNDRV_PCM_HW_PARAM_FIRST_INTERVAL SNDRV_PCM_HW_PARAM_SAMPLE_BITS -#define SNDRV_PCM_HW_PARAM_LAST_INTERVAL SNDRV_PCM_HW_PARAM_TICK_TIME - -/* The kernel indexes masks[] and intervals[] starting at PARAM_ACCESS=0 - * and PARAM_SAMPLE_BITS=0 respectively, so userspace helpers subtract - * the first-* offsets when picking a slot. */ -#define WPK_ALSA_MASK_INDEX(name) ((name) - SNDRV_PCM_HW_PARAM_FIRST_MASK) -#define WPK_ALSA_INTERVAL_INDEX(name) ((name) - SNDRV_PCM_HW_PARAM_FIRST_INTERVAL) - -/* --- mmap page offsets (passed as the mmap(2) offset arg) ------------ * - * v1 mmap policy (per handoff-39): direct mmap(STATUS|CONTROL) returns - * anonymous user pages with no kernel-side mirror. WRITEI_FRAMES is the - * only data path. mmap-of-DATA is a future API surface; v1 demos must - * not rely on it. */ - -#define SNDRV_PCM_MMAP_OFFSET_DATA 0x00000000UL -#define SNDRV_PCM_MMAP_OFFSET_STATUS 0x80000000UL -#define SNDRV_PCM_MMAP_OFFSET_CONTROL 0x81000000UL - -/* --- snd_interval ----------------------------------------------------- * - * Linux packs four flag bits (openmin / openmax / integer / empty) into - * a trailing u32. We keep them as a plain u32 here so the struct size - * matches the kernel-side WpkSndInterval (12B) byte-for-byte. */ - -struct snd_interval { - uint32_t min; - uint32_t max; - /* bit 0 = openmin, 1 = openmax, 2 = integer, 3 = empty */ - uint32_t flags; -}; - -/* --- snd_pcm_hw_params (608 bytes on wasm32) ------------------------- */ - -struct snd_pcm_hw_params { - uint32_t flags; - uint32_t masks[64]; /* 8 snd_mask × u32[8] */ - struct snd_interval intervals[21]; /* 12 active + 9 reserved */ - uint32_t rmask; - uint32_t cmask; - uint32_t info; - uint32_t msbits; - uint32_t rate_num; - uint32_t rate_den; - uint64_t fifo_size; - uint8_t reserved[64]; -}; - -/* --- snd_pcm_sw_params (136 bytes) ----------------------------------- */ - -struct snd_pcm_sw_params { - uint32_t tstamp_mode; - uint32_t period_step; - uint32_t sleep_min; - uint32_t _pad0; - uint64_t avail_min; - uint64_t xfer_align; - uint64_t start_threshold; - uint64_t stop_threshold; - uint64_t silence_threshold; - uint64_t silence_size; - uint64_t boundary; - uint32_t proto; - uint32_t tstamp_type; - uint8_t reserved[56]; -}; - -/* --- snd_pcm_status (128 bytes) -------------------------------------- */ - -struct snd_pcm_status { - uint32_t state; - uint32_t _pad0; - int64_t trigger_tstamp_sec; - int64_t trigger_tstamp_nsec; - int64_t tstamp_sec; - int64_t tstamp_nsec; - int64_t appl_ptr; - int64_t hw_ptr; - int64_t delay; - uint64_t avail; - uint64_t avail_max; - uint64_t overrange; - uint32_t suspended_state; - uint32_t audio_tstamp_data; - int64_t audio_tstamp_sec; - int64_t audio_tstamp_nsec; - uint8_t reserved[16]; -}; - -/* --- snd_pcm_info (288 bytes) ---------------------------------------- */ - -struct snd_pcm_info { - uint32_t device; - uint32_t subdevice; - int32_t stream; - int32_t card; - uint8_t id[64]; - uint8_t name[80]; - uint8_t subname[32]; - uint32_t dev_class; - uint32_t dev_subclass; - uint32_t subdevices_count; - uint32_t subdevices_avail; - uint8_t sync[16]; - uint8_t reserved[64]; -}; - -/* --- snd_pcm_mmap_status (64B) — kernel-writes, userspace-reads ------ */ - -struct snd_pcm_mmap_status { - uint32_t state; - uint32_t _pad0; - int64_t hw_ptr; - int64_t tstamp_sec; - int64_t tstamp_nsec; - uint32_t suspended_state; - uint32_t audio_tstamp_data; - int64_t audio_tstamp_sec; - int64_t audio_tstamp_nsec; - uint8_t reserved[8]; -}; - -/* --- snd_pcm_mmap_control (64B) — userspace-writes, kernel-reads ----- */ - -struct snd_pcm_mmap_control { - int64_t appl_ptr; - int64_t avail_min; - uint8_t reserved[48]; -}; - -/* --- snd_xferi (24B) — argument to WRITEI_FRAMES/READI_FRAMES -------- */ - -struct snd_xferi { - int64_t result; - uint64_t buf; - uint64_t frames; -}; - -/* --- snd_ctl_card_info (256B) ---------------------------------------- */ - -struct snd_ctl_card_info { - int32_t card; - int32_t pad; - uint8_t id[16]; - uint8_t driver[16]; - uint8_t name[32]; - uint8_t longname[80]; - uint8_t reserved_[16]; - uint8_t mixername[80]; - uint8_t components[8]; -}; - -/* --- snd_ctl_elem_id (64B) ------------------------------------------- */ - -struct snd_ctl_elem_id { - uint32_t numid; - uint32_t iface; - uint32_t device; - uint32_t subdevice; - uint8_t name[44]; - uint32_t index; -}; - -/* --- snd_ctl_elem_list (80B) ----------------------------------------- */ - -struct snd_ctl_elem_list { - uint32_t offset; - uint32_t space; - uint32_t used; - uint32_t count; - uint64_t pids; - uint8_t reserved[50]; -}; - -/* --- PCM ioctl numbers ('A' magic) ----------------------------------- * - * Verbatim Linux UAPI v6.10. The kernel-side `audio::` module pins these - * via static-assert against `ioc(...)` to fail loudly if the in-tree - * struct sizes drift. */ - -#define SNDRV_PCM_IOCTL_PVERSION _IOR('A', 0x00, int) -#define SNDRV_PCM_IOCTL_INFO _IOR('A', 0x01, struct snd_pcm_info) -#define SNDRV_PCM_IOCTL_HW_REFINE _IOWR('A', 0x10, struct snd_pcm_hw_params) -#define SNDRV_PCM_IOCTL_HW_PARAMS _IOWR('A', 0x11, struct snd_pcm_hw_params) -#define SNDRV_PCM_IOCTL_HW_FREE _IO('A', 0x12) -#define SNDRV_PCM_IOCTL_SW_PARAMS _IOWR('A', 0x13, struct snd_pcm_sw_params) -#define SNDRV_PCM_IOCTL_STATUS _IOR('A', 0x20, struct snd_pcm_status) -#define SNDRV_PCM_IOCTL_PREPARE _IO('A', 0x40) -#define SNDRV_PCM_IOCTL_START _IO('A', 0x42) -#define SNDRV_PCM_IOCTL_DROP _IO('A', 0x43) -#define SNDRV_PCM_IOCTL_PAUSE _IOW('A', 0x45, int) -#define SNDRV_PCM_IOCTL_WRITEI_FRAMES _IOW('A', 0x50, struct snd_xferi) - -/* --- Control ioctl numbers ('U' magic) ------------------------------- */ - -#define SNDRV_CTL_IOCTL_PVERSION _IOR('U', 0x00, int) -#define SNDRV_CTL_IOCTL_CARD_INFO _IOR('U', 0x01, struct snd_ctl_card_info) -#define SNDRV_CTL_IOCTL_ELEM_LIST _IOWR('U', 0x10, struct snd_ctl_elem_list) - -#ifdef __cplusplus -} -#endif - -#endif /* _SOUND_ASOUND_H */ From 387096fa6a19f345b08010d5c95ed4ee932b36a5 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 13:58:22 +0200 Subject: [PATCH 20/27] feat(input): resolve the length-encoded EVIOC ioctls through a family 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) --- abi/snapshot.json | 62 ++++++-- crates/shared/src/ioctl_contract.rs | 218 +++++++++++++++++++++++++++- host/src/generated/abi.ts | 19 +++ host/src/ioctl-contract.ts | 57 ++++++++ host/src/kernel-worker.ts | 4 +- host/src/kernel.ts | 41 +----- host/test/ioctl-contract.test.ts | 79 ++++++++++ libc/glue/abi_constants.h | 12 ++ tools/xtask/src/dump_abi.rs | 186 +++++++++++++++++++++++- 9 files changed, 616 insertions(+), 62 deletions(-) create mode 100644 host/src/ioctl-contract.ts create mode 100644 host/test/ioctl-contract.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 177c9a3cb4..493d334529 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1224,6 +1224,12 @@ } }, "ioctl_request_contracts": { + "1074021776": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, "1074024452": { "argKind": "pointer", "direction": "in", @@ -1326,6 +1332,12 @@ "wasm32Size": 0, "wasm64Size": 0 }, + "2147763457": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, "2147766274": { "argKind": "pointer", "direction": "out", @@ -1380,6 +1392,12 @@ "wasm32Size": 4, "wasm64Size": 4 }, + "2148025602": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 8, + "wasm64Size": 8 + }, "2148028435": { "argKind": "pointer", "direction": "out", @@ -1759,6 +1777,35 @@ "wasm64Size": null } }, + "ioctl_request_families": [ + { + "dir": 2, + "direction": "out", + "fixedSize": null, + "magic": 69, + "maxCallerSize": 256, + "nrFirst": 6, + "nrLast": 6 + }, + { + "dir": 2, + "direction": "out", + "fixedSize": null, + "magic": 69, + "maxCallerSize": 256, + "nrFirst": 32, + "nrLast": 63 + }, + { + "dir": 2, + "direction": "out", + "fixedSize": 24, + "magic": 69, + "maxCallerSize": null, + "nrFirst": 64, + "nrLast": 127 + } + ], "kernel_exports": [ { "kind": "func", @@ -1800,26 +1847,11 @@ "name": "kernel_audio_channels", "signature": "() -> (i32)" }, - { - "kind": "func", - "name": "kernel_audio_get_appl_ptr", - "signature": "(i32) -> (i64)" - }, - { - "kind": "func", - "name": "kernel_audio_init_sab", - "signature": "(i32,i64,i32) -> ()" - }, { "kind": "func", "name": "kernel_audio_pending", "signature": "() -> (i32)" }, - { - "kind": "func", - "name": "kernel_audio_period_tick", - "signature": "(i32,i32) -> ()" - }, { "kind": "func", "name": "kernel_audio_sample_rate", diff --git a/crates/shared/src/ioctl_contract.rs b/crates/shared/src/ioctl_contract.rs index 4ad93afc76..38a7263575 100644 --- a/crates/shared/src/ioctl_contract.rs +++ b/crates/shared/src/ioctl_contract.rs @@ -138,6 +138,78 @@ macro_rules! pointer { }; } +/// How a request family derives the byte count it marshals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoctlFamilySize { + /// Every member marshals the same structure, so the caller's encoded + /// size must match exactly. + Fixed(u32), + /// The caller chooses the length and encodes it in the request. Bounded + /// so a malformed request cannot stage an oversized scratch buffer. + CallerEncoded { max: u32 }, +} + +/// A contiguous `nr` range that shares one marshalling contract. +/// +/// `EVIOCGNAME(len)` and `EVIOCGBIT(ev, len)` let the caller pick the buffer +/// length, so every length is a distinct request number; `EVIOCGABS(axis)` +/// keeps one structure across 64 axes. Neither shape fits a table keyed by +/// exact request number, so they resolve through `IOCTL_REQUEST_FAMILIES` +/// after `IOCTL_REQUEST_CONTRACTS` misses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IoctlRequestFamily { + pub dir: u32, + pub magic: u32, + pub nr_first: u32, + pub nr_last: u32, + pub direction: IoctlDirection, + pub size: IoctlFamilySize, +} + +/// Largest buffer a caller-encoded request may stage. +/// +/// `EVIOCGNAME` returns a device name and `EVIOCGBIT` a capability bitmap; +/// the widest bitmap Kandelo advertises is `EV_KEY`, which needs +/// `KEY_CNT / 8` bytes. 256 covers both with headroom. +pub const EVIOC_MAX_CALLER_LENGTH: u32 = 256; + +/// Pointer ioctls whose request number varies by length or by axis. +/// +/// Ordering is not load-bearing here — lookup is a linear scan over a short +/// table, and the ranges are disjoint. +pub const IOCTL_REQUEST_FAMILIES: &[IoctlRequestFamily] = &[ + IoctlRequestFamily { + dir: 2, + magic: b'E' as u32, + nr_first: crate::input::EVIOCGNAME_NR, + nr_last: crate::input::EVIOCGNAME_NR, + direction: IoctlDirection::Out, + size: IoctlFamilySize::CallerEncoded { + max: EVIOC_MAX_CALLER_LENGTH, + }, + }, + IoctlRequestFamily { + dir: 2, + magic: b'E' as u32, + nr_first: crate::input::EVIOCGBIT_NR_BASE, + nr_last: crate::input::EVIOCGBIT_NR_BASE + 31, + direction: IoctlDirection::Out, + size: IoctlFamilySize::CallerEncoded { + max: EVIOC_MAX_CALLER_LENGTH, + }, + }, + IoctlRequestFamily { + dir: 2, + magic: b'E' as u32, + nr_first: crate::input::EVIOCGABS_NR_BASE, + nr_last: crate::input::EVIOCGABS_NR_BASE + 63, + direction: IoctlDirection::Out, + size: IoctlFamilySize::Fixed( + core::mem::size_of::() as u32, + ), + }, +]; + /// Ioctls that may reach the Rust kernel dispatcher. /// /// Keep entries sorted by unsigned request number. Network-interface ioctls @@ -192,10 +264,12 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ no_arg!(crate::dri::DRM_IOCTL_SET_MASTER), no_arg!(crate::dri::DRM_IOCTL_DROP_MASTER), pointer!(SIOCATMARK, Out, 4), + scalar_i32!(crate::input::EVIOCGRAB), pointer!(crate::oss::SNDCTL_DSP_SETBLKSIZE, In, 4), pointer!(crate::oss::SNDCTL_DSP_SETTRIGGER, In, 4), pointer!(TIOCSPTLCK, In, 4), pointer!(crate::dri::DRM_IOCTL_GEM_CLOSE, In, 8), + pointer!(crate::input::EVIOCGVERSION, Out, 4), pointer!(crate::oss::SOUND_PCM_READ_RATE, Out, 4), pointer!(crate::oss::SOUND_PCM_READ_BITS, Out, 4), pointer!(crate::oss::SOUND_PCM_READ_CHANNELS, Out, 4), @@ -205,6 +279,11 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ pointer!(crate::oss::SNDCTL_DSP_GETTRIGGER, Out, 4), pointer!(crate::oss::SNDCTL_DSP_GETODELAY, Out, 4), pointer!(TIOCGPTN, Out, 4), + pointer!( + crate::input::EVIOCGID, + Out, + core::mem::size_of::() as u32 + ), pointer!(crate::oss::SNDCTL_DSP_MAPINBUF, Out, 8), pointer!(crate::oss::SNDCTL_DSP_MAPOUTBUF, Out, 8), pointer!(crate::oss::SNDCTL_DSP_GETIPTR, Out, 12), @@ -239,11 +318,50 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ pointer!(crate::dri::DRM_IOCTL_MODE_ADDFB2, InOut, 104), ]; -pub fn request_contract(request: u32) -> Option<&'static IoctlRequestContract> { - IOCTL_REQUEST_CONTRACTS - .binary_search_by_key(&request, |entry| entry.request) - .ok() - .map(|index| &IOCTL_REQUEST_CONTRACTS[index]) +pub fn request_contract(request: u32) -> Option { + if let Ok(index) = + IOCTL_REQUEST_CONTRACTS.binary_search_by_key(&request, |entry| entry.request) + { + return Some(IOCTL_REQUEST_CONTRACTS[index]); + } + family_request_contract(request) +} + +/// Resolve a request that varies by caller-chosen length or by axis. +/// +/// Returns `None` for a member whose encoded size the family does not allow, +/// so a malformed request stays unknown rather than staging a wrong buffer. +pub fn family_request_contract(request: u32) -> Option { + let dir = (request >> 30) & 0x3; + let encoded_size = (request >> 16) & 0x3fff; + let magic = (request >> 8) & 0xff; + let nr = request & 0xff; + + let family = IOCTL_REQUEST_FAMILIES.iter().find(|family| { + family.dir == dir + && family.magic == magic + && family.nr_first <= nr + && nr <= family.nr_last + })?; + + let size = match family.size { + IoctlFamilySize::Fixed(fixed) if encoded_size == fixed => fixed, + IoctlFamilySize::Fixed(_) => return None, + IoctlFamilySize::CallerEncoded { max } + if encoded_size >= 1 && encoded_size <= max => + { + encoded_size + } + IoctlFamilySize::CallerEncoded { .. } => return None, + }; + + Some(IoctlRequestContract { + request, + arg_kind: IoctlArgKind::Pointer, + direction: family.direction, + wasm32_size: Some(size), + wasm64_size: Some(size), + }) } #[cfg(test)] @@ -274,4 +392,94 @@ mod tests { assert_eq!(query.size_for_pointer_width(4), Some(24)); assert_eq!(query.size_for_pointer_width(8), None); } + + /// Builds the same encoding the musl `_IOC` macros produce. + const fn evioc(dir: u32, nr: u32, size: u32) -> u32 { + (dir << 30) | (size << 16) | ((b'E' as u32) << 8) | nr + } + + #[test] + fn fixed_evdev_requests_resolve_through_the_sorted_table() { + let version = request_contract(crate::input::EVIOCGVERSION).unwrap(); + assert_eq!(version.arg_kind, IoctlArgKind::Pointer); + assert_eq!(version.size_for_pointer_width(4), Some(4)); + + let id = request_contract(crate::input::EVIOCGID).unwrap(); + assert_eq!(id.arg_kind, IoctlArgKind::Pointer); + assert_eq!(id.size_for_pointer_width(4), Some(8)); + + let grab = request_contract(crate::input::EVIOCGRAB).unwrap(); + assert_eq!(grab.arg_kind, IoctlArgKind::ScalarI32); + } + + #[test] + fn evioc_gabs_resolves_every_axis_at_the_absinfo_size() { + for axis in 0..64 { + let request = evioc(2, crate::input::EVIOCGABS_NR_BASE + axis, 24); + let contract = request_contract(request).unwrap(); + assert_eq!(contract.arg_kind, IoctlArgKind::Pointer); + assert_eq!(contract.size_for_pointer_width(4), Some(24)); + assert_eq!(contract.size_for_pointer_width(8), Some(24)); + } + } + + #[test] + fn evioc_gabs_rejects_a_size_other_than_absinfo() { + let request = evioc(2, crate::input::EVIOCGABS_NR_BASE, 16); + assert_eq!(request_contract(request), None); + } + + #[test] + fn caller_encoded_evdev_requests_carry_the_callers_length() { + for size in [1, 32, EVIOC_MAX_CALLER_LENGTH] { + let name = evioc(2, crate::input::EVIOCGNAME_NR, size); + assert_eq!( + request_contract(name).unwrap().size_for_pointer_width(4), + Some(size), + ); + + let bit = evioc(2, crate::input::EVIOCGBIT_NR_BASE + 1, size); + assert_eq!( + request_contract(bit).unwrap().size_for_pointer_width(4), + Some(size), + ); + } + } + + #[test] + fn caller_encoded_evdev_requests_reject_zero_and_oversized_lengths() { + for nr in [ + crate::input::EVIOCGNAME_NR, + crate::input::EVIOCGBIT_NR_BASE, + ] { + assert_eq!(request_contract(evioc(2, nr, 0)), None); + assert_eq!( + request_contract(evioc(2, nr, EVIOC_MAX_CALLER_LENGTH + 1)), + None, + ); + } + } + + #[test] + fn evdev_families_ignore_a_foreign_magic_or_write_direction() { + let foreign_magic = + (2 << 30) | (24 << 16) | ((b'D' as u32) << 8) | crate::input::EVIOCGABS_NR_BASE; + assert_eq!(request_contract(foreign_magic), None); + assert_eq!( + request_contract(evioc(1, crate::input::EVIOCGABS_NR_BASE, 24)), + None, + ); + } + + #[test] + fn family_ranges_do_not_overlap_the_sorted_table() { + for contract in IOCTL_REQUEST_CONTRACTS { + assert_eq!( + family_request_contract(contract.request), + None, + "request {:#x} resolves through both tables", + contract.request, + ); + } + } } diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 79dcfb8ad0..c5e513bd55 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1590,10 +1590,12 @@ export const IOCTL_REQUESTS: Record = { 25630: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 25631: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 35077: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 1074021776: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 1074024452: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 1074024464: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 1074025521: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 1074291721: { argKind: "pointer", direction: "in", wasm32Size: 8, wasm64Size: 8 }, + 2147763457: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147766274: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147766277: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147766278: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, @@ -1603,6 +1605,7 @@ export const IOCTL_REQUESTS: Record = { 2147766288: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147766295: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147767344: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2148025602: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, 2148028435: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, 2148028436: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, 2148290577: { argKind: "pointer", direction: "out", wasm32Size: 12, wasm64Size: 12 }, @@ -1636,6 +1639,22 @@ export const IOCTL_REQUESTS: Record = { 3228067000: { argKind: "pointer", direction: "inout", wasm32Size: 104, wasm64Size: 104 }, }; +export interface IoctlRequestFamily { + dir: number; + magic: number; + nrFirst: number; + nrLast: number; + direction: IoctlDirection; + fixedSize: number | null; + maxCallerSize: number | null; +} + +export const IOCTL_REQUEST_FAMILIES: IoctlRequestFamily[] = [ + { dir: 2, magic: 69, nrFirst: 6, nrLast: 6, direction: "out", fixedSize: null, maxCallerSize: 256 }, + { dir: 2, magic: 69, nrFirst: 32, nrLast: 63, direction: "out", fixedSize: null, maxCallerSize: 256 }, + { dir: 2, magic: 69, nrFirst: 64, nrLast: 127, direction: "out", fixedSize: 24, maxCallerSize: null }, +]; + export const SYSCALL_ARGS: Record = { 1: [ { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, diff --git a/host/src/ioctl-contract.ts b/host/src/ioctl-contract.ts new file mode 100644 index 0000000000..2221dc18f3 --- /dev/null +++ b/host/src/ioctl-contract.ts @@ -0,0 +1,57 @@ +/** + * Host-side mirror of `shared::ioctl_contract::request_contract`. + * + * Most ioctls are keyed by an exact request number and resolve straight out + * of `IOCTL_REQUESTS`. The evdev surface is not: `EVIOCGNAME(len)` and + * `EVIOCGBIT(ev, len)` encode a caller-chosen length in the request itself, + * and `EVIOCGABS(axis)` spans 64 axes, so those resolve through + * `IOCTL_REQUEST_FAMILIES` instead. Both hosts must agree with the kernel on + * the staged byte count, so the two tables are consulted in the same order + * here as in Rust. + */ +import { + IOCTL_REQUESTS, + IOCTL_REQUEST_FAMILIES, + type IoctlRequestContract, +} from "./generated/abi"; + +function familyContract(request: number): IoctlRequestContract | undefined { + const dir = (request >>> 30) & 0x3; + const encodedSize = (request >>> 16) & 0x3fff; + const magic = (request >>> 8) & 0xff; + const nr = request & 0xff; + + const family = IOCTL_REQUEST_FAMILIES.find( + (candidate) => + candidate.dir === dir && + candidate.magic === magic && + candidate.nrFirst <= nr && + nr <= candidate.nrLast, + ); + if (!family) return undefined; + + if (family.fixedSize !== null) { + if (encodedSize !== family.fixedSize) return undefined; + } else if (family.maxCallerSize !== null) { + if (encodedSize < 1 || encodedSize > family.maxCallerSize) return undefined; + } else { + return undefined; + } + + return { + argKind: "pointer", + direction: family.direction, + wasm32Size: encodedSize, + wasm64Size: encodedSize, + }; +} + +/** + * Resolve one ioctl request to its marshalling contract, or `undefined` when + * the request is unknown and must reach the device with no staged pointer. + */ +export function resolveIoctlContract( + request: number, +): IoctlRequestContract | undefined { + return IOCTL_REQUESTS[request >>> 0] ?? familyContract(request >>> 0); +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index f7c29c811c..3c8b226559 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -31,6 +31,7 @@ import { WasmPosixKernel, type KernelPointer, } from "./kernel"; +import { resolveIoctlContract } from "./ioctl-contract"; import { createKernelEntryScopedInstance, invokeKernelEntrySerializedHostOperation, @@ -115,7 +116,6 @@ import { FCNTL_FLOCK_BYTES, FILE_MODES, HOST_INTERCEPTED_SYSCALLS, - IOCTL_REQUESTS, OPEN_FLAGS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, @@ -11904,7 +11904,7 @@ export class CentralizedKernelWorker { } if (syscallNr === SYS_IOCTL) { const request = Number(BigInt.asUintN(32, rawArgs[1]!)); - const contract = IOCTL_REQUESTS[request]; + const contract = resolveIoctlContract(request); adjustedArgs[1] = request; adjustedArgs[3] = 0; adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; diff --git a/host/src/kernel.ts b/host/src/kernel.ts index da9b5d3365..9e7724c3b8 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -39,8 +39,8 @@ import { runGlQuery } from "./webgl/query"; import { SubmitQueue } from "./webgl/submit-queue"; import { GlMuxer } from "./webgl/muxer"; import { drainSubmitQueue } from "./webgl/submit-drain"; +import { resolveIoctlContract } from "./ioctl-contract"; import { - IOCTL_REQUESTS, KERNEL_SCRATCH_FD_PAIR_BYTES, KERNEL_SCRATCH_SOCKLEN_BYTES, SELECT_FD_SET_BYTES, @@ -1257,43 +1257,6 @@ export class WasmPosixKernel { ); } - /** - * Push one evdev record into the kernel's `/dev/input/event{0,1}` - * ring. `device` selects keyboard (0) or pointer (1); `ev_type`, - * `code`, `value` mirror the Linux `struct input_event` tail. The - * host runtime is expected to follow each type-specific record with - * an `EV_SYN(SYN_REPORT, 0)` so the kernel sees one logical frame - * per gesture — see `BrowserInputSource`'s dispatch contract. - * Silently dropped if the kernel module is not instantiated yet. - */ - injectInputEvent( - device: 0 | 1, - ev_type: number, - code: number, - value: number, - ): void { - const inject = this.instance?.exports?.kernel_input_event as - | ((device: number, ev_type: number, code: number, value: number) => void) - | undefined; - if (!inject) return; - inject(device, ev_type, code, value); - } - - /** - * Record the host canvas dimensions on the kernel so EVIOCGABS on - * `/dev/input/event1` reports `ABS_X.maximum = width - 1` and - * `ABS_Y.maximum = height - 1`. Must be called once the canvas - * exists and again on any resize; silently dropped if the kernel - * module is not instantiated yet. - */ - setInputCanvasDims(width: number, height: number): void { - const set = this.instance?.exports?.kernel_set_input_canvas_dims as - | ((width: number, height: number) => void) - | undefined; - if (!set) return; - set(width, height); - } - // --------------------------------------------------------------------------- // /dev/dsp — host-drained PCM audio // --------------------------------------------------------------------------- @@ -4032,7 +3995,7 @@ export class WasmPosixKernel { bufLen: number, processPointerWidth: number, ) => number; - const contract = IOCTL_REQUESTS[request >>> 0]; + const contract = resolveIoctlContract(request); const wasm32Size = contract?.wasm32Size; if (contract && wasm32Size === null) { throw new Error( diff --git a/host/test/ioctl-contract.test.ts b/host/test/ioctl-contract.test.ts new file mode 100644 index 0000000000..bcb2596148 --- /dev/null +++ b/host/test/ioctl-contract.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { IOCTL_REQUESTS } from "../src/generated/abi"; +import { resolveIoctlContract } from "../src/ioctl-contract"; + +/** Builds the same encoding the musl `_IOC` macros produce. */ +function evioc(dir: number, nr: number, size: number): number { + return ((dir << 30) | (size << 16) | (0x45 << 8) | nr) >>> 0; +} + +const EVIOCGNAME_NR = 0x06; +const EVIOCGBIT_NR_BASE = 0x20; +const EVIOCGABS_NR_BASE = 0x40; +const MAX_CALLER_LENGTH = 256; + +describe("ioctl contract resolution", () => { + it("resolves exact-numbered requests out of the generated table", () => { + expect(resolveIoctlContract(0x540b)).toEqual(IOCTL_REQUESTS[0x540b]); + expect(resolveIoctlContract(0x8004_4501)).toMatchObject({ + argKind: "pointer", + direction: "out", + wasm32Size: 4, + }); + expect(resolveIoctlContract(0x4004_4590)).toMatchObject({ + argKind: "scalar-i32", + }); + }); + + it("resolves EVIOCGABS on every axis at the absinfo size", () => { + for (let axis = 0; axis < 64; axis++) { + expect( + resolveIoctlContract(evioc(2, EVIOCGABS_NR_BASE + axis, 24)), + `axis ${axis}`, + ).toEqual({ + argKind: "pointer", + direction: "out", + wasm32Size: 24, + wasm64Size: 24, + }); + } + }); + + it("rejects an EVIOCGABS request sized as anything but absinfo", () => { + expect(resolveIoctlContract(evioc(2, EVIOCGABS_NR_BASE, 16))).toBeUndefined(); + }); + + it("carries the caller's length for EVIOCGNAME and EVIOCGBIT", () => { + for (const size of [1, 32, MAX_CALLER_LENGTH]) { + expect( + resolveIoctlContract(evioc(2, EVIOCGNAME_NR, size))?.wasm32Size, + ).toBe(size); + expect( + resolveIoctlContract(evioc(2, EVIOCGBIT_NR_BASE + 1, size))?.wasm32Size, + ).toBe(size); + } + }); + + it("rejects a zero or oversized caller length", () => { + for (const nr of [EVIOCGNAME_NR, EVIOCGBIT_NR_BASE]) { + expect(resolveIoctlContract(evioc(2, nr, 0))).toBeUndefined(); + expect( + resolveIoctlContract(evioc(2, nr, MAX_CALLER_LENGTH + 1)), + ).toBeUndefined(); + } + }); + + it("ignores a foreign magic or a write direction", () => { + const foreignMagic = + ((2 << 30) | (24 << 16) | (0x44 << 8) | EVIOCGABS_NR_BASE) >>> 0; + expect(resolveIoctlContract(foreignMagic)).toBeUndefined(); + expect( + resolveIoctlContract(evioc(1, EVIOCGABS_NR_BASE, 24)), + ).toBeUndefined(); + }); + + it("leaves an unknown request unresolved", () => { + expect(resolveIoctlContract(0xdead_0000)).toBeUndefined(); + }); +}); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 69f3a297b7..379c72ee5d 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -259,6 +259,10 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x00008905u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x40044590u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x40045004u: return pointer_width == 4u ? 4u : @@ -275,6 +279,10 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x40086409u: return pointer_width == 4u ? 8u : pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80044501u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x80045002u: return pointer_width == 4u ? 4u : @@ -311,6 +319,10 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x80045430u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80084502u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x80085013u: return pointer_width == 4u ? 8u : diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index c2a1ec9579..2c5ac60e1e 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -3414,6 +3414,35 @@ fn render_ts_module() -> String { } out.push_str("};\n\n"); + out.push_str("export interface IoctlRequestFamily {\n"); + out.push_str(" dir: number;\n"); + out.push_str(" magic: number;\n"); + out.push_str(" nrFirst: number;\n"); + out.push_str(" nrLast: number;\n"); + out.push_str(" direction: IoctlDirection;\n"); + out.push_str(" fixedSize: number | null;\n"); + out.push_str(" maxCallerSize: number | null;\n"); + out.push_str("}\n\n"); + out.push_str("export const IOCTL_REQUEST_FAMILIES: IoctlRequestFamily[] = [\n"); + for family in shared::ioctl_contract::IOCTL_REQUEST_FAMILIES { + let (fixed_size, max_caller_size) = match family.size { + shared::ioctl_contract::IoctlFamilySize::Fixed(size) => (Some(size), None), + shared::ioctl_contract::IoctlFamilySize::CallerEncoded { max } => (None, Some(max)), + }; + out.push_str(&format!( + " {{ dir: {}, magic: {}, nrFirst: {}, nrLast: {}, direction: {:?}, \ +fixedSize: {}, maxCallerSize: {} }},\n", + family.dir, + family.magic, + family.nr_first, + family.nr_last, + ioctl_direction_name(family.direction), + ts_optional_u32(fixed_size), + ts_optional_u32(max_caller_size), + )); + } + out.push_str("];\n\n"); + out.push_str("export const SYSCALL_ARGS: Record = {\n"); for entry in shared::host_abi::SYSCALL_ARG_DESCRIPTORS { out.push_str(&format!(" {}: [\n", entry.syscall_number)); @@ -3801,6 +3830,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("host_adapter".into(), host_adapter()); root.insert("syscall_arg_descriptors".into(), syscall_arg_descriptors()); root.insert("ioctl_request_contracts".into(), ioctl_request_contracts()); + root.insert("ioctl_request_families".into(), ioctl_request_families()); root.insert("channel_status_codes".into(), channel_status_codes()); root.insert("process_native_layouts".into(), process_native_layouts()); root.insert("process_memory_layout".into(), process_memory_layout()); @@ -5471,6 +5501,35 @@ fn ioctl_request_contracts() -> Value { Value::Object(contracts.into_iter().collect()) } +fn ioctl_request_families() -> Value { + let families = shared::ioctl_contract::IOCTL_REQUEST_FAMILIES + .iter() + .map(|family| { + let (fixed_size, max_caller_size) = match family.size { + shared::ioctl_contract::IoctlFamilySize::Fixed(size) => { + (Some(size), None) + } + shared::ioctl_contract::IoctlFamilySize::CallerEncoded { max } => { + (None, Some(max)) + } + }; + let mut value: JsonMap = BTreeMap::new(); + value.insert("dir".into(), json!(family.dir)); + value.insert("magic".into(), json!(family.magic)); + value.insert("nrFirst".into(), json!(family.nr_first)); + value.insert("nrLast".into(), json!(family.nr_last)); + value.insert( + "direction".into(), + json!(ioctl_direction_name(family.direction)), + ); + value.insert("fixedSize".into(), json!(fixed_size)); + value.insert("maxCallerSize".into(), json!(max_caller_size)); + Value::Object(value.into_iter().collect()) + }) + .collect(); + Value::Array(families) +} + fn host_adapter() -> Value { let manifest = shared::abi::HOST_ADAPTER_MANIFEST; @@ -6954,6 +7013,13 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result { classify_additive_object_by_key(key, old_value, new_value, &mut report)? } + // A request number absent from the table resolved to "unknown" + // before, so adding one cannot change how an older program + // marshals any call it already made. Changing or removing an + // entry would restage a different buffer size and stays breaking. + "ioctl_request_contracts" => { + classify_additive_object_by_key(key, old_value, new_value, &mut report)? + } "vfs_metadata" => { classify_additive_object_by_key(key, old_value, new_value, &mut report)? } @@ -6972,7 +7038,11 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result bool { matches!( section, - "host_adapter" | "io_multiplexing" | "syscall_arg_descriptors" | "vfs_metadata" + "host_adapter" + | "io_multiplexing" + | "ioctl_request_families" + | "syscall_arg_descriptors" + | "vfs_metadata" ) } @@ -8273,6 +8343,120 @@ mod tests { ); } + fn snapshot_with_one_ioctl_contract() -> Value { + let mut snapshot = base_snapshot(); + snapshot.as_object_mut().unwrap().insert( + "ioctl_request_contracts".into(), + json!({ + "1074021776": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + } + }), + ); + snapshot + } + + #[test] + fn adding_an_ioctl_request_contract_entry_is_compatible() { + let old = snapshot_with_one_ioctl_contract(); + let mut new = snapshot_with_one_ioctl_contract(); + new["ioctl_request_contracts"]["2147763457"] = json!({ + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added ioctl_request_contracts entry \"2147763457\""] + ); + } + + #[test] + fn changing_or_removing_an_ioctl_request_contract_entry_is_breaking() { + let old = snapshot_with_one_ioctl_contract(); + let mut resized = snapshot_with_one_ioctl_contract(); + resized["ioctl_request_contracts"]["1074021776"]["wasm32Size"] = json!(4); + + let report = classify_compat_change(&old, &resized).unwrap(); + assert_eq!( + report.breaking, + vec!["changed ioctl_request_contracts entry \"1074021776\""] + ); + + let mut dropped = snapshot_with_one_ioctl_contract(); + dropped["ioctl_request_contracts"] + .as_object_mut() + .unwrap() + .remove("1074021776"); + + let report = classify_compat_change(&old, &dropped).unwrap(); + assert_eq!( + report.breaking, + vec!["removed ioctl_request_contracts entry \"1074021776\""] + ); + } + + #[test] + fn adding_the_ioctl_request_families_section_is_compatible() { + let old = base_snapshot(); + let mut new = base_snapshot(); + new.as_object_mut().unwrap().insert( + "ioctl_request_families".into(), + json!([{ + "dir": 2, + "magic": 69, + "nrFirst": 64, + "nrLast": 127, + "direction": "out", + "fixedSize": 24, + "maxCallerSize": null + }]), + ); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added top-level section \"ioctl_request_families\""] + ); + } + + #[test] + fn narrowing_an_existing_ioctl_request_family_is_breaking() { + let family = |nr_last: u32| { + json!([{ + "dir": 2, + "magic": 69, + "nrFirst": 64, + "nrLast": nr_last, + "direction": "out", + "fixedSize": 24, + "maxCallerSize": null + }]) + }; + let mut old = base_snapshot(); + old.as_object_mut() + .unwrap() + .insert("ioctl_request_families".into(), family(127)); + let mut new = base_snapshot(); + new.as_object_mut() + .unwrap() + .insert("ioctl_request_families".into(), family(96)); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed top-level section \"ioctl_request_families\""] + ); + } + #[test] fn adding_io_multiplexing_section_is_compatible() { let mut old = base_snapshot(); From 5f96d0bfb9f285efe49c345d62d6f9264c8d5cbb Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 13:58:51 +0200 Subject: [PATCH 21/27] fix(input): route evdev ingress through the kernel entry gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- host/src/kernel-worker.ts | 29 +++++++++++-- host/test/kernel-clone-exit-entry.test.ts | 45 ++++++++++++++++++++ host/test/support/kernel-scratch-instance.ts | 8 ++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 3c8b226559..b2b0762d1a 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30132,8 +30132,22 @@ export class CentralizedKernelWorker { code: number, value: number, ): void { - this.kernel.injectInputEvent(device, ev_type, code, value); - this.scheduleWakeBlockedRetries(); + this.#runOrDeferKernelEntry( + "evdev input and wake", + (entry) => { + const inject = entry.instance.exports.kernel_input_event as + | (( + device: number, + ev_type: number, + code: number, + value: number, + ) => void) + | undefined; + if (!inject) return; + inject(device, ev_type, code, value); + this.scheduleWakeBlockedRetries(entry); + }, + ); } /** @@ -30142,7 +30156,16 @@ export class CentralizedKernelWorker { * `ABS_Y.maximum`. Idempotent; call again on canvas resize. */ setInputCanvasDims(width: number, height: number): void { - this.kernel.setInputCanvasDims(width, height); + this.#runOrDeferKernelEntry( + "evdev canvas dimensions", + (entry) => { + const set = entry.instance.exports.kernel_set_input_canvas_dims as + | ((width: number, height: number) => void) + | undefined; + if (!set) return; + set(width, height); + }, + ); } /** diff --git a/host/test/kernel-clone-exit-entry.test.ts b/host/test/kernel-clone-exit-entry.test.ts index 0f0547a3b8..fcdd49d99e 100644 --- a/host/test/kernel-clone-exit-entry.test.ts +++ b/host/test/kernel-clone-exit-entry.test.ts @@ -39,7 +39,9 @@ const KERNEL_EXPORT_NAMES = [ "kernel_get_process_state", "kernel_handle_channel", "kernel_inject_mouse_event", + "kernel_input_event", "kernel_set_current_tid", + "kernel_set_input_canvas_dims", "kernel_take_process_timer_cleanup", "kernel_thread_exit", ] as const; @@ -104,7 +106,9 @@ function makeHarness( kernel_get_process_state: () => PROCESS_STATE_EXITED, kernel_handle_channel: () => 0, kernel_inject_mouse_event: () => 0, + kernel_input_event: () => 0, kernel_set_current_tid: () => 0, + kernel_set_input_canvas_dims: () => 0, kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), kernel_thread_exit: () => 0, ...implementations, @@ -401,6 +405,47 @@ describe("clone and exit entry authority", () => { }, ); + it("defers evdev ingress raised from a detached host callback", async () => { + const order: string[] = []; + const inputEvent = vi.fn(() => { + order.push("queued evdev record"); + return 0; + }); + const canvasDims = vi.fn(() => { + order.push("queued canvas dimensions"); + return 0; + }); + let harness!: LifecycleHarness; + const onExit = vi.fn(() => { + order.push("host exit callback"); + harness.worker.setInputCanvasDims(1024, 768); + harness.worker.injectInputEvent(0, 0x01, 30, 1); + expect(canvasDims).not.toHaveBeenCalled(); + expect(inputEvent).not.toHaveBeenCalled(); + }); + harness = makeHarness( + 4, + { onExit }, + { + kernel_input_event: inputEvent, + kernel_set_input_canvas_dims: canvasDims, + }, + ); + writeSyscall(harness.channel, ABI_SYSCALLS.Exit, [7n]); + + harness.worker.handleSyscall(harness.channel); + + await flushLifecycleContinuations(); + + expect(canvasDims).toHaveBeenCalledExactlyOnceWith(1024, 768); + expect(inputEvent).toHaveBeenCalledExactlyOnceWith(0, 0x01, 30, 1); + expect(order).toEqual([ + "host exit callback", + "queued canvas dimensions", + "queued evdev record", + ]); + }); + it("keeps host exit state private when Rust cannot prove the committed status", async () => { const onExit = vi.fn(); const onKernelFatal = vi.fn(); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index c7375554fc..22e5aaa975 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -215,6 +215,10 @@ function signatures( // genuine Wasm function and the exact gated export lookup. result: i32, }, + kernel_input_event: { + parameters: [i32, i32, i32, i32], + result: i32, + }, kernel_ioctl: { parameters: [i32, i32, pointer, i32, i32], result: i32, @@ -457,6 +461,10 @@ function signatures( parameters: [i32, i32], result: i32, }, + kernel_set_input_canvas_dims: { + parameters: [i32, i32], + result: i32, + }, kernel_set_max_addr: { parameters: [i32, pointer], result: i32, From ec498cbff2a92a7f7f05128dbc3cc7fd6c29879c Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 13:59:41 +0200 Subject: [PATCH 22/27] cleanup(dri): drop the three files 432d4c4d1 added and nothing uses `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) --- host/test/webgl-foreign-texture.test.ts | 67 ----- programs/cube.c | 364 ------------------------ programs/dri_paint.c | 161 ----------- 3 files changed, 592 deletions(-) delete mode 100644 host/test/webgl-foreign-texture.test.ts delete mode 100644 programs/cube.c delete mode 100644 programs/dri_paint.c diff --git a/host/test/webgl-foreign-texture.test.ts b/host/test/webgl-foreign-texture.test.ts deleted file mode 100644 index 3f751d5421..0000000000 --- a/host/test/webgl-foreign-texture.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForeignTextureRegistry } from "../src/webgl/registry.js"; - -class FakeGl { - TEXTURE_2D = 0x0de1; - RGBA = 0x1908; - UNSIGNED_BYTE = 0x1401; - - created = 0; - deleted: object[] = []; - texImage2DArgs: unknown[][] = []; - - createTexture(): object { return { id: ++this.created }; } - bindTexture(_t: number, _tex: object): void {} - texImage2D(...a: unknown[]): void { this.texImage2DArgs.push(a); } - deleteTexture(t: object): void { this.deleted.push(t); } -} - -const asGl = (g: FakeGl) => g as unknown as WebGL2RenderingContext; - -describe("ForeignTextureRegistry", () => { - it("allocate creates one texture sized w×h", () => { - const reg = new ForeignTextureRegistry(); - const gl = new FakeGl(); - reg.allocate(42, 64, 32, asGl(gl)); - expect(gl.created).toBe(1); - const [, , , w, h] = gl.texImage2DArgs[0]; - expect(w).toBe(64); - expect(h).toBe(32); - }); - - it("bind on unknown bo returns -1", () => { - const reg = new ForeignTextureRegistry(); - expect(reg.bind(99, 1)).toBe(-1); - }); - - it("two ctx_ids resolve back to the same WebGLTexture", () => { - const reg = new ForeignTextureRegistry(); - const gl = new FakeGl(); - reg.allocate(7, 16, 16, asGl(gl)); - const id_a = reg.bind(7, 100); - const id_b = reg.bind(7, 200); - expect(id_a).toBeGreaterThan(0); - expect(id_b).toBeGreaterThan(0); - expect(gl.created).toBe(1); - expect(reg.resolve(100, id_a)).toBe(reg.resolve(200, id_b)); - }); - - it("synthetic ids are independent per ctx_id", () => { - const reg = new ForeignTextureRegistry(); - const gl = new FakeGl(); - reg.allocate(1, 4, 4, asGl(gl)); - reg.allocate(2, 4, 4, asGl(gl)); - expect(reg.bind(1, 50)).toBe(1); - expect(reg.bind(2, 50)).toBe(2); - expect(reg.bind(1, 51)).toBe(1); - }); - - it("free deletes the texture and drops the entry", () => { - const reg = new ForeignTextureRegistry(); - const gl = new FakeGl(); - reg.allocate(5, 8, 8, asGl(gl)); - reg.free(5, asGl(gl)); - expect(gl.deleted.length).toBe(1); - expect(reg.bind(5, 1)).toBe(-1); - }); -}); diff --git a/programs/cube.c b/programs/cube.c deleted file mode 100644 index 114ed789cd..0000000000 --- a/programs/cube.c +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Spinning colored cube on wasm-posix-kernel — fork(2)+pipe(2) two-process demo. - * - * Architecture: - * - * parent ── pipe[0] ── read frames ── upload VBO ── glDrawArrays - * │ │ - * └── fork(2) ────────────────────────┐ │ - * ▼ ▼ - * child ── compute rotation matrix ── project 3D ── write pipe[1] - * - * The parent owns the GLES2 context (eglInitialize → eglMakeCurrent), so - * forking after EGL setup would clone the cmdbuf fd and confuse the - * host registry (one canvas, two cmdbufs). We fork *before* any GL - * call, then only the parent enters the GL path. - * - * Per-frame: child computes a tumbling rotation from clock_gettime, - * applies it + a perspective projection to the 8 cube vertices, - * expands to 36 triangle vertices (6 faces × 2 tris × 3 verts), and - * writes one frame's worth of (x,y,z,r,g,b) floats to the pipe. Frame - * size is 36 * 24 = 864 bytes — well under PIPE_BUF, so each write is - * atomic and the parent reads exactly one frame per render. - * - * The vertex shader is a pass-through: projection is already done CPU-side - * because the GLES2 stub doesn't carry uniforms. Depth testing in the - * GPU does the occlusion (24-bit depth requested in the EGL config). - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define CANVAS_W 768 -#define CANVAS_H 768 -#define VERTS 36 -#define VERT_SZ (6 * sizeof(float)) /* x,y,z,r,g,b */ -#define FRAME_SZ (VERTS * VERT_SZ) /* 864 bytes — atomic over pipe */ -#define FRAME_USEC 16000 /* ~60 Hz */ - -/* The 8 corners of a unit cube centred on the origin. */ -static const float cube_v[8][3] = { - {-1, -1, -1}, { 1, -1, -1}, { 1, 1, -1}, {-1, 1, -1}, - {-1, -1, 1}, { 1, -1, 1}, { 1, 1, 1}, {-1, 1, 1}, -}; - -/* 6 faces, each two triangles, indexing into cube_v. - * Order chosen so the outward normal points away from the centre when - * traversed counter-clockwise — matters for any future face culling but - * not for the depth-test path used here. */ -static const int faces[6][6] = { - {0,1,2, 0,2,3}, /* -Z */ - {4,6,5, 4,7,6}, /* +Z */ - {0,4,5, 0,5,1}, /* -Y */ - {3,2,6, 3,6,7}, /* +Y */ - {0,3,7, 0,7,4}, /* -X */ - {1,5,6, 1,6,2}, /* +X */ -}; - -/* Classic 6-color cube palette (red, green, blue, yellow, cyan, magenta). */ -static const float face_col[6][3] = { - {1.00, 0.20, 0.20}, - {0.20, 0.85, 0.30}, - {0.25, 0.45, 1.00}, - {1.00, 0.85, 0.20}, - {0.20, 0.85, 0.95}, - {0.95, 0.30, 0.85}, -}; - -static double monotonic_seconds(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9; -} - -/* SIGUSR1 toggles the paused flag in the *child*. The browser's - * Stop/Resume buttons send the signal directly to the child pid - * (parsed from the parent's stdout banner) via kernel.sendSignal. - * - * Why the child and not the parent: the child parks in usleep(2) - * between frames (pendingSleeps in centralized mode), which - * sendSignalToProcess wakes via completeSleepWithSignalCheck — the - * EINTR path delivers the signal cleanly and the user-space handler - * runs. The parent parks in read(2) on the pipe (pendingPipeReaders), - * which sendSignalToProcess does NOT wake, so signalling it would - * leave the signal queued indefinitely. */ -static volatile sig_atomic_t paused = 0; - -static void on_pause_toggle(int sig) { - (void)sig; - paused = !paused; -} - -/* ──────────────────────────────────────────────────────────────────── - * Child process: simulate rotation, project to clip space, ship frame. - * ──────────────────────────────────────────────────────────────────── */ - -/* 3x3 rotation: rotate `v` around X by ax, then around Y by ay. */ -static void rotate(const float v[3], float ax, float ay, float out[3]) { - float cx = cosf(ax), sx = sinf(ax); - float cy = cosf(ay), sy = sinf(ay); - /* Rx then Ry: out = Ry · Rx · v. */ - float y1 = cx * v[1] - sx * v[2]; - float z1 = sx * v[1] + cx * v[2]; - float x2 = cy * v[0] + sy * z1; - float z2 = -sy * v[0] + cy * z1; - out[0] = x2; - out[1] = y1; - out[2] = z2; -} - -/* Pull the cube back from the camera and apply a simple perspective: - * x' = x * f / (z + d), y' = y * f / (z + d). z' encodes the post-translate - * depth in [-1, 1]ish — only the relative ordering matters for the - * GPU depth test. */ -static void project(const float v[3], float out[3]) { - const float dist = 4.0f; - const float focal = 1.1f; - float zc = v[2] + dist; - if (zc < 0.1f) zc = 0.1f; - out[0] = v[0] * focal / zc; - out[1] = v[1] * focal / zc; - /* Map z roughly into clip space: closer = smaller (renders in front - * with default GL_LESS depth func). The constants are picked so all - * 8 corners stay in the [-0.95, 0.95] range. */ - out[2] = (zc - dist) * 0.25f; -} - -static void child_loop(int write_fd) { - /* Parent died → write returns EPIPE → exit cleanly without a signal. */ - signal(SIGPIPE, SIG_IGN); - /* SIGUSR1 (browser Stop/Resume) flips `paused` and interrupts usleep. */ - signal(SIGUSR1, on_pause_toggle); - - float frame[VERTS * 6]; - /* Pause-aware clock: `t0` is the monotonic instant the cube would - * have started at if it had been running continuously. While - * paused we keep advancing it forward so the un-pause picks up at - * the same angle the pause hit. */ - double t0 = monotonic_seconds(); - double pause_started = 0; - - for (;;) { - if (paused) { - if (pause_started == 0) pause_started = monotonic_seconds(); - usleep(FRAME_USEC); - continue; - } - if (pause_started != 0) { - t0 += monotonic_seconds() - pause_started; - pause_started = 0; - } - double t = monotonic_seconds() - t0; - float ax = (float)(t * 0.7); - float ay = (float)(t * 0.9); - - /* Transform all 8 cube corners once per frame. */ - float xv[8][3]; - for (int i = 0; i < 8; i++) { - float r[3]; - rotate(cube_v[i], ax, ay, r); - project(r, xv[i]); - } - - /* Expand to 36 triangle vertices, attaching the face colour. */ - float *p = frame; - for (int f = 0; f < 6; f++) { - const int *idx = faces[f]; - for (int j = 0; j < 6; j++) { - const float *v = xv[idx[j]]; - *p++ = v[0]; *p++ = v[1]; *p++ = v[2]; - *p++ = face_col[f][0]; - *p++ = face_col[f][1]; - *p++ = face_col[f][2]; - } - } - - const char *buf = (const char *)frame; - size_t left = FRAME_SZ; - while (left > 0) { - ssize_t w = write(write_fd, buf, left); - if (w < 0) { - if (errno == EINTR) continue; - _exit(0); /* parent gone — quietly exit */ - } - buf += w; - left -= (size_t)w; - } - - usleep(FRAME_USEC); - } -} - -/* ──────────────────────────────────────────────────────────────────── - * Parent process: GLES2 setup, per-frame VBO upload, draw, present. - * ──────────────────────────────────────────────────────────────────── */ - -static const char vs_src[] = - "attribute vec3 a_pos;\n" - "attribute vec3 a_col;\n" - "varying vec3 v_col;\n" - "void main() { gl_Position = vec4(a_pos, 1.0); v_col = a_col; }\n"; - -static const char fs_src[] = - "precision mediump float;\n" - "varying vec3 v_col;\n" - "void main() { gl_FragColor = vec4(v_col, 1.0); }\n"; - -/* Read exactly `n` bytes or return -1 on EOF/error. Frames are - * single-write atomic on the producer side (FRAME_SZ < PIPE_BUF), but - * the consumer can still see split reads if it races the writer mid- - * write — loop just in case. */ -static int read_full(int fd, void *buf, size_t n) { - char *p = (char *)buf; - while (n > 0) { - ssize_t r = read(fd, p, n); - if (r < 0) { - if (errno == EINTR) continue; - return -1; - } - if (r == 0) return -1; /* child exited */ - p += r; - n -= (size_t)r; - } - return 0; -} - -static int parent_loop(int read_fd, pid_t child_pid) { - (void)child_pid; /* only used for logging (FPS line); browser drives pause */ - EGLDisplay dpy = eglGetDisplay(EGL_DEFAULT_DISPLAY); - EGLint maj = 0, min = 0; - if (!eglInitialize(dpy, &maj, &min)) return 1; - - EGLint cfg_attribs[] = { - EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, - EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, - EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, - EGL_SURFACE_TYPE, EGL_WINDOW_BIT, - EGL_NONE, - }; - EGLConfig cfg; - EGLint num_cfg = 0; - if (!eglChooseConfig(dpy, cfg_attribs, &cfg, 1, &num_cfg) || num_cfg < 1) return 2; - if (!eglBindAPI(EGL_OPENGL_ES_API)) return 3; - - EGLint ctx_attribs[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; - EGLContext ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, ctx_attribs); - if (ctx == EGL_NO_CONTEXT) return 4; - - EGLSurface surf = eglCreateWindowSurface(dpy, cfg, 0, 0); - if (surf == EGL_NO_SURFACE) return 5; - if (!eglMakeCurrent(dpy, surf, surf, ctx)) return 6; - - GLuint vs = glCreateShader(GL_VERTEX_SHADER); - const char *vs_p = vs_src; glShaderSource(vs, 1, &vs_p, 0); glCompileShader(vs); - GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); - const char *fs_p = fs_src; glShaderSource(fs, 1, &fs_p, 0); glCompileShader(fs); - - GLuint prog = glCreateProgram(); - glAttachShader(prog, vs); - glAttachShader(prog, fs); - glBindAttribLocation(prog, 0, "a_pos"); - glBindAttribLocation(prog, 1, "a_col"); - glLinkProgram(prog); - glUseProgram(prog); - - GLuint vbo; - glGenBuffers(1, &vbo); - glBindBuffer(GL_ARRAY_BUFFER, vbo); - - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, (GLsizei)VERT_SZ, (const void *)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, (GLsizei)VERT_SZ, (const void *)(3 * sizeof(float))); - - glViewport(0, 0, CANVAS_W, CANVAS_H); - glEnable(GL_DEPTH_TEST); - - float frame[VERTS * 6]; - double last_fps_at = monotonic_seconds(); - unsigned frames = 0; - int rc = 0; - - /* Plain blocking read on the pipe — when the child is paused it - * stops writing, so this read parks until the child resumes. */ - for (;;) { - if (read_full(read_fd, frame, FRAME_SZ) < 0) break; - - glClearColor(0.05f, 0.06f, 0.10f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)FRAME_SZ, frame, GL_DYNAMIC_DRAW); - glDrawArrays(GL_TRIANGLES, 0, VERTS); - - if (!eglSwapBuffers(dpy, surf)) { rc = 7; break; } - - frames++; - double now = monotonic_seconds(); - if (now - last_fps_at >= 1.0) { - printf("cube: %u fps (child pid %d)\n", frames, (int)child_pid); - fflush(stdout); - frames = 0; - last_fps_at = now; - } - } - - glDeleteShader(vs); - glDeleteShader(fs); - glDeleteProgram(prog); - eglDestroySurface(dpy, surf); - eglDestroyContext(dpy, ctx); - eglTerminate(dpy); - return rc; -} - -/* ──────────────────────────────────────────────────────────────────── - * main: pipe + fork. Parent owns GL, child owns the math. - * ──────────────────────────────────────────────────────────────────── */ - -int main(void) { - int fds[2]; - if (pipe(fds) != 0) { - perror("pipe"); - return 10; - } - - pid_t k = fork(); - if (k < 0) { - perror("fork"); - return 11; - } - - if (k == 0) { - close(fds[0]); - child_loop(fds[1]); - _exit(0); - } - - /* Parent. No SIGUSR1 handler here — the browser sends Stop/Resume - * SIGUSR1s directly to the child pid (via kernel.sendSignal), - * never to the parent. Default-action SIGUSR1 on a process with - * no handler is Terminate, but since nothing signals the parent - * that's fine. */ - close(fds[1]); - printf("cube: forked child pid %d, parent pid %d\n", - (int)k, (int)getpid()); - fflush(stdout); - - int rc = parent_loop(fds[0], k); - - /* Best-effort tidy: child will EPIPE-out on next write once we - * close the read end. */ - close(fds[0]); - int status = 0; - waitpid(k, &status, WNOHANG); - return rc; -} diff --git a/programs/dri_paint.c b/programs/dri_paint.c deleted file mode 100644 index 4f51938e8f..0000000000 --- a/programs/dri_paint.c +++ /dev/null @@ -1,161 +0,0 @@ -/* - * dri_paint — visible browser demo for milestone (A). - * - * Same flow as dumb_roundtrip (programs/dumb_roundtrip.c), but the - * child writes the verified buffer bytes to /tmp/dri-paint.raw before - * exiting. Pages can read that file via kernel.fs and paint the bytes - * onto a canvas, proving visually that the parent's gradient survived - * PRIME export → fork → PRIME import → mmap on the imported handle. - * - * parent - * 1. open /dev/dri/renderD128 - * 2. gbm_create_device(fd) - * 3. gbm_bo_create(W×H, ARGB8888, LINEAR) - * 4. gbm_bo_map → write a deterministic gradient - * 5. gbm_bo_get_fd → PRIME export the bo - * 6. fork(); prime fd inherited by the child via fd table - * child - * 7. gbm_create_device(fd) on the inherited fd - * 8. gbm_bo_import(GBM_BO_IMPORT_FD, prime_fd) - * 9. gbm_bo_map → MAP_DUMB + mmap on the imported handle - * 10. verify every pixel matches the parent's gradient - * 11. write the verified buffer to /tmp/dri-paint.raw - * 12. _exit(0) on full success - * parent - * 13. waitpid the child; print sentinel; exit 0 - * - * Defense in depth: Playwright asserts both the exit-0 + sentinel - * (program completed) AND samples canvas pixels (the bytes on disk - * really are the gradient — catches a future SAB-sync regression - * that produces zeros without crashing). - * - * Stride is queried via the &stride out-param of gbm_bo_map. Buffer - * length on disk is `stride * H` so the page knows what to read - * even if stride > W*4 (libgbm shim returns W*4 today, but the - * convention survives a future driver that pads rows). - */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#define W 256 -#define H 256 - -#define DUMP_PATH "/tmp/dri-paint.raw" - -int main(void) { - int fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); - if (fd < 0) { perror("open /dev/dri/renderD128"); return 1; } - - struct gbm_device *dev = gbm_create_device(fd); - if (!dev) { perror("gbm_create_device"); return 1; } - - struct gbm_bo *bo = gbm_bo_create(dev, W, H, - DRM_FORMAT_ARGB8888, - GBM_BO_USE_LINEAR); - if (!bo) { perror("gbm_bo_create"); return 1; } - - uint32_t stride = 0; - void *map_data = NULL; - uint32_t *px = gbm_bo_map(bo, 0, 0, W, H, 0, &stride, &map_data); - if (!px) { perror("gbm_bo_map (parent)"); return 1; } - if (stride == 0 || (stride % 4) != 0) { - fprintf(stderr, "FAIL: parent stride bogus (%u)\n", stride); - return 1; - } - - const uint32_t stride_px = stride / 4; - for (uint32_t y = 0; y < H; y++) { - for (uint32_t x = 0; x < W; x++) { - px[y * stride_px + x] = (0xFFu << 24) | (x << 16) | (y << 8); - } - } - - int prime = gbm_bo_get_fd(bo); - if (prime < 0) { perror("gbm_bo_get_fd"); return 1; } - - pid_t pid = fork(); - if (pid < 0) { perror("fork"); return 1; } - - if (pid == 0) { - struct gbm_device *cdev = gbm_create_device(fd); - if (!cdev) { perror("gbm_create_device (child)"); _exit(2); } - - struct gbm_import_fd_data ifd = { - .fd = prime, - .width = W, - .height = H, - .stride = stride, - .format = DRM_FORMAT_ARGB8888, - }; - struct gbm_bo *cbo = gbm_bo_import(cdev, GBM_BO_IMPORT_FD, &ifd, 0); - if (!cbo) { perror("gbm_bo_import"); _exit(2); } - - uint32_t cstride = 0; - void *cmap_data = NULL; - uint32_t *cpx = gbm_bo_map(cbo, 0, 0, W, H, 0, &cstride, &cmap_data); - if (!cpx) { perror("gbm_bo_map (child)"); _exit(2); } - if (cstride != stride) { - fprintf(stderr, "FAIL: child stride %u != parent %u\n", - cstride, stride); - _exit(3); - } - const uint32_t cstride_px = cstride / 4; - for (uint32_t y = 0; y < H; y++) { - for (uint32_t x = 0; x < W; x++) { - uint32_t want = (0xFFu << 24) | (x << 16) | (y << 8); - uint32_t got = cpx[y * cstride_px + x]; - if (got != want) { - fprintf(stderr, - "FAIL: child pixel (%u,%u) = 0x%08x; want 0x%08x\n", - x, y, got, want); - _exit(4); - } - } - } - - /* /tmp is created by both host memfs init paths (browser-kernel-host.ts - * and the Node rootfs.vfs default mounts), but mkdir-with-EEXIST keeps - * the demo robust against a future VFS shape that ships without it. */ - if (mkdir("/tmp", 0755) != 0 && errno != EEXIST) { - perror("FAIL: mkdir /tmp"); _exit(5); - } - int of = open(DUMP_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (of < 0) { perror("FAIL: open " DUMP_PATH); _exit(5); } - size_t total = (size_t)cstride * H; - const uint8_t *bytes = (const uint8_t *)cpx; - size_t written = 0; - while (written < total) { - ssize_t n = write(of, bytes + written, total - written); - if (n < 0) { - if (errno == EINTR) continue; - perror("FAIL: write " DUMP_PATH); _exit(6); - } - written += (size_t)n; - } - if (close(of) != 0) { perror("FAIL: close " DUMP_PATH); _exit(7); } - - _exit(0); - } - - int status = 0; - if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; } - if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { - fprintf(stderr, "FAIL: child exited abnormally (status=0x%x)\n", status); - return 1; - } - - static const char ok[] = "milestone (A) PAINT\n"; - write(1, ok, sizeof ok - 1); - return 0; -} From 6f14d722e1c6c29926cbb42262e66b906571c082 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 14:00:29 +0200 Subject: [PATCH 23/27] feat(input): give evdev_demo.wasm a registry owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- docs/posix-status.md | 3 +- .../registry/evdev-demo/build-evdev-demo.sh | 49 +++++++++++++++++++ packages/registry/evdev-demo/build.toml | 15 ++++++ packages/registry/evdev-demo/package.toml | 26 ++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100755 packages/registry/evdev-demo/build-evdev-demo.sh create mode 100644 packages/registry/evdev-demo/build.toml create mode 100644 packages/registry/evdev-demo/package.toml diff --git a/docs/posix-status.md b/docs/posix-status.md index 65f00d5431..1a3ab10ffe 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -403,7 +403,8 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. Allocation captures the creator's effective UID and, because no separate tty group is configured, effective GID, with mode `0620`. `stat()`, `lstat()`, `fstatat()`, `statx()`, and descriptor stat share that persistent record; authorized chmod/chown operations update it, and open checks use the caller's current effective credentials and complete supplementary groups. Metadata survives slave close/reopen and is discarded with the pair. `/dev/ptmx` remains a distinct root-owned clone node. Also supports `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`, full line discipline, canonical/raw mode, OPOST/ONLCR, and 16 terminal ioctls. | | `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) are accepted on the process's terminal fd so fbDOOM-style software works unmodified; `/dev/fb0` itself is not a terminal. | -| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | +| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol; `evdev` lives on `/dev/input/event{0,1}` below. | +| `/dev/input/event0`, `/dev/input/event1` | Partial | Linux `evdev` character devices: `event0` is the keyboard, `event1` the pointer. `read()` drains whole 24-byte `struct input_event` records and returns `EAGAIN` when the ring is empty; `poll()` reports `POLLIN` only while records are queued. Each OFD owns its own 1024-record ring, so several readers see the stream independently. On overflow the ring latches a drop and the next `read()` returns a `SYN_DROPPED` record so the client can resynchronise. The host pushes records with `kernel_input_event(device, type, code, value)` and publishes the canvas size with `kernel_set_input_canvas_dims(width, height)`, which is what `EVIOCGABS` reports as the `ABS_X`/`ABS_Y` maxima. ioctls: `EVIOCGVERSION` (reports 1.0.1), `EVIOCGID` (`BUS_VIRTUAL`, vendor `0x1209`), `EVIOCGNAME` (`wpk virtual keyboard` / `wpk virtual pointer`, truncated to the caller's length), `EVIOCGBIT` for `EV_*` capability bitmaps, `EVIOCGABS` on `ABS_X`/`ABS_Y` (pointer only; other axes and the keyboard return `ENOTTY`), and `EVIOCGRAB` (records the grab on the OFD without changing routing, since nothing competes for the stream). Every other `E`-magic request returns `ENOTTY`. Ring state is per OFD: released on last close, carried across `fork()` and `exec()` with the descriptor. No force feedback, no `EVIOCSABS`/`EVIOCGKEY`/`EVIOCGLED`, no hotplug, and no devices beyond these two. | | `/dev/dsp` | Partial (playback only) | Source-compatible OSS PCM playback over the implementation-neutral Kandelo PCM core. U8/S16_LE/S16_BE, mono/stereo, 8–192 kHz; bounded fragment queue with blocking/nonblocking backpressure and audio-clock drain. Exclusive ownership is per OFD, not PID. See the matrix below. Capture, duplex, mmap, mixer controls, and multi-client mixing are unsupported. | | `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | diff --git a/packages/registry/evdev-demo/build-evdev-demo.sh b/packages/registry/evdev-demo/build-evdev-demo.sh new file mode 100755 index 0000000000..31e017e973 --- /dev/null +++ b/packages/registry/evdev-demo/build-evdev-demo.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +kandelo_package_prepare_build_roots "$HERE" wasm32 +kandelo_package_select_source_root "$REPO_ROOT" +SOURCE_ROOT="$KANDELO_PACKAGE_SOURCE_ROOT" +EVDEV_DEMO_SOURCE="$SOURCE_ROOT/programs/evdev_demo.c" +WORK_DIR="$KANDELO_PACKAGE_WORK_DIR" +OUT_BIN="$WORK_DIR/evdev_demo.wasm" + +if [ ! -f "$EVDEV_DEMO_SOURCE" ] || [ -L "$EVDEV_DEMO_SOURCE" ]; then + echo "ERROR: evdev_demo source must be a regular file: $EVDEV_DEMO_SOURCE" >&2 + exit 1 +fi + +# A resolver/Formula caller owns the declared work and output roots. Keep the +# reviewed checkout read-only and suppress the developer-only local mirror. +if [ -n "${WASM_POSIX_DEP_WORK_DIR:-}" ] && [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + export WASM_POSIX_INSTALL_LOCAL_MIRROR=0 + export WASM_POSIX_INSTALL_FORK_INSTRUMENTATION=auto +fi + +source "$REPO_ROOT/sdk/activate.sh" +export WASM_POSIX_SYSROOT="$REPO_ROOT/sysroot" + +if [ ! -f "$WASM_POSIX_SYSROOT/include/linux/input.h" ]; then + echo "ERROR: the vendored evdev headers are missing from the sysroot." >&2 + echo "Run: scripts/dev-shell.sh bash scripts/build-musl.sh" >&2 + exit 1 +fi + +echo "==> Building evdev_demo..." +wasm32posix-cc \ + -std=c11 \ + -O2 \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -D_DEFAULT_SOURCE \ + "$EVDEV_DEMO_SOURCE" \ + -o "$OUT_BIN" + +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/install-local-binary.sh" +install_local_binary evdev-demo "$OUT_BIN" evdev_demo.wasm diff --git a/packages/registry/evdev-demo/build.toml b/packages/registry/evdev-demo/build.toml new file mode 100644 index 0000000000..b10cef4be5 --- /dev/null +++ b/packages/registry/evdev-demo/build.toml @@ -0,0 +1,15 @@ +script_path = "packages/registry/evdev-demo/build-evdev-demo.sh" +inputs = [ + "packages/registry/evdev-demo/build-evdev-demo.sh", + "programs/evdev_demo.c", + "libc/musl-overlay/include/linux/input.h", + "libc/musl-overlay/include/linux/input-event-codes.h", + "scripts/build-musl.sh", + "scripts/package-build-roots.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/evdev-demo/package.toml b/packages/registry/evdev-demo/package.toml new file mode 100644 index 0000000000..b0b72d3605 --- /dev/null +++ b/packages/registry/evdev-demo/package.toml @@ -0,0 +1,26 @@ +kind = "program" +# Runtime backing for `/?demo=evdev`. The browser app imports the output +# through `@binaries`, so it needs a registry owner like every other +# product artifact — see scripts/browser-binary-package-roots.mjs. The +# source stays in programs/ so scripts/build-programs.sh keeps building +# the local test fixture, the same split modeset uses. +name = "evdev-demo" +version = "0.1.0" +kernel_abi = 43 +depends_on = [] +arches = ["wasm32"] + +[source] +url = "https://github.com/Automattic/kandelo" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" + +[build] +script_path = "packages/registry/evdev-demo/build-evdev-demo.sh" + +[[outputs]] +name = "evdev_demo" +wasm = "evdev_demo.wasm" From f60729bee0e492b6de3a265f10b468ed9785f133 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 14:01:18 +0200 Subject: [PATCH 24/27] feat(audio): publish the espeak-ng voice data as a runtime file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 2 + images/vfs/scripts/build-shell-vfs-image.sh | 5 - images/vfs/scripts/build-shell-vfs-image.ts | 35 +-- .../registry/espeak-ng/build-espeak-ng.sh | 210 +++++++++++++----- packages/registry/espeak-ng/build.toml | 4 +- packages/registry/espeak-ng/package.toml | 73 ++++-- .../package-system/espeak-ng-package.test.ts | 109 +++++++++ .../package-system/shell-vfs-install.test.ts | 18 -- 8 files changed, 325 insertions(+), 131 deletions(-) create mode 100644 tests/package-system/espeak-ng-package.test.ts delete mode 100644 tests/package-system/shell-vfs-install.test.ts diff --git a/README.md b/README.md index 8f64a4cf58..7269039f41 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Real, unmodified software compiled to WebAssembly: | Vim | 9.1 | Full editor with ncurses terminal UI | | NetHack | 3.6.7 | Classic roguelike with curses UI | | fbDOOM | (maximevince) | id Software's DOOM via the kernel's `/dev/fb0` Linux fbdev surface | +| espeak-ng | 1.52 | Speech synthesis; plays through upstream pcaudiolib's OSS backend on `/dev/dsp` | | Perl | 5.40 | Interpreter with core modules | | Ruby | 3.3 | Interpreter with core stdlib | | SpiderMonkey | 140 ESR | JavaScript engine backing the Node.js-compatible runtime with Intl, SharedArrayBuffer, worker_threads, and npm package installs. | @@ -321,6 +322,7 @@ bash packages/registry/nano/build-nano.sh # GNU nano 8.3 bash packages/registry/curl/build-curl.sh # curl bash packages/registry/netcat/build-netcat.sh # GNU Netcat 0.7.1 bash packages/registry/make/build-make.sh # GNU make +bash packages/registry/espeak-ng/build-espeak-ng.sh # espeak-ng 1.52 ``` See [docs/porting-guide.md](docs/porting-guide.md) for how to port your own software. diff --git a/images/vfs/scripts/build-shell-vfs-image.sh b/images/vfs/scripts/build-shell-vfs-image.sh index 52d0c4677c..dc42a1942d 100755 --- a/images/vfs/scripts/build-shell-vfs-image.sh +++ b/images/vfs/scripts/build-shell-vfs-image.sh @@ -7,8 +7,3 @@ echo "==> Building Shell VFS image..." npx tsx "$SCRIPT_DIR/build-shell-vfs-image.ts" echo "==> Done." ls -lh apps/browser-demos/public/shell.vfs.zst - -# Mirror into local-binaries/ so the @binaries/ Vite alias resolves for -# pages/kandelo/kernel-host/live-setup.ts. See sibling build-nginx-vfs-image.sh for rationale. -source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary shell "$REPO_ROOT/apps/browser-demos/public/shell.vfs.zst" diff --git a/images/vfs/scripts/build-shell-vfs-image.ts b/images/vfs/scripts/build-shell-vfs-image.ts index 4a880b6b8a..dbf4073335 100644 --- a/images/vfs/scripts/build-shell-vfs-image.ts +++ b/images/vfs/scripts/build-shell-vfs-image.ts @@ -8,17 +8,11 @@ * * Usage: npx tsx images/vfs/scripts/build-shell-vfs-image.ts */ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +import { readFileSync } from "node:fs"; import { resolveBinary } from "../../../host/src/binary-resolver"; import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; import { - ensureDirRecursive, saveImage, - walkAndWrite, writeVfsBinary, } from "./vfs-image-helpers"; import { populateShellEnvironment, resolveVfsArtifact } from "./shell-vfs-build"; @@ -49,8 +43,6 @@ async function main() { populateDoomRuntime(fs); console.log("Populating modeset runtime..."); populateModesetRuntime(fs); - console.log("Populating espeak-ng runtime..."); - populateEspeakRuntime(fs); writeMainShellDemoConfig(fs); await saveImage(fs, OUT_FILE); @@ -70,28 +62,3 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { const modesetBytes = readFileSync(resolveVfsArtifact("programs/modeset.wasm", "modeset")); writeVfsBinary(fs, "/usr/local/bin/modeset", new Uint8Array(modesetBytes), 0o755); } - -function populateEspeakRuntime(fs: MemoryFileSystem): void { - const espeakBytes = readFileSync(resolveVfsArtifact("programs/espeak-ng.wasm", "espeak-ng")); - writeVfsBinary(fs, "/usr/bin/espeak-ng", new Uint8Array(espeakBytes), 0o755); - - // espeak-ng's PATH_ESPEAK_DATA macro is baked at build time to - // /usr/share/espeak-ng-data (set via CMAKE_INSTALL_PREFIX=/usr in - // build-espeak-ng.sh). The runtime walks lang//, - // voices/!v/*, phondata, phonindex, phontab, intonations, and per- - // language *_dict files — copying the whole tree is the simplest - // shape and the trimmed English-only data dir is only ~1.9 MB. - const dataDir = path.join( - SCRIPT_DIR, - "../../../packages/registry/espeak-ng/espeak-ng-install/share/espeak-ng-data", - ); - if (!existsSync(dataDir)) { - throw new Error( - `populateEspeakRuntime: espeak-ng-data not found at ${dataDir}. ` + - `Run \`bash packages/registry/espeak-ng/build-espeak-ng.sh\` first.`, - ); - } - ensureDirRecursive(fs, "/usr/share/espeak-ng-data"); - const fileCount = walkAndWrite(fs, dataDir, "/usr/share/espeak-ng-data"); - console.log(` staged ${fileCount} espeak-ng-data files at /usr/share/espeak-ng-data`); -} diff --git a/packages/registry/espeak-ng/build-espeak-ng.sh b/packages/registry/espeak-ng/build-espeak-ng.sh index 2f7f2f6ab9..19ea104ff3 100755 --- a/packages/registry/espeak-ng/build-espeak-ng.sh +++ b/packages/registry/espeak-ng/build-espeak-ng.sh @@ -4,15 +4,15 @@ # # Two-pass build: # -# 1. Native build of espeak-ng on the host. We only need its -# binary (espeak-ng) to compile phoneme + intonation data -# out of phsource/ + dictsource/ via the --compile-* commands. -# No data files are written until the cross-build's `data` -# target runs. -# 2. Cross build of espeak-ng for wasm32. Uses our patched -# pcaudiolib (kandelo backend baked into create_audio_device_object) -# so the resulting espeak-ng.wasm opens /dev/snd/pcmC0D0p directly -# and produces audible speech inside the kandelo browser preset. +# 1. Native build of espeak-ng on the host. Its binary compiles the +# phoneme + intonation data out of phsource/ + dictsource/ via +# the --compile-* commands, and that pass writes the data dir +# this package ships. +# 2. Cross build of espeak-ng for wasm32, linked against upstream +# pcaudiolib built with only its OSS backend. That backend opens +# /dev/dsp, Kandelo's low-level audio API, so the resulting +# espeak-ng.wasm produces audible speech inside the kandelo +# browser preset. Neither source tree is patched. # # Honors the dep-resolver build-script contract — see # packages/registry/libxml2/build-libxml2.sh for the pattern. @@ -23,6 +23,8 @@ # bin/espeak-ng.wasm (executable wasm binary) # share/espeak-ng-data/ (phoneme + voice data dir, # compiled by the native bin) +# share/espeak-ng-data.zip (that dir packed as the +# declared runtime file) # # Default install dir for legacy / ad-hoc invocation is # ./espeak-ng-install/ next to this script. @@ -37,6 +39,17 @@ SRC_DIR="$HERE/espeak-ng-src" # --- Resolver-contract env / legacy fallbacks --- INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$HERE/espeak-ng-install}" +# --- Upstream source pins --- +# espeak-ng publishes no source archive as a release asset, so its pin is +# the tag archive. pcaudiolib publishes one. +ESPEAK_VERSION="${WASM_POSIX_DEP_VERSION:-1.52.0}" +ESPEAK_SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/espeak-ng/espeak-ng/archive/refs/tags/${ESPEAK_VERSION}.tar.gz}" +ESPEAK_SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23}" + +PCAUDIO_VERSION="1.3" +PCAUDIO_SOURCE_URL="https://github.com/espeak-ng/pcaudiolib/releases/download/${PCAUDIO_VERSION}/pcaudiolib-${PCAUDIO_VERSION}.tar.gz" +PCAUDIO_SOURCE_SHA256="e8bd15f460ea171ccd0769ea432e188532a7fb27fa73ec2d526088a082abaaad" + # Languages to compile. The full upstream list is ~80 languages and # bloats the VFS image by ~25 MB. Default to English-only for the demo; # override at build time with e.g. ESPEAK_LANG_LIST="en de fr". @@ -56,6 +69,34 @@ if [ ! -f "$SYSROOT/lib/libc.a" ]; then echo "ERROR: kandelo sysroot not built at $SYSROOT. Run bash scripts/build-musl.sh first." >&2 exit 1 fi +for tool in cmake curl tar shasum python3; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +# --- Fetch upstream sources -------------------------------------------- +# Both trees are gitignored build inputs, not vendored files. Download +# and verify each once, then reuse it across resolves. +fetch_source() { + local url="$1" sha256="$2" dest="$3" name="$4" + [ -d "$dest" ] && return 0 + echo "==> Downloading $name..." + local tarball="$dest.tar.gz" + local staging="$dest.incoming" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$url" -o "$tarball" + echo "$sha256 $tarball" | shasum -a 256 -c - + rm -rf "$staging" + mkdir -p "$staging" + tar xzf "$tarball" -C "$staging" --strip-components=1 + rm -f "$tarball" + mv "$staging" "$dest" +} + +fetch_source "$ESPEAK_SOURCE_URL" "$ESPEAK_SOURCE_SHA256" "$SRC_DIR" "espeak-ng $ESPEAK_VERSION" +fetch_source "$PCAUDIO_SOURCE_URL" "$PCAUDIO_SOURCE_SHA256" "$PCAUDIO_SRC_DIR" "pcaudiolib $PCAUDIO_VERSION" # --- Locate host LLVM (for glue obj compile + native build) --- LLVM_PREFIX="${LLVM_PREFIX:-$(brew --prefix llvm 2>/dev/null || echo /opt/homebrew/opt/llvm)}" @@ -78,47 +119,35 @@ if [ ! -f "$GLUE_OBJ_DIR/channel_syscall.o" ] || \ "$LLVM_CLANG" $WASM_COMPILE_FLAGS -O2 -c "$GLUE_SRC_DIR/compiler_rt.c" -o "$GLUE_OBJ_DIR/compiler_rt.o" fi -# --- Phase 1: libpcaudio.a (kandelo backend) --------------------------- -# We don't run pcaudiolib's autotools / libtool — for two files we just +# --- Phase 1: libpcaudio.a (OSS backend only) -------------------------- +# We don't run pcaudiolib's autotools / libtool — for five files we just # compile and archive directly. See packages/registry/libxml2/ # build-libxml2.sh for the same "skip libtool" rationale. +# +# pcaudiolib picks its backend from config.h, the header its autotools +# run generates. Defining only HAVE_SYS_SOUNDCARD_H leaves src/oss.c as +# the one live backend, and it opens /dev/dsp. The alsa, pulseaudio and +# qsa units compile to `return NULL` stubs; they are still built because +# create_audio_device_object in audio.c references their symbols and +# falls through them to the OSS object. No source file is patched. PCAUDIO_BUILD_DIR="$HERE/pcaudiolib-build" -mkdir -p "$PCAUDIO_BUILD_DIR" +PCAUDIO_CONFIG_DIR="$PCAUDIO_BUILD_DIR/config" +mkdir -p "$PCAUDIO_CONFIG_DIR" +printf '#define HAVE_SYS_SOUNDCARD_H 1\n' > "$PCAUDIO_CONFIG_DIR/config.h" -echo "==> Building libpcaudio.a (kandelo backend)..." +echo "==> Building libpcaudio.a (OSS backend)..." PCAUDIO_CFLAGS=( -O2 - -DHAVE_KANDELO + -I"$PCAUDIO_CONFIG_DIR" -I"$PCAUDIO_SRC_DIR/src" -I"$PCAUDIO_SRC_DIR/src/include" ) -wasm32posix-cc "${PCAUDIO_CFLAGS[@]}" -c "$PCAUDIO_SRC_DIR/src/audio.c" -o "$PCAUDIO_BUILD_DIR/audio.o" -wasm32posix-cc "${PCAUDIO_CFLAGS[@]}" -c "$PCAUDIO_SRC_DIR/src/audio_kandelo.c" -o "$PCAUDIO_BUILD_DIR/audio_kandelo.o" -wasm32posix-ar rcs "$PCAUDIO_BUILD_DIR/libpcaudio.a" \ - "$PCAUDIO_BUILD_DIR/audio.o" "$PCAUDIO_BUILD_DIR/audio_kandelo.o" - -# --- Phase 2: native build of espeak-ng (for data-dir generation) ------ -# The cross-build's `data` target runs the native espeak-ng under -# CMAKE_CROSSCOMPILING with --compile-intonations / --compile-phonemes / -# --compile= to write the phondata / phonindex / phontab / -# intonations / _dict binary files. We just need the binary; we -# don't ship anything from this build. -NATIVE_BUILD_DIR="$HERE/espeak-ng-host-build" -if [ ! -x "$NATIVE_BUILD_DIR/src/espeak-ng" ]; then - echo "==> Native build of espeak-ng (for data tools)..." - mkdir -p "$NATIVE_BUILD_DIR" - cmake -S "$SRC_DIR" -B "$NATIVE_BUILD_DIR" \ - -DCMAKE_INSTALL_PREFIX=/usr \ - -DBUILD_SHARED_LIBS=OFF \ - -DUSE_MBROLA=OFF \ - -DUSE_LIBSONIC=OFF \ - -DUSE_LIBPCAUDIO=OFF \ - -DCOMPILE_INTONATIONS=OFF \ - -DESPEAK_COMPAT=OFF \ - -DENABLE_TESTS=OFF \ - > /dev/null - cmake --build "$NATIVE_BUILD_DIR" --target espeak-ng-bin -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -fi +PCAUDIO_OBJS=() +for unit in audio oss alsa pulseaudio qsa; do + wasm32posix-cc "${PCAUDIO_CFLAGS[@]}" -c "$PCAUDIO_SRC_DIR/src/$unit.c" -o "$PCAUDIO_BUILD_DIR/$unit.o" + PCAUDIO_OBJS+=("$PCAUDIO_BUILD_DIR/$unit.o") +done +wasm32posix-ar rcs "$PCAUDIO_BUILD_DIR/libpcaudio.a" "${PCAUDIO_OBJS[@]}" # Short-circuit the FetchContent of sonic in upstream cmake/deps.cmake. # The upstream file unconditionally clones github.com/waywardgeek/sonic @@ -171,6 +200,64 @@ text = re.sub( open(dst_path, "w").write(text) PYEOF +# --- Phase 2: native build of espeak-ng (for data-dir generation) ------ +# The `data` target runs espeak-ng with --compile-intonations / +# --compile-phonemes / --compile= to write the phondata / +# phonindex / phontab / intonations / _dict files. cmake/data.cmake +# always invokes `$`, the binary of the tree +# it runs in, so the cross tree would try to execute a wasm module. +# Build the data here instead, after the two cmake rewrites above so this +# build honours ESPEAK_LANG_LIST too. The outputs are byte tables, not +# code, and both this host and wasm32 are little-endian, so the cross +# build consumes them unchanged. +NATIVE_BUILD_DIR="$HERE/espeak-ng-host-build" +if [ ! -d "$NATIVE_BUILD_DIR/espeak-ng-data" ]; then + echo "==> Native build of espeak-ng (for data tools)..." + mkdir -p "$NATIVE_BUILD_DIR" + # Use the wrapped cc/c++ drivers on PATH, not the bare LLVM binaries + # CMake finds first. Only the wrappers carry the host C++ standard + # library include paths, and speechPlayer is C++. + cmake -S "$SRC_DIR" -B "$NATIVE_BUILD_DIR" \ + -DCMAKE_C_COMPILER=cc \ + -DCMAKE_CXX_COMPILER=c++ \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DBUILD_SHARED_LIBS=OFF \ + -DUSE_MBROLA=OFF \ + -DUSE_LIBSONIC=OFF \ + -DUSE_LIBPCAUDIO=OFF \ + -DCOMPILE_INTONATIONS=ON \ + -DESPEAK_COMPAT=OFF \ + -DENABLE_TESTS=OFF \ + > /dev/null + cmake --build "$NATIVE_BUILD_DIR" --target espeak-ng-bin -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + cmake --build "$NATIVE_BUILD_DIR" --target data -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" +fi + +# --- Resolve libcxx, then index it into the sysroot -------------------- +# espeak-ng's speechPlayer synthesizer is C++, and upstream builds it +# unconditionally — src/CMakeLists.txt adds the subdirectory without +# testing USE_SPEECHPLAYER. Index the resolved header tree and archives +# into the sysroot the same way build-mariadb.sh does. +LIBCXX_PREFIX="${WASM_POSIX_DEP_LIBCXX_DIR:-}" +if [ -z "$LIBCXX_PREFIX" ]; then + echo "==> Resolving libcxx via cargo xtask build-deps..." + HOST_TARGET="$(rustc -vV | awk '/^host/ {print $2}')" + LIBCXX_PREFIX="$(cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TARGET" --quiet -- build-deps --arch=wasm32 resolve libcxx)" +fi +for artifact in lib/libc++.a lib/libc++abi.a include/c++/v1; do + [ -e "$LIBCXX_PREFIX/$artifact" ] || { + echo "ERROR: libcxx resolve missing $artifact at $LIBCXX_PREFIX" >&2 + exit 1 + } +done + +mkdir -p "$SYSROOT/lib" "$SYSROOT/include/c++" +ln -sf "$LIBCXX_PREFIX/lib/libc++.a" "$SYSROOT/lib/libc++.a" +ln -sf "$LIBCXX_PREFIX/lib/libc++abi.a" "$SYSROOT/lib/libc++abi.a" +rm -rf "$SYSROOT/include/c++/v1" +ln -sfn "$LIBCXX_PREFIX/include/c++/v1" "$SYSROOT/include/c++/v1" +echo "==> libcxx resolved at $LIBCXX_PREFIX (symlinked into $SYSROOT)" + # --- Phase 3: cross build of espeak-ng --------------------------------- CROSS_BUILD_DIR="$HERE/espeak-ng-cross-build" mkdir -p "$CROSS_BUILD_DIR" @@ -190,15 +277,12 @@ cmake -S "$SRC_DIR" -B "$CROSS_BUILD_DIR" \ -DENABLE_TESTS=OFF \ -DCOMPILE_INTONATIONS=ON \ -DESPEAK_COMPAT=OFF \ - -DNativeBuild_DIR="$NATIVE_BUILD_DIR/src" \ - -DNativeBuild="$NATIVE_BUILD_DIR/src" \ -DPCAUDIO_LIB="$PCAUDIO_BUILD_DIR/libpcaudio.a" \ -DPCAUDIO_INC="$PCAUDIO_SRC_DIR/src/include" \ -DHAVE_LIBPCAUDIO=ON \ -DHAVE_PTHREAD=OFF cmake --build "$CROSS_BUILD_DIR" --target espeak-ng-bin -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -cmake --build "$CROSS_BUILD_DIR" --target data -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" # --- Phase 4: stage outputs -------------------------------------------- echo "==> Staging into $INSTALL_DIR..." @@ -208,22 +292,44 @@ mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/share" # to .wasm for the package resolver's binary contract. cp "$CROSS_BUILD_DIR/src/espeak-ng" "$INSTALL_DIR/bin/espeak-ng.wasm" -# Data dir: the cross build wrote it under CROSS_BUILD_DIR/espeak-ng-data/. +# Data dir: the native build wrote it under NATIVE_BUILD_DIR/espeak-ng-data/. rm -rf "$INSTALL_DIR/share/espeak-ng-data" -cp -R "$CROSS_BUILD_DIR/espeak-ng-data" "$INSTALL_DIR/share/espeak-ng-data" +cp -R "$NATIVE_BUILD_DIR/espeak-ng-data" "$INSTALL_DIR/share/espeak-ng-data" # Restore data.cmake + deps.cmake so the source tree stays clean for next build. mv "$DATA_CMAKE_BACKUP" "$DATA_CMAKE" mv "$DEPS_CMAKE_BACKUP" "$DEPS_CMAKE" -# Register the wasm binary in local-binaries so the resolver picks it -# up alongside released archives. The data dir is consumed by the -# shell-VFS builder directly out of $INSTALL_DIR/share/ — no -# install_local_binary path since the data dir is not a wasm output -# (single [[outputs]] entry only). +# Pack the data dir into the declared runtime file. Stored-only, sorted, with +# a fixed timestamp and mode, so the archive bytes follow the voice data alone +# and the package cache key stays stable across rebuilds. Same shape as +# cpython's python-runtime.zip. +DATA_ZIP="$INSTALL_DIR/share/espeak-ng-data.zip" +rm -f "$DATA_ZIP" +python3 - "$INSTALL_DIR/share/espeak-ng-data" "$DATA_ZIP" <<'PY' +from pathlib import Path +import stat +import sys +import zipfile + +root = Path(sys.argv[1]) +output = Path(sys.argv[2]) +timestamp = (1980, 1, 1, 0, 0, 0) +with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED, strict_timestamps=True) as archive: + for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.as_posix()): + info = zipfile.ZipInfo(path.relative_to(root).as_posix(), date_time=timestamp) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + info.compress_type = zipfile.ZIP_STORED + archive.writestr(info, path.read_bytes()) +PY + +# Both filenames exactly match the package.toml [[outputs]] and +# [[runtime_files]] entries; the installer re-checks artifact policy. source "$REPO_ROOT/scripts/install-local-binary.sh" install_local_binary espeak-ng "$INSTALL_DIR/bin/espeak-ng.wasm" +install_local_runtime_file espeak-ng "$DATA_ZIP" echo "==> Done. Outputs:" echo " $INSTALL_DIR/bin/espeak-ng.wasm" -echo " $INSTALL_DIR/share/espeak-ng-data/" +echo " $DATA_ZIP" diff --git a/packages/registry/espeak-ng/build.toml b/packages/registry/espeak-ng/build.toml index 82388dd00c..fef7b34bd1 100644 --- a/packages/registry/espeak-ng/build.toml +++ b/packages/registry/espeak-ng/build.toml @@ -2,12 +2,10 @@ script_path = "packages/registry/espeak-ng/build-espeak-ng.sh" inputs = [ "packages/registry/espeak-ng/build-espeak-ng.sh", "packages/registry/espeak-ng/wasm32-posix-toolchain.cmake", - "packages/registry/espeak-ng/pcaudiolib-src/src/audio.c", - "packages/registry/espeak-ng/pcaudiolib-src/src/audio_kandelo.c", ] repo_url = "https://github.com/Automattic/kandelo.git" commit = "" -revision = 1 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/espeak-ng/package.toml b/packages/registry/espeak-ng/package.toml index b4ddc45614..5dfb8cd052 100644 --- a/packages/registry/espeak-ng/package.toml +++ b/packages/registry/espeak-ng/package.toml @@ -1,32 +1,67 @@ kind = "program" name = "espeak-ng" -version = "1.52.0.1" -# Demo-impact session 41: vendor pcaudiolib + espeak-ng, route audio -# through a new kandelo backend in pcaudiolib (open /dev/snd/pcmC0D0p -# directly via the WRITEI loop from programs/alsa_demo.c), bundle a -# minimal English-only data dir. Image installs the binary at -# /usr/bin/espeak-ng and the data dir at /usr/share/espeak-ng-data, -# matching CMAKE_INSTALL_PREFIX=/usr so libespeak-ng's PATH_ESPEAK_DATA -# resolves correctly. -kernel_abi = 7 -depends_on = [] - -# Upstream is the espeak-ng master branch + pcaudiolib master. We -# vendor both depth-1 under espeak-ng-src/ + pcaudiolib-src/ and -# rebuild from there (mirrors the mariadb package's vendoring shape). -# Source SHA is a placeholder until the matrix workflow pins a release -# tarball — same convention as fbdoom. +version = "1.52.0" +# Speech synthesis for the browser demo. espeak-ng links upstream +# pcaudiolib built with only its OSS backend, so playback goes through +# /dev/dsp like every other Kandelo sound port. Neither source tree is +# patched. The build bundles a minimal English-only data dir, published as +# the runtime file below. Consumers install the binary at /usr/bin/espeak-ng +# and unpack the data at /usr/share/espeak-ng-data, matching +# CMAKE_INSTALL_PREFIX=/usr so libespeak-ng's PATH_ESPEAK_DATA resolves. +kernel_abi = 43 +depends_on = ["libcxx@21.1.7"] + +# espeak-ng publishes no source archive as a release asset, so the pin +# is its tag archive. The build script pins pcaudiolib 1.3 separately +# against its published release tarball. [source] -url = "https://github.com/espeak-ng/espeak-ng" -sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +url = "https://github.com/espeak-ng/espeak-ng/archive/refs/tags/1.52.0.tar.gz" +sha256 = "bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23" [license] spdx = "GPL-3.0-or-later" -url = "https://github.com/espeak-ng/espeak-ng/blob/master/COPYING" +url = "https://github.com/espeak-ng/espeak-ng/blob/1.52.0/COPYING" [build] script_path = "packages/registry/espeak-ng/build-espeak-ng.sh" +[[host_tools]] +name = "cmake" +version_constraint = ">=3.15" +probe = { args = ["--version"], version_regex = "cmake version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "python3" +version_constraint = ">=3.10" +probe = { args = ["--version"], version_regex = "Python (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + [[outputs]] name = "espeak-ng" wasm = "espeak-ng.wasm" + +# The voice data is a separate closure member so image builders and the +# browser demo consume the same immutable bytes through the resolver +# instead of reading the build tree. Stored-only zip, like cpython's. +[[runtime_files]] +artifact = "espeak-ng-data.zip" +guest_path = "/usr/share/espeak-ng/espeak-ng-data.zip" diff --git a/tests/package-system/espeak-ng-package.test.ts b/tests/package-system/espeak-ng-package.test.ts new file mode 100644 index 0000000000..a80d78c0aa --- /dev/null +++ b/tests/package-system/espeak-ng-package.test.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(import.meta.dirname, "../.."); + +function source(path: string): string { + return readFileSync(join(repoRoot, path), "utf8"); +} + +describe("espeak-ng package contract", () => { + it("pins both upstream archives by version and digest", () => { + const manifest = source("packages/registry/espeak-ng/package.toml"); + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(manifest).toContain('version = "1.52.0"'); + expect(manifest).toContain( + 'sha256 = "bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23"', + ); + expect(build).toContain('PCAUDIO_VERSION="1.3"'); + expect(build).toContain( + 'PCAUDIO_SOURCE_SHA256="e8bd15f460ea171ccd0769ea432e188532a7fb27fa73ec2d526088a082abaaad"', + ); + expect(build).toContain("shasum -a 256 -c -"); + }); + + it("selects the OSS backend without patching either source tree", () => { + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect( + existsSync(join(repoRoot, "packages/registry/espeak-ng/patches")), + ).toBe(false); + expect(build).not.toMatch(/^\s*patch\b/m); + expect(build).toContain("#define HAVE_SYS_SOUNDCARD_H 1"); + expect(build).not.toContain("HAVE_ALSA"); + expect(build).not.toContain("HAVE_PULSEAUDIO"); + expect(build).not.toContain("audio_kandelo"); + }); + + it("generates the shipped data dir from the native tree, never the wasm one", () => { + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(build).toContain( + 'cmake --build "$NATIVE_BUILD_DIR" --target data', + ); + expect(build).not.toContain( + 'cmake --build "$CROSS_BUILD_DIR" --target data', + ); + expect(build).toContain('cp -R "$NATIVE_BUILD_DIR/espeak-ng-data"'); + }); + + it("declares every host tool the source build invokes", () => { + const manifest = source("packages/registry/espeak-ng/package.toml"); + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + for (const tool of ["cmake", "curl", "tar", "shasum", "python3"]) { + expect(manifest).toContain(`name = "${tool}"`); + } + expect(build).toContain("for tool in cmake curl tar shasum python3; do"); + expect(manifest).toContain('version_constraint = ">=7.71.0"'); + }); + + it("resolves libcxx, which upstream builds unconditionally for speechPlayer", () => { + const manifest = source("packages/registry/espeak-ng/package.toml"); + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(manifest).toContain('depends_on = ["libcxx@21.1.7"]'); + expect(build).toContain("build-deps --arch=wasm32 resolve libcxx"); + }); + + it("builds through the worktree SDK and publishes one wasm output", () => { + const manifest = source("packages/registry/espeak-ng/package.toml"); + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(build).toContain('source "$REPO_ROOT/sdk/activate.sh"'); + expect(build).toContain("WASM_POSIX_DEP_OUT_DIR"); + expect(manifest).toContain('wasm = "espeak-ng.wasm"'); + }); + + it("publishes the voice data as a runtime file, never from the build tree", () => { + const manifest = source("packages/registry/espeak-ng/package.toml"); + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(manifest).toContain('artifact = "espeak-ng-data.zip"'); + expect(manifest).toContain( + 'guest_path = "/usr/share/espeak-ng/espeak-ng-data.zip"', + ); + expect(build).toContain("install_local_runtime_file espeak-ng"); + + // The projection moves a multi-member package under its own directory, + // so consumers must name the closure paths rather than the flat ones. + const projection = JSON.parse( + source("packages/registry/program-packages.json"), + ) as { + packages: Record }>; + }; + expect( + projection.packages["espeak-ng"]?.members.map((m) => m.mirrorPath), + ).toEqual(["espeak-ng/espeak-ng.wasm", "espeak-ng/espeak-ng-data.zip"]); + }); + + it("builds the archive deterministically so the cache key follows the data", () => { + const build = source("packages/registry/espeak-ng/build-espeak-ng.sh"); + + expect(build).toContain("compression=zipfile.ZIP_STORED"); + expect(build).toContain("timestamp = (1980, 1, 1, 0, 0, 0)"); + expect(build).toContain("key=lambda item: item.as_posix()"); + }); +}); diff --git a/tests/package-system/shell-vfs-install.test.ts b/tests/package-system/shell-vfs-install.test.ts deleted file mode 100644 index 1186099260..0000000000 --- a/tests/package-system/shell-vfs-install.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); - -describe("build-shell-vfs-image.sh", () => { - it("installs shell.vfs.zst into local-binaries so the @binaries import resolves", () => { - const script = readFileSync( - join(repoRoot, "images/vfs/scripts/build-shell-vfs-image.sh"), - "utf8", - ); - expect(script).toMatch( - /install_local_binary\s+shell\s+"\$REPO_ROOT\/apps\/browser-demos\/public\/shell\.vfs\.zst"/, - ); - }); -}); From d60b5faa7ba864ea86a887898a6c1a4661f172ed Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 14:01:49 +0200 Subject: [PATCH 25/27] fix(browser): bake the espeak and evdev binaries into the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pages/kandelo/kernel-host/live-setup.ts | 126 +++++++++++++----- apps/browser-demos/pages/kandelo/presets.ts | 4 +- .../browser-demos/test/kandelo-espeak.spec.ts | 37 +++-- docs/browser-support.md | 2 + 4 files changed, 114 insertions(+), 55 deletions(-) diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 0cd808c800..c24b68755e 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -8,10 +8,6 @@ import { type ImageOwnedRuntimeLazyAssets, } from "../../../lib/init/image-owned-runtime-urls"; import { BrowserInputSource } from "../../../../../host/src/input/browser-input-source"; -import { - ensureServiceWorkerReady, - initServiceWorkerBridge, -} from "../../../lib/init/service-worker-bridge"; import { HttpBridgeHost } from "../../../lib/http-bridge"; import { rewriteShellLazyFileUrls } from "../../../lib/init/shell-lazy-files"; import { resolveShellLazyArchiveUrl } from "../../../lib/init/lazy-archives"; @@ -31,6 +27,10 @@ import { WORDPRESS_MARIADB_SOCKET_PATH, } from "../../../lib/init/wordpress-mariadb-readiness"; import { MemoryFileSystem } from "../../../../../host/src/vfs/memory-fs"; +import { + extractZipEntry, + parseZipCentralDirectory, +} from "../../../../../host/src/vfs/zip"; import { loadHomebrewBottleMirrorClosedAssets } from "../../../../../host/src/homebrew-bottle-mirror-browser"; import { HOMEBREW_BOTTLE_MIRROR_PLAN_VFS_PATH } from "../../../../../host/src/homebrew-bottle-mirror-plan"; import { @@ -208,6 +208,20 @@ const OPTIONAL_BINARY_URLS = { ...import.meta.glob("../../../../../binaries/programs/wasm32/evdev_demo.wasm", { query: "?url", import: "default", }), + // espeak-ng publishes a wasm output plus a runtime file, so the resolver + // mirrors its whole closure under the package directory. + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", { + query: "?url", import: "default", + }), } as Record Promise>; async function optionalBinaryUrl( @@ -218,7 +232,11 @@ async function optionalBinaryUrl( const loader = OPTIONAL_BINARY_URLS[relPath]; if (loader) return loader(); } - throw new Error(`${label} is not built. Run: ./run.sh build programs`); + throw new Error( + `${label} is not built. Run: ./run.sh build programs, ` + + `or for package-owned binaries: ` + + `cargo xtask build-deps resolve `, + ); } type GalleryPackageRequirement = { @@ -570,11 +588,11 @@ interface LiveProfile { evdevDemo: boolean; /** * Spawn `espeak-ng "..."` from the booted shell. espeak-ng links - * against our patched pcaudiolib whose `create_audio_device_object` - * is wired to the kandelo backend, so a single binary invocation - * produces audible synthesised speech through `/dev/dsp` without - * any host-side pipeline. The binary + data dir are baked into - * the shell VFS image via `populateEspeakRuntime`. + * upstream pcaudiolib built with only its OSS backend, so + * `create_audio_device_object` falls through to `/dev/dsp` and a + * single binary invocation produces audible synthesised speech + * without any host-side pipeline. The binary + data dir are baked + * into the image via `stageEspeakRuntime`. */ espeakDemo: boolean; } @@ -1563,6 +1581,18 @@ async function bootProfile( ensureDirRecursive(buildFs, dirname(profile.init.argv[0])); writeVfsBinary(buildFs, profile.init.argv[0], new Uint8Array(bytes), 0o755); } + // Both demos run their binary from a path, so the bytes have to be in the + // image before the worker takes exclusive ownership of the VFS. + if (profile.espeakDemo) { + tick("staging espeak-ng..."); + await stageEspeakRuntime(buildFs); + assertCurrent(); + } + if (profile.evdevDemo) { + tick("staging evdev_demo..."); + await stageEvdevDemo(buildFs); + assertCurrent(); + } ensureDemoHomes(buildFs); } // Bake the shell + gallery-software binaries into the image before the @@ -1894,10 +1924,9 @@ async function bootProfile( assertCurrent, ); } else if (profile.espeakDemo) { - // espeak-ng + its data dir are baked into the shell VFS image - // (see populateEspeakRuntime in build-shell-vfs-image.ts), so - // no runtime binary staging is needed. Playback rides the same - // /dev/dsp path every other sound demo uses. + // The binary and its voice data are already in the image; see + // stageEspeakRuntime. Playback rides the /dev/dsp path every other + // sound demo uses. void (async () => { try { tick("running espeak-ng..."); @@ -1911,27 +1940,12 @@ async function bootProfile( } })(); } else if (profile.evdevDemo) { - // autoCommand can't run this: the InputSource must be attached - // before the binary starts polling /dev/input/event{0,1}, and - // the binary itself has to be staged into the VFS first. + // autoCommand can't run this: the InputSource must be attached before + // the binary starts polling /dev/input/event{0,1}. The binary itself is + // already in the image; see stageEvdevDemo. const kernelForEvdev = kernel; void (async () => { try { - const evdevDemoWasmUrl = await optionalBinaryUrl([ - "../../../../../local-binaries/programs/wasm32/evdev_demo.wasm", - "../../../../../binaries/programs/wasm32/evdev_demo.wasm", - ], "evdev_demo.wasm"); - tick("staging evdev_demo binary..."); - const bytes = await fetch(evdevDemoWasmUrl) - .then(failOn("evdev_demo.wasm")) - .then((r) => r.arrayBuffer()); - ensureDirRecursive(kernelForEvdev.fs, "/usr/local/bin"); - writeVfsBinary( - kernelForEvdev.fs, - "/usr/local/bin/evdev_demo", - new Uint8Array(bytes), - 0o755, - ); tick("attaching input source..."); kernelForEvdev.attachInputSource(new BrowserInputSource(window), { width: window.innerWidth, @@ -2026,6 +2040,54 @@ function stageShellUtilities( } } +/** + * Bake espeak-ng and its voice data into the image. + * + * Both come from the espeak-ng package closure, so the demo consumes the same + * bytes the resolver published. libespeak-ng's PATH_ESPEAK_DATA is fixed to + * /usr/share at build time, so the data tree has to land unpacked there. + */ +async function stageEspeakRuntime(fs: MemoryFileSystem): Promise { + const binaryUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", + "../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng.wasm", + ], "espeak-ng.wasm"); + const binary = await fetch(binaryUrl) + .then(failOn("espeak-ng.wasm")) + .then((r) => r.arrayBuffer()); + ensureDirRecursive(fs, "/usr/bin"); + writeVfsBinary(fs, "/usr/bin/espeak-ng", new Uint8Array(binary), 0o755); + + const dataUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", + "../../../../../binaries/programs/wasm32/espeak-ng/espeak-ng-data.zip", + ], "espeak-ng-data.zip"); + const data = await fetch(dataUrl) + .then(failOn("espeak-ng-data.zip")) + .then((r) => r.arrayBuffer()); + const zipBytes = new Uint8Array(data); + const root = "/usr/share/espeak-ng-data"; + ensureDirRecursive(fs, root); + for (const entry of parseZipCentralDirectory(zipBytes)) { + if (entry.isDirectory) continue; + const target = `${root}/${entry.fileName}`; + ensureDirRecursive(fs, target.slice(0, target.lastIndexOf("/"))); + writeVfsBinary(fs, target, extractZipEntry(zipBytes, entry), 0o644); + } +} + +async function stageEvdevDemo(fs: MemoryFileSystem): Promise { + const url = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/evdev_demo.wasm", + "../../../../../binaries/programs/wasm32/evdev_demo.wasm", + ], "evdev_demo.wasm"); + const bytes = await fetch(url) + .then(failOn("evdev_demo.wasm")) + .then((r) => r.arrayBuffer()); + ensureDirRecursive(fs, "/usr/local/bin"); + writeVfsBinary(fs, "/usr/local/bin/evdev_demo", new Uint8Array(bytes), 0o755); +} + function ensureDemoHomes(fs: MemoryFileSystem): void { ensureDirRecursive(fs, "/home"); ensureOwnedDir(fs, DEMO_HOME, 0o755, DEMO_UID, DEMO_GID); diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index b505df953c..9334246892 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -141,8 +141,8 @@ export const PRESET_LIBRARY: Preset[] = [ }, { id: "espeak", - title: "ALSA - Espeak-NG", - summary: "The kernel speaks: espeak-ng synthesises text directly through libpcaudio's kandelo backend.", + title: "OSS - Espeak-NG", + summary: "The kernel speaks: espeak-ng synthesises text directly through libpcaudio's OSS backend.", base: SHELL_BASE, packages: ["bash@local", "coreutils@local"], accent: "#f48fb1", diff --git a/apps/browser-demos/test/kandelo-espeak.spec.ts b/apps/browser-demos/test/kandelo-espeak.spec.ts index bb63badac3..322928f659 100644 --- a/apps/browser-demos/test/kandelo-espeak.spec.ts +++ b/apps/browser-demos/test/kandelo-espeak.spec.ts @@ -17,36 +17,31 @@ async function terminalText(page: Page): Promise { return page.locator(".xterm-rows").first().evaluate((node) => node.textContent ?? ""); } -async function framesConsumed(page: Page): Promise { - return page.evaluate(() => { - const w = window as unknown as { __alsaFramesConsumed?: number }; - return w.__alsaFramesConsumed ?? 0; - }); -} - -test("Kandelo espeak-ng demo speaks through pcaudiolib + /dev/snd/pcmC0D0p", async ({ page }) => { +test("Kandelo espeak-ng demo speaks through pcaudiolib + /dev/dsp", async ({ page }) => { test.setTimeout(300_000); await gotoOrSkip(page, "/?demo=espeak"); - // The boot-path branch in live-setup.ts attaches the BrowserAudioDriver - // and then runs `espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`. + // Web Audio starts only after a trusted gesture. App.tsx activates the + // PCM sink from a capturing pointerdown listener, so a physical click + // anywhere moves the machine to its running audio state. + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await expect(page.locator("[data-audio-state]")).toHaveAttribute( + "data-audio-state", + "running", + { timeout: 60_000 }, + ); + + // The boot-path branch in live-setup.ts runs + // `espeak-ng "Welcome to Kandelo, the WebAssembly POSIX kernel"`. // espeak-ng prints a few status lines on stderr; the more reliable // signal that the synth path worked end-to-end is the bash prompt // reappearing after the binary exits. We watch for the trailing // shell prompt instead of a specific espeak output line so the test - // doesn't break on cosmetic CLI changes upstream. + // doesn't break on cosmetic CLI changes upstream. pcaudiolib aborts + // the run when it cannot open /dev/dsp, so reaching the prompt with + // a running sink proves the OSS backend negotiated the device. await expect .poll(() => terminalText(page), { timeout: 180_000 }) .toMatch(/[#$]\s*$/); - - // Frames-consumed counter: the instrumented audio driver bumps - // `window.__alsaFramesConsumed` from the per-period tick - // callback. The phrase is ~3 s of audio at 22050 Hz mono = - // ~66150 frames. Demand at least 22050 (~1 s) so the test passes - // even with aggressive worklet startup delay or early-exit - // synthesis variants. A non-zero count proves the worklet → main - // → kernel pipeline (browser-host parity). - const consumed = await framesConsumed(page); - expect(consumed).toBeGreaterThanOrEqual(22_050); }); diff --git a/docs/browser-support.md b/docs/browser-support.md index 46d736b7bf..7e41d29f48 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -318,6 +318,8 @@ Located in `apps/browser-demos/pages/`: | benchmark | (per-suite) | legacy spawn | Micro-benchmarks + WordPress + Erlang ring | | network | dash + GNU Netcat + curl | `kernel.boot` x 3 | Boots multiple local Kandelo machines and verifies UDP datagrams, TCP streams, and HTTP over virtual TCP | | doom | fbDOOM | legacy spawn | `/dev/fb0` framebuffer + canvas renderer + keyboard via stdin + mouse via `/dev/input/mice` (pointer-locked) + SFX **and** OPL2-synthesized music via `/dev/dsp` → AudioContext. The shareware `doom1.wad` is **fetched at page load** from a commit-pinned CDN URL (SHA-256 verified, Cache API cached); no IWAD ships in the package archive. | +| evdev | evdev_demo | dinit | Reads `/dev/input/event{0,1}` and prints each record. A `BrowserInputSource` translates DOM key and pointer events into `EV_KEY`/`EV_REL`/`EV_ABS` and pushes them through `kernel_input_event`. The binary comes from the `evdev-demo` package and is baked into the image before boot; the input source is attached first, because the binary polls as soon as it runs. | +| espeak | espeak-ng | dinit | Speech synthesis through upstream pcaudiolib's OSS backend, so playback rides the same `/dev/dsp` path as the doom demo. The binary and the voice data both come from the `espeak-ng` package closure — the data as the `espeak-ng-data.zip` runtime file, unpacked into `/usr/share/espeak-ng-data` while the image is composed, because libespeak-ng's `PATH_ESPEAK_DATA` is fixed at build time. | The "Boot pattern" column reflects how the demo enters the kernel: - **`kernel.boot`** — `kernelOwnedFs: true`, exec the language interpreter as the first user process. From f05894c8a20913707fad46944d4d87c225e92215 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 19 Aug 2026 14:02:19 +0200 Subject: [PATCH 26/27] chore(packages): regenerate the program package projection 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) --- packages/registry/program-packages.json | 1235 ++++++++++++----------- 1 file changed, 652 insertions(+), 583 deletions(-) diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 8f683eecc6..b775ac2155 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,358 @@ "bash": { "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", "cacheKeys": { - "wasm32": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54", - "wasm64": "3513602ddc7d88d11e8e04bc9e7dcd569862b96270b549e76d179b19180a2586" + "wasm32": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269", + "wasm64": "892c9c836c1718d2aa49ddff0071eedd843526cdf4f4276f8a82f275e80785b6" } }, "bc": { "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", "cacheKeys": { - "wasm32": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d", - "wasm64": "e1cdba537940df33cfd9fecccc0a0eeeb85032e4708d61e993b15d3b8efb755b" + "wasm32": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6", + "wasm64": "ed5ee5fb9d55fff4586c1cbaf68842feb48f2d00a038bb66f78267dfee29e2b1" } }, "bzip2": { "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", "cacheKeys": { - "wasm32": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778", - "wasm64": "9678856dff9a5794e2c696370100089065b6d6e8f0447ca00100a56ac6729618" + "wasm32": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86", + "wasm64": "1f6e7f57356f20484fb05f4488664e0a3cbdb10ecdc7e4de58bfd5b7ff5bd282" } }, "coreutils": { "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", "cacheKeys": { - "wasm32": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24", - "wasm64": "ed081404db705f2d4dcf3a1e5319c4a22809dff9b84b9fd0bf4b3ae3f3913e3b" + "wasm32": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96", + "wasm64": "718d3028065caf74d3e9d6f4563133690dcd44eeb12dfc20e2efe790315770c4" } }, "cpython": { "manifestSha256": "3e5bd98bf8221902ca214d73117fa71d7dfeb0c75cf5c1fe219b82eb94bb9ff0", "cacheKeys": { - "wasm32": "671600d839333cd841b35d44f182836e9d9f515b0da5f13ece812b15c99aa797", - "wasm64": "43d0892454e97970a0e6db0be8be9cd987942d8926fcf8633978e7ad1b635a4c" + "wasm32": "24754c020c2a08b5cf2502ea4f3010c53c1c0b4c2094fa5d597221d36afc29b1", + "wasm64": "2439e021e1195a424e61d6d477dd843501a697e914d12dd07ee108e314c2056a" } }, "curl": { "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", "cacheKeys": { - "wasm32": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668", - "wasm64": "95dedeacb60f00877b225f2521e1edb4a4ed7a231ffaf3084e456d1b3a303651" + "wasm32": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7", + "wasm64": "35d33e823da444eaf1cbd02e4408b0b7b9814d747afea707c309834a5d4834ac" } }, "dash": { "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", "cacheKeys": { - "wasm32": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0", - "wasm64": "8a658122b3dbef11e1a4cdbff2b56b9756cde0e4c3942c3398cfa01046da3cc0" + "wasm32": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf", + "wasm64": "bf94c26f54f24404557058c49c772d0f25cab107d0b1a3a8a937fb6fdb71713a" } }, "diffutils": { "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", "cacheKeys": { - "wasm32": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985", - "wasm64": "2275c153bf3aac6cdf80f64ec498b9f54c08711c3022af8b5fbd002c68927242" + "wasm32": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e", + "wasm64": "df1994778c95ddde2eeec8a0b6cf0503a9e5310377158b5ea775cff30155eca1" } }, "dinit": { "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", "cacheKeys": { - "wasm32": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf", - "wasm64": "cf9024c623d71b848048b4b68ace29ed182fe173df89b56ed02177018a557ce7" + "wasm32": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255", + "wasm64": "986479c451806fa661c3a1773f3aea1d1390603e1fe547c661d298fb2d1df1e7" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "54c816a671fdaa12a5ab18580c4e54d423f546c56cb5a237449f090c68cda9d8", - "wasm64": "a70ae3d720d9f827bd21f4fd9f378fb982a1ec09cbb90db1a764ec8e9ff63c52" + "wasm32": "a84f5187db542cc4a7f68d9747a5e9096e610a261335e629e785eb4b798f79b3", + "wasm64": "0fc3b6b7683689375872052d3a10018bf33b57df2019b60bb7a5e265a5b2817d" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "0d2025175cc7675a3d69bef53ffb084b8b2bb719fca8f437147ca36341c28014", - "wasm64": "f96824ee4e1f6acc3dceabf45b6514c76050e283488246fbfc36a8db92cdf496" + "wasm32": "a959310a47cb3b6e483ce0737ea31ca4c3cef6361e40b9ec28c2cfc38efa5f52", + "wasm64": "03b03659f6a56b3ddb7ad6aa5e771812fe22497e3d53a9792d165c29641a3674" + } + }, + "espeak-ng": { + "manifestSha256": "f0fa6c1a30ac07341e5ef0b6f3b1b29be8de9d32f11130a9196fb1505927d835", + "cacheKeys": { + "wasm32": "0c3735cdb7871c3782a3494617163b6c5294db3d8afe6aaa1a6b7ec56b2e2d4a", + "wasm64": "9af3ba96ec7382e8f4875c64dfad3eeccbe7759cd580ad2999049fba0f68269e" + } + }, + "evdev-demo": { + "manifestSha256": "0943919404dcb8592ecc32f7171fdea68047d3c850a46f6fc88a06b65668ad2f", + "cacheKeys": { + "wasm32": "0cb7324a23a8380757d5e5921e6f73f4c55ccd983f72d129d3ea4567adbf5ffe", + "wasm64": "b444e77dd10593ef070b956dfc6daad73ebef8c77d8c06f5611e04b8e9c7643a" } }, "fbdoom": { "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", "cacheKeys": { - "wasm32": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229", - "wasm64": "116b1d9e44e323c42819e0ebf5bedae5588637fd0e5229ff2803f9a522405c55" + "wasm32": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80", + "wasm64": "827e782f7568de46df5b3cb0bb1a8c6d77d8f747dda6af0a77b8875a3cfad180" } }, "file": { "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", "cacheKeys": { - "wasm32": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1", - "wasm64": "aac8bd9b1687c450bf74cfa0f8325b53e155346f4a101ddb4faafadae7ad812d" + "wasm32": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8", + "wasm64": "1d6f508aef4603f51e6d80801569b64e1ff53365ad99f49b36afb9b84d1073e8" } }, "findutils": { "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", "cacheKeys": { - "wasm32": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c", - "wasm64": "78eb4c3e829ef7698fa678014a5c5d758713df463ccc376e9e08aba8f3884ae8" + "wasm32": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906", + "wasm64": "c39d2c2a21e320d61335e1c02b2e466378d31939e966578a71438a98c8d87239" } }, "gawk": { "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", "cacheKeys": { - "wasm32": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d", - "wasm64": "a12fa4d7e51f6692791c998d078b74b3cb58891892f2f3c9fc05328d0378c79f" + "wasm32": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072", + "wasm64": "8221ab5a612e051733a46d3cade389b6758f251ade60a349837d0d26e76c78ba" } }, "git": { "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", "cacheKeys": { - "wasm32": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf", - "wasm64": "e516189d137bbeda0df157bed40eae356901d74acf0583601ae1d42f6739297b" + "wasm32": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1", + "wasm64": "e9b2cd5ffa0699c8f0657cf4e4078703c5f4d541c164c0c2670b58cb2511a784" } }, "grep": { "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", "cacheKeys": { - "wasm32": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc", - "wasm64": "7fd4dc51aa8820505934fe68bac99a276836be52c4c26b588febba956a09964e" + "wasm32": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647", + "wasm64": "8dfdc9b0c715992f75c9dfacf981812124915924f45dbcc81902ce6f88dbbe5f" } }, "gzip": { "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", "cacheKeys": { - "wasm32": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544", - "wasm64": "c2af1b8ca56a9bfae55ee3a033e928e3142313868801cd3eadf2ada21e5f0fe5" + "wasm32": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7", + "wasm64": "d2c66c3f3850e34fb0b5a68a3a0b7245b781a6b2e3d19989339c1da7493c9034" } }, "homebrew-bootstrap": { "manifestSha256": "c4ed5ba78c33909ac70410c86a80bb877f86fa6ac786c8f11025410a8df1fd24", "cacheKeys": { - "wasm32": "bf2afee293e5c1c5520beb9311d410b81be3140134d414f51c3580dd5c672fa4", - "wasm64": "1fca4e3a69ea4dbb7c6843ec75db428dae9b3983813c831d890fef56d3805ee0" + "wasm32": "91b465b6881947705159383ae7f518d0877628f55f6a54ae201f9d764c2315e4", + "wasm64": "bbb898a8b0c53323012e57b9099f09cad31fde4f78f16aed6ceb18e26c7a60a9" } }, "icu": { "manifestSha256": "e1169851c1978d3356c4e4c0f396672cb6b7785375a44411f7211ddfc90e7ad5", "cacheKeys": { - "wasm32": "29b737564e6d227336ef5ab8853ea2a2bf18b6d01f4619e04252cb9a66aea401", - "wasm64": "cdb369e0272499a0fee60eb852de2cc8b5fd16d1472f5c486979bff289fb09b1" + "wasm32": "d3da6e8512e1edd9452bf2f7898756037a1074bbedf191912c6bbbb827ef245a", + "wasm64": "0025c9e9883214f94f520c7f148cb9f58da9bd29543495a7318c1cd08dfdeb23" } }, "kandelo-sdk": { "manifestSha256": "82057bff05e5e819b26c7ea4457cff198cadf7075b6b02aeb22014811e677abe", "cacheKeys": { - "wasm32": "325158d5bb1f323c3f99650703e0123c26dda5abbf2bf6c2bfc814ae6a70d304", - "wasm64": "4e617a34764c4036907a071345104f5a1026299c7bdd7230b4b92a59afe4d942" + "wasm32": "1b32cb3488a50b98494858b057e82eb29f53cbce1d27d40480bb1559158a5735", + "wasm64": "07be0f2dea0cadbc56a830f14b8271b171daad881aa71a9e83a21e2cb2630807" } }, "kernel": { "manifestSha256": "3cfdfa2130a0917b23ee9ae760901e5caa225654169a51f767413c38025b7b2d", "cacheKeys": { - "wasm32": "6f7364081f0971d3948eaf7c3f8b11b00b0ce98bc9d1525c75d03ef056f65d71", - "wasm64": "72d2fcb1844d95f0ecf60b907d83c356cd0ef300ba36baf9fbf2fc1383defd93" + "wasm32": "db62f6bd1409ee6c7f465f21d796dc4717d63fa2bed8bb2c6e7d86cbc4c5b217", + "wasm64": "24138379abc03991f7318688304862270df09b19366b4440186874ca33b6a47b" } }, "lamp": { "manifestSha256": "3aafffb710a15cd75d974d1cd918c2fe9966c3809144fc46c6057e3ed6c89ae3", "cacheKeys": { - "wasm32": "d206b4754b798d0d45e5f2c8c3a7d4f6f08eececd660ad8cf484d6ddcac1e0e2", - "wasm64": "8e990ba70d1fb887c1c3f8c57748bed9802078ac95737250452f1035ee596761" + "wasm32": "5c9dc3d7f0101fbcb958ce8517938789071796d02de39c05730effe81437318b", + "wasm64": "09699ebedd9ef27729c268ec296be98f283556b2379e932ec00612db9da16821" } }, "less": { "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", "cacheKeys": { - "wasm32": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8", - "wasm64": "083f4c4cbd7282faf93ca840182195ccb4ed0c764c0b2e0baf0dd21d283f1511" + "wasm32": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989", + "wasm64": "fbab51fbeb01039e6a812ffb7874fa02debaf5eb815ba9f533c6fe8bd613ff43" } }, "libcurl": { "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", "cacheKeys": { - "wasm32": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1", - "wasm64": "7ece61a1cbbe02608a6894c3c4ef2367b0c374d852d6e0f9abac8ee3223e8f14" + "wasm32": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4", + "wasm64": "5291543971eb2a6a82851eb6b4a05c94c97568353ad9b4fcc9480a772c57e46f" } }, "libcxx": { "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", "cacheKeys": { - "wasm32": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66", - "wasm64": "5fed4cef387e8aa1dba438ab123b54e9324dc7b33a448a31d203f78df73b1d02" + "wasm32": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a", + "wasm64": "67b0dd58954a0d9f9433bcdb5d5064f3575bd48ba84dd7ca7ff1400ea0181467" } }, "libiconv": { "manifestSha256": "7ae4f68412db04f01e027f94b0d14c1130ac4dfb2041270207994ddab56f0855", "cacheKeys": { - "wasm32": "939a64271c7d51277a5ec7a49c45c55fb4c4b252c68361ca3c6beb6bf9a3111b", - "wasm64": "71d34d650ee8d46d24f5a9d9bc807392b66a1669397a60660ab128c68fe57123" + "wasm32": "2bc2df82f5537c5f74f7e06dc3e6375f4ef24a0866e54df0f2e75f0152baf538", + "wasm64": "0023ade8bddf07af5e939b03ca76e9b8286f6fb7d4c261861a5366f18e5db4fa" } }, "libpng": { "manifestSha256": "d21c80ef4dedf35828c1cc74d741abdafc003f7048cf474b2fdfd9143016bd76", "cacheKeys": { - "wasm32": "7b652e83e5ea6e2619e01d2cc13415258f5b87013bbf7e373a72b848b6b5bec9", - "wasm64": "6fc690ea5fe2c08ff26104c92635e5cd614976593ecbaa4c2a782d41b8393399" + "wasm32": "42ca6a45c189e22774e064cefc799d7790a9dc83ab06ccd41a6b7594e9ff8e93", + "wasm64": "eb32a005c81c8fa7ff1828aff897ae54551ac6ec6e78d914596fff513bf9541d" } }, "libxml2": { "manifestSha256": "87301bfcb607ee24c00925787cfe071ba506c750437ff5b807b2ea0897058e91", "cacheKeys": { - "wasm32": "0f93f0ef28cebb24d87516d0db6683a8909359a10f1577db92a8374b04ae7cdd", - "wasm64": "18314ef7d2eaf6c112c27a7ba6195626a7fe67cf946d670c8375bfc27b2d408a" + "wasm32": "a90b8526a47a799b2917d4dc3c6d16f1d65ece66005e484a322c503e5c5805d8", + "wasm64": "162fd040ad754386e0d172835b8843e7ca3d6efc39144ce6d07e16abdbb79759" } }, "libzip": { "manifestSha256": "84a6e61fc67c7dd6cf5b4aacd80682a86d402c78810efdd03a9ff58da8eaf6a0", "cacheKeys": { - "wasm32": "f7b217af4f83eeb207d7cbfca570144719a67f4df6ae3c1fc56a8694dd0568ce", - "wasm64": "c39dcd026786435afeb70ca8cd1a8ece4e99af1e9ca73150d55fbfbc67bea46d" + "wasm32": "7ddffc7b50eea7a8ecebc94f88c1f6e6953b1d1bfd0bb30bb6f33805c3f100da", + "wasm64": "918b4ed985861add1ac86218955f8b5178c7a96e8547dab61d5e49a1091a5ff0" } }, "lsof": { "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", "cacheKeys": { - "wasm32": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8", - "wasm64": "c23d85540e06702588e7bc94ad2bace5ea6d0242a3c70278e57128eeaa19cd2a" + "wasm32": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760", + "wasm64": "1f25079f00d5ea3bf259d4258bed06f34508a95711e81335773db1a6cb3683c4" } }, "m4": { "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", "cacheKeys": { - "wasm32": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695", - "wasm64": "312bd294fa500808e76e79d5ac3d0cdbc920bccb44e67a3f30132e66eaedc95d" + "wasm32": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d", + "wasm64": "a2f64accb5325fca10d10cb009d7943a6263394ba05b2076ed94eff11e7db87f" } }, "make": { "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", "cacheKeys": { - "wasm32": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5", - "wasm64": "7f57a202f65c5c1ace8e35c1fe083f6208861d98c7705ea23155a6794ee62148" + "wasm32": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb", + "wasm64": "5607e274689479929e9e9c665034a1a43961e2761c4f0b133b948f5d0427e3a5" } }, "mariadb": { "manifestSha256": "2e0699de7658d2ae002556c84f49ccb61a70e96b500b41c5e73246570d921bea", "cacheKeys": { - "wasm32": "48bc77b348e61faa8b0e7430fa8cc02cf0136548cfd6b342bb3d7dfccf9c138b", - "wasm64": "7ecdf7257a8395280927964eecda07d31a6eacd1f09f9a0775a5ee29876f87db" + "wasm32": "11a884f2ab079b77a5476dca2475c0ab8f693c0fd9078a9218fcd03f81e1c4d0", + "wasm64": "f1dd516fed2d03f9699d77d69b5abcece8ebfe03628cc208f8d0d1c5116608f2" } }, "mariadb-test": { "manifestSha256": "e88dffcf866ca8ed351b2601ae3645986fdbd1761b19b4d92d17c6d4eae471e0", "cacheKeys": { - "wasm32": "dca8b89a45f43c9ed3b079d8a5505d4870724ffeb84e20f404668a631f1a0263", - "wasm64": "1ca2525de6ae90e4f0673decd4b28f50f8a4aeadd7dcecac56a0e107e4277443" + "wasm32": "c416deee68dc98a54c46f762ff9ba69363cb77f647bc3093855494f022f21936", + "wasm64": "bc9427d2b837d7e7b7d739033950f23ebc12997e158b9537012f530281585c1b" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "a41210be1a20284debb0b47837090c26aefda421515ba67c126fc9b1f9604ff7", - "wasm64": "000555b6d6f44d64e84211d2963cc30e733beff57ceebd0dc04444e5cfb034e9" + "wasm32": "38422a41bba8901715bd540b312b7eb3cf6381eb55bd5640524ea3bbbea10367", + "wasm64": "3858257711ebd3f6250a2085c03e69662ae95e44abb6d906400b908d5094e92b" } }, "modeset": { "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", "cacheKeys": { - "wasm32": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60", - "wasm64": "c60c077cadd1ec3561250e505f45bb0aad3ed0026cd8578a219e830da7352e1d" + "wasm32": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0", + "wasm64": "cba500205a355f7d848f6faeceb5b25fbcc0b32070ad05d49666fef2391be6ac" } }, "msmtpd": { "manifestSha256": "eb554efa7f11b5d2c2268492eecf35bc0d80eeebbcffc1441b590b5541b4ec16", "cacheKeys": { - "wasm32": "9a013fcbb76a91f84bcf10962e77fb9f79ab66ad8ab9cc066d1053a92aa6a4a7", - "wasm64": "27a056a9efe257f65caa72f021d226b86c257d7496faad824afcba1dacc7d34d" + "wasm32": "7c8c1a37ebb098b5648349ac9d1600b212ace546c6d8ef87258e6796d78e0be1", + "wasm64": "aca10e8da3a8e7dbb822480f61f09bf1c33f879fed479e83edf9bb69ec3d9b53" } }, "nano": { "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", "cacheKeys": { - "wasm32": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086", - "wasm64": "37d3481fa0bf7efc7bd97ff3f379e6210bc19796c482e28955d8a174d8a34646" + "wasm32": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce", + "wasm64": "a4bc6044a870801efada6c2a9e362f634531b8f3571499abf1a3c5040a0baf3f" } }, "ncurses": { "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", "cacheKeys": { - "wasm32": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97", - "wasm64": "291382c9ade0dbf66dbc47710038693a8069803e06d95cfd93eab1e6f50cfe62" + "wasm32": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4", + "wasm64": "773055d116ec398bd852fdb89d557d5a296c5cec434788f355a4bed624d315f8" } }, "netcat": { "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", "cacheKeys": { - "wasm32": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28", - "wasm64": "b6f9e093570515f50625f9270097bae855db48d6839b2efa9c9bd0e8aaed6906" + "wasm32": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a", + "wasm64": "415a72e2ef1148f41db8f3dcefb8064ca2aa828e1c4354098a115997889b8b92" } }, "nethack": { "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", "cacheKeys": { - "wasm32": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d", - "wasm64": "6fa7f33044b08db08fd376da60c44dacc7a49f2ee3e2b25301be152a15f800dc" + "wasm32": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12", + "wasm64": "d36b9a3ce47de215080934fd61c53fcb65eebcdffa28b32da8423dbb1a3fa6d8" } }, "nethack-browser-bundle": { "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", "cacheKeys": { - "wasm32": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487", - "wasm64": "de395aacbc3558d9539ae5d248d1ba69baa23f36ad2ad2c4547557fe3629c16b" + "wasm32": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6", + "wasm64": "136ec10be0ba7a3a383da37739ec34c3fb539ee6ab40fe8345db63320a348812" } }, "nginx": { "manifestSha256": "b78552502c63bef2814c8aae76fedfdb4f273e4ee0c42023d6157ddb298ff815", "cacheKeys": { - "wasm32": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640", - "wasm64": "fd39a47be95708eb12f5f8a7729dda000d50e319d01d28535ccf5cc80be7a7bd" + "wasm32": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a", + "wasm64": "8f76827a72b21bf7285092e575b3ab15fbc220b6afb5e0c9d59eef46e290f505" } }, "nginx-php-vfs": { "manifestSha256": "39e6795e7824a252715a0e8dde34d0ddbacd42757a603f2263e50a278ea114d5", "cacheKeys": { - "wasm32": "e90acec39405a648ced9a361bc1008b198322d5d2abb40b32aaaecff59379b09", - "wasm64": "9329c97cf06450c15200c7dd9dbaf7086698342f072900152f536c9044bf563a" + "wasm32": "e4462832b24f124b23314eb26add749ca515fe1a38f78834816c0fae0c49da82", + "wasm64": "ab41af32f0ba8b41e7d752ccf7b7a7012b4ee4083efec0c0422485282e85ec48" } }, "nginx-vfs": { "manifestSha256": "66c8f01601e7cf0d826fbd22b641a3d7f7d050bb0980a69efbce73d2045d9866", "cacheKeys": { - "wasm32": "e9a37566343366381720bd5d48cb5bffd3b74c1164901ce8e20970279332948c", - "wasm64": "bb8f67d990eaa15fe01bfa32fbe447181519666ee70f659a37d514bb03f90874" + "wasm32": "d3df523ac43da00f2dde4c938389e7f6bff55c86bf582a8300b519ee412e23e3", + "wasm64": "5f17052e400710b4c22b8d79e01a927efb6523e940af80ae1694383b851cf56f" } }, "node": { "manifestSha256": "353bce1b61a21b2132340a9f6f1e2d7e9354a592b3d9762165a215ad390c23c2", "cacheKeys": { - "wasm32": "010787fcdad88941f9bb4fda4d3b19b3c479de269ffd5d47588e25bdf375ab1e", - "wasm64": "b316c070c4f7577afe4e8683386ed9c13a19f3c7ab692d9f786eef694f7c522b" + "wasm32": "d090c5dffda26124b0fd2f6c439e9346fd336ab65e5e98c27f734fc30627a0f2", + "wasm64": "c589a4d09b2003260552107f30e8965a1c77b52b1ac74981b56c5b45fa92bdc7" } }, "node-vfs": { "manifestSha256": "44f46b62e2cf5211cf383e639b62e9841b9b8ba8f5b06f5c098a8e319b3d79aa", "cacheKeys": { - "wasm32": "bdcaae9a5dc421e4a4fbd7cebac8b628c585923f75562ca0917e176c39b36aa9", - "wasm64": "9e4032f5ccacc668096cfdb7afc4be263db4fcc2c05af7f64d3ecf5b3332aad4" + "wasm32": "7a6c0893e1404bcf91813971d297e87afcd96363308731e1364bce5c4458bfdb", + "wasm64": "20192b4a4683b3ba0a452a2eac89b26bbc982b2f132824686c5c6327f342aa10" } }, "openssl": { "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", "cacheKeys": { - "wasm32": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246", - "wasm64": "5b8a8e6d882753b092233ecb65fe37add307d8ef9bff73e53d0ea4a1f06bc690" + "wasm32": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec", + "wasm64": "926ecabe79fbf960e9ff74074298c1c298a98dbc1a29a1adee52d9f346130f75" } }, "pcre2-source": { @@ -354,197 +368,197 @@ "perl": { "manifestSha256": "dfebfffe4136be5b38e5f7aef14d75068e42ac9a05541582d005a6992f41c216", "cacheKeys": { - "wasm32": "e4ec3cdc5b7ceacb9b9be1d93ff3c52247e95d8ec3e07927b6c3fb059909740f", - "wasm64": "3db5669812995a6ae9c8bba0ac262b72eba868594b6a5cd41d99c9032e7b2d6d" + "wasm32": "a96df7fab0a5a61a0541f75698b2a963ee80732d6efad7dc57a6094c80d0a7d7", + "wasm64": "8b0bb019b6d46eb4e6661069615bceeeb2f972845348cc3466dbe698f0f4fb14" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "a8a53f1dfe72eec9bfd6c6867aa9c0babd6fc2783988747109173ba605bab1e1", - "wasm64": "7dd8cb1b1e9d3a2e94f3bfb7567bea31a5efd04c577af4ddfa2b719e2454ee08" + "wasm32": "7a52dff193297d1d438d032687fad3debbb98ed6b70ac9a6f56547d86c65ee5e", + "wasm64": "b1db07c79fde2643c5aa5b2c7a01b7c3c73d395413c0a7d96bd52b52bfa21ced" } }, "php": { "manifestSha256": "14114e280941f1f2f6a4c5abc11698b4b01f909be4cf2aac5b0e68845fed4644", "cacheKeys": { - "wasm32": "6062638f2f20a7254948ee6bede9c4a1515d0ff4565abb15084b037c3510ab05", - "wasm64": "40aabe41edeaa6f8c3b29a7129a5b06e44ac14584254c9961a873e4ef5e23c0b" + "wasm32": "70a85fce082656d22b33c356285b2659a00ff59e3efeda9f35862e8fdd6c0abd", + "wasm64": "5119fe080a78e0c899d60922142753c5ed19bf27d1e1ecd31cad5685b5fff762" } }, "posix-utils-lite": { "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", "cacheKeys": { - "wasm32": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9", - "wasm64": "afff63e0cf32abebccbf6f1d1b3f19eeca013c4d325d05654d490f047b5b8eb8" + "wasm32": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2", + "wasm64": "edd6ab0985d46fec372b2222281c49281a2731b21d9f13071179f3294473ed5f" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "d99fdd4b6bac0a13639e1e1a99bcd10a5122f4f2fa86d908c9a6267219bc7ee1", - "wasm64": "2e757bc143010ef2bdb44df0ec8c24bf5f770a762519f9a9ef1af2eaa505e890" + "wasm32": "79403e28bbf504b073ee6c4bcadffe3344e2004454eb5d906118b11e6ade602c", + "wasm64": "06767b946c57f8ad36406ce6454a0a8d48890599870a0116b55c43e76e38cb68" } }, "redis": { "manifestSha256": "94bfd24dd43bea67bb036fa52e8ab9a88ab5c29d9597e2a7e32b30cac9474d42", "cacheKeys": { - "wasm32": "4a90606ee4c37280f794d9b6249b80f8533c76a9148ee86ebc0ca609d437ba05", - "wasm64": "62427ccb76e52b58c1928578bf7b5f62cc7e526066ac69e31c53ae0a500affdb" + "wasm32": "c6d0e0623fd00ec516ed08d36771c34cc0c76019bf3a65bda82371e0d49e3239", + "wasm64": "6125646975e74e2be27575300b36e5eea0e5c05832890ae77f0f88c7b4f1d2c6" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "13a85bd7b4cfadbebf9d2aa3966988dac7e37aea7897453a6eaa4bc8e9f9ab0d", - "wasm64": "293bf84ec790e413df07047d84fe1099aa389e1a8a057a1d4bdc8a062987c4e0" + "wasm32": "bc088fa32d51ca9ad14533e12f66ed674cb3fc9e92057086b6ccf6ed214a7462", + "wasm64": "a4d0d6aa1d306138eb701953ae4c1315345234ba4eaae333e0dec20798682061" } }, "rootfs": { "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", "cacheKeys": { - "wasm32": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1", - "wasm64": "b99e1aa6075fb4c3baf261341d2698497978635320f1fcf6de25bf0d444b2237" + "wasm32": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8", + "wasm64": "b75a7e620d377a39adb35452f549d815e73561370140d80b9fdef670804869f4" } }, "ruby": { "manifestSha256": "9b8ab3aafc78e1a8c9e2f5b2f79a17976a6278ce52c23240d2c5a6f8cce3c52d", "cacheKeys": { - "wasm32": "c75e43269ce4d42c1f87291a152eafbba0158cf6ec9625ee62befaeabe14e2b2", - "wasm64": "b059d0f056c29d725d5b0a0e83ef0fef1df00d26cfd58aa586b22cb0a8fb3d97" + "wasm32": "14dd36525a93479b58177b3df9f030b83960f181a7c0cceeb265b2c8277e65e0", + "wasm64": "d7d6974e5b17f9dbcb7a336a19afab64211309f41c71c72597952767e53a9f23" } }, "sdl-dsp-test": { "manifestSha256": "4842ce3b88887a65c347dbb45a52d499f78f4c30430ce4cdfb3d5ec661ea0f67", "cacheKeys": { - "wasm32": "e36804a9f073136982cb60e7f52da451427dc850ab5760e99ed92b3ebe70daa0", - "wasm64": "02adf1994d507c4f6e04e0b43d3c2293d5d8610a722bb6dcb4812dc6702ce77d" + "wasm32": "2b6f207dfb1e1eccda5871c3aba08e4fb580bd9970436f35861fe51b5236f435", + "wasm64": "d2119a058f3d41b76e96f9ef819e72e863738f5f8b3c1805b5e1341441ca8bb1" } }, "sdl2": { "manifestSha256": "b6e6a01586cc01f6005fbaa79027b81a08dcc4cd77ff51a99a093d04ef930366", "cacheKeys": { - "wasm32": "064daacee36ac921255ee74911d5660b10499010a105fba8b365861890e70177", - "wasm64": "4c757a2ff021936ccd23fecfbae512afcf5b37a32b8f742ed170721664ff8bbb" + "wasm32": "5383b697827537e8a890edbd61d13bea292b3e1304009394ef4be33b697bf2f2", + "wasm64": "2338428c0d21dfa9109af25268b3cdae2dd38db1ca0cfe32142c6dddd89cfeec" } }, "sdl2-mixer-playwave": { "manifestSha256": "10b05fbd53e13fac3fcc4a5783022199b2ccaf94697b3a0de42330a88ef78ca5", "cacheKeys": { - "wasm32": "30dbc38b217dcb6e0a80cbcc4c3780aec647cccb76c2a3870a4da786a24a4d59", - "wasm64": "293c0d8566d0604bca52610505b3e274da626e6362d7daf661011fff01e1c113" + "wasm32": "64e111f88438f52575eb7fbbe95a93e32e3a05b68012f5d38ff35e63d4845220", + "wasm64": "112c3bb58d5ef1e9dd8b4715c644769f26056b1911a0c2185ce04abd7a18d1f6" } }, "sdl3": { "manifestSha256": "fc849fbc7da35401a040c328245b5e6d490e8f994b77c954ed2ab463d0892aca", "cacheKeys": { - "wasm32": "b8040a63705623aea889f697b1fed6fc58326c89fa2720dbfc89432f8b09d572", - "wasm64": "eb7ba370a6f027fa67b4e548e56671797f32b8df62b9a24ce39ddc26cb2bd6bb" + "wasm32": "5492484e32cf245385424cc9e73781d21ab919e89362418a0efdf48be3332119", + "wasm64": "970ebf01b2e50cdd74361283dd4310bf4f3fdfa19a7a86d296347d7cfa3177e3" } }, "sed": { "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", "cacheKeys": { - "wasm32": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024", - "wasm64": "2c5011d02e5dd2653cc951497157e7ba5088d6ff4898a9d4d4c650c4e8ed1fc0" + "wasm32": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9", + "wasm64": "5ee81879e1573be1b174a0fab99a129202e87dfbac09af03976d010246c18369" } }, "shell": { "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", "cacheKeys": { - "wasm32": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca", - "wasm64": "1e23fe9305f2f52c166f83df1725d5316e499d11843bc9a2f02fa4184358d1a3" + "wasm32": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e", + "wasm64": "b02ac7be30746a7d7d4e2f51fbdd199d9b2cd846abb710ee30110f9fbba9b6cf" } }, "spidermonkey": { "manifestSha256": "0460143a58135f834705ccd1316f9007f7d9bb1feee1de58574d2c6b2891ac40", "cacheKeys": { - "wasm32": "dc6d9e16ef5cb90621c7e083a38df6e0ca78f0c1ee7d08cda5e3eee159d8ae0a", - "wasm64": "ba678f6fc05490ce6fc65361c455db7ff4487d106f0a59bb5725ba877cc2541e" + "wasm32": "e19fa8ad12e75737398605395089a6f2ffff11a11c0e8fe8b1e6d1af005a0e05", + "wasm64": "765bd71da2116a988d4338d2b39b18d998313e5e8fdb6bcd83f5fc03791ac8f0" } }, "spidermonkey-node": { "manifestSha256": "a6bf29c653889576dec0914e45421ff05a374dc515613567990f7c52689921ff", "cacheKeys": { - "wasm32": "9dc5f6e47c3249ec4604dffa9d4f9097750574e97aa3c806c3d17cb9aba4e5a0", - "wasm64": "7c2eb0e3260aed664c30bfced29dd6769573a8bc9ff8557f5dd3cdbaad9c0502" + "wasm32": "63e3185dd513a0ba9760f5907fb7abd7bda32c5330e15f1a0298f84c8e158e8f", + "wasm64": "084d13708dc1328afe30a9d95f3a6c605809a64c71a09f1136ee3e0fea1f2c07" } }, "sqlite": { "manifestSha256": "77110d20137fe7dfd55d6be0f99c959ec3d4bc23ebbf516e5eb51f3886d8bcee", "cacheKeys": { - "wasm32": "225473952aaa1f400b89870afc6943881cbbbbbe96019b5b7fd47e913a29fcb2", - "wasm64": "8247eefd3a48051a1a7fff31f9a44658610c56a7601a05267133cb221d609a16" + "wasm32": "fce7d13e41a79094256bc45771a13581e2c699dead5a29841561c76d435425c6", + "wasm64": "85f4853b1c10c8fff49ad60136480ecac85dee4c7d958c35731a3562c7209964" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "6b64a2825e98a8d41b7f6d19093080092591b74b20717cdbb66e9508e21bef66", - "wasm64": "d9a89f33a07a9eb62abbd022698d57a944fa614034c6243f900d3b2ab39c5a17" + "wasm32": "e842a724c62debb88c9381709205d21869a4989127a51448a19dbd92f2ae8137", + "wasm64": "27c3ec7f7d530527deed8f0dff684a767af9d29db1dbee101f70022d5a2e0b38" } }, "tar": { "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", "cacheKeys": { - "wasm32": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068", - "wasm64": "ef88ea445cd39db8f54a6fdcd307429cb55fb65ab23f649e33a17f5d6f56b05f" + "wasm32": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923", + "wasm64": "0b0d0730dc661c5b4b2cffecd832035bcf2694f6559e6edc0c43e68448b9cd24" } }, "tcl": { "manifestSha256": "3a92ed6294ffb22589cec3ef1551a90065a47e0ff7d62ee95dcb0b38a64c5781", "cacheKeys": { - "wasm32": "570b6000d025ccaed627b2c38902c7c0f1b6f1352255de8dbe2dc6f76d4147cd", - "wasm64": "46113f5b5849423755e8ebb9541d49206cedfe19fb4c7fd9fd84ad5cbb919416" + "wasm32": "a6c314bbab19e91bc004a6aba12cee9c1ef081154c3424a0a91dde67bf39370b", + "wasm64": "c8f58feec55113b40eac1cac68cbf52a68cb58e165310f0746b4647c2a7dacf1" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "599e13a4bd4426240dd18c50d176d032fe67deb80d431b7f983cc8eec85adeb4", - "wasm64": "2c41f509ab90a82c7c4072f04bb091155630151ebba1e0256882d3b562b44eba" + "wasm32": "e323130e3c0a89e934b1568bc43ba464655b8f4a8a756524840cc75ef1d65482", + "wasm64": "72cd6f416a4d376b6465d7789fb35cd532c76b7df87959ec402fe6b79c73a5a6" } }, "unzip": { "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", "cacheKeys": { - "wasm32": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033", - "wasm64": "2ae40aad671cd27771d98a0d3acb79e5f5671618c3f54fc7a4c4d22ac27bb37f" + "wasm32": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85", + "wasm64": "2cbe655687fa1fe409e9ab2008bd9db8ed8b27a860601126f1e045f9ef97645f" } }, "userspace": { "manifestSha256": "e18c1dd7a811e09b336d20dea545e54d14ee8e35da545a5cde8721d301a62c85", "cacheKeys": { - "wasm32": "c255fe62d44fb7f8d741c4fcd99cd87c7048cd76d4b08db86f026c81aec713a8", - "wasm64": "d68bad5cad1736f502c027d0948fba43c9a0da51ced3be59371708c88b2abca4" + "wasm32": "2279ef997b2635a3b69e93f530e4f95bc92cece58ed8fd6d2dc6c8201aebf2a4", + "wasm64": "efed20ce94a9a4bfd81dc2804fb003687945f25f25ac4d9c986628c3394319a5" } }, "vim": { "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", "cacheKeys": { - "wasm32": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa", - "wasm64": "89d61a7fb76cea51aaa97feefabc2934386347b23ab3649b5efc834b8bfdf8eb" + "wasm32": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea", + "wasm64": "edf1e47c494b22994341f024c320c4d06c763100e98374c723a7da7511ab7c6c" } }, "vim-browser-bundle": { "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", "cacheKeys": { - "wasm32": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51", - "wasm64": "886ce9d0a2d9bc2387f5718a4e9856a423e1a244a6422b0d47bda947982db2d2" + "wasm32": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61", + "wasm64": "c79ef76a7d922c4929e45020c7dc271b931c1164c72b02b3abd9971a6e4d7ca4" } }, "wget": { "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", "cacheKeys": { - "wasm32": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142", - "wasm64": "8d58d6206b7abf3826f7667553c013c5777ab00b1a71693869c443aa029e803a" + "wasm32": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1", + "wasm64": "c84a4e6fad4d2be3dc55d18da0e081c54396a8473ad638ccacfe416e13002f52" } }, "wordpress": { "manifestSha256": "346cbc47088979d57da3ecf79d0607ee5e2463f7e4b0e01a48a983e29f059e3d", "cacheKeys": { - "wasm32": "18970d4c019d106a8c99af8e62e4db0bb3bc1d2c1a631f8be4ca29f856e996e3", - "wasm64": "40ef860252187ab2c792952c7a19ab3927305e79a419c6f7aaccb87359dc6a53" + "wasm32": "420af5ddd540a93ae4fa87a205066b8417ffe60a558c82ee20b1438797eb863a", + "wasm64": "45eb13ad56d03bb01267038caae7a9c561b02d32b088e77ddbfceda2640be3d5" } }, "wordpress-sqlite-integration-source": { @@ -557,29 +571,29 @@ "xz": { "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", "cacheKeys": { - "wasm32": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450", - "wasm64": "77fb8df1199e83cec2628474530840881d43af2e16c95f33b469c7d9590a240e" + "wasm32": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c", + "wasm64": "269de0fbfe0f6cc1745a467f23afae440b45202bcdd9e7866e7e229c76806032" } }, "zip": { "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", "cacheKeys": { - "wasm32": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862", - "wasm64": "3bef9e9ca2235546ec8da2dce3d053eb43a8fe45d69b376d0c559f6cbd3f94e9" + "wasm32": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8", + "wasm64": "2afbebd734e99f3da937f8473a0d5f4fbcb7e5f94aeba96704853c8181d23b38" } }, "zlib": { "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", "cacheKeys": { - "wasm32": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6", - "wasm64": "d49126e6fa312c3a1f93c4c51803ce01a4df3c50a4fe4f2b5ebf5e0d4b172a81" + "wasm32": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce", + "wasm64": "0ab07005ed9a8837205721ebdf7ad49fe1e3eb36601b5485d52aeada4ff1ef7c" } }, "zstd": { "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", "cacheKeys": { - "wasm32": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec", - "wasm64": "b5b4767826d5c3d6caa32dd9fc14e77271fba6cc7b213c74a1a2879d89343c42" + "wasm32": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89", + "wasm64": "c167e5a971e802ae56703eb0e52a02ccf21a542f0485b1a2db51df6fb489d3e1" } } }, @@ -590,14 +604,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "wasm32": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" } ] }, @@ -617,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "wasm32": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, "dependencyClosures": { "wasm32": [] @@ -638,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "wasm32": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, "dependencyClosures": { "wasm32": [] @@ -659,7 +673,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "wasm32": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, "dependencyClosures": { "wasm32": [] @@ -680,14 +694,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "671600d839333cd841b35d44f182836e9d9f515b0da5f13ece812b15c99aa797" + "wasm32": "24754c020c2a08b5cf2502ea4f3010c53c1c0b4c2094fa5d597221d36afc29b1" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -714,19 +728,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "wasm32": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -746,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "wasm32": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, "dependencyClosures": { "wasm32": [] @@ -767,7 +781,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "wasm32": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, "dependencyClosures": { "wasm32": [] @@ -809,14 +823,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "wasm32": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" } ] }, @@ -850,7 +864,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "54c816a671fdaa12a5ab18580c4e54d423f546c56cb5a237449f090c68cda9d8" + "wasm32": "a84f5187db542cc4a7f68d9747a5e9096e610a261335e629e785eb4b798f79b3" }, "dependencyClosures": { "wasm32": [] @@ -878,14 +892,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0d2025175cc7675a3d69bef53ffb084b8b2bb719fca8f437147ca36341c28014" + "wasm32": "a959310a47cb3b6e483ce0737ea31ca4c3cef6361e40b9ec28c2cfc38efa5f52" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "54c816a671fdaa12a5ab18580c4e54d423f546c56cb5a237449f090c68cda9d8" + "cacheKey": "a84f5187db542cc4a7f68d9747a5e9096e610a261335e629e785eb4b798f79b3" } ] }, @@ -899,13 +913,68 @@ } ] }, + "espeak-ng": { + "manifestSha256": "f0fa6c1a30ac07341e5ef0b6f3b1b29be8de9d32f11130a9196fb1505927d835", + "arches": [ + "wasm32" + ], + "cacheKeys": { + "wasm32": "0c3735cdb7871c3782a3494617163b6c5294db3d8afe6aaa1a6b7ec56b2e2d4a" + }, + "dependencyClosures": { + "wasm32": [ + { + "packageName": "libcxx", + "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" + } + ] + }, + "members": [ + { + "kind": "output", + "sourceArtifact": "espeak-ng.wasm", + "mirrorPath": "espeak-ng/espeak-ng.wasm", + "outputName": "espeak-ng", + "forkInstrumentation": "auto" + }, + { + "kind": "runtime-file", + "sourceArtifact": "espeak-ng-data.zip", + "mirrorPath": "espeak-ng/espeak-ng-data.zip", + "guestPath": "/usr/share/espeak-ng/espeak-ng-data.zip", + "mode": 420 + } + ] + }, + "evdev-demo": { + "manifestSha256": "0943919404dcb8592ecc32f7171fdea68047d3c850a46f6fc88a06b65668ad2f", + "arches": [ + "wasm32" + ], + "cacheKeys": { + "wasm32": "0cb7324a23a8380757d5e5921e6f73f4c55ccd983f72d129d3ea4567adbf5ffe" + }, + "dependencyClosures": { + "wasm32": [] + }, + "members": [ + { + "kind": "output", + "sourceArtifact": "evdev_demo.wasm", + "mirrorPath": "evdev_demo.wasm", + "outputName": "evdev_demo", + "forkInstrumentation": "auto" + } + ] + }, "fbdoom": { "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "wasm32": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, "dependencyClosures": { "wasm32": [] @@ -926,7 +995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "wasm32": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, "dependencyClosures": { "wasm32": [] @@ -954,7 +1023,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "wasm32": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, "dependencyClosures": { "wasm32": [] @@ -982,7 +1051,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "wasm32": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, "dependencyClosures": { "wasm32": [] @@ -1003,24 +1072,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "wasm32": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -1047,7 +1116,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "wasm32": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, "dependencyClosures": { "wasm32": [] @@ -1068,7 +1137,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "wasm32": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, "dependencyClosures": { "wasm32": [] @@ -1089,7 +1158,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bf2afee293e5c1c5520beb9311d410b81be3140134d414f51c3580dd5c672fa4" + "wasm32": "91b465b6881947705159383ae7f518d0877628f55f6a54ae201f9d764c2315e4" }, "dependencyClosures": { "wasm32": [] @@ -1117,14 +1186,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "325158d5bb1f323c3f99650703e0123c26dda5abbf2bf6c2bfc814ae6a70d304" + "wasm32": "1b32cb3488a50b98494858b057e82eb29f53cbce1d27d40480bb1559158a5735" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" } ] }, @@ -1144,189 +1213,189 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d206b4754b798d0d45e5f2c8c3a7d4f6f08eececd660ad8cf484d6ddcac1e0e2" + "wasm32": "5c9dc3d7f0101fbcb958ce8517938789071796d02de39c05730effe81437318b" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "icu", "manifestSha256": "e1169851c1978d3356c4e4c0f396672cb6b7785375a44411f7211ddfc90e7ad5", - "cacheKey": "29b737564e6d227336ef5ab8853ea2a2bf18b6d01f4619e04252cb9a66aea401" + "cacheKey": "d3da6e8512e1edd9452bf2f7898756037a1074bbedf191912c6bbbb827ef245a" }, { "packageName": "kernel", "manifestSha256": "3cfdfa2130a0917b23ee9ae760901e5caa225654169a51f767413c38025b7b2d", - "cacheKey": "6f7364081f0971d3948eaf7c3f8b11b00b0ce98bc9d1525c75d03ef056f65d71" + "cacheKey": "db62f6bd1409ee6c7f465f21d796dc4717d63fa2bed8bb2c6e7d86cbc4c5b217" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "libiconv", "manifestSha256": "7ae4f68412db04f01e027f94b0d14c1130ac4dfb2041270207994ddab56f0855", - "cacheKey": "939a64271c7d51277a5ec7a49c45c55fb4c4b252c68361ca3c6beb6bf9a3111b" + "cacheKey": "2bc2df82f5537c5f74f7e06dc3e6375f4ef24a0866e54df0f2e75f0152baf538" }, { "packageName": "libxml2", "manifestSha256": "87301bfcb607ee24c00925787cfe071ba506c750437ff5b807b2ea0897058e91", - "cacheKey": "0f93f0ef28cebb24d87516d0db6683a8909359a10f1577db92a8374b04ae7cdd" + "cacheKey": "a90b8526a47a799b2917d4dc3c6d16f1d65ece66005e484a322c503e5c5805d8" }, { "packageName": "libzip", "manifestSha256": "84a6e61fc67c7dd6cf5b4aacd80682a86d402c78810efdd03a9ff58da8eaf6a0", - "cacheKey": "f7b217af4f83eeb207d7cbfca570144719a67f4df6ae3c1fc56a8694dd0568ce" + "cacheKey": "7ddffc7b50eea7a8ecebc94f88c1f6e6953b1d1bfd0bb30bb6f33805c3f100da" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "mariadb", "manifestSha256": "2e0699de7658d2ae002556c84f49ccb61a70e96b500b41c5e73246570d921bea", - "cacheKey": "48bc77b348e61faa8b0e7430fa8cc02cf0136548cfd6b342bb3d7dfccf9c138b" + "cacheKey": "11a884f2ab079b77a5476dca2475c0ab8f693c0fd9078a9218fcd03f81e1c4d0" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "msmtpd", "manifestSha256": "eb554efa7f11b5d2c2268492eecf35bc0d80eeebbcffc1441b590b5541b4ec16", - "cacheKey": "9a013fcbb76a91f84bcf10962e77fb9f79ab66ad8ab9cc066d1053a92aa6a4a7" + "cacheKey": "7c8c1a37ebb098b5648349ac9d1600b212ace546c6d8ef87258e6796d78e0be1" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "nginx", "manifestSha256": "b78552502c63bef2814c8aae76fedfdb4f273e4ee0c42023d6157ddb298ff815", - "cacheKey": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640" + "cacheKey": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "pcre2-source", @@ -1336,77 +1405,77 @@ { "packageName": "php", "manifestSha256": "14114e280941f1f2f6a4c5abc11698b4b01f909be4cf2aac5b0e68845fed4644", - "cacheKey": "6062638f2f20a7254948ee6bede9c4a1515d0ff4565abb15084b037c3510ab05" + "cacheKey": "70a85fce082656d22b33c356285b2659a00ff59e3efeda9f35862e8fdd6c0abd" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "shell", "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", - "cacheKey": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "cacheKey": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, { "packageName": "sqlite", "manifestSha256": "77110d20137fe7dfd55d6be0f99c959ec3d4bc23ebbf516e5eb51f3886d8bcee", - "cacheKey": "225473952aaa1f400b89870afc6943881cbbbbbe96019b5b7fd47e913a29fcb2" + "cacheKey": "fce7d13e41a79094256bc45771a13581e2c699dead5a29841561c76d435425c6" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -1426,7 +1495,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "wasm32": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, "dependencyClosures": { "wasm32": [] @@ -1447,7 +1516,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "wasm32": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, "dependencyClosures": { "wasm32": [] @@ -1468,7 +1537,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "wasm32": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, "dependencyClosures": { "wasm32": [] @@ -1489,7 +1558,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "wasm32": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, "dependencyClosures": { "wasm32": [] @@ -1511,15 +1580,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "48bc77b348e61faa8b0e7430fa8cc02cf0136548cfd6b342bb3d7dfccf9c138b", - "wasm64": "7ecdf7257a8395280927964eecda07d31a6eacd1f09f9a0775a5ee29876f87db" + "wasm32": "11a884f2ab079b77a5476dca2475c0ab8f693c0fd9078a9218fcd03f81e1c4d0", + "wasm64": "f1dd516fed2d03f9699d77d69b5abcece8ebfe03628cc208f8d0d1c5116608f2" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "pcre2-source", @@ -1531,7 +1600,7 @@ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "5fed4cef387e8aa1dba438ab123b54e9324dc7b33a448a31d203f78df73b1d02" + "cacheKey": "67b0dd58954a0d9f9433bcdb5d5064f3575bd48ba84dd7ca7ff1400ea0181467" }, { "packageName": "pcre2-source", @@ -1577,34 +1646,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dca8b89a45f43c9ed3b079d8a5505d4870724ffeb84e20f404668a631f1a0263" + "wasm32": "c416deee68dc98a54c46f762ff9ba69363cb77f647bc3093855494f022f21936" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "mariadb", "manifestSha256": "2e0699de7658d2ae002556c84f49ccb61a70e96b500b41c5e73246570d921bea", - "cacheKey": "48bc77b348e61faa8b0e7430fa8cc02cf0136548cfd6b342bb3d7dfccf9c138b" + "cacheKey": "11a884f2ab079b77a5476dca2475c0ab8f693c0fd9078a9218fcd03f81e1c4d0" }, { "packageName": "pcre2-source", @@ -1630,35 +1699,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "a41210be1a20284debb0b47837090c26aefda421515ba67c126fc9b1f9604ff7", - "wasm64": "000555b6d6f44d64e84211d2963cc30e733beff57ceebd0dc04444e5cfb034e9" + "wasm32": "38422a41bba8901715bd540b312b7eb3cf6381eb55bd5640524ea3bbbea10367", + "wasm64": "3858257711ebd3f6250a2085c03e69662ae95e44abb6d906400b908d5094e92b" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "mariadb", "manifestSha256": "2e0699de7658d2ae002556c84f49ccb61a70e96b500b41c5e73246570d921bea", - "cacheKey": "48bc77b348e61faa8b0e7430fa8cc02cf0136548cfd6b342bb3d7dfccf9c138b" + "cacheKey": "11a884f2ab079b77a5476dca2475c0ab8f693c0fd9078a9218fcd03f81e1c4d0" }, { "packageName": "pcre2-source", @@ -1670,27 +1739,27 @@ { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "ed081404db705f2d4dcf3a1e5319c4a22809dff9b84b9fd0bf4b3ae3f3913e3b" + "cacheKey": "718d3028065caf74d3e9d6f4563133690dcd44eeb12dfc20e2efe790315770c4" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "8a658122b3dbef11e1a4cdbff2b56b9756cde0e4c3942c3398cfa01046da3cc0" + "cacheKey": "bf94c26f54f24404557058c49c772d0f25cab107d0b1a3a8a937fb6fdb71713a" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "cf9024c623d71b848048b4b68ace29ed182fe173df89b56ed02177018a557ce7" + "cacheKey": "986479c451806fa661c3a1773f3aea1d1390603e1fe547c661d298fb2d1df1e7" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "5fed4cef387e8aa1dba438ab123b54e9324dc7b33a448a31d203f78df73b1d02" + "cacheKey": "67b0dd58954a0d9f9433bcdb5d5064f3575bd48ba84dd7ca7ff1400ea0181467" }, { "packageName": "mariadb", "manifestSha256": "2e0699de7658d2ae002556c84f49ccb61a70e96b500b41c5e73246570d921bea", - "cacheKey": "7ecdf7257a8395280927964eecda07d31a6eacd1f09f9a0775a5ee29876f87db" + "cacheKey": "f1dd516fed2d03f9699d77d69b5abcece8ebfe03628cc208f8d0d1c5116608f2" }, { "packageName": "pcre2-source", @@ -1715,7 +1784,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "wasm32": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, "dependencyClosures": { "wasm32": [] @@ -1736,7 +1805,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9a013fcbb76a91f84bcf10962e77fb9f79ab66ad8ab9cc066d1053a92aa6a4a7" + "wasm32": "7c8c1a37ebb098b5648349ac9d1600b212ace546c6d8ef87258e6796d78e0be1" }, "dependencyClosures": { "wasm32": [] @@ -1757,14 +1826,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "wasm32": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" } ] }, @@ -1784,7 +1853,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "wasm32": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, "dependencyClosures": { "wasm32": [] @@ -1868,7 +1937,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "wasm32": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, "dependencyClosures": { "wasm32": [] @@ -1889,14 +1958,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "wasm32": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" } ] }, @@ -1916,19 +1985,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "wasm32": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" } ] }, @@ -1948,7 +2017,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640" + "wasm32": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a" }, "dependencyClosures": { "wasm32": [] @@ -1969,254 +2038,254 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e90acec39405a648ced9a361bc1008b198322d5d2abb40b32aaaecff59379b09" + "wasm32": "e4462832b24f124b23314eb26add749ca515fe1a38f78834816c0fae0c49da82" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "icu", "manifestSha256": "e1169851c1978d3356c4e4c0f396672cb6b7785375a44411f7211ddfc90e7ad5", - "cacheKey": "29b737564e6d227336ef5ab8853ea2a2bf18b6d01f4619e04252cb9a66aea401" + "cacheKey": "d3da6e8512e1edd9452bf2f7898756037a1074bbedf191912c6bbbb827ef245a" }, { "packageName": "kernel", "manifestSha256": "3cfdfa2130a0917b23ee9ae760901e5caa225654169a51f767413c38025b7b2d", - "cacheKey": "6f7364081f0971d3948eaf7c3f8b11b00b0ce98bc9d1525c75d03ef056f65d71" + "cacheKey": "db62f6bd1409ee6c7f465f21d796dc4717d63fa2bed8bb2c6e7d86cbc4c5b217" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "libiconv", "manifestSha256": "7ae4f68412db04f01e027f94b0d14c1130ac4dfb2041270207994ddab56f0855", - "cacheKey": "939a64271c7d51277a5ec7a49c45c55fb4c4b252c68361ca3c6beb6bf9a3111b" + "cacheKey": "2bc2df82f5537c5f74f7e06dc3e6375f4ef24a0866e54df0f2e75f0152baf538" }, { "packageName": "libxml2", "manifestSha256": "87301bfcb607ee24c00925787cfe071ba506c750437ff5b807b2ea0897058e91", - "cacheKey": "0f93f0ef28cebb24d87516d0db6683a8909359a10f1577db92a8374b04ae7cdd" + "cacheKey": "a90b8526a47a799b2917d4dc3c6d16f1d65ece66005e484a322c503e5c5805d8" }, { "packageName": "libzip", "manifestSha256": "84a6e61fc67c7dd6cf5b4aacd80682a86d402c78810efdd03a9ff58da8eaf6a0", - "cacheKey": "f7b217af4f83eeb207d7cbfca570144719a67f4df6ae3c1fc56a8694dd0568ce" + "cacheKey": "7ddffc7b50eea7a8ecebc94f88c1f6e6953b1d1bfd0bb30bb6f33805c3f100da" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "nginx", "manifestSha256": "b78552502c63bef2814c8aae76fedfdb4f273e4ee0c42023d6157ddb298ff815", - "cacheKey": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640" + "cacheKey": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "php", "manifestSha256": "14114e280941f1f2f6a4c5abc11698b4b01f909be4cf2aac5b0e68845fed4644", - "cacheKey": "6062638f2f20a7254948ee6bede9c4a1515d0ff4565abb15084b037c3510ab05" + "cacheKey": "70a85fce082656d22b33c356285b2659a00ff59e3efeda9f35862e8fdd6c0abd" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "shell", "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", - "cacheKey": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "cacheKey": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, { "packageName": "sqlite", "manifestSha256": "77110d20137fe7dfd55d6be0f99c959ec3d4bc23ebbf516e5eb51f3886d8bcee", - "cacheKey": "225473952aaa1f400b89870afc6943881cbbbbbe96019b5b7fd47e913a29fcb2" + "cacheKey": "fce7d13e41a79094256bc45771a13581e2c699dead5a29841561c76d435425c6" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -2236,219 +2305,219 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e9a37566343366381720bd5d48cb5bffd3b74c1164901ce8e20970279332948c" + "wasm32": "d3df523ac43da00f2dde4c938389e7f6bff55c86bf582a8300b519ee412e23e3" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "nginx", "manifestSha256": "b78552502c63bef2814c8aae76fedfdb4f273e4ee0c42023d6157ddb298ff815", - "cacheKey": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640" + "cacheKey": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "shell", "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", - "cacheKey": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "cacheKey": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -2468,29 +2537,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "010787fcdad88941f9bb4fda4d3b19b3c479de269ffd5d47588e25bdf375ab1e" + "wasm32": "d090c5dffda26124b0fd2f6c439e9346fd336ab65e5e98c27f734fc30627a0f2" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "spidermonkey", "manifestSha256": "0460143a58135f834705ccd1316f9007f7d9bb1feee1de58574d2c6b2891ac40", - "cacheKey": "dc6d9e16ef5cb90621c7e083a38df6e0ca78f0c1ee7d08cda5e3eee159d8ae0a" + "cacheKey": "e19fa8ad12e75737398605395089a6f2ffff11a11c0e8fe8b1e6d1af005a0e05" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -2510,219 +2579,219 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bdcaae9a5dc421e4a4fbd7cebac8b628c585923f75562ca0917e176c39b36aa9" + "wasm32": "7a6c0893e1404bcf91813971d297e87afcd96363308731e1364bce5c4458bfdb" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "node", "manifestSha256": "353bce1b61a21b2132340a9f6f1e2d7e9354a592b3d9762165a215ad390c23c2", - "cacheKey": "010787fcdad88941f9bb4fda4d3b19b3c479de269ffd5d47588e25bdf375ab1e" + "cacheKey": "d090c5dffda26124b0fd2f6c439e9346fd336ab65e5e98c27f734fc30627a0f2" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "shell", "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", - "cacheKey": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "cacheKey": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, { "packageName": "spidermonkey", "manifestSha256": "0460143a58135f834705ccd1316f9007f7d9bb1feee1de58574d2c6b2891ac40", - "cacheKey": "dc6d9e16ef5cb90621c7e083a38df6e0ca78f0c1ee7d08cda5e3eee159d8ae0a" + "cacheKey": "e19fa8ad12e75737398605395089a6f2ffff11a11c0e8fe8b1e6d1af005a0e05" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -2742,7 +2811,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e4ec3cdc5b7ceacb9b9be1d93ff3c52247e95d8ec3e07927b6c3fb059909740f" + "wasm32": "a96df7fab0a5a61a0541f75698b2a963ee80732d6efad7dc57a6094c80d0a7d7" }, "dependencyClosures": { "wasm32": [] @@ -2763,14 +2832,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a8a53f1dfe72eec9bfd6c6867aa9c0babd6fc2783988747109173ba605bab1e1" + "wasm32": "7a52dff193297d1d438d032687fad3debbb98ed6b70ac9a6f56547d86c65ee5e" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "dfebfffe4136be5b38e5f7aef14d75068e42ac9a05541582d005a6992f41c216", - "cacheKey": "e4ec3cdc5b7ceacb9b9be1d93ff3c52247e95d8ec3e07927b6c3fb059909740f" + "cacheKey": "a96df7fab0a5a61a0541f75698b2a963ee80732d6efad7dc57a6094c80d0a7d7" } ] }, @@ -2790,54 +2859,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6062638f2f20a7254948ee6bede9c4a1515d0ff4565abb15084b037c3510ab05" + "wasm32": "70a85fce082656d22b33c356285b2659a00ff59e3efeda9f35862e8fdd6c0abd" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "e1169851c1978d3356c4e4c0f396672cb6b7785375a44411f7211ddfc90e7ad5", - "cacheKey": "29b737564e6d227336ef5ab8853ea2a2bf18b6d01f4619e04252cb9a66aea401" + "cacheKey": "d3da6e8512e1edd9452bf2f7898756037a1074bbedf191912c6bbbb827ef245a" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "libiconv", "manifestSha256": "7ae4f68412db04f01e027f94b0d14c1130ac4dfb2041270207994ddab56f0855", - "cacheKey": "939a64271c7d51277a5ec7a49c45c55fb4c4b252c68361ca3c6beb6bf9a3111b" + "cacheKey": "2bc2df82f5537c5f74f7e06dc3e6375f4ef24a0866e54df0f2e75f0152baf538" }, { "packageName": "libxml2", "manifestSha256": "87301bfcb607ee24c00925787cfe071ba506c750437ff5b807b2ea0897058e91", - "cacheKey": "0f93f0ef28cebb24d87516d0db6683a8909359a10f1577db92a8374b04ae7cdd" + "cacheKey": "a90b8526a47a799b2917d4dc3c6d16f1d65ece66005e484a322c503e5c5805d8" }, { "packageName": "libzip", "manifestSha256": "84a6e61fc67c7dd6cf5b4aacd80682a86d402c78810efdd03a9ff58da8eaf6a0", - "cacheKey": "f7b217af4f83eeb207d7cbfca570144719a67f4df6ae3c1fc56a8694dd0568ce" + "cacheKey": "7ddffc7b50eea7a8ecebc94f88c1f6e6953b1d1bfd0bb30bb6f33805c3f100da" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "sqlite", "manifestSha256": "77110d20137fe7dfd55d6be0f99c959ec3d4bc23ebbf516e5eb51f3886d8bcee", - "cacheKey": "225473952aaa1f400b89870afc6943881cbbbbbe96019b5b7fd47e913a29fcb2" + "cacheKey": "fce7d13e41a79094256bc45771a13581e2c699dead5a29841561c76d435425c6" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -2913,7 +2982,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "wasm32": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, "dependencyClosures": { "wasm32": [] @@ -3186,19 +3255,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d99fdd4b6bac0a13639e1e1a99bcd10a5122f4f2fa86d908c9a6267219bc7ee1" + "wasm32": "79403e28bbf504b073ee6c4bcadffe3344e2004454eb5d906118b11e6ade602c" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "3e5bd98bf8221902ca214d73117fa71d7dfeb0c75cf5c1fe219b82eb94bb9ff0", - "cacheKey": "671600d839333cd841b35d44f182836e9d9f515b0da5f13ece812b15c99aa797" + "cacheKey": "24754c020c2a08b5cf2502ea4f3010c53c1c0b4c2094fa5d597221d36afc29b1" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3218,7 +3287,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4a90606ee4c37280f794d9b6249b80f8533c76a9148ee86ebc0ca609d437ba05" + "wasm32": "c6d0e0623fd00ec516ed08d36771c34cc0c76019bf3a65bda82371e0d49e3239" }, "dependencyClosures": { "wasm32": [] @@ -3246,24 +3315,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "13a85bd7b4cfadbebf9d2aa3966988dac7e37aea7897453a6eaa4bc8e9f9ab0d" + "wasm32": "bc088fa32d51ca9ad14533e12f66ed674cb3fc9e92057086b6ccf6ed214a7462" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "redis", "manifestSha256": "94bfd24dd43bea67bb036fa52e8ab9a88ab5c29d9597e2a7e32b30cac9474d42", - "cacheKey": "4a90606ee4c37280f794d9b6249b80f8533c76a9148ee86ebc0ca609d437ba05" + "cacheKey": "c6d0e0623fd00ec516ed08d36771c34cc0c76019bf3a65bda82371e0d49e3239" } ] }, @@ -3283,79 +3352,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "wasm32": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" } ] }, @@ -3375,14 +3444,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c75e43269ce4d42c1f87291a152eafbba0158cf6ec9625ee62befaeabe14e2b2" + "wasm32": "14dd36525a93479b58177b3df9f030b83960f181a7c0cceeb265b2c8277e65e0" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3409,19 +3478,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e36804a9f073136982cb60e7f52da451427dc850ab5760e99ed92b3ebe70daa0" + "wasm32": "2b6f207dfb1e1eccda5871c3aba08e4fb580bd9970436f35861fe51b5236f435" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "b6e6a01586cc01f6005fbaa79027b81a08dcc4cd77ff51a99a093d04ef930366", - "cacheKey": "064daacee36ac921255ee74911d5660b10499010a105fba8b365861890e70177" + "cacheKey": "5383b697827537e8a890edbd61d13bea292b3e1304009394ef4be33b697bf2f2" }, { "packageName": "sdl3", "manifestSha256": "fc849fbc7da35401a040c328245b5e6d490e8f994b77c954ed2ab463d0892aca", - "cacheKey": "b8040a63705623aea889f697b1fed6fc58326c89fa2720dbfc89432f8b09d572" + "cacheKey": "5492484e32cf245385424cc9e73781d21ab919e89362418a0efdf48be3332119" } ] }, @@ -3448,14 +3517,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "30dbc38b217dcb6e0a80cbcc4c3780aec647cccb76c2a3870a4da786a24a4d59" + "wasm32": "64e111f88438f52575eb7fbbe95a93e32e3a05b68012f5d38ff35e63d4845220" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "b6e6a01586cc01f6005fbaa79027b81a08dcc4cd77ff51a99a093d04ef930366", - "cacheKey": "064daacee36ac921255ee74911d5660b10499010a105fba8b365861890e70177" + "cacheKey": "5383b697827537e8a890edbd61d13bea292b3e1304009394ef4be33b697bf2f2" } ] }, @@ -3475,7 +3544,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "wasm32": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, "dependencyClosures": { "wasm32": [] @@ -3496,199 +3565,199 @@ "wasm32" ], "cacheKeys": { - "wasm32": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "wasm32": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -3708,24 +3777,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dc6d9e16ef5cb90621c7e083a38df6e0ca78f0c1ee7d08cda5e3eee159d8ae0a" + "wasm32": "e19fa8ad12e75737398605395089a6f2ffff11a11c0e8fe8b1e6d1af005a0e05" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3745,29 +3814,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9dc5f6e47c3249ec4604dffa9d4f9097750574e97aa3c806c3d17cb9aba4e5a0" + "wasm32": "63e3185dd513a0ba9760f5907fb7abd7bda32c5330e15f1a0298f84c8e158e8f" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "spidermonkey", "manifestSha256": "0460143a58135f834705ccd1316f9007f7d9bb1feee1de58574d2c6b2891ac40", - "cacheKey": "dc6d9e16ef5cb90621c7e083a38df6e0ca78f0c1ee7d08cda5e3eee159d8ae0a" + "cacheKey": "e19fa8ad12e75737398605395089a6f2ffff11a11c0e8fe8b1e6d1af005a0e05" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3787,7 +3856,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6b64a2825e98a8d41b7f6d19093080092591b74b20717cdbb66e9508e21bef66" + "wasm32": "e842a724c62debb88c9381709205d21869a4989127a51448a19dbd92f2ae8137" }, "dependencyClosures": { "wasm32": [] @@ -3808,7 +3877,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "wasm32": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, "dependencyClosures": { "wasm32": [] @@ -3829,7 +3898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "570b6000d025ccaed627b2c38902c7c0f1b6f1352255de8dbe2dc6f76d4147cd" + "wasm32": "a6c314bbab19e91bc004a6aba12cee9c1ef081154c3424a0a91dde67bf39370b" }, "dependencyClosures": { "wasm32": [] @@ -3850,19 +3919,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "599e13a4bd4426240dd18c50d176d032fe67deb80d431b7f983cc8eec85adeb4" + "wasm32": "e323130e3c0a89e934b1568bc43ba464655b8f4a8a756524840cc75ef1d65482" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "d21c80ef4dedf35828c1cc74d741abdafc003f7048cf474b2fdfd9143016bd76", - "cacheKey": "7b652e83e5ea6e2619e01d2cc13415258f5b87013bbf7e373a72b848b6b5bec9" + "cacheKey": "42ca6a45c189e22774e064cefc799d7790a9dc83ab06ccd41a6b7594e9ff8e93" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3889,7 +3958,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "wasm32": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, "dependencyClosures": { "wasm32": [] @@ -3910,7 +3979,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "wasm32": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, "dependencyClosures": { "wasm32": [] @@ -3931,14 +4000,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "wasm32": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" } ] }, @@ -3958,19 +4027,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "wasm32": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" } ] }, @@ -3990,239 +4059,239 @@ "wasm32" ], "cacheKeys": { - "wasm32": "18970d4c019d106a8c99af8e62e4db0bb3bc1d2c1a631f8be4ca29f856e996e3" + "wasm32": "420af5ddd540a93ae4fa87a205066b8417ffe60a558c82ee20b1438797eb863a" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "2dd728f9834b4dcb62229e032e12d87b1428921d029d7d77b4f68c8debabba0f", - "cacheKey": "6f5dabe2d2610159ce080299c44cbd209be88dda2bd995a503d76c07ba257e54" + "cacheKey": "dbb123525faf0c3ae0c6f07d052d76b6287e8593b1150825280347da38916269" }, { "packageName": "bc", "manifestSha256": "5c44519f861efc263ab401b2ac95a5c20fdc1c4d945d8bff196400cfd93fa22b", - "cacheKey": "624b65f00e1971398ac04a007e37382b341123990dcded17d74ceb9a042b246d" + "cacheKey": "630496425f9c318d7bbd8477336af2df14551861dca7c657a9f95b0de2fe61e6" }, { "packageName": "bzip2", "manifestSha256": "bb06517207c2ceb40ccf9b04914d310c42017aae39aa803e8bd71b06724764b2", - "cacheKey": "9703581822273fda2f601c4f7e2829e09b05ba03237a8b97a9b9bd11d9b49778" + "cacheKey": "fefdb1bea01c7a2c26630276c6ea2a6de46c10c8bf394021e287223b1ed62d86" }, { "packageName": "coreutils", "manifestSha256": "7de40d1f4c454ead124f49ceb0f8da6a57f536bb367f8d6fec44b4976c3543d9", - "cacheKey": "19c4e83604a1f08775c68956a19fd019763045ce67439000fd84ab24b4565e24" + "cacheKey": "2443b7c15bb0f5ff3b3607004e55b3c232b59af1eeba5699bb8f72495990cd96" }, { "packageName": "curl", "manifestSha256": "4b42c4709ee0db6ae4ed02e342781debed91435c793e5a2a7b1e6b8ca724801a", - "cacheKey": "6a8f4f28617f51241f6fcfbb0b0941dbba1d734a1ad5b1a826f649a6812ae668" + "cacheKey": "da7d6f9e9ecd25f6dac100a8c1752582abf897b831511e86b1adcad7aab5cbe7" }, { "packageName": "dash", "manifestSha256": "f9ba4e11179cca17a8f9177d558391a48242468b579115aa8a4d949bfb69a875", - "cacheKey": "53ce7a3a80766fad54e87c3de65ded218c6ebf79eb84c73fdf1965f639a4d9d0" + "cacheKey": "5afda7833197534c939ed6f6575dfe30af595183976b30d9a113877a17b3d5cf" }, { "packageName": "diffutils", "manifestSha256": "81d1846004b3c669292e3d381b70cc0937c7afc59c139917acfee25a7b85f45f", - "cacheKey": "de768e358feda937be1ed513ee098f1cabbe338df2dd8b23e4806ce98a538985" + "cacheKey": "0cbda5e2e319fba08d0fab6e1237c112d8fcac3b0483f4cb62d787b67cc7091e" }, { "packageName": "dinit", "manifestSha256": "1de1650c6ca61a8b19787b354c7d093c7614014ea2bc88e5e1ee174f3008bef8", - "cacheKey": "efbe355335c492b649d8de161c4dc07026b8e9bcfd53acc310041a797dd339bf" + "cacheKey": "758506281588915ff814a618ad91277433c0dac2e9ad36af2112ab70169de255" }, { "packageName": "fbdoom", "manifestSha256": "2fee10c281849df0569c85ae082494ce4cecaf83257bb7b5ceb9c57974740cb8", - "cacheKey": "3d2056faa4f7f2dbae6255ff2e5120cd5c76cba01916c2365b1ac29f8500b229" + "cacheKey": "39ccfc9a7c5408ee49c760825676eb172684b65da83cfa77f63ba319b59cfc80" }, { "packageName": "file", "manifestSha256": "af9bdc22a415acdb621b5f5c266540aacb856953acb7d4c19ef7e575b0e83682", - "cacheKey": "659cae184c039ede37a3c0ae4f21ecee68ea4decf443227ff5155dca7904b1e1" + "cacheKey": "a03c7c73c2401cd0515cfb18037e4721b66a40ea6f28bfc04ceea1d0704e67c8" }, { "packageName": "findutils", "manifestSha256": "89c73153e867738697aaf7c382a766ef6d1b1abdd8c09c29dcde20212596960c", - "cacheKey": "ff3329505ccd07d15dfe5d049d0dadc4ae2ce81c2541eccc69c1f9c1060e7d9c" + "cacheKey": "e6420a15e8509fa0e6c0cd3880d784e789eadd0bc871cbad3736ea131d925906" }, { "packageName": "gawk", "manifestSha256": "a541a82d7c3049235c12c0bb19edf44e7e4a4954ddb5a5033c20d417d0e7c2a4", - "cacheKey": "cc9dbfdcfc74eb507d7495c200ee3ae532706711ecd62b70238dfede5f012d4d" + "cacheKey": "034c08992d9a9112c786495e4897f69c9a07b809a460a57d25e14763aceeb072" }, { "packageName": "git", "manifestSha256": "ad5c8e8612e7431e7c7b73e69d517564292bbc41f6408e5d5cb3190881097403", - "cacheKey": "732c9d08b431a75ec9bb5bd67530ea21a738f3c191269d8b2417ee27b0a4aebf" + "cacheKey": "da833f7fd84b54207c10fba98fbe460731e97ba61e1dbfee10a656e6011815c1" }, { "packageName": "grep", "manifestSha256": "82bea07002f3bd2d70b340849f0a8d5f9a479e67a59f5fdc5f5ed02a26ed8745", - "cacheKey": "b94bdcee325360c714a9af22cfbc3cb0ffb1356d039887dcd2500da8b58152fc" + "cacheKey": "dbb4316e6181020556cffdff08a9fabba357b003db4ce1c0cc4d1ba8fd9fd647" }, { "packageName": "gzip", "manifestSha256": "5d0056490a3edb52473814878025ca05c47ac525c25679fd8ae0378d75b6a831", - "cacheKey": "0f05172e0ae43d531e2730cffbdddb4de4706451bd2e5f57d4169b0797d63544" + "cacheKey": "77229f70955c1156a63e64c4ae8bc167456f2f75b9c6240098054070329ff5c7" }, { "packageName": "icu", "manifestSha256": "e1169851c1978d3356c4e4c0f396672cb6b7785375a44411f7211ddfc90e7ad5", - "cacheKey": "29b737564e6d227336ef5ab8853ea2a2bf18b6d01f4619e04252cb9a66aea401" + "cacheKey": "d3da6e8512e1edd9452bf2f7898756037a1074bbedf191912c6bbbb827ef245a" }, { "packageName": "kernel", "manifestSha256": "3cfdfa2130a0917b23ee9ae760901e5caa225654169a51f767413c38025b7b2d", - "cacheKey": "6f7364081f0971d3948eaf7c3f8b11b00b0ce98bc9d1525c75d03ef056f65d71" + "cacheKey": "db62f6bd1409ee6c7f465f21d796dc4717d63fa2bed8bb2c6e7d86cbc4c5b217" }, { "packageName": "less", "manifestSha256": "65a0b5507655e741acc125a27b694c8ada5ed643299fdf9b41afe790f3c84468", - "cacheKey": "459cb5f7ade58fb86b7d84e9f30c76c3abff3016d25d0ce0f1606ab6cac80da8" + "cacheKey": "f3a5c19d76e37d223018e566db381eef0ea04265151571e146eb7fded3293989" }, { "packageName": "libcurl", "manifestSha256": "d81c9d71f1eacc9f630f7774e0adbe6eb736a83d2f5271ea9814e9c672512b80", - "cacheKey": "354bf0d6a423b81dcd5d3847d6b505f57fb0615db4fb86a994b2142c5856fda1" + "cacheKey": "961e5b1c7a3fc50240846a3ef69a9fb55711225a8b55d0ba6de7d4019b5542f4" }, { "packageName": "libcxx", "manifestSha256": "7a2de391ea6ee39accafcd40e7b60c1d43ce4d82336f1064d935f738f063004f", - "cacheKey": "1d8f7ff496ad523216fe91ff741b2c99a629292e59cda8baa4dc46dce676de66" + "cacheKey": "b9a42eff6f300432a180f4917ec1f816e502db20765f3098f8ae61046626749a" }, { "packageName": "libiconv", "manifestSha256": "7ae4f68412db04f01e027f94b0d14c1130ac4dfb2041270207994ddab56f0855", - "cacheKey": "939a64271c7d51277a5ec7a49c45c55fb4c4b252c68361ca3c6beb6bf9a3111b" + "cacheKey": "2bc2df82f5537c5f74f7e06dc3e6375f4ef24a0866e54df0f2e75f0152baf538" }, { "packageName": "libxml2", "manifestSha256": "87301bfcb607ee24c00925787cfe071ba506c750437ff5b807b2ea0897058e91", - "cacheKey": "0f93f0ef28cebb24d87516d0db6683a8909359a10f1577db92a8374b04ae7cdd" + "cacheKey": "a90b8526a47a799b2917d4dc3c6d16f1d65ece66005e484a322c503e5c5805d8" }, { "packageName": "libzip", "manifestSha256": "84a6e61fc67c7dd6cf5b4aacd80682a86d402c78810efdd03a9ff58da8eaf6a0", - "cacheKey": "f7b217af4f83eeb207d7cbfca570144719a67f4df6ae3c1fc56a8694dd0568ce" + "cacheKey": "7ddffc7b50eea7a8ecebc94f88c1f6e6953b1d1bfd0bb30bb6f33805c3f100da" }, { "packageName": "lsof", "manifestSha256": "f2c156121bbc14891ed642cd4d0e0f8b6695861023882a16a4d086f6b0d650d9", - "cacheKey": "2e059b4fc2aa0f9972fcfb2ebc60fa93d58317b6b9310377d717ca0d59c335d8" + "cacheKey": "013dde10d8ef51c9f703d79d3a05d6b1ec9c19bf66f94d127ee2154a030c2760" }, { "packageName": "m4", "manifestSha256": "919dd8f79a58c8fa7ff55a13003dd38c3f07d9c8cd150213fb4d855fc7269e9b", - "cacheKey": "2bb4f2d522a1f16c6da830c058ebbba808ddc6c5f065fa2d789a62d081495695" + "cacheKey": "9aaa483203deb8062749281074335885baeb6dd38ea4ae0a14647c8a73377f3d" }, { "packageName": "make", "manifestSha256": "c01bf3f64968c041bba5d18950c08a854d5c681ea7fd883856408cd6f1409a63", - "cacheKey": "c877adfa51c778e4f579f751a4b4695d9185bb3f6e23ede0ca91128ece524bd5" + "cacheKey": "d94425e48a77368549234e42055f277835ea1c01c8d9316ec5137173242fcbdb" }, { "packageName": "modeset", "manifestSha256": "7f3e362369709c3699963efc55864f39ef8cff6f8a1bf57a8053029fb91534b7", - "cacheKey": "ec3e48fde1c602afd114f6e43a48eb4267c2283a05cd5635c7a178abab59fb60" + "cacheKey": "bc9716a91a63bd1c17dd27d15ddd8b80fcbaab9380fc7e33812b3737865e45d0" }, { "packageName": "msmtpd", "manifestSha256": "eb554efa7f11b5d2c2268492eecf35bc0d80eeebbcffc1441b590b5541b4ec16", - "cacheKey": "9a013fcbb76a91f84bcf10962e77fb9f79ab66ad8ab9cc066d1053a92aa6a4a7" + "cacheKey": "7c8c1a37ebb098b5648349ac9d1600b212ace546c6d8ef87258e6796d78e0be1" }, { "packageName": "nano", "manifestSha256": "f9c694eef9e6136e8b0f30904d720ffc75c7cd03cb80ffe8c08d1ba5911535ae", - "cacheKey": "0556a9e66f11d29a51f75ca564a90ee9500489292ab7b36683fd8d8ed8da3086" + "cacheKey": "8ccc600f7d195eea56d8297f95983d0eeebe347821027876cba8aefca48de1ce" }, { "packageName": "ncurses", "manifestSha256": "633681b4495fbcb0522dfc9a15ce9aec76ff85bbcdfed9767efbb3931877f194", - "cacheKey": "79998bb8ec023af459faadccd3a5f3efa9779cad66fdfa8d5431e5ac94608c97" + "cacheKey": "3bf70a3343176529778e131d396392192503d81622424bd525c3f3be3cfbdfa4" }, { "packageName": "netcat", "manifestSha256": "b54857de3d16117aaba37168a01ceb9d7f9ea2b8b00537aa7ecd6b6207fcf63f", - "cacheKey": "c56c7df2a9afc4ffb5ca5e141080e52b611eddd4e7c386a0bd478e97fe0b1b28" + "cacheKey": "bc50624baba901d1d799ee8f5e1162d16e54e743c7aaec162c9c68ffdd01135a" }, { "packageName": "nethack", "manifestSha256": "fc65d8274f946089c178c42092e29cec558ead0a82e339fa85fc5dad5d992dd8", - "cacheKey": "ebe7bf355ad4dfe61391f106a2d2096a632c984c2f37f10a1fcd4af6dae8ba2d" + "cacheKey": "921e37b24dc9b441523548398566bfa090997af77feba3f84ef6bab6c2f39e12" }, { "packageName": "nethack-browser-bundle", "manifestSha256": "054104dd336691407a7c06a437fdd2cfc7e15036dd64800912b97d445a3ce73a", - "cacheKey": "dee7f5bffd3b39600ac150e1e2f8315c455b3fb037fe450dc96acacd224d5487" + "cacheKey": "044e9e1933d3cc379886cd7f0328f1319b997a33649397fe6d98af3d9ef220b6" }, { "packageName": "nginx", "manifestSha256": "b78552502c63bef2814c8aae76fedfdb4f273e4ee0c42023d6157ddb298ff815", - "cacheKey": "592477087290da56bd3b002bed30d2cbe23245871bea5c4734070bdd161ad640" + "cacheKey": "35614805625547ec02399b25562f922cebc0e2c639703c07ad50f4ac9fc41d5a" }, { "packageName": "openssl", "manifestSha256": "865dc8ce9577ce1cc7af69d85c063336a5b586a28b759aa926b3aa98c0fd6e19", - "cacheKey": "eaab6c0fc5d1c5b493e4484bbdfe0f3cd72b76fce5d2de926191cb3fc8824246" + "cacheKey": "0aec27fe558e0ea39004e05971fb622869504a2070991ab1dc3fd3d579faccec" }, { "packageName": "php", "manifestSha256": "14114e280941f1f2f6a4c5abc11698b4b01f909be4cf2aac5b0e68845fed4644", - "cacheKey": "6062638f2f20a7254948ee6bede9c4a1515d0ff4565abb15084b037c3510ab05" + "cacheKey": "70a85fce082656d22b33c356285b2659a00ff59e3efeda9f35862e8fdd6c0abd" }, { "packageName": "posix-utils-lite", "manifestSha256": "988198a2ed062a1c292f2f55373eb213a8ba20c2ad0ab377fc46d82cbb610fc9", - "cacheKey": "4e1f08e7289352b70e10055a9aa128a1f55ffbb64c71ec1d399d39e0647dedf9" + "cacheKey": "786cca62acce051524fc3d07167d7416d9804cef413b2b22aaa425e083e138c2" }, { "packageName": "rootfs", "manifestSha256": "fd79258e3fd38a31b2550fd156fd53e460669db5fc47f34f3e9ac654c545d7dc", - "cacheKey": "ccb9ae9c4ad17b7ce6eb87066c0379f5abb4da6ac1f4fb879793c7abce5331f1" + "cacheKey": "824f2fd46dc0a9771d8aba1b4edbd146490093b97db7b126d884df8e20a1bcc8" }, { "packageName": "sed", "manifestSha256": "0ac72c93e26ffe357cb6efe39ff8b20c9093023a839d9e48e25bd90ec64e55b3", - "cacheKey": "7bd877086d79a9de826184c951847a26cbe3a588721fe1731aa0808aecf4a024" + "cacheKey": "caf574024999578d4daf8a5229d5d5fbeff6e86a53d34c3c66e7fb14e2d11be9" }, { "packageName": "shell", "manifestSha256": "cafd0902b76845067dd291c16cad2730fc2a12b6d2ceb9774734509f6dbc95ed", - "cacheKey": "03ad86067626316dd2a54b3c0375242cc59c934345f45f67dc51428cada8dcca" + "cacheKey": "87ef333a1ace9c3f490eb4013f01015148c3d6b3678661281cee727f0780752e" }, { "packageName": "sqlite", "manifestSha256": "77110d20137fe7dfd55d6be0f99c959ec3d4bc23ebbf516e5eb51f3886d8bcee", - "cacheKey": "225473952aaa1f400b89870afc6943881cbbbbbe96019b5b7fd47e913a29fcb2" + "cacheKey": "fce7d13e41a79094256bc45771a13581e2c699dead5a29841561c76d435425c6" }, { "packageName": "tar", "manifestSha256": "f81edb1068a9c85e9c9ed54fee800dad824ebbf431695f7f56c501da9a457ba1", - "cacheKey": "38058bb49eca256b350b86a28e5bfd8e603e809fdc866aecc479ae440235d068" + "cacheKey": "c0f8fa2a1f81fdc49baef2545d4d99ac2a606410013c91497025520f908a8923" }, { "packageName": "unzip", "manifestSha256": "05b365fd79288e6f3dc0902ee5b26896eac3ae8c9df07f41fa86c7b9591c2240", - "cacheKey": "b5cf139d9b2bf064a73f6d7c5af1893f22435da2a74bea3a5775a11188419033" + "cacheKey": "4f988be92f7df98e01b8189da167c97b65f2051052e46d2e8d5284f7746e2f85" }, { "packageName": "vim", "manifestSha256": "b3641cb49f2fd59057cc63e8d62424de26cd0eb72ae5847ea7867964e601f86c", - "cacheKey": "2bd24d9a584a23a56de78d4f5cc79d6373c9391f7114a7a6d4393b87fad085aa" + "cacheKey": "c992bf5f62eebd9bd62044874b3b389819edd3876c3cd91acc2b9f9062cec3ea" }, { "packageName": "vim-browser-bundle", "manifestSha256": "c70153f22f254a772c53fe58aeb85d1777f51b856541e417f92174a211b1fb7f", - "cacheKey": "499b12428415f585682af4044919d45d4bf4dc01801d83a555875c86d98b3b51" + "cacheKey": "f87795e98217f2bbdf7335e7c168f8acef7f8c752646ff2732fb2be30f653a61" }, { "packageName": "wget", "manifestSha256": "36f273b9ca7a938777f6d51256d8210d6cea7bb4ff74f085c3ee43b06f867d9f", - "cacheKey": "ca0f0449df40c212ca6b482f4c2aa8d0ee8a8b63c49fdc90d90f64b268888142" + "cacheKey": "7bdb053e72627a7516ed3216ce7ec6aa60bde6991fea1180572a85e6688fe6d1" }, { "packageName": "wordpress-sqlite-integration-source", @@ -4232,22 +4301,22 @@ { "packageName": "xz", "manifestSha256": "22bf03414f0abc7eb80c8aa4cce61b08386116ad6ef3f9fe27d985b53ca996ce", - "cacheKey": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "cacheKey": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, { "packageName": "zip", "manifestSha256": "86546152e545f1cc5c1579074fea0c938e8e59c83119fa11460c202970da7742", - "cacheKey": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "cacheKey": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, { "packageName": "zlib", "manifestSha256": "95773710b08f492b4dc83cb7dd3d9a18fda901ced19b4936be330ba5a6734983", - "cacheKey": "f4880c5db1724d8c999ba19965dbf9cb21948a66355d88430603f48c7d85c5a6" + "cacheKey": "b71989d458b4b6b460553292b64f1ce5eff74952212c600b3fa07db0c81361ce" }, { "packageName": "zstd", "manifestSha256": "848833483664496f204f9d66c65a403e0578d34cf45c4b00590b09956a355036", - "cacheKey": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "cacheKey": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" } ] }, @@ -4267,7 +4336,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "24bc0eefa6026efa694b610279056d2eea703093d477ffc2e618b19b315c2450" + "wasm32": "3068f7ee29969ae70a05db6c9bbdabba080a2a84ab9c05273a961e1f88c29d8c" }, "dependencyClosures": { "wasm32": [] @@ -4288,7 +4357,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3cd126749fb97d0dd1d3616b72027c416c44ddfb48a9e372b2657beea2e6f862" + "wasm32": "7ecdbe10d64115f05753396dab34e3f97beb5d9b6b9948ee3a9c1bcf2076bab8" }, "dependencyClosures": { "wasm32": [] @@ -4309,7 +4378,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3fe56fe859bd4fa407b8ea69e8ac1e68e66de1724fa98a092b51769075870cec" + "wasm32": "00b999f693a06a1444022d375067db898a296d1efc03684d39d9489bb8632c89" }, "dependencyClosures": { "wasm32": [] From f498fc46ff26ee78e5117e53c01b56a1269768fc Mon Sep 17 00:00:00 2001 From: mho22 Date: Mon, 24 Aug 2026 12:46:44 +0200 Subject: [PATCH 27/27] fix(packages): classify espeak-ng and evdev-demo for the local source build Main `316178125` makes `packages/sets/local-supported.toml` an exact partition of the registry: `validate_registry_partition` rejects every root the set does not name, so `cargo test -p xtask` fails here with `unclassified registry root`. Both packages build from source, so they join `[[packages]]` rather than an exclusion list. A selected package must declare `[source].provider` explicitly: espeak-ng pins a tag archive, and evdev-demo builds from this repository the way `modeset` does. Naming the provider changes each manifest hash, so the program package projection is regenerated with it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/registry/espeak-ng/package.toml | 1 + packages/registry/evdev-demo/package.toml | 1 + packages/registry/program-packages.json | 8 ++++---- packages/sets/local-supported.toml | 8 ++++++++ tools/xtask/src/local_build.rs | 11 ++++++----- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/registry/espeak-ng/package.toml b/packages/registry/espeak-ng/package.toml index 5dfb8cd052..44ccd72960 100644 --- a/packages/registry/espeak-ng/package.toml +++ b/packages/registry/espeak-ng/package.toml @@ -17,6 +17,7 @@ depends_on = ["libcxx@21.1.7"] [source] url = "https://github.com/espeak-ng/espeak-ng/archive/refs/tags/1.52.0.tar.gz" sha256 = "bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23" +provider = "archive" [license] spdx = "GPL-3.0-or-later" diff --git a/packages/registry/evdev-demo/package.toml b/packages/registry/evdev-demo/package.toml index b0b72d3605..87994ad491 100644 --- a/packages/registry/evdev-demo/package.toml +++ b/packages/registry/evdev-demo/package.toml @@ -13,6 +13,7 @@ arches = ["wasm32"] [source] url = "https://github.com/Automattic/kandelo" sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +provider = "repository" [license] spdx = "GPL-2.0-or-later" diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index b775ac2155..5095fdebff 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -79,14 +79,14 @@ } }, "espeak-ng": { - "manifestSha256": "f0fa6c1a30ac07341e5ef0b6f3b1b29be8de9d32f11130a9196fb1505927d835", + "manifestSha256": "ae6c7b301617b5541bbf9fb9ff68a886f05c6e19f5393eacf03b50172ae5477d", "cacheKeys": { "wasm32": "0c3735cdb7871c3782a3494617163b6c5294db3d8afe6aaa1a6b7ec56b2e2d4a", "wasm64": "9af3ba96ec7382e8f4875c64dfad3eeccbe7759cd580ad2999049fba0f68269e" } }, "evdev-demo": { - "manifestSha256": "0943919404dcb8592ecc32f7171fdea68047d3c850a46f6fc88a06b65668ad2f", + "manifestSha256": "79e367095cbe154238d3b54b472fdea8426e4aa9d3a96afce61b04f656126952", "cacheKeys": { "wasm32": "0cb7324a23a8380757d5e5921e6f73f4c55ccd983f72d129d3ea4567adbf5ffe", "wasm64": "b444e77dd10593ef070b956dfc6daad73ebef8c77d8c06f5611e04b8e9c7643a" @@ -914,7 +914,7 @@ ] }, "espeak-ng": { - "manifestSha256": "f0fa6c1a30ac07341e5ef0b6f3b1b29be8de9d32f11130a9196fb1505927d835", + "manifestSha256": "ae6c7b301617b5541bbf9fb9ff68a886f05c6e19f5393eacf03b50172ae5477d", "arches": [ "wasm32" ], @@ -948,7 +948,7 @@ ] }, "evdev-demo": { - "manifestSha256": "0943919404dcb8592ecc32f7171fdea68047d3c850a46f6fc88a06b65668ad2f", + "manifestSha256": "79e367095cbe154238d3b54b472fdea8426e4aa9d3a96afce61b04f656126952", "arches": [ "wasm32" ], diff --git a/packages/sets/local-supported.toml b/packages/sets/local-supported.toml index b6170d16ab..d6c6c1a3e8 100644 --- a/packages/sets/local-supported.toml +++ b/packages/sets/local-supported.toml @@ -66,6 +66,14 @@ class = "user-software" name = "dinit" class = "user-software" +[[packages]] +name = "espeak-ng" +class = "user-software" + +[[packages]] +name = "evdev-demo" +class = "user-software" + [[packages]] name = "fbdoom" class = "user-software" diff --git a/tools/xtask/src/local_build.rs b/tools/xtask/src/local_build.rs index 42578dc37a..2bdade81aa 100644 --- a/tools/xtask/src/local_build.rs +++ b/tools/xtask/src/local_build.rs @@ -3555,7 +3555,7 @@ mod tests { ); assert_eq!(first.schema, 1); assert_eq!(first.policy, "source-only-v1"); - assert_eq!(first.packages.len(), 72); + assert_eq!(first.packages.len(), 74); assert_eq!(first.products.len(), 7); assert_eq!( first @@ -3563,7 +3563,8 @@ mod tests { .iter() .map(|package| package.name.as_str()) .collect::>(), - "bash bc bzip2 coreutils cpython curl dash diffutils dinit fbdoom file \ + "bash bc bzip2 coreutils cpython curl dash diffutils dinit espeak-ng \ + evdev-demo fbdoom file \ findutils gawk git grep gzip icu kandelo-sdk kernel lamp less libcurl libcxx \ libiconv libpng libxml2 libzip lsof m4 make mariadb mariadb-test modeset \ msmtpd nano ncurses netcat nethack nethack-browser-bundle nginx nginx-php-vfs \ @@ -3586,7 +3587,7 @@ mod tests { ("browser-product", 8), ("platform", 16), ("test-support", 3), - ("user-software", 45), + ("user-software", 47), ]), ); for (class, expected) in [ @@ -5048,14 +5049,14 @@ materialization = "lazy" assert!(source.provider_was_explicit, "{name} must be explicit"); *providers.entry(source.provider.as_str()).or_default() += 1; } - assert_eq!(set.packages.len(), 72); + assert_eq!(set.packages.len(), 74); assert_eq!( set.dependency_only, ["pcre2-source", "wordpress-sqlite-integration-source"], ); assert_eq!( providers, - BTreeMap::from([("archive", 57), ("dev-shell", 1), ("repository", 16)]), + BTreeMap::from([("archive", 58), ("dev-shell", 1), ("repository", 17)]), ); }