From 83b87d0f6ac100da130a76300a3f0589a742876e Mon Sep 17 00:00:00 2001 From: Kandelo Agent Date: Sat, 11 Jul 2026 08:52:19 -0400 Subject: [PATCH 1/5] build: allow arch-specific cancellation-point syscall slots Teach the musl build to use a separate non-variadic cancellation-point argument type while leaving the public variadic syscall argument type unchanged. --- scripts/build-musl.sh | 109 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh index 421215a9b..5c9607b38 100755 --- a/scripts/build-musl.sh +++ b/scripts/build-musl.sh @@ -94,6 +94,115 @@ if [ -d "$OVERLAY_DIR/src" ]; then fi fi +# musl's src/internal/syscall.h uses syscall_arg_t for the public +# varargs syscall() path and also hard-codes it into the non-varargs +# __syscall_cp() cancellation-point prototype. On wasm32posix those +# two paths intentionally differ: +# +# - syscall_arg_t must remain long/i32 because syscall(long, ...) +# reads varargs with va_arg(ap, syscall_arg_t); widening that type +# would read past 32-bit caller arguments. +# - __syscall_cp() is not variadic and must use the same widened i64 +# slots as __syscallN so cancellation-point syscalls preserve +# 64-bit offsets/lengths and match libc/glue/channel_syscall.c's +# wasm function signature. +# +# Let arch/syscall_arch.h opt into separate syscall-number and argument +# types while keeping upstream musl behavior for arches that define neither. +python3 - "$MUSL_DIR/src/internal/syscall.h" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() + +default_block = """#ifndef SYSCALL_CP_NR_T +#define SYSCALL_CP_NR_T syscall_arg_t +#endif +#ifndef SYSCALL_CP_ARG_T +#define SYSCALL_CP_ARG_T syscall_arg_t +#endif + +""" +old_default_block = """#ifndef SYSCALL_CP_ARG_T +#define SYSCALL_CP_ARG_T syscall_arg_t +#endif + +""" +insert_after = """#endif + +""" +if "SYSCALL_CP_NR_T" not in text: + if old_default_block in text: + text = text.replace(old_default_block, default_block, 1) + elif "SYSCALL_CP_ARG_T" in text: + raise SystemExit("build-musl: found an unknown partial syscall-cp type patch") + else: + marker = insert_after + "hidden long __syscall_ret" + if marker not in text: + raise SystemExit("build-musl: could not patch syscall.h: insertion marker not found") + text = text.replace(marker, insert_after + default_block + "hidden long __syscall_ret", 1) + +old_proto = """__syscall_cp(syscall_arg_t, syscall_arg_t, syscall_arg_t, syscall_arg_t, +\t syscall_arg_t, syscall_arg_t, syscall_arg_t)""" +intermediate_proto = """__syscall_cp(SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, +\t SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T)""" +new_proto = """__syscall_cp(SYSCALL_CP_NR_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, +\t SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T, SYSCALL_CP_ARG_T)""" +for candidate in (old_proto, intermediate_proto): + if candidate in text: + text = text.replace(candidate, new_proto, 1) + break +else: + if new_proto not in text: + raise SystemExit("build-musl: could not patch syscall.h: __syscall_cp prototype not found") + +path.write_text(text) +PY + +python3 - "$MUSL_DIR/src/thread/__syscall_cp.c" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() + +replacements = [ + ( + """static long sccp(syscall_arg_t nr, + syscall_arg_t u, syscall_arg_t v, syscall_arg_t w, + syscall_arg_t x, syscall_arg_t y, syscall_arg_t z)""", + """static long sccp(SYSCALL_CP_ARG_T nr, + SYSCALL_CP_ARG_T u, SYSCALL_CP_ARG_T v, SYSCALL_CP_ARG_T w, + SYSCALL_CP_ARG_T x, SYSCALL_CP_ARG_T y, SYSCALL_CP_ARG_T z)""", + """static long sccp(SYSCALL_CP_NR_T nr, + SYSCALL_CP_ARG_T u, SYSCALL_CP_ARG_T v, SYSCALL_CP_ARG_T w, + SYSCALL_CP_ARG_T x, SYSCALL_CP_ARG_T y, SYSCALL_CP_ARG_T z)""", + ), + ( + """long (__syscall_cp)(syscall_arg_t nr, + syscall_arg_t u, syscall_arg_t v, syscall_arg_t w, + syscall_arg_t x, syscall_arg_t y, syscall_arg_t z)""", + """long (__syscall_cp)(SYSCALL_CP_ARG_T nr, + SYSCALL_CP_ARG_T u, SYSCALL_CP_ARG_T v, SYSCALL_CP_ARG_T w, + SYSCALL_CP_ARG_T x, SYSCALL_CP_ARG_T y, SYSCALL_CP_ARG_T z)""", + """long (__syscall_cp)(SYSCALL_CP_NR_T nr, + SYSCALL_CP_ARG_T u, SYSCALL_CP_ARG_T v, SYSCALL_CP_ARG_T w, + SYSCALL_CP_ARG_T x, SYSCALL_CP_ARG_T y, SYSCALL_CP_ARG_T z)""", + ), +] +for upstream, intermediate, final in replacements: + for candidate in (upstream, intermediate): + if candidate in text: + text = text.replace(candidate, final, 1) + break + else: + if final not in text: + raise SystemExit(f"build-musl: could not patch __syscall_cp.c pattern: {upstream.splitlines()[0]}") + +path.write_text(text) +PY + # Copy CRT overlay (e.g., Wasm-specific crt1.c with proper main signature) if [ -d "$OVERLAY_DIR/crt" ]; then cp -r "$OVERLAY_DIR/crt/"* "$MUSL_DIR/crt/" From 671feba1a69f6d80d639574d8ad04f72b5686612 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 08:53:40 -0400 Subject: [PATCH 2/5] fix: preserve 64-bit cancellation-point syscall arguments Use i64 slots for wasm32 non-variadic cancellation-point calls while retaining the public variadic syscall calling convention. Align the linked glue signature, cover an offset above 4 GiB, and document the sysroot ABI boundary. --- docs/abi-versioning.md | 4 ++ docs/architecture.md | 22 ++++---- examples/syscall_cp_offset_test.c | 54 +++++++++++++++++++ host/test/global-setup.ts | 1 + host/test/syscall-cp-offset.test.ts | 31 +++++++++++ libc/glue/channel_syscall.c | 8 ++- .../arch/wasm32posix/syscall_arch.h | 6 +++ 7 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 examples/syscall_cp_offset_test.c create mode 100644 host/test/syscall-cp-offset.test.ts diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 7e6757bc0..a1a41b180 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -39,6 +39,10 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: fork-using user program. The kernel does not read these exports directly, but the host runtime in `host/src/worker-main.ts` does — a rename here silently breaks fork for every already-built binary. +- Changing the linked musl/glue syscall function types or argument-slot widths, + including the wasm32 cancellation-point `__syscall_cp` path. These are not + currently visible in the structural snapshot, but stale objects and archives + can otherwise link with incompatible Wasm function signatures. - Changing the name, version, encoding, or role semantics of the `kandelo.wpk_fork.capabilities` custom section. The host uses these claims to decide whether a main/side-module pair can safely coordinate fork replay. diff --git a/docs/architecture.md b/docs/architecture.md index 4a4642917..9cece4c65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,18 +176,20 @@ Each process has a dedicated channel region in its SharedArrayBuffer memory. The Offset Size Field 0 4 status (Atomics.wait/notify target) 4 4 syscall_number -8 4 arg0 -12 4 arg1 -16 4 arg2 -20 4 arg3 -24 4 arg4 -28 4 arg5 -32 4 return_value -36 4 errno_value -40 65536 data_buffer (for path strings, read/write buffers, etc.) +8 48 arguments (6 × i64) +56 8 return_value (i64) +64 4 errno_value (i32) +68 4 reserved/padding +72 65536 data_buffer (for path strings, read/write buffers, etc.) ``` -Total: 65,576 bytes (header 40 bytes + data buffer 65,536 bytes). +Total: 65,608 bytes (header 72 bytes + data buffer 65,536 bytes). + +Both wasm32 and wasm64 write six 64-bit argument slots. On wasm32, musl's +public variadic `syscall()` entry point still reads 32-bit `long` arguments, +because that is the C calling convention its callers use. The non-variadic +`__syscallN` and cancellation-point `__syscall_cp` paths widen values to 64 +bits before calling the glue layer so offsets and lengths are not truncated. ### Status Values diff --git a/examples/syscall_cp_offset_test.c b/examples/syscall_cp_offset_test.c new file mode 100644 index 000000000..036a93908 --- /dev/null +++ b/examples/syscall_cp_offset_test.c @@ -0,0 +1,54 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc > 2) { + fprintf(stderr, "usage: %s [FILE]\n", argv[0]); + return 2; + } + const char *path = argc == 2 ? argv[1] : "/tmp/syscall-cp-offset.bin"; + + int fd = open(path, O_CREAT | O_TRUNC | O_RDWR, 0600); + if (fd < 0) { + perror("open"); + return 3; + } + if (write(fd, "abc", 3) != 3) { + perror("write"); + close(fd); + return 4; + } + + /* musl routes pread() through __syscall_cp. If wasm32 truncates its i64 + * argument slot, this offset becomes 1 and incorrectly reads 'b'. */ + const off_t high_offset = ((off_t)1 << 32) + 1; + char byte = '?'; + errno = 0; + ssize_t n = pread(fd, &byte, 1, high_offset); + if (n != 0) { + fprintf(stderr, + "high pread: expected EOF, got n=%zd byte=%d errno=%d (%s)\n", + n, (unsigned char)byte, errno, strerror(errno)); + close(fd); + return 5; + } + + byte = '?'; + n = pread(fd, &byte, 1, 1); + if (n != 1 || byte != 'b') { + fprintf(stderr, "control pread: n=%zd byte=%d errno=%d (%s)\n", + n, (unsigned char)byte, errno, strerror(errno)); + close(fd); + return 6; + } + + close(fd); + puts("PASS syscall_cp 64-bit offset"); + return 0; +} diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index f512821e5..ed17fb3fb 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -22,6 +22,7 @@ const fixturesDir = join(__dirname, "fixtures"); /** C programs that tests depend on. */ const TEST_PROGRAMS = [ "clock_getcpuclockid_test.c", + "syscall_cp_offset_test.c", "putenv_test.c", "getaddrinfo_test.c", "sysv_ipc_test.c", diff --git a/host/test/syscall-cp-offset.test.ts b/host/test/syscall-cp-offset.test.ts new file mode 100644 index 000000000..c4ee7b359 --- /dev/null +++ b/host/test/syscall-cp-offset.test.ts @@ -0,0 +1,31 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { NodePlatformIO } from "../src/platform/node"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/syscall_cp_offset_test.wasm"); + +describe.skipIf(!existsSync(program))("wasm32 cancellation-point syscall slots", () => { + it("preserves a pread offset above 4 GiB", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "kandelo-syscall-cp-")); + try { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["syscall_cp_offset_test", join(tempRoot, "offset.bin")], + io: new NodePlatformIO(), + useDefaultRootfs: false, + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PASS syscall_cp 64-bit offset"); + expect(result.stderr).toBe(""); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 882cd8211..dd02adabe 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -458,13 +458,11 @@ long __syscall6(long n, long long a1, long long a2, long long a3, long long a4, extern void __testcancel(void); extern long __syscall_cp_check(long r); -long __syscall_cp(long n, long a1, long a2, long a3, long a4, long a5, - long a6) +long __syscall_cp(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6) { __testcancel(); - long r = __do_syscall((long long)n, (long long)a1, (long long)a2, - (long long)a3, (long long)a4, (long long)a5, - (long long)a6); + long r = __do_syscall(n, a1, a2, a3, a4, a5, a6); return __syscall_cp_check(r); } diff --git a/libc/musl-overlay/arch/wasm32posix/syscall_arch.h b/libc/musl-overlay/arch/wasm32posix/syscall_arch.h index 64de86af7..b2d4f9526 100644 --- a/libc/musl-overlay/arch/wasm32posix/syscall_arch.h +++ b/libc/musl-overlay/arch/wasm32posix/syscall_arch.h @@ -25,6 +25,12 @@ #define __scc(X) ((long long) (X)) typedef long syscall_arg_t; +/* Cancellation-point syscalls are ordinary, non-variadic C calls. Keep their + * slots wide enough for the i64 channel ABI even though public syscall(...) + * must continue reading 32-bit `long` values from its va_list. */ +#define SYSCALL_CP_NR_T long +#define SYSCALL_CP_ARG_T long long + /* * Declare the dispatch functions. Args are long long to match the * i64 channel layout — on wasm32, long is 32-bit but long long is From 3e0194488aa55b3da389fcf11003ac8e91a62ef0 Mon Sep 17 00:00:00 2001 From: Kandelo Agent Date: Sat, 11 Jul 2026 08:54:06 -0400 Subject: [PATCH 3/5] fix: preserve offsets after invalid seeks Reject negative resulting offsets in both Node host-file backends before mutating their open-file-description cursor, with shared backend coverage and an authoritative POSIX-status note. --- .../pages/test-runner/index.html | 1 + .../test/fixtures/opfs-seek-client-worker.ts | 74 ++++++++++ apps/browser-demos/test/opfs-seek.spec.ts | 106 +++++++++++++++ crates/kernel/src/syscalls.rs | 126 +++++++++++++++++- docs/posix-status.md | 2 +- examples/lseek_invalid_test.c | 54 ++++++++ host/src/platform/node.ts | 28 +++- host/src/vfs/host-fs.ts | 28 +++- host/src/vfs/i64.ts | 24 ++++ host/src/vfs/opfs-channel.ts | 31 ++++- host/src/vfs/opfs-worker.ts | 83 +++++++++--- host/src/vfs/opfs.ts | 37 +++-- host/src/vfs/sharedfs-vendor.ts | 10 +- host/test/global-setup.ts | 1 + host/test/lseek-invalid-guest.test.ts | 31 +++++ host/test/node-host-vfs-only-metadata.test.ts | 25 ++++ host/test/opfs-channel.test.ts | 30 +++++ host/test/sharedfs-safety.test.ts | 21 +++ 18 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts create mode 100644 apps/browser-demos/test/opfs-seek.spec.ts create mode 100644 examples/lseek_invalid_test.c create mode 100644 host/src/vfs/i64.ts create mode 100644 host/test/lseek-invalid-guest.test.ts diff --git a/apps/browser-demos/pages/test-runner/index.html b/apps/browser-demos/pages/test-runner/index.html index facb25a35..ff4e39e85 100644 --- a/apps/browser-demos/pages/test-runner/index.html +++ b/apps/browser-demos/pages/test-runner/index.html @@ -3,6 +3,7 @@ POSIX Test Runner +
Loading kernel...
diff --git a/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts new file mode 100644 index 000000000..1047d2c94 --- /dev/null +++ b/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts @@ -0,0 +1,74 @@ +import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; + +const O_RDWR = 0x0002; +const O_CREAT = 0x0040; +const O_TRUNC = 0x0200; +const SEEK_SET = 0; +const SEEK_CUR = 1; + +function errorName(action: () => unknown): string | null { + try { + action(); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +self.onmessage = ( + event: MessageEvent<{ buffer: SharedArrayBuffer; path: string }>, +) => { + const { buffer, path } = event.data; + const fs = OpfsFileSystem.create(buffer); + let fd = -1; + + try { + fd = fs.open(path, O_CREAT | O_TRUNC | O_RDWR, 0o600); + const data = new TextEncoder().encode("abcdef"); + if (fs.write(fd, data, null, data.length) !== data.length) { + throw new Error("short OPFS fixture write"); + } + + fs.seek(fd, 2, SEEK_SET); + const negativeError = errorName(() => fs.seek(fd, -3, SEEK_CUR)); + const afterNegative = fs.seek(fd, 0, SEEK_CUR); + + const wideOffset = 2 ** 32 + 1; + const wideResult = fs.seek(fd, wideOffset, SEEK_SET); + + fs.seek(fd, Number.MAX_SAFE_INTEGER, SEEK_SET); + const overflowError = errorName(() => fs.seek(fd, 1, SEEK_CUR)); + const afterOverflow = fs.seek(fd, 0, SEEK_CUR); + + fs.close(fd); + fd = -1; + fs.unlink(path); + self.postMessage({ + type: "result", + negativeError, + afterNegative, + wideResult, + overflowError, + afterOverflow, + }); + } catch (error) { + if (fd >= 0) { + try { + fs.close(fd); + } catch { + // Preserve the original failure. + } + } + try { + fs.unlink(path); + } catch { + // Preserve the original failure. + } + self.postMessage({ + type: "error", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + self.close(); + } +}; diff --git a/apps/browser-demos/test/opfs-seek.spec.ts b/apps/browser-demos/test/opfs-seek.spec.ts new file mode 100644 index 000000000..2240c5ebb --- /dev/null +++ b/apps/browser-demos/test/opfs-seek.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const proxyWorkerPath = resolve( + __dirname, + "../../../host/src/vfs/opfs-worker.ts", +); +const clientWorkerPath = resolve( + __dirname, + "fixtures/opfs-seek-client-worker.ts", +); + +test("OPFS preserves signed 64-bit seek results and failed offsets", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "OPFS sync access handles are Chromium-only here", + ); + expect(baseURL).toBeTruthy(); + + const proxyWorkerUrl = new URL(`/@fs/${proxyWorkerPath}`, baseURL).href; + const clientWorkerUrl = new URL(`/@fs/${clientWorkerPath}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ proxyWorkerUrl, clientWorkerUrl }) => { + const buffer = new SharedArrayBuffer(4 * 1024 * 1024); + const proxy = new Worker(proxyWorkerUrl, { type: "module" }); + const client = new Worker(clientWorkerUrl, { type: "module" }); + + const receive = (worker: Worker, expectedType: string): Promise => + new Promise((resolvePromise, reject) => { + const timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${expectedType}`)), + 15_000, + ); + worker.addEventListener( + "message", + (event) => { + if (event.data?.type !== expectedType) { + if (event.data?.type === "error") { + clearTimeout(timeout); + reject(new Error(event.data.error)); + } + return; + } + clearTimeout(timeout); + resolvePromise(event.data as T); + }, + { once: false }, + ); + worker.addEventListener( + "error", + (event) => { + clearTimeout(timeout); + reject( + new Error( + `${expectedType}: ${event.message || "worker module failed to load"} ` + + `(${event.filename}:${event.lineno}:${event.colno})`, + ), + ); + }, + { once: true }, + ); + }); + + try { + const ready = receive<{ type: "ready" }>(proxy, "ready"); + proxy.postMessage({ type: "init", buffer }); + await ready; + + const pending = receive<{ + type: "result"; + negativeError: string | null; + afterNegative: number; + wideResult: number; + overflowError: string | null; + afterOverflow: number; + }>(client, "result"); + client.postMessage({ + buffer, + path: `/kandelo-opfs-seek-${crypto.randomUUID()}`, + }); + return await pending; + } finally { + client.terminate(); + proxy.terminate(); + } + }, + { proxyWorkerUrl, clientWorkerUrl }, + ); + + expect(result).toEqual({ + type: "result", + negativeError: "EINVAL", + afterNegative: 2, + wideResult: 2 ** 32 + 1, + overflowError: "EOVERFLOW", + afterOverflow: Number.MAX_SAFE_INTEGER, + }); +}); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 0c9a47511..5ec8c360d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2933,6 +2933,9 @@ pub fn sys_lseek( // directory position. The cookie is the d_off value from getdents64. if ofd.file_type == FileType::Directory { if whence == SEEK_SET { + if offset < 0 { + return Err(Errno::EINVAL); + } // Close the existing dir handle if open if ofd.dir_host_handle >= 0 { let _ = host.host_closedir(ofd.dir_host_handle); @@ -2987,8 +2990,10 @@ pub fn sys_lseek( let cur = ofd.offset; let new_off = match whence { SEEK_SET => offset, - SEEK_CUR => cur + offset, - SEEK_END => FB_SMEM_LEN as i64 + offset, + SEEK_CUR => cur.checked_add(offset).ok_or(Errno::EOVERFLOW)?, + SEEK_END => (FB_SMEM_LEN as i64) + .checked_add(offset) + .ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_off < 0 { @@ -3006,6 +3011,9 @@ pub fn sys_lseek( || ofd.host_handle == crate::devfs::DEVFS_DIR_HANDLE { if whence == SEEK_SET { + if offset < 0 { + return Err(Errno::EINVAL); + } ofd.dir_synth_state = 0; ofd.dir_entry_offset = 0; ofd.offset = offset; @@ -3042,7 +3050,7 @@ pub fn sys_lseek( let size = synthetic_file_content(&ofd.path).map_or(0, |d| d.len() as i64); let new_pos = match whence { SEEK_SET => offset, - SEEK_CUR => ofd.offset + offset, + SEEK_CUR => ofd.offset.checked_add(offset).ok_or(Errno::EOVERFLOW)?, SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; @@ -3067,7 +3075,7 @@ pub fn sys_lseek( let new_pos = match whence { SEEK_SET => offset, SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, - SEEK_END => size + offset, + SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { @@ -3079,11 +3087,17 @@ pub fn sys_lseek( let new_offset = match whence { SEEK_SET => { + if offset < 0 { + return Err(Errno::EINVAL); + } host.host_seek(ofd.host_handle, offset, whence)?; offset } SEEK_CUR => { - let pos = ofd.offset + offset; + let pos = ofd.offset.checked_add(offset).ok_or(Errno::EOVERFLOW)?; + if pos < 0 { + return Err(Errno::EINVAL); + } host.host_seek(ofd.host_handle, pos, SEEK_SET)?; pos } @@ -13660,6 +13674,108 @@ mod tests { assert_eq!(pos, 90); } + #[test] + fn lseek_errors_preserve_kernel_owned_offsets() { + fn install_fd( + proc: &mut Process, + file_type: FileType, + host_handle: i64, + path: &[u8], + ) -> (i32, usize) { + let ofd_idx = proc + .ofd_table + .create(file_type, O_RDWR, host_handle, path.to_vec()); + let fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + (fd, ofd_idx) + } + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + let (dir_fd, dir_ofd) = install_fd(&mut proc, FileType::Directory, 71, b"/dir"); + { + let ofd = proc.ofd_table.get_mut(dir_ofd).unwrap(); + ofd.offset = 4; + ofd.dir_synth_state = 2; + ofd.dir_entry_offset = 4; + } + assert_eq!( + sys_lseek(&mut proc, &mut host, dir_fd, -1, SEEK_SET), + Err(Errno::EINVAL) + ); + let ofd = proc.ofd_table.get(dir_ofd).unwrap(); + assert_eq!((ofd.offset, ofd.dir_synth_state, ofd.dir_entry_offset), (4, 2, 4)); + + let (proc_fd, proc_ofd) = install_fd( + &mut proc, + FileType::Regular, + crate::procfs::PROCFS_DIR_HANDLE, + b"/proc", + ); + proc.ofd_table.get_mut(proc_ofd).unwrap().offset = 3; + assert_eq!( + sys_lseek(&mut proc, &mut host, proc_fd, -1, SEEK_SET), + Err(Errno::EINVAL) + ); + assert_eq!(proc.ofd_table.get(proc_ofd).unwrap().offset, 3); + + let (fb_fd, fb_ofd) = install_fd( + &mut proc, + FileType::CharDevice, + VirtualDevice::Fb0.host_handle(), + b"/dev/fb0", + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, fb_fd, 7, SEEK_SET), + Ok(7) + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, fb_fd, i64::MAX, SEEK_CUR), + Err(Errno::EOVERFLOW) + ); + assert_eq!(proc.ofd_table.get(fb_ofd).unwrap().offset, 7); + + let (synthetic_fd, synthetic_ofd) = install_fd( + &mut proc, + FileType::Regular, + SYNTHETIC_FILE_HANDLE, + b"/etc/mtab", + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, synthetic_fd, 2, SEEK_SET), + Ok(2) + ); + assert_eq!( + sys_lseek( + &mut proc, + &mut host, + synthetic_fd, + i64::MAX, + SEEK_CUR, + ), + Err(Errno::EOVERFLOW) + ); + assert_eq!(proc.ofd_table.get(synthetic_ofd).unwrap().offset, 2); + + let memfd = sys_memfd_create(&mut proc, b"seek-overflow", 0).unwrap(); + sys_write(&mut proc, &mut host, memfd, b"abcdef").unwrap(); + assert_eq!( + sys_lseek(&mut proc, &mut host, memfd, 2, SEEK_SET), + Ok(2) + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, memfd, i64::MAX, SEEK_END), + Err(Errno::EOVERFLOW) + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, memfd, 0, SEEK_CUR), + Ok(2) + ); + } + #[test] fn test_lseek_pipe_fails() { let mut proc = Process::new(1); diff --git a/docs/posix-status.md b/docs/posix-status.md index 245aee249..d6a40ab45 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -46,7 +46,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `pread()` | Partial | Host-delegated via seek-read-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. | | `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). O_APPEND seeks to end before write. RLIMIT_FSIZE enforced (EFBIG + SIGXFSZ). | | `pwrite()` | Partial | Host-delegated via seek-write-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. | -| `lseek()` | Full | SEEK_SET, SEEK_CUR, SEEK_END all implemented. SEEK_END delegates to host for file size calculation. | +| `lseek()` | Full | SEEK_SET, SEEK_CUR, SEEK_END all implemented. SEEK_END delegates to host for file size calculation. A seek whose resulting offset would be negative fails with EINVAL without changing the open-file-description offset; arithmetic or host-number overflow fails with EOVERFLOW. | | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | | `dup2()` | Full | Atomic close-and-dup. Same-fd no-op. FD_CLOEXEC cleared. | | `dup3()` | Full | Like dup2 but returns EINVAL if oldfd==newfd. Supports O_CLOEXEC flag. | diff --git a/examples/lseek_invalid_test.c b/examples/lseek_invalid_test.c new file mode 100644 index 000000000..aa2ba51af --- /dev/null +++ b/examples/lseek_invalid_test.c @@ -0,0 +1,54 @@ +#include +#include +#include +#include +#include +#include + +static int expect_failure(int fd, off_t offset, int whence, int expected_errno) +{ + errno = 0; + if (lseek(fd, offset, whence) != (off_t)-1 || errno != expected_errno) { + fprintf(stderr, "lseek(%lld, %d): errno=%d (%s), expected %d\n", + (long long)offset, whence, errno, strerror(errno), expected_errno); + return -1; + } + if (lseek(fd, 0, SEEK_CUR) != 2) { + fputs("invalid seek changed the file offset\n", stderr); + return -1; + } + return 0; +} + +int main(int argc, char **argv) +{ + if (argc > 2) { + fprintf(stderr, "usage: %s [FILE]\n", argv[0]); + return 2; + } + const char *path = argc == 2 ? argv[1] : "/tmp/lseek-invalid.bin"; + + int fd = open(path, O_CREAT | O_TRUNC | O_RDWR, 0600); + if (fd < 0 || write(fd, "abcdef", 6) != 6 || lseek(fd, 2, SEEK_SET) != 2) { + perror("lseek setup"); + return 3; + } + if (expect_failure(fd, -1, SEEK_SET, EINVAL) != 0 || + expect_failure(fd, -3, SEEK_CUR, EINVAL) != 0 || + expect_failure(fd, -7, SEEK_END, EINVAL) != 0 || + expect_failure(fd, (off_t)(1ULL << 53), SEEK_SET, EOVERFLOW) != 0 || + expect_failure(fd, (off_t)LLONG_MAX, SEEK_CUR, EOVERFLOW) != 0) { + close(fd); + return 4; + } + + char byte = '?'; + if (read(fd, &byte, 1) != 1 || byte != 'c') { + fprintf(stderr, "offset control read returned %d\n", (unsigned char)byte); + close(fd); + return 5; + } + close(fd); + puts("PASS invalid lseek preserves offset"); + return 0; +} diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 8c9c4a273..3b7105a62 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -16,6 +16,26 @@ import { NativeMetadataOverlay } from "./native-metadata"; const UTIME_NOW = 0x3fffffff; const UTIME_OMIT = 0x3ffffffe; +function makeFsError(code: string, message: string): Error & { code: string } { + const error = new Error(`${code}: ${message}`) as Error & { code: string }; + error.code = code; + return error; +} + +function checkedSeekPosition(base: number, offset: number): number { + if (!Number.isSafeInteger(base) || !Number.isSafeInteger(offset)) { + throw makeFsError("EOVERFLOW", "seek offset is not exactly representable"); + } + const position = base + offset; + if (!Number.isSafeInteger(position)) { + throw makeFsError("EOVERFLOW", "seek result is not exactly representable"); + } + if (position < 0) { + throw makeFsError("EINVAL", "negative seek offset"); + } + return position; +} + export class NodePlatformIO implements PlatformIO { private dirHandles = new Map(); private nextDirHandle = 1; @@ -116,21 +136,21 @@ export class NodePlatformIO implements PlatformIO { let newPos: number; switch (whence) { case 0: // SEEK_SET - newPos = offset; + newPos = checkedSeekPosition(0, offset); break; case 1: { // SEEK_CUR const cur = this.fdPositions.get(handle) ?? 0; - newPos = cur + offset; + newPos = checkedSeekPosition(cur, offset); break; } case 2: { // SEEK_END — compute from file size const stat = fs.fstatSync(handle); - newPos = stat.size + offset; + newPos = checkedSeekPosition(stat.size, offset); break; } default: - throw new Error(`Invalid whence value: ${whence}`); + throw makeFsError("EINVAL", `invalid whence value: ${whence}`); } this.fdPositions.set(handle, newPos); return newPos; diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index 79d12221d..f1625a2de 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -15,6 +15,26 @@ import { DEFAULT_STATFS_BLOCK_SIZE, DEFAULT_STATFS_NAMELEN } from "../statfs"; const UTIME_NOW = 0x3fffffff; const UTIME_OMIT = 0x3ffffffe; +function makeHostFsError(code: string, message: string): Error & { code: string } { + const error = new Error(`${code}: ${message}`) as Error & { code: string }; + error.code = code; + return error; +} + +function checkedSeekPosition(base: number, offset: number): number { + if (!Number.isSafeInteger(base) || !Number.isSafeInteger(offset)) { + throw makeHostFsError("EOVERFLOW", "seek offset is not exactly representable"); + } + const position = base + offset; + if (!Number.isSafeInteger(position)) { + throw makeHostFsError("EOVERFLOW", "seek result is not exactly representable"); + } + if (position < 0) { + throw makeHostFsError("EINVAL", "negative seek offset"); + } + return position; +} + /** * Translate Linux/POSIX open flags (as used by musl libc) to the * platform-native flag values that Node.js `fs.openSync` expects. @@ -305,16 +325,16 @@ export class HostFileSystem implements FileSystemBackend { let newPos: number; switch (whence) { case 0: // SEEK_SET - newPos = offset; + newPos = checkedSeekPosition(0, offset); break; case 1: // SEEK_CUR - newPos = (this.fdPositions.get(handle) ?? 0) + offset; + newPos = checkedSeekPosition(this.fdPositions.get(handle) ?? 0, offset); break; case 2: // SEEK_END - newPos = fs.fstatSync(handle).size + offset; + newPos = checkedSeekPosition(fs.fstatSync(handle).size, offset); break; default: - throw new Error(`Invalid whence value: ${whence}`); + throw makeHostFsError("EINVAL", `invalid whence value: ${whence}`); } this.fdPositions.set(handle, newPos); return newPos; diff --git a/host/src/vfs/i64.ts b/host/src/vfs/i64.ts new file mode 100644 index 000000000..4c88c3060 --- /dev/null +++ b/host/src/vfs/i64.ts @@ -0,0 +1,24 @@ +const MIN_SAFE_I64 = BigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE_I64 = BigInt(Number.MAX_SAFE_INTEGER); + +/** Split an exactly representable JavaScript integer into signed i64 words. */ +export function splitSafeI64(value: number): [low: number, high: number] { + if (!Number.isSafeInteger(value)) { + throw new RangeError("i64 value is not exactly representable"); + } + + const wide = BigInt(value); + return [ + Number(BigInt.asIntN(32, wide)), + Number(BigInt.asIntN(32, wide >> 32n)), + ]; +} + +/** Join signed i64 words when the result is exactly representable in JavaScript. */ +export function joinSafeI64(low: number, high: number): number { + const wide = (BigInt(high) << 32n) | BigInt(low >>> 0); + if (wide < MIN_SAFE_I64 || wide > MAX_SAFE_I64) { + throw new RangeError("i64 value is not exactly representable"); + } + return Number(wide); +} diff --git a/host/src/vfs/opfs-channel.ts b/host/src/vfs/opfs-channel.ts index 6e58b20ec..f9003abc0 100644 --- a/host/src/vfs/opfs-channel.ts +++ b/host/src/vfs/opfs-channel.ts @@ -8,11 +8,13 @@ * 0 4B status (IDLE=0, PENDING=1, COMPLETE=2, ERROR=3) * 4 4B opcode * 8 48B args (12 × i32) - * 56 4B result - * 60 4B result2 (secondary return value) + * 56 4B result / i64 result low word + * 60 4B result2 / i64 result high word * 64 ... data section (path strings, read/write buffers) */ +import { joinSafeI64, splitSafeI64 } from "./i64"; + const STATUS_OFFSET = 0; const OPCODE_OFFSET = 4; const ARGS_OFFSET = 8; @@ -94,6 +96,16 @@ export class OpfsChannel { this.view.setInt32(ARGS_OFFSET + index * 4, value, true); } + setI64Arg(index: number, value: number): void { + const [low, high] = splitSafeI64(value); + this.setArg(index, low); + this.setArg(index + 1, high); + } + + getI64Arg(index: number): number { + return joinSafeI64(this.getArg(index), this.getArg(index + 1)); + } + // --- Result --- get result(): number { @@ -112,6 +124,16 @@ export class OpfsChannel { this.view.setInt32(RESULT2_OFFSET, value, true); } + get i64Result(): number { + return joinSafeI64(this.result, this.result2); + } + + set i64Result(value: number) { + const [low, high] = splitSafeI64(value); + this.result = low; + this.result2 = high; + } + // --- Data section --- get dataBuffer(): Uint8Array { @@ -134,7 +156,8 @@ export class OpfsChannel { /** Read a UTF-8 string from the data section, up to `length` bytes. */ readString(length: number): string { const decoder = new TextDecoder(); - return decoder.decode(new Uint8Array(this.buffer, DATA_OFFSET, length)); + const bytes = new Uint8Array(this.buffer, DATA_OFFSET, length).slice(); + return decoder.decode(bytes); } /** @@ -156,7 +179,7 @@ export class OpfsChannel { * Read two null-separated strings from the data section. */ readTwoStrings(totalLength: number): [string, string] { - const data = new Uint8Array(this.buffer, DATA_OFFSET, totalLength); + const data = new Uint8Array(this.buffer, DATA_OFFSET, totalLength).slice(); const nullIdx = data.indexOf(0); const decoder = new TextDecoder(); const s1 = decoder.decode(data.subarray(0, nullIdx)); diff --git a/host/src/vfs/opfs-worker.ts b/host/src/vfs/opfs-worker.ts index 66b2ab1a2..0ec107ab0 100644 --- a/host/src/vfs/opfs-worker.ts +++ b/host/src/vfs/opfs-worker.ts @@ -1,5 +1,7 @@ /// +import { joinSafeI64, splitSafeI64 } from "./i64"; + /** * OPFS Proxy Worker — dedicated Web Worker that executes async OPFS * operations on behalf of the synchronous OpfsFileSystem. @@ -55,6 +57,7 @@ const EISDIR = -21; const EINVAL = -22; const ENOSPC = -28; const ENOTEMPTY = -39; +const EOVERFLOW = -75; const ENOTSUP = -95; // Open flags (Linux values) @@ -110,6 +113,10 @@ class WorkerChannel { return this.view.getInt32(ARGS_OFFSET + index * 4, true); } + getI64Arg(index: number): number { + return joinSafeI64(this.getArg(index), this.getArg(index + 1)); + } + set result(value: number) { this.view.setInt32(RESULT_OFFSET, value, true); } @@ -118,16 +125,23 @@ class WorkerChannel { this.view.setInt32(RESULT2_OFFSET, value, true); } + set i64Result(value: number) { + const [low, high] = splitSafeI64(value); + this.result = low; + this.result2 = high; + } + get dataBuffer(): Uint8Array { return new Uint8Array(this.buffer, DATA_OFFSET); } readString(length: number): string { - return new TextDecoder().decode(new Uint8Array(this.buffer, DATA_OFFSET, length)); + const bytes = new Uint8Array(this.buffer, DATA_OFFSET, length).slice(); + return new TextDecoder().decode(bytes); } readTwoStrings(totalLength: number): [string, string] { - const data = new Uint8Array(this.buffer, DATA_OFFSET, totalLength); + const data = new Uint8Array(this.buffer, DATA_OFFSET, totalLength).slice(); const nullIdx = data.indexOf(0); const decoder = new TextDecoder(); return [ @@ -376,9 +390,20 @@ async function handleRead(): Promise { } try { - const readAt = hasOffset - ? (offsetHi * 0x100000000 + (offsetLo >>> 0)) - : entry.position; + let readAt: number; + if (hasOffset) { + try { + readAt = joinSafeI64(offsetLo, offsetHi); + } catch (error) { + if (error instanceof RangeError) { + channel.notifyError(EOVERFLOW); + return; + } + throw error; + } + } else { + readAt = entry.position; + } const data = channel.dataBuffer; const target = data.subarray(0, length); @@ -411,7 +436,15 @@ async function handleWrite(): Promise { try { let writeAt: number; if (hasOffset) { - writeAt = offsetHi * 0x100000000 + (offsetLo >>> 0); + try { + writeAt = joinSafeI64(offsetLo, offsetHi); + } catch (error) { + if (error instanceof RangeError) { + channel.notifyError(EOVERFLOW); + return; + } + throw error; + } } else if (entry.appendMode) { writeAt = entry.handle.getSize(); } else { @@ -449,7 +482,16 @@ async function handleSeek(): Promise { } try { - const offset = offsetHi * 0x100000000 + (offsetLo >>> 0); + let offset: number; + try { + offset = joinSafeI64(offsetLo, offsetHi); + } catch (error) { + if (error instanceof RangeError) { + channel.notifyError(EOVERFLOW); + return; + } + throw error; + } let newPos: number; switch (whence) { @@ -467,13 +509,17 @@ async function handleSeek(): Promise { return; } + if (!Number.isSafeInteger(newPos)) { + channel.notifyError(EOVERFLOW); + return; + } if (newPos < 0) { channel.notifyError(EINVAL); return; } entry.position = newPos; - channel.result = newPos; + channel.i64Result = newPos; channel.notifyComplete(); } catch (err) { channel.notifyError(mapError(err)); @@ -515,10 +561,6 @@ async function handleFstat(): Promise { async function handleFtruncate(): Promise { const handle = channel.getArg(0); - const lengthLo = channel.getArg(1); - const lengthHi = channel.getArg(2); - const length = lengthHi * 0x100000000 + (lengthLo >>> 0); - const entry = fileHandles.get(handle); if (!entry || !entry.handle) { channel.notifyError(EBADF); @@ -526,6 +568,16 @@ async function handleFtruncate(): Promise { } try { + let length: number; + try { + length = channel.getI64Arg(1); + } catch (error) { + if (error instanceof RangeError) { + channel.notifyError(EOVERFLOW); + return; + } + throw error; + } entry.handle.truncate(length); channel.result = 0; channel.notifyComplete(); @@ -881,11 +933,8 @@ async function pollLoop(): Promise { } await dispatch(); - - // Reset to Idle after complete/error has been consumed - // (The caller side reads the result after waitForComplete returns, - // then we're ready for the next request. The caller sets Idle - // implicitly by calling setPending for the next op.) + // The caller resets Complete/Error to Idle only after consuming the + // result, so the proxy cannot race the synchronous waiter here. } } diff --git a/host/src/vfs/opfs.ts b/host/src/vfs/opfs.ts index cbef2d3c6..636e1e350 100644 --- a/host/src/vfs/opfs.ts +++ b/host/src/vfs/opfs.ts @@ -25,11 +25,12 @@ export class OpfsFileSystem implements FileSystemBackend { this.channel.opcode = opcode; this.channel.setPending(); const status = this.channel.waitForComplete(); + const result = this.channel.result; + this.channel.status = OpfsChannelStatus.Idle; if (status === OpfsChannelStatus.Error) { - const errno = this.channel.result; - throw this.errnoToError(errno); + throw this.errnoToError(result); } - return this.channel.result; + return result; } private errnoToError(negErrno: number): Error { @@ -45,12 +46,22 @@ export class OpfsFileSystem implements FileSystemBackend { [-22]: "EINVAL", [-28]: "ENOSPC", [-39]: "ENOTEMPTY", + [-75]: "EOVERFLOW", [-95]: "ENOTSUP", }; const name = ERRNO_NAMES[negErrno] || `errno(${negErrno})`; return new Error(name); } + private setI64Arg(index: number, value: number): void { + try { + this.channel.setI64Arg(index, value); + } catch (error) { + if (error instanceof RangeError) throw this.errnoToError(-75); + throw error; + } + } + // --- File handle operations --- open(path: string, flags: number, mode: number): number { @@ -71,8 +82,7 @@ export class OpfsFileSystem implements FileSystemBackend { this.channel.setArg(0, handle); this.channel.setArg(1, length); if (offset !== null) { - this.channel.setArg(2, offset & 0xffffffff); // offset_lo - this.channel.setArg(3, (offset / 0x100000000) | 0); // offset_hi + this.setI64Arg(2, offset); this.channel.setArg(4, 1); // has_offset } else { this.channel.setArg(2, 0); @@ -90,8 +100,7 @@ export class OpfsFileSystem implements FileSystemBackend { this.channel.setArg(0, handle); this.channel.setArg(1, length); if (offset !== null) { - this.channel.setArg(2, offset & 0xffffffff); - this.channel.setArg(3, (offset / 0x100000000) | 0); + this.setI64Arg(2, offset); this.channel.setArg(4, 1); } else { this.channel.setArg(2, 0); @@ -104,10 +113,15 @@ export class OpfsFileSystem implements FileSystemBackend { seek(handle: number, offset: number, whence: number): number { this.channel.setArg(0, handle); - this.channel.setArg(1, offset & 0xffffffff); - this.channel.setArg(2, (offset / 0x100000000) | 0); + this.setI64Arg(1, offset); this.channel.setArg(3, whence); - return this.call(OpfsOpcode.SEEK); + this.call(OpfsOpcode.SEEK); + try { + return this.channel.i64Result; + } catch (error) { + if (error instanceof RangeError) throw this.errnoToError(-75); + throw error; + } } fstat(handle: number): StatResult { @@ -118,8 +132,7 @@ export class OpfsFileSystem implements FileSystemBackend { ftruncate(handle: number, length: number): void { this.channel.setArg(0, handle); - this.channel.setArg(1, length & 0xffffffff); - this.channel.setArg(2, (length / 0x100000000) | 0); + this.setI64Arg(1, length); this.call(OpfsOpcode.FTRUNCATE); } diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index 47ed03713..8ad1d9d66 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -77,6 +77,7 @@ export const ENOSPC = -28; export const ENAMETOOLONG = -36; export const ENOTEMPTY = -39; export const ELOOP = -40; +export const EOVERFLOW = -75; // Superblock field byte offsets const SB_MAGIC = 0; @@ -205,6 +206,7 @@ const ERROR_MESSAGES: Record = { [ENAMETOOLONG]: "File name too long", [ENOTEMPTY]: "Directory not empty", [ELOOP]: "Too many symbolic links", + [EOVERFLOW]: "Value too large for data type", }; export class SFSError extends Error { @@ -1315,6 +1317,12 @@ export class SharedFS { if (size > MAX_FILE_SIZE) throw new SFSError(EFBIG); } + private validateSeekPosition(position: number): void { + if (!Number.isSafeInteger(position)) throw new SFSError(EOVERFLOW); + if (position < 0) throw new SFSError(EINVAL); + if (position > MAX_FILE_SIZE) throw new SFSError(EFBIG); + } + // ── Directory operations ───────────────────────────────────────── private touchDirectoryMutation(dirIno: number): void { @@ -2454,7 +2462,7 @@ export class SharedFS { throw new SFSError(EINVAL); } - this.validateFileSize(newOffset); + this.validateSeekPosition(newOffset); const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; this.w64(base + FD_OFFSET, newOffset); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index ed17fb3fb..f8c29604a 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -23,6 +23,7 @@ const fixturesDir = join(__dirname, "fixtures"); const TEST_PROGRAMS = [ "clock_getcpuclockid_test.c", "syscall_cp_offset_test.c", + "lseek_invalid_test.c", "putenv_test.c", "getaddrinfo_test.c", "sysv_ipc_test.c", diff --git a/host/test/lseek-invalid-guest.test.ts b/host/test/lseek-invalid-guest.test.ts new file mode 100644 index 000000000..f1eb6d307 --- /dev/null +++ b/host/test/lseek-invalid-guest.test.ts @@ -0,0 +1,31 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { NodePlatformIO } from "../src/platform/node"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/lseek_invalid_test.wasm"); + +describe.skipIf(!existsSync(program))("invalid lseek guest", () => { + it("keeps the host-file offset unchanged", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "kandelo-lseek-")); + try { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["lseek_invalid_test", join(tempRoot, "seek.bin")], + io: new NodePlatformIO(), + useDefaultRootfs: false, + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PASS invalid lseek preserves offset"); + expect(result.stderr).toBe(""); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/node-host-vfs-only-metadata.test.ts b/host/test/node-host-vfs-only-metadata.test.ts index f0d3bc77e..c9431831e 100644 --- a/host/test/node-host-vfs-only-metadata.test.ts +++ b/host/test/node-host-vfs-only-metadata.test.ts @@ -30,6 +30,8 @@ interface MetadataBackend { stat(path: string): { mode: number; uid: number; gid: number }; open(path: string, flags: number, mode: number): number; close(handle: number): number; + read(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; + seek(handle: number, offset: number, whence: number): number; fstat(handle: number): { mode: number; uid: number; gid: number }; chmod(path: string, mode: number): void; chown(path: string, uid: number, gid: number): void; @@ -111,6 +113,29 @@ const backendFactories: Array<[string, () => BackendCase]> = [ ]; describe.each(backendFactories)("%s", (_name, makeCase) => { + it("rejects negative seek targets without changing the file offset", () => { + const c = makeCase(); + const native = c.nativePath("seek-file"); + writeFileSync(native, "abcdef"); + + const fd = c.backend.open(c.vfsPath("seek-file"), O_RDWR, 0); + try { + expect(c.backend.seek(fd, 2, 0 /* SEEK_SET */)).toBe(2); + expect(() => c.backend.seek(fd, -1, 0 /* SEEK_SET */)).toThrow(/EINVAL/); + expect(() => c.backend.seek(fd, -5, 1 /* SEEK_CUR */)).toThrow(/EINVAL/); + expect(() => c.backend.seek(fd, -7, 2 /* SEEK_END */)).toThrow(/EINVAL/); + expect(() => c.backend.seek(fd, Number.MAX_SAFE_INTEGER, 1 /* SEEK_CUR */)) + .toThrow(/EOVERFLOW/); + expect(c.backend.seek(fd, 0, 1 /* SEEK_CUR */)).toBe(2); + + const buf = new Uint8Array(1); + expect(c.backend.read(fd, buf, null, 1)).toBe(1); + expect(new TextDecoder().decode(buf)).toBe("c"); + } finally { + c.backend.close(fd); + } + }); + it("keeps path chmod/chown changes in VFS metadata only", () => { const c = makeCase(); const native = c.nativePath("path-file"); diff --git a/host/test/opfs-channel.test.ts b/host/test/opfs-channel.test.ts index 997baf41d..2f100de4e 100644 --- a/host/test/opfs-channel.test.ts +++ b/host/test/opfs-channel.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { OpfsChannel, OpfsChannelStatus, OpfsOpcode, OPFS_CHANNEL_SIZE } from "../src/vfs/opfs-channel"; +import { OpfsFileSystem } from "../src/vfs/opfs"; describe("OpfsChannel", () => { function makeChannel(): OpfsChannel { @@ -42,6 +43,35 @@ describe("OpfsChannel", () => { expect(ch.result).toBe(-2); }); + it("round-trips exactly representable signed i64 arguments and results", () => { + const ch = makeChannel(); + for (const value of [ + Number.MIN_SAFE_INTEGER, + -0x1_0000_0001, + -1, + 0, + 0x1_0000_0001, + Number.MAX_SAFE_INTEGER, + ]) { + ch.setI64Arg(0, value); + ch.i64Result = value; + expect(ch.getI64Arg(0)).toBe(value); + expect(ch.i64Result).toBe(value); + } + }); + + it("rejects i64 values that JavaScript cannot represent exactly", () => { + const ch = makeChannel(); + expect(() => ch.setI64Arg(0, 2 ** 53)).toThrow(RangeError); + + ch.result = -1; + ch.result2 = 0x7fffffff; + expect(() => ch.i64Result).toThrow(RangeError); + + const fs = OpfsFileSystem.create(ch.buffer); + expect(() => fs.seek(7, 2 ** 53, 0)).toThrow(/EOVERFLOW/); + }); + it("writes and reads strings in data section", () => { const ch = makeChannel(); const len = ch.writeString("/tmp/hello.txt"); diff --git a/host/test/sharedfs-safety.test.ts b/host/test/sharedfs-safety.test.ts index 557e70441..e1da65942 100644 --- a/host/test/sharedfs-safety.test.ts +++ b/host/test/sharedfs-safety.test.ts @@ -28,6 +28,27 @@ function listDir(fs: MemoryFileSystem, path: string): string[] { } describe("SharedFS sparse-file safety", () => { + it("rejects invalid seek results without changing the file offset", () => { + const fs = create(); + const fd = fs.open("/seek", O_CREAT | O_RDWR | O_TRUNC, 0o644); + fs.write(fd, new TextEncoder().encode("abcdef"), null, 6); + expect(fs.seek(fd, 2, SEEK_SET)).toBe(2); + + expect(() => fs.seek(fd, -1, SEEK_SET)).toThrow(/Invalid argument/); + expect(() => fs.seek(fd, 2 ** 53, SEEK_SET)).toThrow( + /Value too large for data type/, + ); + expect(() => fs.seek(fd, Number.MAX_SAFE_INTEGER, 1)).toThrow( + /Value too large for data type/, + ); + expect(fs.seek(fd, 0, 1)).toBe(2); + + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + expect(byte[0]).toBe("c".charCodeAt(0)); + fs.close(fd); + }); + it("extends a multi-gigabyte sparse file without scanning its holes", () => { const fs = create(); const fd = fs.open("/sparse", O_CREAT | O_RDWR | O_TRUNC, 0o644); From c0dc5fc247f1b042dffcddc5ad4a07ca75abfa3e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 08:59:23 -0400 Subject: [PATCH 4/5] fix: interrupt blocking select calls with caught signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return EINTR only when a caught signal interrupts a would-block select or pselect outcome, including select’s no-fd sleep path. Bind the exact channel thread, preserve ready results, retain default-death handling, and cover pselect mask restoration in a real guest. --- docs/posix-status.md | 4 +- examples/select_signal_test.c | 101 ++++++++++++++ host/src/kernel-worker.ts | 33 ++++- host/test/global-setup.ts | 1 + host/test/select-signal-guest.test.ts | 23 ++++ host/test/select-signal-outcome.test.ts | 172 ++++++++++++++++++++++++ 6 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 examples/select_signal_test.c create mode 100644 host/test/select-signal-guest.test.ts create mode 100644 host/test/select-signal-outcome.test.ts diff --git a/docs/posix-status.md b/docs/posix-status.md index d6a40ab45..005a710df 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -243,10 +243,10 @@ shortcuts. | `sendmsg()` / `recvmsg()` | Partial | Minimal first-iovec wrappers are implemented. Input `MSG_TRUNC` reaches the datagram receive behavior above, but `recvmsg()` does not yet populate output `msg_flags`, including `MSG_TRUNC`. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | -| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via polling loop. | +| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via a host retry loop. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | | `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. Timeout supported via polling loop with 1ms sleep intervals. Returns EINTR on pending signals. | | `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. Timespec converted to timeout_ms in glue layer. | -| `pselect6()` | Full | Wraps select() with atomic signal mask swap. Sigmask extracted from pselect6-style {sigset_t*, size_t} struct in glue layer. | +| `pselect6()` | Partial | Wraps select() with an atomic signal-mask swap across the host retry loop. The pselect6-style `{sigset_t *, size_t}` argument supplies the mask; timeout precision is rounded to host milliseconds. Caught signals interrupt a would-block retry with EINTR after the temporary mask is restored. | | `epoll_create1()` | Full | Creates epoll instance with per-process interest list. EPOLL_CLOEXEC flag supported. | | `epoll_ctl()` | Full | EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL. Stores interest set with events + data. | | `epoll_pwait()` | Full | Builds pollfd from interest set, delegates to poll, maps results back to epoll_event structs. Optional signal mask swap. | diff --git a/examples/select_signal_test.c b/examples/select_signal_test.c new file mode 100644 index 000000000..e087c7c40 --- /dev/null +++ b/examples/select_signal_test.c @@ -0,0 +1,101 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t alarm_count; + +static void on_alarm(int signo) +{ + (void)signo; + alarm_count++; +} + +static int arm_alarm(long usec) +{ + struct itimerval timer = { + .it_value = { .tv_sec = 0, .tv_usec = usec }, + }; + return setitimer(ITIMER_REAL, &timer, NULL); +} + +int main(void) +{ + struct sigaction action = { .sa_handler = on_alarm }; + sigemptyset(&action.sa_mask); + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction"); + return 2; + } + + if (arm_alarm(20 * 1000) != 0) { + perror("setitimer(select)"); + return 3; + } + errno = 0; + if (select(0, NULL, NULL, NULL, NULL) != -1 || errno != EINTR || alarm_count != 1) { + fprintf(stderr, "select result mismatch: errno=%d alarms=%d\n", + errno, (int)alarm_count); + return 4; + } + + sigset_t alarm_set; + sigset_t old_set; + sigset_t empty_set; + sigset_t restored_set; + sigemptyset(&alarm_set); + sigaddset(&alarm_set, SIGALRM); + sigemptyset(&empty_set); + if (sigprocmask(SIG_BLOCK, &alarm_set, &old_set) != 0) { + perror("sigprocmask(block)"); + return 5; + } + if (arm_alarm(20 * 1000) != 0) { + perror("setitimer(pselect)"); + return 6; + } + + const struct timespec timeout = { .tv_sec = 5, .tv_nsec = 0 }; + errno = 0; + if (pselect(0, NULL, NULL, NULL, &timeout, &empty_set) != -1 || + errno != EINTR || alarm_count != 2) { + fprintf(stderr, "pselect result mismatch: errno=%d alarms=%d\n", + errno, (int)alarm_count); + return 7; + } + if (sigprocmask(SIG_SETMASK, NULL, &restored_set) != 0 || + !sigismember(&restored_set, SIGALRM)) { + fputs("pselect did not restore the caller signal mask\n", stderr); + return 8; + } + if (sigprocmask(SIG_SETMASK, &old_set, NULL) != 0) { + perror("sigprocmask(restore)"); + return 9; + } + + action.sa_handler = SIG_IGN; + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction(ignore)"); + return 10; + } + if (arm_alarm(20 * 1000) != 0) { + perror("setitimer(ignored select)"); + return 11; + } + struct timeval ignored_timeout = { .tv_sec = 0, .tv_usec = 50 * 1000 }; + errno = 0; + if (select(0, NULL, NULL, NULL, &ignored_timeout) != 0 || + errno != 0 || alarm_count != 2) { + fprintf(stderr, "ignored select mismatch: errno=%d alarms=%d\n", + errno, (int)alarm_count); + return 12; + } + + puts("PASS select and pselect EINTR"); + return 0; +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index a24610e60..590254b82 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -5382,6 +5382,21 @@ export class CentralizedKernelWorker { * own code is `select(0, NULL, NULL, NULL, &tv)` (mysys/my_sleep.c) — the * pure-sleep case, fast-path'd to a setTimeout. */ + private completeSelectSignalOutcome( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + interruptCaughtSignal: boolean, + ): boolean { + const deliveredSignal = this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return true; + if (interruptCaughtSignal && deliveredSignal > 0) { + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EINTR_ERRNO); + return true; + } + return false; + } + private handleSelect(channel: ChannelInfo, origArgs: number[]): void { const FD_SET_SIZE = 128; const nfds = origArgs[0]; @@ -5417,6 +5432,7 @@ export class CentralizedKernelWorker { // (handleKill -> scheduleWakeBlockedRetries -> wakeAllBlockedRetries // already iterates pendingSelectRetries entries). if (nfds === 0 && readPtr === 0 && writePtr === 0 && exceptPtr === 0) { + if (this.completeSelectSignalOutcome(channel, SYS_SELECT, origArgs, true)) return; if (kernelTimeoutMs === 0) { this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); return; @@ -5508,8 +5524,12 @@ export class CentralizedKernelWorker { } } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + if (this.completeSelectSignalOutcome( + channel, + SYS_SELECT, + origArgs, + retVal === -1 && errVal === EAGAIN, + )) return; // EAGAIN retry for blocking select. Mirrors handlePselect6. if (retVal === -1 && errVal === EAGAIN) { @@ -5660,9 +5680,12 @@ export class CentralizedKernelWorker { } } - // Handle signal delivery - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + if (this.completeSelectSignalOutcome( + channel, + SYS_PSELECT6, + origArgs, + retVal === -1 && errVal === EAGAIN, + )) return; // Handle EAGAIN retry for blocking select if (retVal === -1 && errVal === EAGAIN) { diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index f8c29604a..cdad65e4a 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -23,6 +23,7 @@ const fixturesDir = join(__dirname, "fixtures"); const TEST_PROGRAMS = [ "clock_getcpuclockid_test.c", "syscall_cp_offset_test.c", + "select_signal_test.c", "lseek_invalid_test.c", "putenv_test.c", "getaddrinfo_test.c", diff --git a/host/test/select-signal-guest.test.ts b/host/test/select-signal-guest.test.ts new file mode 100644 index 000000000..7bc404d1b --- /dev/null +++ b/host/test/select-signal-guest.test.ts @@ -0,0 +1,23 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/select_signal_test.wasm"); + +describe.skipIf(!existsSync(program))("select signal guest", () => { + it("interrupts select and pselect and restores the pselect mask", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["select_signal_test"], + useDefaultRootfs: false, + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PASS select and pselect EINTR"); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/select-signal-outcome.test.ts b/host/test/select-signal-outcome.test.ts new file mode 100644 index 000000000..72e46ba91 --- /dev/null +++ b/host/test/select-signal-outcome.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ERRNO, + CH_RETURN, + CH_SIG_BASE, +} from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const EAGAIN = 11; +const EINTR = 4; + +function createSharedMemory(pages = 2): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); +} + +function createHarness(options: { + handlerSignal?: number; + exitSignal?: number; + returnValue?: number; + errno?: number; +} = {}) { + const handlerSignal = options.handlerSignal ?? 0; + const exitSignal = options.exitSignal ?? -1; + const returnValue = options.returnValue ?? -1; + const errno = options.errno ?? EAGAIN; + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel: any = { + pid: 42, + channelOffset: 0, + memory: processMemory, + }; + const handleChannel = vi.fn(() => { + const view = new DataView(kernelMemory.buffer); + view.setBigInt64(CH_RETURN, BigInt(returnValue), true); + view.setUint32(CH_ERRNO, errno, true); + return 0; + }); + const dequeueSignal = vi.fn((_pid: number, outPtr: number) => { + if (handlerSignal > 0) { + new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); + } + return handlerSignal; + }); + const setCurrentTid = vi.fn(); + const completeChannel = vi.fn(); + const handleProcessTerminated = vi.fn(); + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelInstance: { + exports: { + kernel_handle_channel: handleChannel, + kernel_dequeue_signal: dequeueSignal, + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_set_current_tid: setCurrentTid, + }, + }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + processes: new Map([ + [42, { pid: 42, memory: processMemory, channels: [channel], ptrWidth: 4 }], + ]), + activeChannels: [channel], + channelTids: new Map([["42:0", 43]]), + pendingSelectRetries: new Map(), + pendingPollRetries: new Map(), + pendingSleeps: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + completeChannel, + handleProcessTerminated, + }); + + return { + channel, + completeChannel, + dequeueSignal, + handleChannel, + handleProcessTerminated, + processMemory, + setCurrentTid, + worker, + }; +} + +describe("select and pselect signal outcomes", () => { + it("returns EINTR instead of re-parking pselect after a caught signal", () => { + const harness = createHarness({ handlerSignal: 10 }); + const readfdsPtr = 1024; + const timespecPtr = 2048; + const view = new DataView(harness.processMemory.buffer); + view.setUint8(readfdsPtr, 1); + view.setBigInt64(timespecPtr, 1n, true); + view.setBigInt64(timespecPtr + 8, 0n, true); + const args = [1, readfdsPtr, 0, 0, timespecPtr, 0]; + + harness.worker.handlePselect6(harness.channel, args); + + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Pselect6, + args, + undefined, + -1, + EINTR, + ); + expect(harness.worker.pendingSelectRetries.size).toBe(0); + expect(harness.setCurrentTid).toHaveBeenCalledWith(43); + expect(harness.setCurrentTid.mock.invocationCallOrder.at(-1)).toBeLessThan( + harness.dequeueSignal.mock.invocationCallOrder[0], + ); + }); + + it("interrupts the pure-sleep select fast path without entering the kernel", () => { + const harness = createHarness({ handlerSignal: 12 }); + const args = [0, 0, 0, 0, 0]; + + harness.worker.handleSelect(harness.channel, args); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Select, + args, + undefined, + -1, + EINTR, + ); + expect(harness.worker.pendingSelectRetries.size).toBe(0); + }); + + it("re-parks pure-sleep select when no caught signal is delivered", () => { + const harness = createHarness(); + + harness.worker.handleSelect(harness.channel, [0, 0, 0, 0, 0]); + + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingSelectRetries.has(harness.channel)).toBe(true); + }); + + it("reaps a default signal death without waking select guest code", () => { + const harness = createHarness({ exitSignal: 15 }); + + harness.worker.handleSelect(harness.channel, [0, 0, 0, 0, 0]); + + expect(harness.handleProcessTerminated).toHaveBeenCalledWith(harness.channel); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingSelectRetries.size).toBe(0); + }); + + it("preserves a ready select result when a handler signal arrives concurrently", () => { + const harness = createHarness({ handlerSignal: 10, returnValue: 1, errno: 0 }); + const args = [1, 1024, 0, 0, 0]; + new DataView(harness.processMemory.buffer).setUint8(1024, 1); + + harness.worker.handleSelect(harness.channel, args); + + expect( + new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), + ).toBe(10); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Select, + args, + undefined, + 1, + 0, + ); + }); +}); From 55ecc91cd78cd9487d1fda473adad4449a69dc33 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 09:20:05 -0400 Subject: [PATCH 5/5] test: retire the shared-condvar Sortix xfail --- docs/compromising-xfails.md | 30 ++++++++++++++++------------- scripts/run-browser-sortix-tests.sh | 1 - scripts/run-sortix-tests.sh | 3 --- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/compromising-xfails.md b/docs/compromising-xfails.md index 17d53e917..7497fe528 100644 --- a/docs/compromising-xfails.md +++ b/docs/compromising-xfails.md @@ -68,19 +68,23 @@ This entry is retained below as **optional future hardening work**, not as an XF --- -### 3. `PTHREAD_PROCESS_SHARED` — pthread primitives only (DONE). Shared data memory still a gap. - -**Status:** Pthread sync primitives (mutex, cond, barrier) with `PTHREAD_PROCESS_SHARED` are now implemented in `crates/kernel/src/pshared.rs`. `pthread_mutexattr_setpshared` and `pthread_barrierattr_setpshared` tests pass. `pthread_condattr_setpshared` remains XFAIL because the test uses `MAP_SHARED|MAP_ANONYMOUS` to share a `state` variable across fork and our wasm model gives each process its own linear memory — a separate architectural gap, not a pthread issue. - -**Affected tests still XFAIL:** -- sortix basic: `pthread/pthread_condattr_setpshared` - -**To fully close this test:** implement cross-process shared memory for `MAP_SHARED|MAP_ANONYMOUS` after fork. Not trivial: each wasm process has its own `WebAssembly.Memory`; direct loads/stores can't be intercepted without wasm instrumentation. One viable angle is to sync specific shared-region ranges on every syscall (lazy coherence), but that's architecturally significant and out of scope for the pthread-primitives target. - -**Starting files (for the remaining gap):** -- `crates/kernel/src/syscalls.rs` — `sys_mmap` (where `MAP_SHARED|MAP_ANONYMOUS` currently falls through to private) -- `host/src/memory-manager.ts` — per-process mmap tracking -- `crates/kernel/src/fork.rs` — fork memory snapshot +### 3. `PTHREAD_PROCESS_SHARED` and anonymous shared mappings (PARTIAL) + +**Status:** Pthread sync primitives (mutex, cond, barrier) with +`PTHREAD_PROCESS_SHARED` are implemented in `crates/kernel/src/pshared.rs`. +Anonymous `MAP_SHARED` mappings now publish changed byte runs to authoritative +host backing and import peer changes when a process crosses a syscall boundary. +That is sufficient for Sortix's `pthread_condattr_setpshared` test, whose state +changes cross condition-variable syscalls, so the test passes under both Node +and the browser and is no longer an XFAIL. + +This remains syscall-boundary coherence, not shared physical memory. Immediate +direct-load visibility between processes is unavailable until either process +enters the kernel, and futex waits/wakes still target the caller's own process +memory. Cross-process futex-backed synchronization that bypasses Kandelo's +kernel-owned pshared primitives therefore remains unsupported. See +`docs/architecture.md` and `docs/wasm-limitations.md` for the maintained +platform boundary. --- diff --git a/scripts/run-browser-sortix-tests.sh b/scripts/run-browser-sortix-tests.sh index 73ce4e02d..eec04bcbc 100755 --- a/scripts/run-browser-sortix-tests.sh +++ b/scripts/run-browser-sortix-tests.sh @@ -36,7 +36,6 @@ BASIC_EXPECTED_FAIL=( # (exec/spawn/popen/system/wordexp now pass — browser exec support with tool binaries) "aio/aio_fsync" "pthread/pthread_barrierattr_setpshared" - "pthread/pthread_condattr_setpshared" "pthread/pthread_create" "threads/thrd_create" "pthread/pthread_attr_setinheritsched" diff --git a/scripts/run-sortix-tests.sh b/scripts/run-sortix-tests.sh index 1dadac835..9235d01d5 100755 --- a/scripts/run-sortix-tests.sh +++ b/scripts/run-sortix-tests.sh @@ -38,9 +38,6 @@ INCLUDE_EXPECTED_FAIL=( BASIC_EXPECTED_FAIL=( "devctl/posix_devctl" # device control (Sortix/2024, not in musl) - "pthread/pthread_condattr_setpshared" # cross-process MAP_SHARED|MAP_ANONYMOUS memory - # not supported on wasm (pthread primitives ARE - # supported — see crates/kernel/src/pshared.rs) "pthread/pthread_attr_setinheritsched" # priority scheduling not supported "strings/ffsll" # wasm32 test bug (long vs long long) # aio/aio_cancel was flaky (FAIL once, XPASS next run) — left