diff --git a/README.md b/README.md index 7cd264395d..a6aa917813 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. | @@ -349,6 +350,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/abi/snapshot.json b/abi/snapshot.json index 5f15ade07d..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", @@ -2405,6 +2452,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 +3022,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", 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 4db8b052d2..e410887c5b 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 { WORDPRESS_CONFIG_INIT_SCRIPT, WORDPRESS_URL_MU_PLUGIN, @@ -23,6 +24,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 { resolveBrowserCorsProxyConfig, } from "../../../lib/browser-cors-proxy"; @@ -170,6 +175,26 @@ 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", + }), + // 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( @@ -180,7 +205,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 `, + ); } const HTTP_PORT = 8080; @@ -303,6 +332,8 @@ const LIVE_DEMO_IDS = [ "wordpress-mariadb", "doom", "modeset", + "evdev", + "espeak", ] as const; type LiveDemoId = (typeof LIVE_DEMO_IDS)[number]; @@ -401,6 +432,12 @@ const LIVE_DEMO_SPECS: Record = { image: "shell", features: ["kms"], }, + evdev: { + image: "shell", + }, + espeak: { + image: "shell", + }, }; const DEFAULT_DEMO_FOR_VFS_IMAGE: Record = { @@ -458,6 +495,22 @@ 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. + */ + evdevDemo: boolean; + /** + * Spawn `espeak-ng "..."` from the booted shell. espeak-ng links + * 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; } interface WebReadinessState { @@ -896,6 +949,8 @@ function customVfsProfile( shell: "default", maxVfsByteLength: CUSTOM_VFS_PROFILE_MAX_BYTES, framebufferTest: fb === "test", + evdevDemo: false, + espeakDemo: false, }; } @@ -944,6 +999,8 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { }, }, framebufferTest: fb === "test", + evdevDemo: normalized === "evdev", + espeakDemo: normalized === "espeak", }; } @@ -1220,6 +1277,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); } assertImageTerminalProgram(buildFs, terminalSession.initial); @@ -1452,6 +1521,49 @@ async function bootProfile( tick, assertCurrent, ); + } else if (profile.espeakDemo) { + // 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..."); + 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}`); + } + })(); + } else if (profile.evdevDemo) { + // 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 { + 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) => { @@ -1527,6 +1639,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 776c142df8..9334246892 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -128,4 +128,26 @@ 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, + }, + { + id: "espeak", + 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", + 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..322928f659 --- /dev/null +++ b/apps/browser-demos/test/kandelo-espeak.spec.ts @@ -0,0 +1,47 @@ +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 espeak-ng demo speaks through pcaudiolib + /dev/dsp", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=espeak"); + + // 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. 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*$/); +}); 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/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index be2b772aab..f1a362a03a 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -13866,6 +13866,41 @@ pub extern "C" fn kernel_vblank() -> u32 { crate::dri::vblank_tick() } +/// Fan one translated DOM input event out to every open OFD bound to +/// `/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, + 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 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); +} + /// 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/runtime-core/src/devfs.rs b/crates/runtime-core/src/devfs.rs index f6b862331c..616efa07c2 100644 --- a/crates/runtime-core/src/devfs.rs +++ b/crates/runtime-core/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,20 @@ 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")); + 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/runtime-core/src/fork.rs b/crates/runtime-core/src/fork.rs index 893594d6e8..9630e9b344 100644 --- a/crates/runtime-core/src/fork.rs +++ b/crates/runtime-core/src/fork.rs @@ -639,6 +639,12 @@ 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; + +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 { @@ -706,6 +712,28 @@ 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 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>, +) -> 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.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 +816,43 @@ 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_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, + }))) + } + _ => Err(Errno::EINVAL), + } +} + fn read_dri_state( r: &mut Reader<'_>, ) -> Result>, Errno> { @@ -928,6 +993,11 @@ 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,6 +1348,7 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er dir_position_generation: 0, dir_pending_entry: None, dri_state, + input_state, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -1759,6 +1831,11 @@ 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, @@ -1953,6 +2031,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { dir_position_generation: 0, dir_pending_entry: None, dri_state, + input_state, }; ofd.reset_directory_iterator_for_reopen(); ofd_entries[index] = Some(ofd); @@ -3498,4 +3577,5 @@ mod tests { "exec keeps the same process identity; KMS master should survive" ); } + } diff --git a/crates/runtime-core/src/input/dispatch.rs b/crates/runtime-core/src/input/dispatch.rs new file mode 100644 index 0000000000..2e66512e3a --- /dev/null +++ b/crates/runtime-core/src/input/dispatch.rs @@ -0,0 +1,191 @@ +//! 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; + +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 = keyboard, 1 = pointer). Other device numbers are dropped. +/// +/// 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. +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; + 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; + } + if input.event_ring.len() + RECORD_SIZE > INPUT_RING_MAX_BYTES { + input.dropped = true; + continue; + } + push_record(&mut input.event_ring, &ev); + delivered += 1; + } + } + }); + 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}; + + // 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() }; + table.processes.insert(pid, Process::new(pid)); + let proc = table.processes.get_mut(&pid).unwrap(); + unsafe { &mut *(proc as *mut Process) } + } + + 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() { + 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(); + 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); + + // 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!( + proc.ofd_table.get(ofd_idx).unwrap().input().unwrap().dropped, + "dropped flag must latch on overflow" + ); + + 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_fans_out_to_every_open_ofd_for_the_device() { + 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() { + 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/runtime-core/src/input/mod.rs b/crates/runtime-core/src/input/mod.rs new file mode 100644 index 0000000000..747c63ef79 --- /dev/null +++ b/crates/runtime-core/src/input/mod.rs @@ -0,0 +1,168 @@ +//! evdev input subsystem — backs `/dev/input/event{0,1}`. +//! +//! 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; + +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) { + (_, 0) => { + set_bit(buf, EV_SYN); + set_bit(buf, EV_KEY); + if device == 1 { + set_bit(buf, EV_REL); + set_bit(buf, EV_ABS); + } + } + // 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); + } + } + (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() { + 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() { + // 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); + assert_ne!(buf[0] & (1 << KEY_ESC), 0); + } +} diff --git a/crates/runtime-core/src/lib.rs b/crates/runtime-core/src/lib.rs index d3a86d7d8f..6daee37597 100644 --- a/crates/runtime-core/src/lib.rs +++ b/crates/runtime-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod exec_target; pub mod fd; pub mod fifo; pub mod fork; +pub mod input; pub mod ipc; pub mod ipc_wire; pub mod lock; diff --git a/crates/runtime-core/src/ofd.rs b/crates/runtime-core/src/ofd.rs index add78b831e..2f99742b00 100644 --- a/crates/runtime-core/src/ofd.rs +++ b/crates/runtime-core/src/ofd.rs @@ -274,6 +274,48 @@ 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, +} + #[derive(Clone)] pub struct OpenFileDesc { /// Machine-wide identity of this open file description. Independent @@ -312,6 +354,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 +582,17 @@ 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 +630,7 @@ impl OfdTable { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, }; self.insert(ofd) @@ -612,6 +670,7 @@ impl OfdTable { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, }; ofd.reset_directory_iterator_for_reopen(); self.insert(ofd) @@ -962,6 +1021,7 @@ mod tests { dir_position_generation: 0, dir_pending_entry: None, dri_state: None, + input_state: None, }); } @@ -1164,6 +1224,42 @@ 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!(st.event_ring.is_empty()); + + 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 per-fd memory budget so it cannot drift silently. + 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/runtime-core/src/process.rs b/crates/runtime-core/src/process.rs index dbe5fd76c0..696602aa43 100644 --- a/crates/runtime-core/src/process.rs +++ b/crates/runtime-core/src/process.rs @@ -2494,7 +2494,30 @@ pub 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/runtime-core/src/process_table.rs b/crates/runtime-core/src/process_table.rs index b31a925ffb..2f7da0492e 100644 --- a/crates/runtime-core/src/process_table.rs +++ b/crates/runtime-core/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/runtime-core/src/syscalls.rs b/crates/runtime-core/src/syscalls.rs index 98cb4b737c..ce31e43e5a 100644 --- a/crates/runtime-core/src/syscalls.rs +++ b/crates/runtime-core/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,7 @@ impl VirtualDevice { VirtualDevice::Dsp => -7, VirtualDevice::DriRenderD128 => -8, VirtualDevice::DriCard0 => -9, + VirtualDevice::InputEvent { device } => -10 - device as i64, } } @@ -189,6 +194,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 +212,7 @@ impl VirtualDevice { VirtualDevice::Dsp => 7, VirtualDevice::DriRenderD128 => 8, VirtualDevice::DriCard0 => 9, + VirtualDevice::InputEvent { device } => 10 + device as u64, } } } @@ -226,6 +234,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 +738,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 @@ -981,6 +1004,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. @@ -1844,6 +1887,122 @@ fn handle_dri_card_ioctl( } } +/// `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, + request: u32, + buf: &mut [u8], +) -> Result<(), Errno> { + use wasm_posix_shared::input::*; + + let dir = (request >> 30) & 0x3; + let magic = (request >> 8) & 0xff; + let nr = request & 0xff; + let size = ((request >> 16) & 0x3fff) as usize; + + if magic != b'E' as u32 { + return Err(Errno::ENOTTY); + } + + match nr { + 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(()) + } + 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(()) + } + 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(()) + } + 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(()) + } + 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; + 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(()) + } + 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. @@ -3070,6 +3229,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); @@ -3655,6 +3815,7 @@ fn release_ofd_reference_impl( } }; + let freed = proc.ofd_table.dec_ref(idx); if freed { @@ -4542,6 +4703,59 @@ 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), + VirtualDevice::InputEvent { .. } => { + 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)?; + // 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); + } + return Ok(0); + } + let mut written = 0; + // 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) + .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 @@ -13543,6 +13757,49 @@ 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 { .. }) + ) + { + // 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 { + revents |= POLLIN; + } + } + } } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -13938,6 +14195,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); @@ -14598,6 +14856,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 @@ -34834,29 +35104,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, - ] { - 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. - assert_eq!(VirtualDevice::from_host_handle(-10), None); - } - // ===== Loopback socket tests ===== #[test] @@ -40241,8 +40488,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] @@ -45039,4 +45284,557 @@ 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()); + // input + dri sidecars are disjoint state machines. + 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() { + 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() { + 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); + } + + 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"); + // "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]; + 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]; + 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]; + 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 so other tests running in parallel see + // 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(); + 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(); + 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; + 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(); + 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; + 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; + 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); + + 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(); + + 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); + + sys_close(&mut child, &mut host, parent_fd).unwrap(); + assert!(child.ofd_table.get(ofd_idx).is_none()); + + 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; EINVAL fatals it. + let (mut proc, mut host, fd) = open_evdev(614, b"/dev/input/event0"); + 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() { + // 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"); + 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); + } + + /// 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 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(); + 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 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); + 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); + 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); + // 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() + .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() { + 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 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"); + 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() { + 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); + } } 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/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index ab62925f7d..8c4d8930ff 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), @@ -4985,6 +4987,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 +5651,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); + } +} diff --git a/docs/browser-support.md b/docs/browser-support.md index 7546f62762..84bb4aefd1 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -321,6 +321,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. diff --git a/docs/posix-status.md b/docs/posix-status.md index 6a503661df..fd0b16ce52 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/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index c4d387cdae..a9425845bd 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"; @@ -1001,6 +1002,55 @@ 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". + */ + 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 96a245f78e..1b3adb9b5e 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -300,6 +300,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 @@ -451,6 +480,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 da71489cbe..517491cc42 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -4462,6 +4462,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/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/input/browser-input-source.ts b/host/src/input/browser-input-source.ts new file mode 100644 index 0000000000..fd7e9e7947 --- /dev/null +++ b/host/src/input/browser-input-source.ts @@ -0,0 +1,156 @@ +/** + * `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) {} + + 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/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/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/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/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 a3d5018d67..09d7ad3965 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; @@ -30122,6 +30122,57 @@ 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.#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); + }, + ); + } + + /** + * 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.#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); + }, + ); + } + /** * 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..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, @@ -3995,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/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 8b975ebae1..69d45d44e8 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -47,6 +47,7 @@ import type { MountSpec } from "./vfs/default-mounts"; import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; import { FILE_MODES } from "./generated/abi"; import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; +import type { InputSource } from "./input/input-source"; export type { HttpRequest, HttpResponse }; @@ -661,6 +662,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 4f288eb9f1..44deed7459 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -334,6 +334,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 @@ -366,7 +392,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 340312a76a..ae8a22c582 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -3921,6 +3921,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/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([]); + }); +}); diff --git a/host/test/input-attach-source.test.ts b/host/test/input-attach-source.test.ts new file mode 100644 index 0000000000..c3251c7070 --- /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"; + +/** + * Dual-host parity test. Covers the Node side of `attachInputSource` + * — the browser side is mirror-imaged in `BrowserKernel` and exercised + * end-to-end by the Playwright demo spec. 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, + }, + ]); + }); +}); diff --git a/host/test/input-evdev.test.ts b/host/test/input-evdev.test.ts new file mode 100644 index 0000000000..415a001836 --- /dev/null +++ b/host/test/input-evdev.test.ts @@ -0,0 +1,185 @@ +/** + * 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` — the same shape + * a real `BrowserInputSource` drives. + * + * 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/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 }, + ]); + }); +}); 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/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/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); + }); +}); 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, 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/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..bd33437ca8 --- /dev/null +++ b/libc/musl-overlay/include/linux/input.h @@ -0,0 +1,84 @@ +/* + * 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. + */ +#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/packages/registry/espeak-ng/build-espeak-ng.sh b/packages/registry/espeak-ng/build-espeak-ng.sh new file mode 100755 index 0000000000..19ea104ff3 --- /dev/null +++ b/packages/registry/espeak-ng/build-espeak-ng.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# +# Build espeak-ng for wasm32-posix-kernel. +# +# Two-pass build: +# +# 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. +# +# Output layout: +# +# $INSTALL_DIR/ +# 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. + +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}" + +# --- 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". +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 +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)}" +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 (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" +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 (OSS backend)..." +PCAUDIO_CFLAGS=( + -O2 + -I"$PCAUDIO_CONFIG_DIR" + -I"$PCAUDIO_SRC_DIR/src" + -I"$PCAUDIO_SRC_DIR/src/include" +) +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 +# 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 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" + +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 \ + -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)" + +# --- 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 native build wrote it under NATIVE_BUILD_DIR/espeak-ng-data/. +rm -rf "$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" + +# 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 " $DATA_ZIP" diff --git a/packages/registry/espeak-ng/build.toml b/packages/registry/espeak-ng/build.toml new file mode 100644 index 0000000000..8c8f8d8aa9 --- /dev/null +++ b/packages/registry/espeak-ng/build.toml @@ -0,0 +1,8 @@ +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", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "" +revision = 3 diff --git a/packages/registry/espeak-ng/package.toml b/packages/registry/espeak-ng/package.toml new file mode 100644 index 0000000000..44ccd72960 --- /dev/null +++ b/packages/registry/espeak-ng/package.toml @@ -0,0 +1,68 @@ +kind = "program" +name = "espeak-ng" +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/archive/refs/tags/1.52.0.tar.gz" +sha256 = "bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23" +provider = "archive" + +[license] +spdx = "GPL-3.0-or-later" +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/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/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..eab84942c0 --- /dev/null +++ b/packages/registry/evdev-demo/build.toml @@ -0,0 +1,12 @@ +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 diff --git a/packages/registry/evdev-demo/package.toml b/packages/registry/evdev-demo/package.toml new file mode 100644 index 0000000000..87994ad491 --- /dev/null +++ b/packages/registry/evdev-demo/package.toml @@ -0,0 +1,27 @@ +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" +provider = "repository" + +[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" diff --git a/packages/sets/local-supported.toml b/packages/sets/local-supported.toml index f95957edb4..4d2db75755 100644 --- a/packages/sets/local-supported.toml +++ b/packages/sets/local-supported.toml @@ -71,6 +71,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/programs/evdev_demo.c b/programs/evdev_demo.c new file mode 100644 index 0000000000..1f2c53a8a4 --- /dev/null +++ b/programs/evdev_demo.c @@ -0,0 +1,89 @@ +/* + * evdev_demo — runtime backing for `/?demo=evdev`. + * + * 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 inlines its own + * definitions on purpose, so that fixture's ABI check stays independent + * of this header. + */ +#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); + 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]); + } + } + } +} diff --git a/programs/input-evdev-smoke.c b/programs/input-evdev-smoke.c new file mode 100644 index 0000000000..4bb3be11ec --- /dev/null +++ b/programs/input-evdev-smoke.c @@ -0,0 +1,130 @@ +/* + * Three-phase fixture driven by host/test/input-evdev.test.ts. Structs + * and ioctl numbers are inlined rather than taken from + * so this ABI check stays independent of the vendored header. + */ +#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 + +#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) { + 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(); + + /* 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 (;;) { + 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; +} 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/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();