diff --git a/apps/browser-demos/pages/test-runner/index.html b/apps/browser-demos/pages/test-runner/index.html
index facb25a353..ff4e39e85b 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 0000000000..1047d2c94d
--- /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 0000000000..2240c5ebb9
--- /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 0c9a475119..5ec8c360d2 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/abi-versioning.md b/docs/abi-versioning.md
index 7e6757bc0b..a1a41b1809 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 4a4642917b..9cece4c65b 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/docs/compromising-xfails.md b/docs/compromising-xfails.md
index 17d53e9170..7497fe528f 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/docs/posix-status.md b/docs/posix-status.md
index 245aee249d..005a710dfa 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. |
@@ -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/lseek_invalid_test.c b/examples/lseek_invalid_test.c
new file mode 100644
index 0000000000..aa2ba51aff
--- /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/examples/select_signal_test.c b/examples/select_signal_test.c
new file mode 100644
index 0000000000..e087c7c407
--- /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/examples/syscall_cp_offset_test.c b/examples/syscall_cp_offset_test.c
new file mode 100644
index 0000000000..036a939088
--- /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/src/kernel-worker.ts b/host/src/kernel-worker.ts
index a24610e601..590254b825 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/src/platform/node.ts b/host/src/platform/node.ts
index 8c9c4a2738..3b7105a62a 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 79d12221d3..f1625a2de2 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 0000000000..4c88c3060a
--- /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 6e58b20ec3..f9003abc0c 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 66b2ab1a2e..0ec107ab0e 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 cbef2d3c6c..636e1e3500 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 47ed03713a..8ad1d9d66e 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 f512821e54..cdad65e4a3 100644
--- a/host/test/global-setup.ts
+++ b/host/test/global-setup.ts
@@ -22,6 +22,9 @@ const fixturesDir = join(__dirname, "fixtures");
/** C programs that tests depend on. */
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",
"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 0000000000..f1eb6d3074
--- /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 f0d3bc77e9..c9431831eb 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 997baf41d2..2f100de4e2 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/select-signal-guest.test.ts b/host/test/select-signal-guest.test.ts
new file mode 100644
index 0000000000..7bc404d1b5
--- /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 0000000000..72e46ba915
--- /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,
+ );
+ });
+});
diff --git a/host/test/sharedfs-safety.test.ts b/host/test/sharedfs-safety.test.ts
index 557e704415..e1da659423 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);
diff --git a/host/test/syscall-cp-offset.test.ts b/host/test/syscall-cp-offset.test.ts
new file mode 100644
index 0000000000..c4ee7b3597
--- /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 882cd8211a..dd02adabe9 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 64de86af7e..b2d4f9526e 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
diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh
index 421215a9b3..5c9607b38b 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/"
diff --git a/scripts/run-browser-sortix-tests.sh b/scripts/run-browser-sortix-tests.sh
index 73ce4e02d1..eec04bcbc1 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 1dadac835a..9235d01d55 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